From eb0e77df3636ec89d76669e6e8ecbbf0627bacb7 Mon Sep 17 00:00:00 2001 From: mohanadft Date: Sat, 20 Jun 2026 11:29:55 +0300 Subject: [PATCH 1/8] feat(admin): unify conversion dashboard with membership tracking Add Membership goal alongside One-time and Monthly donations. Merge tracked and dropped conversions into a single count per goal. Add goal filter dropdown to view all or individual conversion types. Rename route from /admin/dropped-conversions to /admin/conversions. --- astro.config.mjs | 2 +- public/_redirects | 3 +- src/components/ConversionDashboard.tsx | 347 ++++++------------ ...ed-conversions.astro => conversions.astro} | 2 +- src/pages/api/admin/conversion-stats.ts | 9 +- src/pages/api/pipe.ts | 2 +- 6 files changed, 126 insertions(+), 239 deletions(-) rename src/pages/admin/{dropped-conversions.astro => conversions.astro} (90%) diff --git a/astro.config.mjs b/astro.config.mjs index 4ebde6ed..0ef7898a 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -64,7 +64,7 @@ export default defineConfig({ "/event-details/", "/404/", "/js/web.js/", - "/admin/dropped-conversions/", + "/admin/conversions/", ]; return !exclude.some((path) => page.endsWith(path)); }, diff --git a/public/_redirects b/public/_redirects index 404e3729..270d86d6 100644 --- a/public/_redirects +++ b/public/_redirects @@ -4,4 +4,5 @@ /project/* /projects 301 /project-apply /projects 301 /project-details/* /projects 301 -/mentor-application /mentorship 301 \ No newline at end of file +/mentor-application /mentorship 301 +/admin/dropped-conversions /admin/conversions 301 \ No newline at end of file diff --git a/src/components/ConversionDashboard.tsx b/src/components/ConversionDashboard.tsx index 23380b2c..4f019953 100644 --- a/src/components/ConversionDashboard.tsx +++ b/src/components/ConversionDashboard.tsx @@ -24,18 +24,9 @@ interface DailyCount { count: number; } -interface DroppedEvent { - eventName: string; - timestamp: string; - props: Record; - hasIp: boolean; - hasUserAgent: boolean; -} - interface StatsResponse { plausible: DailyCount[]; dropped: DailyCount[]; - droppedEvents: DroppedEvent[]; dateFrom: string; dateTo: string; error?: string; @@ -45,15 +36,17 @@ interface ConversionDashboardProps { token: string; } -function formatDate(iso: string): string { - return new Date(iso).toLocaleDateString("en-US", { - year: "numeric", - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - }); -} +const GOAL_LABELS: Record = { + "One-time-donate": "One-time Donation", + "Monthly-donate": "Monthly Donation", + Membership: "Membership", +}; + +const GOAL_COLORS: Record = { + "One-time-donate": { bg: "#16803980", border: "#168039" }, + "Monthly-donate": { bg: "#2563eb80", border: "#2563eb" }, + Membership: { bg: "#7c3aed80", border: "#7c3aed" }, +}; function buildDateRange(days: number): [string, string] { const to = new Date(); @@ -62,12 +55,28 @@ function buildDateRange(days: number): [string, string] { return [from.toISOString().slice(0, 10), to.toISOString().slice(0, 10)]; } +function mergeCountsByGoal( + plausible: DailyCount[], + dropped: DailyCount[] +): DailyCount[] { + const merged = new Map(); + for (const d of [...plausible, ...dropped]) { + const key = `${d.date}:${d.goal}`; + merged.set(key, (merged.get(key) || 0) + d.count); + } + return Array.from(merged, ([key, count]) => { + const [date, ...goalParts] = key.split(":"); + return { date, goal: goalParts.join(":"), count }; + }); +} + export default function ConversionDashboard({ token, }: ConversionDashboardProps) { const [defaultFrom, defaultTo] = buildDateRange(30); const [dateFrom, setDateFrom] = useState(defaultFrom); const [dateTo, setDateTo] = useState(defaultTo); + const [goalFilter, setGoalFilter] = useState("all"); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -106,20 +115,30 @@ export default function ConversionDashboard({ useEffect(() => { if (!data || !chartRef.current) return; - const allDates = new Set(); - for (const d of data.plausible) allDates.add(d.date); - for (const d of data.dropped) allDates.add(d.date); - const dates = Array.from(allDates).sort(); + const allCounts = mergeCountsByGoal(data.plausible, data.dropped); - const plausibleByDate = new Map(); - const droppedByDate = new Map(); + const filtered = + goalFilter === "all" + ? allCounts + : allCounts.filter((d) => d.goal === goalFilter); - for (const d of data.plausible) { - plausibleByDate.set(d.date, (plausibleByDate.get(d.date) || 0) + d.count); - } - for (const d of data.dropped) { - droppedByDate.set(d.date, (droppedByDate.get(d.date) || 0) + d.count); - } + const dates = Array.from(new Set(filtered.map((d) => d.date))).sort(); + const goals = Array.from(new Set(filtered.map((d) => d.goal))); + + const datasets = goals.map((goal) => { + const byDate = new Map(); + for (const d of filtered.filter((c) => c.goal === goal)) { + byDate.set(d.date, (byDate.get(d.date) || 0) + d.count); + } + const colors = GOAL_COLORS[goal] || { bg: "#16803980", border: "#168039" }; + return { + label: GOAL_LABELS[goal] || goal, + data: dates.map((d) => byDate.get(d) || 0), + backgroundColor: colors.bg, + borderColor: colors.border, + borderWidth: 1, + }; + }); if (chartInstance.current) { chartInstance.current.destroy(); @@ -134,22 +153,7 @@ export default function ConversionDashboard({ day: "numeric", }) ), - datasets: [ - { - label: "Tracked (Plausible)", - data: dates.map((d) => plausibleByDate.get(d) || 0), - backgroundColor: "#16803980", - borderColor: "#168039", - borderWidth: 1, - }, - { - label: "Dropped", - data: dates.map((d) => droppedByDate.get(d) || 0), - backgroundColor: "#dc262680", - borderColor: "#dc2626", - borderWidth: 1, - }, - ], + datasets, }, options: { responsive: true, @@ -177,16 +181,15 @@ export default function ConversionDashboard({ chartInstance.current?.destroy(); chartInstance.current = null; }; - }, [data]); + }, [data, goalFilter]); + + const allCounts = data + ? mergeCountsByGoal(data.plausible, data.dropped) + : []; + + const totalConversions = allCounts.reduce((sum, d) => sum + d.count, 0); - const totalTracked = - data?.plausible.reduce((sum, d) => sum + d.count, 0) || 0; - const totalDropped = - data?.dropped.reduce((sum, d) => sum + d.count, 0) || 0; - const dropRate = - totalTracked + totalDropped > 0 - ? ((totalDropped / (totalTracked + totalDropped)) * 100).toFixed(1) - : "0"; + const goalKeys = ["One-time-donate", "Monthly-donate", "Membership"] as const; return (
@@ -194,11 +197,10 @@ export default function ConversionDashboard({ Donation Conversion Dashboard

- Tracked conversions from Plausible vs. silently dropped events (VPN - users, bot-like browsers). + Conversion events tracked across /donate and /membership pages.

- {/* Date range filter */} + {/* Date range + goal filter */}
- - + ))} +
+
+
@@ -280,164 +284,47 @@ export default function ConversionDashboard({ {data && !loading && ( <> {/* Summary cards */} -
+

- Tracked Conversions + Total Conversions

- {totalTracked} + {totalConversions}

-
-

- Dropped Conversions -

-

- {totalDropped} -

-
-
-

Drop Rate

-

- {dropRate}% -

-
-
- - {/* Chart */} -
-

- Daily Conversions -

-
- -
-
- - {/* Breakdown by goal */} -
- {["One-time-donate", "Monthly-donate"].map((goal) => { - const tracked = - data.plausible - .filter((d) => d.goal === goal) - .reduce((s, d) => s + d.count, 0) || 0; - const dropped = - data.dropped - .filter((d) => d.goal === goal) - .reduce((s, d) => s + d.count, 0) || 0; - const label = - goal === "One-time-donate" ? "One-time" : "Monthly"; + {goalKeys.map((goal) => { + const count = allCounts + .filter((d) => d.goal === goal) + .reduce((s, d) => s + d.count, 0); + const colors = GOAL_COLORS[goal]; return (
-

- {label} Donations -

-
- - {tracked} - - tracked - - {dropped} - - dropped -
+

+ {GOAL_LABELS[goal]} +

+

+ {count} +

); })}
- {/* Dropped events table */} -
-

- Dropped Events Detail + {/* Chart */} +
+

+ Daily Conversions

- {data.droppedEvents.length === 0 ? ( -
-

- No dropped conversions in this date range. -

-
- ) : ( -
-

- {data.droppedEvents.length} dropped event - {data.droppedEvents.length !== 1 ? "s" : ""} -

- - - - - - - - - - - - - {data.droppedEvents.map((event, i) => ( - - - - - - - - - ))} - -
- Date - - Event - - Amount - - Source - - IP - - UA -
- {formatDate(event.timestamp)} - - {event.eventName} - - {event.props.amount - ? `$${event.props.amount}` - : "—"} - - {event.props.source || - event.props.donate_form_variant || - "—"} - - - {event.hasIp ? "Yes" : "No"} - - - - {event.hasUserAgent ? "Yes" : "No"} - -
-
- )} +
+ +
)} diff --git a/src/pages/admin/dropped-conversions.astro b/src/pages/admin/conversions.astro similarity index 90% rename from src/pages/admin/dropped-conversions.astro rename to src/pages/admin/conversions.astro index 7f5843e9..5819ed07 100644 --- a/src/pages/admin/dropped-conversions.astro +++ b/src/pages/admin/conversions.astro @@ -16,5 +16,5 @@ if (!expectedToken || !token || !constantTimeEqual(token, expectedToken)) { --- - + diff --git a/src/pages/api/admin/conversion-stats.ts b/src/pages/api/admin/conversion-stats.ts index 7cdf42b8..35e60f76 100644 --- a/src/pages/api/admin/conversion-stats.ts +++ b/src/pages/api/admin/conversion-stats.ts @@ -6,7 +6,7 @@ import { reportError } from "../../../lib/report-error"; const PLAUSIBLE_API = "https://plausible.io/api/v2/query"; const SITE_ID = "techforpalestine.org"; -const GOALS = ["Monthly-donate", "One-time-donate"]; +const GOALS = ["Monthly-donate", "One-time-donate", "Membership"]; interface PlausibleResult { dimensions: string[]; @@ -71,7 +71,7 @@ async function fetchDroppedEvents( kv: KVNamespace, dateFrom: string, dateTo: string -): Promise<{ daily: DailyCount[]; events: DroppedEvent[] }> { +): Promise<{ daily: DailyCount[] }> { const allKeys: string[] = []; let cursor: string | undefined; do { @@ -118,7 +118,7 @@ async function fetchDroppedEvents( daily.push({ date, goal: goalParts.join(":"), count }); } - return { daily, events: filtered.reverse() }; + return { daily }; } export const GET: APIRoute = async ({ request, locals }) => { @@ -145,14 +145,13 @@ export const GET: APIRoute = async ({ request, locals }) => { : Promise.resolve([]), kv ? fetchDroppedEvents(kv, dateFrom, dateTo) - : Promise.resolve({ daily: [], events: [] }), + : Promise.resolve({ daily: [] }), ]); return new Response( JSON.stringify({ plausible, dropped: dropped.daily, - droppedEvents: dropped.events, dateFrom, dateTo, }), diff --git a/src/pages/api/pipe.ts b/src/pages/api/pipe.ts index 652a4389..6c368cb5 100644 --- a/src/pages/api/pipe.ts +++ b/src/pages/api/pipe.ts @@ -4,7 +4,7 @@ import { reportError } from "../../lib/report-error"; const ALLOWED_ORIGIN = "https://techforpalestine.org"; const PLAUSIBLE_API = "https://plausible.io/api/event"; -const CONVERSION_EVENTS = new Set(["Monthly-donate", "One-time-donate"]); +const CONVERSION_EVENTS = new Set(["Monthly-donate", "One-time-donate", "Membership"]); function parseEventName(body: string): string { try { From 6193ca5e9a04ba24db678e1d7cc28d39796fa995 Mon Sep 17 00:00:00 2001 From: mohanadft Date: Sat, 20 Jun 2026 11:37:46 +0300 Subject: [PATCH 2/8] fix(pipe): allow Cloudflare Pages preview origins --- src/pages/api/pipe.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/pages/api/pipe.ts b/src/pages/api/pipe.ts index 6c368cb5..29095f24 100644 --- a/src/pages/api/pipe.ts +++ b/src/pages/api/pipe.ts @@ -3,9 +3,14 @@ import * as Sentry from "@sentry/astro"; import { reportError } from "../../lib/report-error"; const ALLOWED_ORIGIN = "https://techforpalestine.org"; +const PREVIEW_ORIGIN_SUFFIX = ".pages.dev"; const PLAUSIBLE_API = "https://plausible.io/api/event"; const CONVERSION_EVENTS = new Set(["Monthly-donate", "One-time-donate", "Membership"]); +function isAllowedOrigin(origin: string): boolean { + return origin === ALLOWED_ORIGIN || origin.endsWith(PREVIEW_ORIGIN_SUFFIX); +} + function parseEventName(body: string): string { try { const parsed = JSON.parse(body); @@ -18,7 +23,7 @@ function parseEventName(body: string): string { export const POST: APIRoute = async ({ request, locals }) => { const ctx = locals.runtime?.ctx; const origin = request.headers.get("origin"); - if (origin && origin !== ALLOWED_ORIGIN) { + if (origin && !isAllowedOrigin(origin)) { return new Response("Forbidden", { status: 403 }); } From f3eba2559ce1d7f065556b55702f9fceafedbe2b Mon Sep 17 00:00:00 2001 From: mohanadft Date: Sat, 20 Jun 2026 11:53:36 +0300 Subject: [PATCH 3/8] fix(admin): query Plausible goals individually to handle missing goals Goals that don't exist in Plausible (like Membership) would cause the entire API query to fail. Query each goal separately and skip any that return errors. --- src/pages/api/admin/conversion-stats.ts | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/pages/api/admin/conversion-stats.ts b/src/pages/api/admin/conversion-stats.ts index 35e60f76..dcb516cf 100644 --- a/src/pages/api/admin/conversion-stats.ts +++ b/src/pages/api/admin/conversion-stats.ts @@ -34,8 +34,9 @@ function defaultDateRange(): [string, string] { return [from.toISOString().slice(0, 10), to.toISOString().slice(0, 10)]; } -async function fetchPlausibleStats( +async function fetchPlausibleStatsForGoal( apiKey: string, + goal: string, dateFrom: string, dateTo: string ): Promise { @@ -49,14 +50,12 @@ async function fetchPlausibleStats( site_id: SITE_ID, metrics: ["events"], date_range: [dateFrom, dateTo], - filters: [["is", "event:goal", GOALS]], + filters: [["is", "event:goal", [goal]]], dimensions: ["time:day", "event:goal"], }), }); - if (!response.ok) { - throw new Error(`Plausible API error: ${response.status}`); - } + if (!response.ok) return []; const data = (await response.json()) as { results: PlausibleResult[] }; @@ -67,6 +66,17 @@ async function fetchPlausibleStats( })); } +async function fetchPlausibleStats( + apiKey: string, + dateFrom: string, + dateTo: string +): Promise { + const results = await Promise.all( + GOALS.map((goal) => fetchPlausibleStatsForGoal(apiKey, goal, dateFrom, dateTo)) + ); + return results.flat(); +} + async function fetchDroppedEvents( kv: KVNamespace, dateFrom: string, From 74b304c1b22437b2eb75181b84292ad225c3cf55 Mon Sep 17 00:00:00 2001 From: mohanadft Date: Sat, 20 Jun 2026 12:05:01 +0300 Subject: [PATCH 4/8] feat(membership): rename Plausible event to Membership-complete Avoids collision with the existing Membership pageview goal. The event already sends amount and membership_variant props. --- src/components/ConversionDashboard.tsx | 6 +++--- src/components/MembershipPage.tsx | 2 +- src/pages/api/admin/conversion-stats.ts | 2 +- src/pages/api/pipe.ts | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/components/ConversionDashboard.tsx b/src/components/ConversionDashboard.tsx index 4f019953..5e8d56d3 100644 --- a/src/components/ConversionDashboard.tsx +++ b/src/components/ConversionDashboard.tsx @@ -39,13 +39,13 @@ interface ConversionDashboardProps { const GOAL_LABELS: Record = { "One-time-donate": "One-time Donation", "Monthly-donate": "Monthly Donation", - Membership: "Membership", + "Membership-complete": "Membership", }; const GOAL_COLORS: Record = { "One-time-donate": { bg: "#16803980", border: "#168039" }, "Monthly-donate": { bg: "#2563eb80", border: "#2563eb" }, - Membership: { bg: "#7c3aed80", border: "#7c3aed" }, + "Membership-complete": { bg: "#7c3aed80", border: "#7c3aed" }, }; function buildDateRange(days: number): [string, string] { @@ -189,7 +189,7 @@ export default function ConversionDashboard({ const totalConversions = allCounts.reduce((sum, d) => sum + d.count, 0); - const goalKeys = ["One-time-donate", "Monthly-donate", "Membership"] as const; + const goalKeys = ["One-time-donate", "Monthly-donate", "Membership-complete"] as const; return (
diff --git a/src/components/MembershipPage.tsx b/src/components/MembershipPage.tsx index 1baa19a1..9094b4c6 100644 --- a/src/components/MembershipPage.tsx +++ b/src/components/MembershipPage.tsx @@ -99,7 +99,7 @@ export default function MembershipPage() { const transaction = detail?.QGIV?.transaction ?? {}; if (typeof window.plausible !== "undefined") { - window.plausible("Membership", { + window.plausible("Membership-complete", { props: { amount: transaction.total != null ? String(transaction.total) : "", membership_variant: variant, diff --git a/src/pages/api/admin/conversion-stats.ts b/src/pages/api/admin/conversion-stats.ts index dcb516cf..beaf0da2 100644 --- a/src/pages/api/admin/conversion-stats.ts +++ b/src/pages/api/admin/conversion-stats.ts @@ -6,7 +6,7 @@ import { reportError } from "../../../lib/report-error"; const PLAUSIBLE_API = "https://plausible.io/api/v2/query"; const SITE_ID = "techforpalestine.org"; -const GOALS = ["Monthly-donate", "One-time-donate", "Membership"]; +const GOALS = ["Monthly-donate", "One-time-donate", "Membership-complete"]; interface PlausibleResult { dimensions: string[]; diff --git a/src/pages/api/pipe.ts b/src/pages/api/pipe.ts index 29095f24..46a65bc3 100644 --- a/src/pages/api/pipe.ts +++ b/src/pages/api/pipe.ts @@ -5,7 +5,7 @@ import { reportError } from "../../lib/report-error"; const ALLOWED_ORIGIN = "https://techforpalestine.org"; const PREVIEW_ORIGIN_SUFFIX = ".pages.dev"; const PLAUSIBLE_API = "https://plausible.io/api/event"; -const CONVERSION_EVENTS = new Set(["Monthly-donate", "One-time-donate", "Membership"]); +const CONVERSION_EVENTS = new Set(["Monthly-donate", "One-time-donate", "Membership-complete"]); function isAllowedOrigin(origin: string): boolean { return origin === ALLOWED_ORIGIN || origin.endsWith(PREVIEW_ORIGIN_SUFFIX); From 7e98b670abf7d95db6f764f392622cfe64f2b180 Mon Sep 17 00:00:00 2001 From: mohanadft Date: Sat, 20 Jun 2026 12:16:11 +0300 Subject: [PATCH 5/8] feat(admin): show amount totals and membership variant breakdown Query Plausible for custom property breakdowns per goal. Display total donation amounts per goal type and Calculator vs No Calculator split for membership conversions. --- src/components/ConversionDashboard.tsx | 76 +++++++++++++++++++++++++ src/pages/api/admin/conversion-stats.ts | 60 ++++++++++++++++++- 2 files changed, 135 insertions(+), 1 deletion(-) diff --git a/src/components/ConversionDashboard.tsx b/src/components/ConversionDashboard.tsx index 5e8d56d3..a1c181e1 100644 --- a/src/components/ConversionDashboard.tsx +++ b/src/components/ConversionDashboard.tsx @@ -24,9 +24,17 @@ interface DailyCount { count: number; } +interface PropBreakdown { + goal: string; + prop: string; + value: string; + count: number; +} + interface StatsResponse { plausible: DailyCount[]; dropped: DailyCount[]; + propBreakdowns: PropBreakdown[]; dateFrom: string; dateTo: string; error?: string; @@ -326,6 +334,74 @@ export default function ConversionDashboard({

+ + {/* Property breakdowns */} + {data.propBreakdowns.length > 0 && ( +
+ {/* Amount totals per goal */} + {goalKeys.map((goal) => { + const amounts = data.propBreakdowns.filter( + (p) => p.goal === goal && p.prop === "amount" && p.value + ); + if (amounts.length === 0) return null; + const total = amounts.reduce( + (sum, p) => sum + parseFloat(p.value || "0") * p.count, + 0 + ); + return ( +
+

+ {GOAL_LABELS[goal]} — Total Amount +

+

+ ${total.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} +

+

+ {amounts.reduce((s, p) => s + p.count, 0)} conversions +

+
+ ); + })} + + {/* Membership variant breakdown */} + {(() => { + const variants = data.propBreakdowns.filter( + (p) => + p.goal === "Membership-complete" && + p.prop === "membership_variant" + ); + if (variants.length === 0) return null; + return ( +
+

+ Membership — Calculator Variant +

+
+ {variants.map((v) => ( +
+ + {v.value || "(not set)"} + + + {v.count} + +
+ ))} +
+
+ ); + })()} +
+ )} )}
diff --git a/src/pages/api/admin/conversion-stats.ts b/src/pages/api/admin/conversion-stats.ts index beaf0da2..fccc4df6 100644 --- a/src/pages/api/admin/conversion-stats.ts +++ b/src/pages/api/admin/conversion-stats.ts @@ -77,6 +77,60 @@ async function fetchPlausibleStats( return results.flat(); } +interface PropBreakdown { + goal: string; + prop: string; + value: string; + count: number; +} + +async function fetchPlausiblePropBreakdown( + apiKey: string, + goal: string, + prop: string, + dateFrom: string, + dateTo: string +): Promise { + const response = await fetch(PLAUSIBLE_API, { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + site_id: SITE_ID, + metrics: ["events"], + date_range: [dateFrom, dateTo], + filters: [["is", "event:goal", [goal]]], + dimensions: [`event:props:${prop}`], + }), + }); + + if (!response.ok) return []; + + const data = (await response.json()) as { results: PlausibleResult[] }; + + return data.results.map((r) => ({ + goal, + prop, + value: r.dimensions[0], + count: r.metrics[0], + })); +} + +async function fetchPropertyBreakdowns( + apiKey: string, + dateFrom: string, + dateTo: string +): Promise { + const queries = [ + ...GOALS.map((goal) => fetchPlausiblePropBreakdown(apiKey, goal, "amount", dateFrom, dateTo)), + fetchPlausiblePropBreakdown(apiKey, "Membership-complete", "membership_variant", dateFrom, dateTo), + ]; + const results = await Promise.all(queries); + return results.flat(); +} + async function fetchDroppedEvents( kv: KVNamespace, dateFrom: string, @@ -149,19 +203,23 @@ export const GET: APIRoute = async ({ request, locals }) => { const ctx = locals.runtime?.ctx; try { - const [plausible, dropped] = await Promise.all([ + const [plausible, dropped, propBreakdowns] = await Promise.all([ apiKey ? fetchPlausibleStats(apiKey, dateFrom, dateTo) : Promise.resolve([]), kv ? fetchDroppedEvents(kv, dateFrom, dateTo) : Promise.resolve({ daily: [] }), + apiKey + ? fetchPropertyBreakdowns(apiKey, dateFrom, dateTo) + : Promise.resolve([]), ]); return new Response( JSON.stringify({ plausible, dropped: dropped.daily, + propBreakdowns, dateFrom, dateTo, }), From 0c4d87ec900dd93abd4bcaf7f0426233f04394f8 Mon Sep 17 00:00:00 2001 From: mohanadft Date: Sat, 20 Jun 2026 12:18:17 +0300 Subject: [PATCH 6/8] feat(admin): show individual amounts per goal in conversion dashboard Replace single total with a table showing each donation amount and how many times it occurred, sorted by amount descending. --- src/components/ConversionDashboard.tsx | 101 ++++++++++++++----------- 1 file changed, 58 insertions(+), 43 deletions(-) diff --git a/src/components/ConversionDashboard.tsx b/src/components/ConversionDashboard.tsx index a1c181e1..b3c019b9 100644 --- a/src/components/ConversionDashboard.tsx +++ b/src/components/ConversionDashboard.tsx @@ -335,73 +335,88 @@ export default function ConversionDashboard({
- {/* Property breakdowns */} + {/* Amount breakdowns per goal */} {data.propBreakdowns.length > 0 && ( -
- {/* Amount totals per goal */} +
{goalKeys.map((goal) => { - const amounts = data.propBreakdowns.filter( - (p) => p.goal === goal && p.prop === "amount" && p.value - ); + const amounts = data.propBreakdowns + .filter((p) => p.goal === goal && p.prop === "amount" && p.value) + .sort((a, b) => parseFloat(b.value) - parseFloat(a.value)); if (amounts.length === 0) return null; const total = amounts.reduce( (sum, p) => sum + parseFloat(p.value || "0") * p.count, 0 ); + const colors = GOAL_COLORS[goal]; return (

- {GOAL_LABELS[goal]} — Total Amount + {GOAL_LABELS[goal]}

${total.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}

-

- {amounts.reduce((s, p) => s + p.count, 0)} conversions -

-
- ); - })} - - {/* Membership variant breakdown */} - {(() => { - const variants = data.propBreakdowns.filter( - (p) => - p.goal === "Membership-complete" && - p.prop === "membership_variant" - ); - if (variants.length === 0) return null; - return ( -
-

- Membership — Calculator Variant -

-
- {variants.map((v) => ( -
- - {v.value || "(not set)"} - - - {v.count} - -
- ))} +
+ + + + + + + + + {amounts.map((a) => ( + + + + + ))} + +
AmountCount
${parseFloat(a.value).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}{a.count}
); - })()} + })}
)} + + {/* Membership variant breakdown */} + {(() => { + const variants = data.propBreakdowns.filter( + (p) => + p.goal === "Membership-complete" && + p.prop === "membership_variant" + ); + if (variants.length === 0) return null; + return ( +
+

+ Membership — Calculator Variant +

+
+ {variants.map((v) => ( +
+ + {v.value || "(not set)"} + + + {v.count} + +
+ ))} +
+
+ ); + })()} )}
From 1876bb09f9bf6accbe41ade50a720e1f845c3fb0 Mon Sep 17 00:00:00 2001 From: mohanadft Date: Sat, 20 Jun 2026 12:23:36 +0300 Subject: [PATCH 7/8] fix(admin): show NaN amounts as untracked instead of $NaN --- src/components/ConversionDashboard.tsx | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/components/ConversionDashboard.tsx b/src/components/ConversionDashboard.tsx index b3c019b9..ecf47528 100644 --- a/src/components/ConversionDashboard.tsx +++ b/src/components/ConversionDashboard.tsx @@ -339,12 +339,18 @@ export default function ConversionDashboard({ {data.propBreakdowns.length > 0 && (
{goalKeys.map((goal) => { - const amounts = data.propBreakdowns - .filter((p) => p.goal === goal && p.prop === "amount" && p.value) + 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)); - if (amounts.length === 0) return null; - const total = amounts.reduce( - (sum, p) => sum + parseFloat(p.value || "0") * p.count, + const untrackedCount = allAmounts + .filter((p) => !p.value || isNaN(parseFloat(p.value))) + .reduce((s, p) => s + p.count, 0); + const total = tracked.reduce( + (sum, p) => sum + parseFloat(p.value) * p.count, 0 ); const colors = GOAL_COLORS[goal]; @@ -371,12 +377,18 @@ export default function ConversionDashboard({ - {amounts.map((a) => ( + {tracked.map((a) => ( ${parseFloat(a.value).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} {a.count} ))} + {untrackedCount > 0 && ( + + Untracked + {untrackedCount} + + )}
From 95c970977e4cc68fc77960208200ed3a01215602 Mon Sep 17 00:00:00 2001 From: mohanadft Date: Sat, 20 Jun 2026 12:25:41 +0300 Subject: [PATCH 8/8] fix(admin): include dropped event props in amount breakdown Dropped events stored in KV have amount and other props that were not included in the property breakdowns. Extract and merge them so all conversions show in the amount tables. --- src/pages/api/admin/conversion-stats.ts | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/pages/api/admin/conversion-stats.ts b/src/pages/api/admin/conversion-stats.ts index fccc4df6..f674a6d3 100644 --- a/src/pages/api/admin/conversion-stats.ts +++ b/src/pages/api/admin/conversion-stats.ts @@ -135,7 +135,7 @@ async function fetchDroppedEvents( kv: KVNamespace, dateFrom: string, dateTo: string -): Promise<{ daily: DailyCount[] }> { +): Promise<{ daily: DailyCount[]; propBreakdowns: PropBreakdown[] }> { const allKeys: string[] = []; let cursor: string | undefined; do { @@ -182,7 +182,21 @@ async function fetchDroppedEvents( daily.push({ date, goal: goalParts.join(":"), count }); } - return { daily }; + const propCounts = new Map(); + 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 propBreakdowns: PropBreakdown[] = []; + for (const [key, count] of propCounts) { + const [goal, prop, ...valueParts] = key.split(":"); + propBreakdowns.push({ goal, prop, value: valueParts.join(":"), count }); + } + + return { daily, propBreakdowns }; } export const GET: APIRoute = async ({ request, locals }) => { @@ -209,7 +223,7 @@ export const GET: APIRoute = async ({ request, locals }) => { : Promise.resolve([]), kv ? fetchDroppedEvents(kv, dateFrom, dateTo) - : Promise.resolve({ daily: [] }), + : Promise.resolve({ daily: [], propBreakdowns: [] }), apiKey ? fetchPropertyBreakdowns(apiKey, dateFrom, dateTo) : Promise.resolve([]), @@ -219,7 +233,7 @@ export const GET: APIRoute = async ({ request, locals }) => { JSON.stringify({ plausible, dropped: dropped.daily, - propBreakdowns, + propBreakdowns: [...propBreakdowns, ...dropped.propBreakdowns], dateFrom, dateTo, }),