feat(donate): add PostHog analytics events
Build and Push Image / build-frontend (push) Successful in 5m19s
Build and Push Image / build-backend (push) Successful in 3m46s
Build and Push Image / push (push) Successful in 1m20s

Track donation lifecycle across form, widget, admin, and backend:
- Form: donation_completed, donation_*_failed, donation_slip_conflict, donation_artifact_conflict
- Widget: donation_widget_displayed, donation_widget_tts_failed, donation_widget_animation_end, donation_widget_connected, donation_widget_heartbeat_failure
- Admin: donation_admin_test_popup, donation_admin_widget_reload, donation_admin_resend
- Backend: donation_widget_state_playing, donation_widget_state_shown, donation_resent
This commit is contained in:
2026-06-08 20:51:07 +00:00 Unverified
parent 995d5c1614
commit 5edd92a97f
6 changed files with 58 additions and 14 deletions
+7
View File
@@ -6,10 +6,13 @@ import { donations } from "@/lib/db/schema";
import { sse } from "@/lib/db/sse-endpoints";
import { fileToDataUrl } from "@/lib/utils";
import { eq } from "drizzle-orm";
import { getPostHogClient } from "@/lib/posthog-server";
export async function testPopup() {
if (!(await adminCheck())) throw new Error("Unauthorized");
getPostHogClient().capture({ distinctId: "admin", event: "donation_admin_test_popup" });
sse.donate.pub("ping", {
id: "test",
name: "Mr. Buzz",
@@ -21,6 +24,8 @@ export async function testPopup() {
export async function reloadWidget() {
if (!(await adminCheck())) throw new Error("Unauthorized");
getPostHogClient().capture({ distinctId: "admin", event: "donation_admin_widget_reload" });
sse.donate.pub("refresh", null);
}
@@ -33,6 +38,8 @@ export async function resendPopup(id: string) {
.returning();
if (!sub) throw new Error("not found");
getPostHogClient().capture({ distinctId: "admin", event: "donation_admin_resend", properties: { id } });
sse.donate.pub("ping", {
...sub,
message: sub.message ?? "",
+12 -11
View File
@@ -21,6 +21,7 @@ import { useEllipsisVisible } from "react-hook-text-overflow";
import { ActionButton } from "../../../../components/action-button";
import { reloadWidget, resendPopup, testPopup } from "./api";
import { useEffect } from "react";
import posthog from "posthog-js";
import { sse } from "@/lib/db/sse-endpoints";
import { useRouter } from "next/navigation";
@@ -65,16 +66,16 @@ const columns: ColumnDef<typeof donations.$inferSelect>[] = [
return (
<div className="flex gap-1">
<SimpleTooltip text="Send test popup">
<ActionButton variant="outline" size="icon-sm" action={testPopup}>
<ActionButton variant="outline" size="icon-sm" action={() => { posthog.capture("donation_admin_test_popup"); return testPopup(); }}>
<BugPlay />
</ActionButton>
</SimpleTooltip>
<SimpleTooltip text="Reload all widget">
<ActionButton
variant="outline"
size="icon-sm"
action={reloadWidget}
>
<ActionButton
variant="outline"
size="icon-sm"
action={() => { posthog.capture("donation_admin_widget_reload"); return reloadWidget(); }}
>
<ChevronsLeftRightEllipsis />
</ActionButton>
</SimpleTooltip>
@@ -91,11 +92,11 @@ const columns: ColumnDef<typeof donations.$inferSelect>[] = [
)}
>
<SimpleTooltip text="Re-send Popup">
<ActionButton
variant="outline"
size="icon-sm"
action={() => resendPopup(row.row.original.id)}
>
<ActionButton
variant="outline"
size="icon-sm"
action={() => { posthog.capture("donation_admin_resend", { id: row.row.original.id }); return resendPopup(row.row.original.id); }}
>
<MessageCircleWarning />
</ActionButton>
</SimpleTooltip>
+26 -3
View File
@@ -33,6 +33,7 @@ import {
SlipUpload,
} from "../rubgram/admin/@modal/manual/client";
import { DownloadButton } from "../rubgram/client";
import { getPostHogClient } from "@/lib/posthog-server";
const { TMN_DEST_PHONE_NUM, SASTIFY_API_PRIVKEY } = process.env as Record<
string,
@@ -103,18 +104,23 @@ export default async function () {
const { $, error } = formParse(Schema, data);
if (error) return { error };
const ph = getPostHogClient();
const distinctId = crypto.randomUUID();
return await db.transaction(async (tx): Promise<FormSubmitResult> => {
if ($.type === "pp") {
const arrayBuffer = await $.slip.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const processed = await checkSlip(buffer, $.slip.type, $.amount);
if (!processed.success)
if (!processed.success) {
ph.capture({ distinctId, event: "donation_slip_check_failed", properties: { amount: $.amount, code: processed.code, message: processed.message } });
return {
error: {
where: "slip",
what: `${processed.code}: ${processed.message}`,
},
};
}
const [check] = await tx
.insert(endgameSlips)
.values({
@@ -128,8 +134,10 @@ export default async function () {
console.log(e);
return [{ id: "conflict" }];
});
if (check.id === "conflict")
if (check.id === "conflict") {
ph.capture({ distinctId, event: "donation_slip_conflict", properties: { amount: $.amount } });
return { error: { where: "slip", what: "สลิปนี้ถูกใช้ไปแล้ว" } };
}
} else {
const res: SastifyApiResponse = await fetch(
"https://api.sastify.xyz/v1/gateway/tmn",
@@ -149,8 +157,10 @@ export default async function () {
)
.then((r) => r.json())
.catch((e) => e);
if (!res.success)
if (!res.success) {
ph.capture({ distinctId, event: "donation_payment_failed", properties: { amount: $.amount, message: res.message } });
return { error: { where: "link", what: res.message } };
}
}
const { name, amount, message, image } = $;
@@ -186,6 +196,7 @@ export default async function () {
.catch(() => "conflict");
if (res === "conflict") {
tx.rollback();
ph.capture({ distinctId, event: "donation_artifact_conflict", properties: { amount: $.amount, uid: $.uid } });
return { error: { where: "uid", what: "ไม่สามารถสร้างคิวลัดได้" } };
}
}
@@ -199,6 +210,18 @@ export default async function () {
});
else sse.donate.pub("update", null);
ph.capture({
distinctId,
event: "donation_completed",
properties: {
amount: $.amount,
payment_method: $.type,
artifact: $.artifact === "true",
has_image: !!image,
on_screen: $.amount >= 10,
},
});
return { toast: "ส่งเรียบร้อย", reset: true };
});
}
+2
View File
@@ -4,6 +4,7 @@ import { sse } from "@/lib/db/sse-endpoints";
import { fileToDataUrl } from "@/lib/utils";
import { and, asc, not, sql } from "drizzle-orm";
import { NextRequest } from "next/server";
import { getPostHogClient } from "@/lib/posthog-server";
export async function PATCH(req: NextRequest) {
const tag = Number(req.nextUrl.searchParams.get("tag") ?? "abc");
@@ -23,6 +24,7 @@ export async function PATCH(req: NextRequest) {
.limit(1)
.orderBy(asc(donations.id));
if (!havent) return;
getPostHogClient().capture({ distinctId: String(havent.id), event: "donation_resent", properties: { donation_id: havent.id, amount: havent.amount } });
sse.donate.pub("ping", {
...havent,
message: havent.message ?? "",
+5
View File
@@ -3,8 +3,11 @@
import { db } from "@/lib/db";
import { donations } from "@/lib/db/schema";
import { eq } from "drizzle-orm";
import { getPostHogClient } from "@/lib/posthog-server";
export async function markRunning(id: string) {
getPostHogClient().capture({ distinctId: id, event: "donation_widget_state_playing", properties: { donation_id: id } });
await db
.update(donations)
.set({ lastPing: new Date() })
@@ -12,5 +15,7 @@ export async function markRunning(id: string) {
}
export async function markDone(id: string) {
getPostHogClient().capture({ distinctId: id, event: "donation_widget_state_shown", properties: { donation_id: id } });
await db.update(donations).set({ sent: true }).where(eq(donations.id, id));
}
+6
View File
@@ -2,6 +2,7 @@
import Image from "next/image";
import { AnimatePresence, motion } from "motion/react";
import posthog from "posthog-js";
import { useEffect, useRef, useState } from "react";
import { VersionCheck } from "@/app/client";
import { cn } from "@/lib/utils";
@@ -47,8 +48,10 @@ export default function () {
(tts.onerror = () => r(false))
),
);
if (!ttsAvailable) posthog.capture("donation_widget_tts_failed", { amount: data.amount });
console.log("Transitioning in");
setMounted(true);
posthog.capture("donation_widget_displayed", { amount: data.amount, name_length: data.name.length, has_image: !!data.image });
console.log("Running SFX");
if (sfx.current) {
const player = sfx.current;
@@ -61,6 +64,7 @@ export default function () {
if (ttsAvailable) await new Promise((r) => (tts.onended = r));
console.log("Done");
markDone(data.id);
posthog.capture("donation_widget_animation_end", { amount: data.amount, tts_available: ttsAvailable });
await new Promise((r) => setTimeout(r, 3000));
setMounted(false);
await new Promise((r) => setTimeout(r, 1200));
@@ -77,6 +81,7 @@ export default function () {
ping: enqueue,
refresh: () => location.reload(),
});
posthog.capture("donation_widget_connected");
const interval = setInterval(async () => {
const tag = Math.floor(Math.random() * 1000);
@@ -93,6 +98,7 @@ export default function () {
} catch {
console.error("Heartbeat timed out");
setFailed((x) => x + 1);
posthog.capture("donation_widget_heartbeat_failure", { fail_count: failed + 1 });
if (failed > 12) location.reload();
}
}, 10000);