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..ecf47528 100644 --- a/src/components/ConversionDashboard.tsx +++ b/src/components/ConversionDashboard.tsx @@ -24,18 +24,17 @@ interface DailyCount { count: number; } -interface DroppedEvent { - eventName: string; - timestamp: string; - props: Record; - hasIp: boolean; - hasUserAgent: boolean; +interface PropBreakdown { + goal: string; + prop: string; + value: string; + count: number; } interface StatsResponse { plausible: DailyCount[]; dropped: DailyCount[]; - droppedEvents: DroppedEvent[]; + propBreakdowns: PropBreakdown[]; dateFrom: string; dateTo: string; error?: string; @@ -45,15 +44,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-complete": "Membership", +}; + +const GOAL_COLORS: Record = { + "One-time-donate": { bg: "#16803980", border: "#168039" }, + "Monthly-donate": { bg: "#2563eb80", border: "#2563eb" }, + "Membership-complete": { bg: "#7c3aed80", border: "#7c3aed" }, +}; function buildDateRange(days: number): [string, string] { const to = new Date(); @@ -62,12 +63,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 +123,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 +161,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 +189,15 @@ export default function ConversionDashboard({ chartInstance.current?.destroy(); chartInstance.current = null; }; - }, [data]); + }, [data, goalFilter]); + + const allCounts = data + ? mergeCountsByGoal(data.plausible, data.dropped) + : []; - 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 totalConversions = allCounts.reduce((sum, d) => sum + d.count, 0); + + const goalKeys = ["One-time-donate", "Monthly-donate", "Membership-complete"] as const; return (
@@ -194,11 +205,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,29 +292,37 @@ export default function ConversionDashboard({ {data && !loading && ( <> {/* Summary cards */} -
+

- Tracked Conversions + Total Conversions

- {totalTracked} -

-
-
-

- Dropped Conversions -

-

- {totalDropped} -

-
-
-

Drop Rate

-

- {dropRate}% + {totalConversions}

+ {goalKeys.map((goal) => { + const count = allCounts + .filter((d) => d.goal === goal) + .reduce((s, d) => s + d.count, 0); + const colors = GOAL_COLORS[goal]; + return ( +
+

+ {GOAL_LABELS[goal]} +

+

+ {count} +

+
+ ); + })}
{/* Chart */} @@ -315,130 +335,100 @@ export default function ConversionDashboard({
- {/* 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"; - return ( -
-

- {label} Donations -

-
- - {tracked} - - tracked - - {dropped} - - dropped + {/* Amount breakdowns per goal */} + {data.propBreakdowns.length > 0 && ( +
+ {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 total = tracked.reduce( + (sum, p) => sum + parseFloat(p.value) * p.count, + 0 + ); + const colors = GOAL_COLORS[goal]; + return ( +
+

+ {GOAL_LABELS[goal]} +

+

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

+
+ + + + + + + + + {tracked.map((a) => ( + + + + + ))} + {untrackedCount > 0 && ( + + + + + )} + +
AmountCount
${parseFloat(a.value).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}{a.count}
Untracked{untrackedCount}
+
-
- ); - })} -
+ ); + })} +
+ )} - {/* Dropped events table */} -
-

- Dropped Events Detail -

- {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"} - -
+ {/* 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/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/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..f674a6d3 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-complete"]; interface PlausibleResult { dimensions: string[]; @@ -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,11 +66,76 @@ 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(); +} + +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, dateTo: string -): Promise<{ daily: DailyCount[]; events: DroppedEvent[] }> { +): Promise<{ daily: DailyCount[]; propBreakdowns: PropBreakdown[] }> { const allKeys: string[] = []; let cursor: string | undefined; do { @@ -118,7 +182,21 @@ async function fetchDroppedEvents( daily.push({ date, goal: goalParts.join(":"), count }); } - return { daily, events: filtered.reverse() }; + 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 }) => { @@ -139,20 +217,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: [], events: [] }), + : Promise.resolve({ daily: [], propBreakdowns: [] }), + apiKey + ? fetchPropertyBreakdowns(apiKey, dateFrom, dateTo) + : Promise.resolve([]), ]); return new Response( JSON.stringify({ plausible, dropped: dropped.daily, - droppedEvents: dropped.events, + propBreakdowns: [...propBreakdowns, ...dropped.propBreakdowns], dateFrom, dateTo, }), diff --git a/src/pages/api/pipe.ts b/src/pages/api/pipe.ts index 652a4389..46a65bc3 100644 --- a/src/pages/api/pipe.ts +++ b/src/pages/api/pipe.ts @@ -3,8 +3,13 @@ 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"]); +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); +} function parseEventName(body: string): string { try { @@ -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 }); }