diff --git a/.gitignore b/.gitignore
index 063aec0..107a1f1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,16 +1,7 @@
node_modules/
-__pycache__/
-*.pyc
-.DS_Store
-*.swp
-*.swo
-
-# API keys — never commit real credentials
-.env
-
.pi/agent-sessions/
-
-
+calvana.tar.gz
+*.tmp
+nul
+.env
.playwright-cli/
-
-tmp/
\ No newline at end of file
diff --git a/.pi/extensions/calvana-shiplog.ts b/.pi/extensions/calvana-shiplog.ts
new file mode 100644
index 0000000..c2b8cca
--- /dev/null
+++ b/.pi/extensions/calvana-shiplog.ts
@@ -0,0 +1,719 @@
+/**
+ * Calvana Ship Log Extension
+ *
+ * Automatically tracks what you're shipping and updates the live Calvana site.
+ *
+ * Tools (LLM-callable):
+ * - calvana_ship: Add/update/complete shipping log entries
+ * - calvana_oops: Log mistakes and fixes
+ * - calvana_deploy: Push changes to the live site
+ *
+ * Commands (user):
+ * /ships — View current shipping log
+ * /ship-deploy — Force deploy to calvana.quikcue.com
+ *
+ * How it works:
+ * 1. When you work on tasks, the LLM uses calvana_ship to track progress
+ * 2. If something breaks, calvana_oops logs it
+ * 3. calvana_deploy rebuilds the /live page HTML and pushes it to the server
+ * 4. The extension auto-injects context so the LLM knows to track ships
+ *
+ * Edit the SSH/deploy config in the DEPLOY_CONFIG section below.
+ */
+
+import { StringEnum } from "@mariozechner/pi-ai";
+import type { ExtensionAPI, ExtensionContext, Theme } from "@mariozechner/pi-coding-agent";
+import { Text, truncateToWidth, matchesKey } from "@mariozechner/pi-tui";
+import { Type } from "@sinclair/typebox";
+
+// ════════════════════════════════════════════════════════════════════
+// CONFIGURATION — Edit these to change deploy target, copy, links
+// ════════════════════════════════════════════════════════════════════
+
+const DEPLOY_CONFIG = {
+ sshHost: "root@159.195.60.33",
+ sshPort: "22",
+ container: "qc-server-new",
+ sitePath: "/opt/calvana/html",
+ domain: "calvana.quikcue.com",
+};
+
+const SITE_CONFIG = {
+ title: "Calvana",
+ tagline: "I break rules. Not production.",
+ email: "omair@quikcue.com",
+ referralLine: "PS — Umar pointed me here. If this turns into a hire, I want him to get paid.",
+};
+
+// ════════════════════════════════════════════════════════════════════
+// TYPES
+// ════════════════════════════════════════════════════════════════════
+
+type ShipStatus = "planned" | "shipping" | "shipped";
+
+interface ShipEntry {
+ id: number;
+ title: string;
+ status: ShipStatus;
+ timestamp: string;
+ metric: string;
+ prLink: string;
+ deployLink: string;
+ loomLink: string;
+}
+
+interface OopsEntry {
+ id: number;
+ description: string;
+ fixTime: string;
+ commitLink: string;
+ timestamp: string;
+}
+
+interface ShipLogState {
+ ships: ShipEntry[];
+ oops: OopsEntry[];
+ nextShipId: number;
+ nextOopsId: number;
+ lastDeployed: string | null;
+}
+
+// ════════════════════════════════════════════════════════════════════
+// TOOL SCHEMAS
+// ════════════════════════════════════════════════════════════════════
+
+const ShipParams = Type.Object({
+ action: StringEnum(["add", "update", "list"] as const),
+ title: Type.Optional(Type.String({ description: "Ship title (for add)" })),
+ id: Type.Optional(Type.Number({ description: "Ship ID (for update)" })),
+ status: Type.Optional(StringEnum(["planned", "shipping", "shipped"] as const)),
+ metric: Type.Optional(Type.String({ description: "What moved — metric line" })),
+ prLink: Type.Optional(Type.String({ description: "PR link" })),
+ deployLink: Type.Optional(Type.String({ description: "Deploy link" })),
+ loomLink: Type.Optional(Type.String({ description: "Loom clip link" })),
+});
+
+const OopsParams = Type.Object({
+ action: StringEnum(["add", "list"] as const),
+ description: Type.Optional(Type.String({ description: "What broke and how it was fixed" })),
+ fixTime: Type.Optional(Type.String({ description: "Time to fix, e.g. '3 min'" })),
+ commitLink: Type.Optional(Type.String({ description: "Link to the fix commit" })),
+});
+
+const DeployParams = Type.Object({
+ dryRun: Type.Optional(Type.Boolean({ description: "If true, generate HTML but don't deploy" })),
+});
+
+// ════════════════════════════════════════════════════════════════════
+// EXTENSION
+// ════════════════════════════════════════════════════════════════════
+
+export default function (pi: ExtensionAPI) {
+ // ── State ──
+ let state: ShipLogState = {
+ ships: [],
+ oops: [],
+ nextShipId: 1,
+ nextOopsId: 1,
+ lastDeployed: null,
+ };
+
+ // ── State reconstruction from session ──
+ const reconstructState = (ctx: ExtensionContext) => {
+ state = { ships: [], oops: [], nextShipId: 1, nextOopsId: 1, lastDeployed: null };
+
+ for (const entry of ctx.sessionManager.getBranch()) {
+ if (entry.type !== "message") continue;
+ const msg = entry.message;
+ if (msg.role !== "toolResult") continue;
+ if (msg.toolName === "calvana_ship" || msg.toolName === "calvana_oops" || msg.toolName === "calvana_deploy") {
+ const details = msg.details as { state?: ShipLogState } | undefined;
+ if (details?.state) {
+ state = details.state;
+ }
+ }
+ }
+ };
+
+ pi.on("session_start", async (_event, ctx) => {
+ reconstructState(ctx);
+ if (ctx.hasUI) {
+ const theme = ctx.ui.theme;
+ const shipCount = state.ships.length;
+ const shipped = state.ships.filter(s => s.status === "shipped").length;
+ ctx.ui.setStatus("calvana", theme.fg("dim", `🚀 ${shipped}/${shipCount} shipped`));
+ }
+ });
+ pi.on("session_switch", async (_event, ctx) => reconstructState(ctx));
+ pi.on("session_fork", async (_event, ctx) => reconstructState(ctx));
+ pi.on("session_tree", async (_event, ctx) => reconstructState(ctx));
+
+ // ── Inject context so LLM knows about ship tracking ──
+ pi.on("before_agent_start", async (event, _ctx) => {
+ const shipContext = `
+[Calvana Ship Log Extension Active]
+You have access to these tools for tracking work:
+- calvana_ship: Track shipping progress (add/update/list entries)
+- calvana_oops: Log mistakes and fixes
+- calvana_deploy: Push updates to the live site at https://${DEPLOY_CONFIG.domain}/live
+
+When you START working on a task, use calvana_ship to add or update it to "shipping".
+When you COMPLETE a task, update it to "shipped" with a metric.
+If something BREAKS, log it with calvana_oops.
+After significant changes, use calvana_deploy to push updates live.
+
+Current ships: ${state.ships.length} (${state.ships.filter(s => s.status === "shipped").length} shipped)
+Current oops: ${state.oops.length}
+`;
+ return {
+ systemPrompt: event.systemPrompt + shipContext,
+ };
+ });
+
+ // ── Update status bar on turn end ──
+ pi.on("turn_end", async (_event, ctx) => {
+ if (ctx.hasUI) {
+ const theme = ctx.ui.theme;
+ const shipped = state.ships.filter(s => s.status === "shipped").length;
+ const shipping = state.ships.filter(s => s.status === "shipping").length;
+ const total = state.ships.length;
+ let statusText = `🚀 ${shipped}/${total} shipped`;
+ if (shipping > 0) statusText += ` · ${shipping} in flight`;
+ if (state.lastDeployed) statusText += ` · last deploy ${state.lastDeployed}`;
+ ctx.ui.setStatus("calvana", theme.fg("dim", statusText));
+ }
+ });
+
+ // ════════════════════════════════════════════════════════════════
+ // TOOL: calvana_ship
+ // ════════════════════════════════════════════════════════════════
+
+ pi.registerTool({
+ name: "calvana_ship",
+ label: "Ship Log",
+ description: "Track shipping progress. Actions: add (new entry), update (change status/links), list (show all). Use this whenever you start, progress, or finish a task.",
+ parameters: ShipParams,
+
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
+ const now = new Date().toISOString().replace("T", " ").slice(0, 19) + " GMT+8";
+
+ switch (params.action) {
+ case "add": {
+ if (!params.title) {
+ return {
+ content: [{ type: "text", text: "Error: title required" }],
+ details: { state: { ...state }, error: "title required" },
+ };
+ }
+ const entry: ShipEntry = {
+ id: state.nextShipId++,
+ title: params.title,
+ status: (params.status as ShipStatus) || "planned",
+ timestamp: now,
+ metric: params.metric || "—",
+ prLink: params.prLink || "#pr",
+ deployLink: params.deployLink || "#deploy",
+ loomLink: params.loomLink || "#loomclip",
+ };
+ state.ships.push(entry);
+ return {
+ content: [{ type: "text", text: `Ship #${entry.id} added: "${entry.title}" [${entry.status}]` }],
+ details: { state: { ...state, ships: [...state.ships] } },
+ };
+ }
+
+ case "update": {
+ if (params.id === undefined) {
+ return {
+ content: [{ type: "text", text: "Error: id required for update" }],
+ details: { state: { ...state }, error: "id required" },
+ };
+ }
+ const ship = state.ships.find(s => s.id === params.id);
+ if (!ship) {
+ return {
+ content: [{ type: "text", text: `Ship #${params.id} not found` }],
+ details: { state: { ...state }, error: `#${params.id} not found` },
+ };
+ }
+ if (params.status) ship.status = params.status as ShipStatus;
+ if (params.metric) ship.metric = params.metric;
+ if (params.prLink) ship.prLink = params.prLink;
+ if (params.deployLink) ship.deployLink = params.deployLink;
+ if (params.loomLink) ship.loomLink = params.loomLink;
+ ship.timestamp = now;
+ return {
+ content: [{ type: "text", text: `Ship #${ship.id} updated: "${ship.title}" [${ship.status}]` }],
+ details: { state: { ...state, ships: [...state.ships] } },
+ };
+ }
+
+ case "list": {
+ if (state.ships.length === 0) {
+ return {
+ content: [{ type: "text", text: "No ships logged yet." }],
+ details: { state: { ...state } },
+ };
+ }
+ const lines = state.ships.map(s =>
+ `#${s.id} [${s.status.toUpperCase()}] ${s.title} (${s.timestamp}) — ${s.metric}`
+ );
+ return {
+ content: [{ type: "text", text: lines.join("\n") }],
+ details: { state: { ...state } },
+ };
+ }
+
+ default:
+ return {
+ content: [{ type: "text", text: `Unknown action: ${params.action}` }],
+ details: { state: { ...state } },
+ };
+ }
+ },
+
+ renderCall(args, theme) {
+ let text = theme.fg("toolTitle", theme.bold("🚀 ship "));
+ text += theme.fg("muted", args.action || "");
+ if (args.title) text += " " + theme.fg("dim", `"${args.title}"`);
+ if (args.id !== undefined) text += " " + theme.fg("accent", `#${args.id}`);
+ if (args.status) text += " → " + theme.fg("accent", args.status);
+ return new Text(text, 0, 0);
+ },
+
+ renderResult(result, { expanded }, theme) {
+ const details = result.details as { state?: ShipLogState; error?: string } | undefined;
+ if (details?.error) return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0);
+
+ const st = details?.state;
+ if (!st || st.ships.length === 0) return new Text(theme.fg("dim", "No ships"), 0, 0);
+
+ const shipped = st.ships.filter(s => s.status === "shipped").length;
+ const total = st.ships.length;
+ let text = theme.fg("success", `${shipped}/${total} shipped`);
+
+ if (expanded) {
+ for (const s of st.ships) {
+ const badge = s.status === "shipped" ? theme.fg("success", "✓")
+ : s.status === "shipping" ? theme.fg("warning", "●")
+ : theme.fg("dim", "○");
+ text += `\n ${badge} ${theme.fg("accent", `#${s.id}`)} ${theme.fg("muted", s.title)}`;
+ }
+ }
+ return new Text(text, 0, 0);
+ },
+ });
+
+ // ════════════════════════════════════════════════════════════════
+ // TOOL: calvana_oops
+ // ════════════════════════════════════════════════════════════════
+
+ pi.registerTool({
+ name: "calvana_oops",
+ label: "Oops Log",
+ description: "Log mistakes and fixes. Actions: add (new oops entry), list (show all). Use when something breaks during a task.",
+ parameters: OopsParams,
+
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
+ const now = new Date().toISOString().replace("T", " ").slice(0, 19) + " GMT+8";
+
+ switch (params.action) {
+ case "add": {
+ if (!params.description) {
+ return {
+ content: [{ type: "text", text: "Error: description required" }],
+ details: { state: { ...state }, error: "description required" },
+ };
+ }
+ const entry: OopsEntry = {
+ id: state.nextOopsId++,
+ description: params.description,
+ fixTime: params.fixTime || "—",
+ commitLink: params.commitLink || "#commit",
+ timestamp: now,
+ };
+ state.oops.push(entry);
+ return {
+ content: [{ type: "text", text: `Oops #${entry.id}: "${entry.description}" (fixed in ${entry.fixTime})` }],
+ details: { state: { ...state, oops: [...state.oops] } },
+ };
+ }
+
+ case "list": {
+ if (state.oops.length === 0) {
+ return {
+ content: [{ type: "text", text: "No oops entries. Clean run so far." }],
+ details: { state: { ...state } },
+ };
+ }
+ const lines = state.oops.map(o =>
+ `#${o.id} ${o.description} — fixed in ${o.fixTime}`
+ );
+ return {
+ content: [{ type: "text", text: lines.join("\n") }],
+ details: { state: { ...state } },
+ };
+ }
+
+ default:
+ return {
+ content: [{ type: "text", text: `Unknown action: ${params.action}` }],
+ details: { state: { ...state } },
+ };
+ }
+ },
+
+ renderCall(args, theme) {
+ let text = theme.fg("toolTitle", theme.bold("💥 oops "));
+ text += theme.fg("muted", args.action || "");
+ if (args.description) text += " " + theme.fg("dim", `"${args.description}"`);
+ return new Text(text, 0, 0);
+ },
+
+ renderResult(result, _options, theme) {
+ const details = result.details as { state?: ShipLogState; error?: string } | undefined;
+ if (details?.error) return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0);
+ const text = result.content[0];
+ return new Text(theme.fg("warning", text?.type === "text" ? text.text : ""), 0, 0);
+ },
+ });
+
+ // ════════════════════════════════════════════════════════════════
+ // TOOL: calvana_deploy
+ // ════════════════════════════════════════════════════════════════
+
+ pi.registerTool({
+ name: "calvana_deploy",
+ label: "Deploy Calvana",
+ description: `Regenerate the /live page with current ship log and deploy to https://${DEPLOY_CONFIG.domain}. Call this after adding/updating ships or oops entries to push changes live.`,
+ parameters: DeployParams,
+
+ async execute(_toolCallId, params, signal, onUpdate, _ctx) {
+ onUpdate?.({ content: [{ type: "text", text: "Generating HTML..." }] });
+
+ const liveHtml = generateLivePageHtml(state);
+
+ if (params.dryRun) {
+ return {
+ content: [{ type: "text", text: `Dry run — generated ${liveHtml.length} bytes of HTML.\n\n${liveHtml.slice(0, 500)}...` }],
+ details: { state: { ...state }, dryRun: true },
+ };
+ }
+
+ onUpdate?.({ content: [{ type: "text", text: "Deploying to server..." }] });
+
+ try {
+ // Write HTML to server via SSH + incus exec
+ const escapedHtml = liveHtml.replace(/'/g, "'\\''");
+ const sshCmd = `ssh -o ConnectTimeout=10 -p ${DEPLOY_CONFIG.sshPort} ${DEPLOY_CONFIG.sshHost}`;
+ const writeCmd = `${sshCmd} "incus exec ${DEPLOY_CONFIG.container} -- bash -c 'cat > ${DEPLOY_CONFIG.sitePath}/live/index.html << '\\''HTMLEOF'\\''
+${liveHtml}
+HTMLEOF
+'"`;
+
+ // Use base64 to avoid all escaping nightmares
+ const b64Html = Buffer.from(liveHtml).toString("base64");
+ const deployResult = await pi.exec("bash", ["-c",
+ `ssh -o ConnectTimeout=10 -p ${DEPLOY_CONFIG.sshPort} ${DEPLOY_CONFIG.sshHost} "incus exec ${DEPLOY_CONFIG.container} -- bash -c 'echo ${b64Html} | base64 -d > ${DEPLOY_CONFIG.sitePath}/live/index.html'"`
+ ], { signal, timeout: 30000 });
+
+ if (deployResult.code !== 0) {
+ return {
+ content: [{ type: "text", text: `Deploy failed: ${deployResult.stderr}` }],
+ details: { state: { ...state }, error: deployResult.stderr },
+ isError: true,
+ };
+ }
+
+ // Rebuild and update docker service
+ const rebuildResult = await pi.exec("bash", ["-c",
+ `ssh -o ConnectTimeout=10 -p ${DEPLOY_CONFIG.sshPort} ${DEPLOY_CONFIG.sshHost} "incus exec ${DEPLOY_CONFIG.container} -- bash -c 'cd /opt/calvana && docker build -t calvana:latest . 2>&1 | tail -2 && docker service update --force calvana 2>&1 | tail -2'"`
+ ], { signal, timeout: 60000 });
+
+ const now = new Date().toISOString().replace("T", " ").slice(0, 19);
+ state.lastDeployed = now;
+
+ return {
+ content: [{ type: "text", text: `✓ Deployed to https://${DEPLOY_CONFIG.domain}/live\n${rebuildResult.stdout}` }],
+ details: { state: { ...state, lastDeployed: now } },
+ };
+ } catch (err: any) {
+ return {
+ content: [{ type: "text", text: `Deploy error: ${err.message}` }],
+ details: { state: { ...state }, error: err.message },
+ isError: true,
+ };
+ }
+ },
+
+ renderCall(_args, theme) {
+ return new Text(theme.fg("toolTitle", theme.bold("🌐 deploy calvana")), 0, 0);
+ },
+
+ renderResult(result, _options, theme) {
+ const details = result.details as { error?: string } | undefined;
+ if (details?.error) return new Text(theme.fg("error", `✗ ${details.error}`), 0, 0);
+ return new Text(theme.fg("success", `✓ Live at https://${DEPLOY_CONFIG.domain}/live`), 0, 0);
+ },
+ });
+
+ // ════════════════════════════════════════════════════════════════
+ // COMMAND: /ships
+ // ════════════════════════════════════════════════════════════════
+
+ pi.registerCommand("ships", {
+ description: "View current Calvana shipping log",
+ handler: async (_args, ctx) => {
+ if (!ctx.hasUI) {
+ ctx.ui.notify("Requires interactive mode", "error");
+ return;
+ }
+
+ await ctx.ui.custom((_tui, theme, _kb, done) => {
+ return new ShipLogComponent(state, theme, () => done());
+ });
+ },
+ });
+
+ // ════════════════════════════════════════════════════════════════
+ // COMMAND: /ship-deploy
+ // ════════════════════════════════════════════════════════════════
+
+ pi.registerCommand("ship-deploy", {
+ description: "Force deploy the Calvana site with current ship log",
+ handler: async (_args, ctx) => {
+ const ok = await ctx.ui.confirm("Deploy?", `Push ship log to https://${DEPLOY_CONFIG.domain}/live?`);
+ if (!ok) return;
+
+ // Queue a deploy via the LLM
+ pi.sendUserMessage("Use calvana_deploy to push the current ship log to the live site.", { deliverAs: "followUp" });
+ },
+ });
+}
+
+// ════════════════════════════════════════════════════════════════════
+// UI COMPONENT: /ships viewer
+// ════════════════════════════════════════════════════════════════════
+
+class ShipLogComponent {
+ private state: ShipLogState;
+ private theme: Theme;
+ private onClose: () => void;
+ private cachedWidth?: number;
+ private cachedLines?: string[];
+
+ constructor(state: ShipLogState, theme: Theme, onClose: () => void) {
+ this.state = state;
+ this.theme = theme;
+ this.onClose = onClose;
+ }
+
+ handleInput(data: string): void {
+ if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
+ this.onClose();
+ }
+ }
+
+ render(width: number): string[] {
+ if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
+
+ const lines: string[] = [];
+ const th = this.theme;
+
+ lines.push("");
+ lines.push(truncateToWidth(
+ th.fg("borderMuted", "─".repeat(3)) +
+ th.fg("accent", " 🚀 Calvana Ship Log ") +
+ th.fg("borderMuted", "─".repeat(Math.max(0, width - 26))),
+ width
+ ));
+ lines.push("");
+
+ // Ships
+ if (this.state.ships.length === 0) {
+ lines.push(truncateToWidth(` ${th.fg("dim", "No ships yet.")}`, width));
+ } else {
+ const shipped = this.state.ships.filter(s => s.status === "shipped").length;
+ lines.push(truncateToWidth(
+ ` ${th.fg("muted", `${shipped}/${this.state.ships.length} shipped`)}`,
+ width
+ ));
+ lines.push("");
+
+ for (const s of this.state.ships) {
+ const badge = s.status === "shipped" ? th.fg("success", "✓ SHIPPED ")
+ : s.status === "shipping" ? th.fg("warning", "● SHIPPING")
+ : th.fg("dim", "○ PLANNED ");
+ lines.push(truncateToWidth(
+ ` ${badge} ${th.fg("accent", `#${s.id}`)} ${th.fg("text", s.title)}`,
+ width
+ ));
+ lines.push(truncateToWidth(
+ ` ${th.fg("dim", s.timestamp)} · ${th.fg("dim", s.metric)}`,
+ width
+ ));
+ }
+ }
+
+ // Oops
+ if (this.state.oops.length > 0) {
+ lines.push("");
+ lines.push(truncateToWidth(` ${th.fg("warning", "💥 Oops Log")}`, width));
+ for (const o of this.state.oops) {
+ lines.push(truncateToWidth(
+ ` ${th.fg("error", "─")} ${th.fg("muted", o.description)} ${th.fg("dim", `(${o.fixTime})`)}`,
+ width
+ ));
+ }
+ }
+
+ lines.push("");
+ if (this.state.lastDeployed) {
+ lines.push(truncateToWidth(` ${th.fg("dim", `Last deployed: ${this.state.lastDeployed}`)}`, width));
+ }
+ lines.push(truncateToWidth(` ${th.fg("dim", "Press Escape to close")}`, width));
+ lines.push("");
+
+ this.cachedWidth = width;
+ this.cachedLines = lines;
+ return lines;
+ }
+
+ invalidate(): void {
+ this.cachedWidth = undefined;
+ this.cachedLines = undefined;
+ }
+}
+
+// ════════════════════════════════════════════════════════════════════
+// HTML GENERATOR — Builds the /live page from current state
+// ════════════════════════════════════════════════════════════════════
+
+function generateLivePageHtml(state: ShipLogState): string {
+ const now = new Date().toISOString();
+
+ const shipCards = state.ships.map(s => {
+ const badgeClass = s.status === "shipped" ? "badge-shipped"
+ : s.status === "shipping" ? "badge-shipping"
+ : "badge-planned";
+ const badgeLabel = s.status.charAt(0).toUpperCase() + s.status.slice(1);
+ const titleSuffix = s.status === "shipped" ? " ✓" : "";
+
+ return `
+
+
⏱ ${escapeHtml(s.timestamp)}
+
What moved: ${escapeHtml(s.metric)}
+
+
`;
+ }).join("\n");
+
+ const oopsEntries = state.oops.map(o => {
+ return `
+
${escapeHtml(o.description)}${o.fixTime !== "—" ? ` Fixed in ${escapeHtml(o.fixTime)}.` : ""}
+
→ commit
+
`;
+ }).join("\n");
+
+ // If no ships yet, show placeholder
+ const shipsSection = state.ships.length > 0 ? shipCards : `
+
+
⏱ —
+
What moved: —
+
`;
+
+ const oopsSection = state.oops.length > 0 ? oopsEntries : `
+
Nothing broken yet. Give it time.
+
→ waiting
+
`;
+
+ return `
+
+
+
+
+ Calvana — Live Shipping Log
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Live Shipping Log
+ Intentional chaos. Full receipts.
+
+
+ Today's Ships
+
+${shipsSection}
+
+
+
+
+
+
+
Rules I broke today
+
+ Didn't ask permission
+ Didn't wait for alignment
+ Didn't write a PRD
+ Didn't submit a normal application
+
+
+
+
Rules I refuse to break
+
+ No silent failures
+ No unbounded AI spend
+ No hallucinations shipped to users
+ No deploy without rollback path
+
+
+
+
+
+
+ Oops Log
+ If it's not here, I haven't broken it yet.
+
+${oopsSection}
+
+
+
+
+
+ Last updated: ${now}
+
+
+
+`;
+}
+
+function escapeHtml(str: string): string {
+ return str
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+}
diff --git a/CLAUDE.md b/CLAUDE.md
index 8d816f2..7f119f9 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,8 +1,5 @@
# Pi vs CC — Extension Playground
-## Infrastructure Access
-**Always read `.pi/infra.md` at the start of every session** — it contains live credentials and connection details.
-
Pi Coding Agent extension examples and experiments.
## Tooling
diff --git a/calvana-build/Dockerfile b/calvana-build/Dockerfile
new file mode 100644
index 0000000..040c466
--- /dev/null
+++ b/calvana-build/Dockerfile
@@ -0,0 +1,4 @@
+FROM nginx:alpine
+COPY nginx.conf /etc/nginx/conf.d/default.conf
+COPY html/ /usr/share/nginx/html/
+EXPOSE 80
diff --git a/calvana-build/html/404.html b/calvana-build/html/404.html
new file mode 100644
index 0000000..10b74f8
--- /dev/null
+++ b/calvana-build/html/404.html
@@ -0,0 +1,26 @@
+
+
+
+
+
+ 404 — Calvana
+
+
+
+
+
+
+
+ 404
+ This page doesn't exist yet. But give me 10 minutes.
+ ← Back to manifesto
+
+
+
diff --git a/calvana-build/html/css/style.css b/calvana-build/html/css/style.css
new file mode 100644
index 0000000..cecc4de
--- /dev/null
+++ b/calvana-build/html/css/style.css
@@ -0,0 +1,104 @@
+:root {
+ --bg: #0a0a0a;
+ --bg-card: #111;
+ --bg-card-hover: #161616;
+ --text: #e5e5e5;
+ --text-muted: #666;
+ --text-dim: #444;
+ --accent: #00ff9f;
+ --accent-dim: #00cc7f;
+ --cta: #ff6b35;
+ --cta-hover: #ff8c5a;
+ --yellow: #ffd93d;
+ --red: #ff4757;
+ --border: #1a1a1a;
+ --border-light: #222;
+ --font-mono: 'SF Mono','Fira Code','JetBrains Mono','Cascadia Code',monospace;
+ --font-sans: -apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;
+ --max-w: 780px;
+}
+*,*::before,*::after{margin:0;padding:0;box-sizing:border-box}
+html{font-size:16px;scroll-behavior:smooth}
+body{background:var(--bg);color:var(--text);font-family:var(--font-sans);line-height:1.65;min-height:100vh;-webkit-font-smoothing:antialiased}
+
+nav{position:sticky;top:0;z-index:100;background:rgba(10,10,10,0.85);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border-bottom:1px solid var(--border);padding:.75rem 1.5rem}
+nav .nav-inner{max-width:var(--max-w);margin:0 auto;display:flex;align-items:center;justify-content:space-between;gap:1rem}
+nav .logo{font-family:var(--font-mono);font-size:.95rem;font-weight:700;color:var(--accent);text-decoration:none;letter-spacing:-.02em}
+nav .logo span{color:var(--text-muted);font-weight:400}
+nav .nav-links{display:flex;gap:.25rem;list-style:none}
+nav .nav-links a{font-family:var(--font-mono);font-size:.8rem;color:var(--text-muted);text-decoration:none;padding:.35rem .65rem;border-radius:6px;transition:all .15s ease}
+nav .nav-links a:hover,nav .nav-links a.active{color:var(--text);background:var(--bg-card)}
+
+.page{max-width:var(--max-w);margin:0 auto;padding:3.5rem 1.5rem 5rem}
+
+.hero-title{font-family:var(--font-mono);font-size:clamp(2rem,6vw,3.2rem);font-weight:800;line-height:1.15;letter-spacing:-.03em;color:var(--text);margin-bottom:1rem}
+.hero-title .accent{color:var(--accent)}
+h2{font-family:var(--font-mono);font-size:1.35rem;font-weight:700;color:var(--text);margin-bottom:1rem;letter-spacing:-.02em}
+h3{font-family:var(--font-mono);font-size:1rem;font-weight:600;color:var(--text);margin-bottom:.5rem}
+.subtitle{font-size:1.05rem;color:var(--text-muted);line-height:1.7;max-width:600px}
+.section{margin-top:3.5rem}
+
+.btn-row{display:flex;flex-wrap:wrap;gap:.75rem;margin-top:2rem}
+.btn{display:inline-flex;align-items:center;gap:.4rem;font-family:var(--font-mono);font-size:.85rem;font-weight:600;padding:.65rem 1.25rem;border-radius:8px;text-decoration:none;transition:all .15s ease;cursor:pointer;border:none}
+.btn-primary{background:var(--accent);color:#0a0a0a}
+.btn-primary:hover{background:#33ffb3;transform:translateY(-1px)}
+.btn-cta{background:var(--cta);color:#fff}
+.btn-cta:hover{background:var(--cta-hover);transform:translateY(-1px)}
+.btn-outline{background:transparent;color:var(--text-muted);border:1px solid var(--border-light)}
+.btn-outline:hover{color:var(--text);border-color:var(--text-muted);transform:translateY(-1px)}
+
+.card-grid{display:grid;grid-template-columns:1fr;gap:1rem;margin-top:1rem}
+.card{background:var(--bg-card);border:1px solid var(--border);border-radius:10px;padding:1.25rem;transition:border-color .15s ease}
+.card:hover{border-color:var(--border-light)}
+.card-header{display:flex;align-items:flex-start;justify-content:space-between;gap:.75rem;margin-bottom:.6rem}
+.card-title{font-family:var(--font-mono);font-size:.9rem;font-weight:600;line-height:1.4}
+.card-meta{font-size:.78rem;color:var(--text-muted);margin-bottom:.5rem}
+.card-links{display:flex;gap:.75rem;margin-top:.6rem}
+.card-links a{font-family:var(--font-mono);font-size:.75rem;color:var(--accent-dim);text-decoration:none}
+.card-links a:hover{color:var(--accent);text-decoration:underline}
+
+.badge{font-family:var(--font-mono);font-size:.7rem;font-weight:600;padding:.2rem .55rem;border-radius:99px;white-space:nowrap;flex-shrink:0}
+.badge-planned{color:var(--text-muted);border:1px solid var(--border-light)}
+.badge-shipping{color:#0a0a0a;background:var(--yellow)}
+.badge-shipped{color:#0a0a0a;background:var(--accent)}
+
+.two-col{display:grid;grid-template-columns:1fr 1fr;gap:1.5rem;margin-top:1rem}
+.col{background:var(--bg-card);border:1px solid var(--border);border-radius:10px;padding:1.25rem}
+.col h3{margin-bottom:.75rem}
+.col ul{list-style:none;display:flex;flex-direction:column;gap:.5rem}
+.col ul li{font-size:.88rem;color:var(--text-muted);padding-left:1.25rem;position:relative}
+.col ul li::before{content:'';position:absolute;left:0;top:.55rem;width:6px;height:6px;border-radius:50%}
+.col-broke ul li::before{background:var(--cta)}
+.col-kept ul li::before{background:var(--accent)}
+
+.oops-log{margin-top:1rem;display:flex;flex-direction:column;gap:.6rem}
+.oops-entry{display:flex;align-items:center;justify-content:space-between;gap:1rem;font-size:.85rem;color:var(--text-muted);padding:.75rem 1rem;background:var(--bg-card);border:1px solid var(--border);border-radius:8px;border-left:3px solid var(--red)}
+.oops-entry a{font-family:var(--font-mono);font-size:.75rem;color:var(--accent-dim);text-decoration:none;white-space:nowrap}
+.oops-entry a:hover{color:var(--accent)}
+
+.manifesto-para{font-size:1.05rem;color:var(--text-muted);line-height:1.75;margin-top:3.5rem;max-width:620px;border-left:3px solid var(--accent);padding-left:1rem}
+
+.hire-ctas{display:flex;flex-direction:column;gap:1rem;margin-top:2rem;max-width:480px}
+.hire-ctas .btn{justify-content:center;padding:1rem 1.5rem;font-size:.9rem}
+.hire-note{margin-top:3.5rem;font-size:.9rem;color:var(--text-muted);line-height:1.7}
+.hire-referral{margin-top:2rem;font-size:.85rem;color:var(--text-dim);font-style:italic}
+
+footer{margin-top:5rem;padding-top:2rem;border-top:1px solid var(--border)}
+footer p{font-family:var(--font-mono);font-size:.78rem;color:var(--text-dim)}
+.footer-tagline{color:var(--text-muted)!important;font-style:italic}
+.metric{font-family:var(--font-mono);font-size:.78rem;color:var(--text-dim)}
+
+@media(max-width:640px){
+ .page{padding:2rem 1rem 3rem}
+ .hero-title{font-size:clamp(1.7rem,7vw,2.4rem)}
+ .btn-row{flex-direction:column}.btn-row .btn{width:100%;justify-content:center}
+ .two-col{grid-template-columns:1fr}
+ nav .nav-links a{font-size:.75rem;padding:.3rem .5rem}
+ .card-header{flex-direction:column;gap:.4rem}
+ .oops-entry{flex-direction:column;align-items:flex-start;gap:.4rem}
+ .hire-ctas{max-width:100%}
+}
+@media(max-width:380px){
+ nav .logo span{display:none}
+ nav .nav-links{gap:0}
+}
diff --git a/calvana-build/html/hire/index.html b/calvana-build/html/hire/index.html
new file mode 100644
index 0000000..7b93ff8
--- /dev/null
+++ b/calvana-build/html/hire/index.html
@@ -0,0 +1,43 @@
+
+
+
+
+
+ Calvana — Hire
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ If you're reading this, you already know.
+
+
+ If you want safe hands, hire safe people.
+ If you want velocity with control — let's talk.
+
+ PS — Umar pointed me here. If this turns into a hire, I want him to get paid.
+
+
+
+
diff --git a/calvana-build/html/index.html b/calvana-build/html/index.html
new file mode 100644
index 0000000..f507eb6
--- /dev/null
+++ b/calvana-build/html/index.html
@@ -0,0 +1,5 @@
+
+
+Calvana
+Redirecting to /manifesto …
+
diff --git a/calvana-build/html/live/index.html b/calvana-build/html/live/index.html
new file mode 100644
index 0000000..f198905
--- /dev/null
+++ b/calvana-build/html/live/index.html
@@ -0,0 +1,121 @@
+
+
+
+
+
+ Calvana — Live Shipping Log
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Live Shipping Log
+ Intentional chaos. Full receipts.
+
+
+ Today's Ships
+
+
+
+
⏱ —
+
What moved: —
+
+
+
+
+
⏱ —
+
What moved: —
+
+
+
+
+
⏱ —
+
What moved: —
+
+
+
+
+
⏱ 2026-03-02 14:00 GMT+8
+
What moved: 0 → live in one session
+
+
+
+
+
+
+
+
+
Rules I broke today
+
+ Didn't ask permission
+ Didn't wait for alignment
+ Didn't write a PRD
+ Didn't submit a normal application
+
+
+
+
Rules I refuse to break
+
+ No silent failures
+ No unbounded AI spend
+ No hallucinations shipped to users
+ No deploy without rollback path
+
+
+
+
+
+
+ Oops Log
+ If it's not here, I haven't broken it yet.
+
+
+
Traefik label typo → 404 on first deploy. Fixed in 3 min.
+
→ commit
+
+
+
CSS grid overflow on mobile. Caught in preview, fixed before push.
+
→ commit
+
+
+
Forgot meta viewport tag. Pinch-zoom chaos. Fixed in 90 seconds.
+
→ commit
+
+
+
+
+
+
+
+
diff --git a/calvana-build/html/manifesto/index.html b/calvana-build/html/manifesto/index.html
new file mode 100644
index 0000000..8683251
--- /dev/null
+++ b/calvana-build/html/manifesto/index.html
@@ -0,0 +1,47 @@
+
+
+
+
+
+ Calvana — I don't apply. I deploy.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ I don't apply.I deploy.
+
+ You're hiring engineers. I'm showing you what changes when you hire an engine.
+ This application is a product. Built for you. Right now.
+
+
+
+ Most applications prove the past. I'm proving the next 7 days.
+ Build → test → ship → observe → iterate… at speed, without breaking reality.
+
+
+
+
+
diff --git a/calvana-build/nginx.conf b/calvana-build/nginx.conf
new file mode 100644
index 0000000..4f10346
--- /dev/null
+++ b/calvana-build/nginx.conf
@@ -0,0 +1,28 @@
+server {
+ listen 80;
+ server_name calvana.quikcue.com;
+ root /usr/share/nginx/html;
+ index index.html;
+
+ # Fix: behind reverse proxy, use relative redirects
+ absolute_redirect off;
+
+ location / {
+ try_files $uri $uri/ =404;
+ }
+
+ location /css/ {
+ expires 1h;
+ add_header Cache-Control "public, immutable";
+ }
+
+ add_header X-Frame-Options "SAMEORIGIN" always;
+ add_header X-Content-Type-Options "nosniff" always;
+ add_header Referrer-Policy "strict-origin-when-cross-origin" always;
+
+ gzip on;
+ gzip_types text/html text/css application/javascript text/plain;
+ gzip_min_length 256;
+
+ error_page 404 /404.html;
+}
diff --git a/justfile b/justfile
index 743cf2a..0a4160f 100644
--- a/justfile
+++ b/justfile
@@ -1,4 +1,5 @@
set dotenv-load := true
+set shell := ["pwsh", "-NoProfile", "-Command"]
default:
@just --list
@@ -87,20 +88,16 @@ ext-theme-cycler:
# Open pi with one or more stacked extensions in a new terminal: just open minimal tool-counter
open +exts:
- #!/usr/bin/env bash
- args=""
- for ext in {{exts}}; do
- args="$args -e extensions/$ext.ts"
- done
- cmd="cd '{{justfile_directory()}}' && pi$args"
- escaped="${cmd//\\/\\\\}"
- escaped="${escaped//\"/\\\"}"
- osascript -e "tell application \"Terminal\" to do script \"$escaped\""
+ #!/usr/bin/env pwsh
+ $args_str = ""
+ foreach ($ext in "{{exts}}".Split(" ")) { $args_str += " -e extensions/$ext.ts" }
+ $cmd = "cd '{{justfile_directory()}}'; pi$args_str"
+ Start-Process wt -ArgumentList "pwsh", "-NoExit", "-Command", $cmd
# Open every extension in its own terminal window
all:
just open pi
- just open pure-focus
+ just open pure-focus
just open minimal theme-cycler
just open cross-agent minimal
just open purpose-gate minimal
diff --git a/pledge-now-pay-later/public/.gitkeep b/pledge-now-pay-later/public/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/pledge-now-pay-later/src/app/api/events/[id]/qr/route.ts b/pledge-now-pay-later/src/app/api/events/[id]/qr/route.ts
index 68f008d..e670977 100644
--- a/pledge-now-pay-later/src/app/api/events/[id]/qr/route.ts
+++ b/pledge-now-pay-later/src/app/api/events/[id]/qr/route.ts
@@ -51,6 +51,9 @@ export async function GET(
scanCount: s.scanCount,
pledgeCount: s._count.pledges,
totalPledged: s.pledges.reduce((sum: number, p: QrPledge) => sum + p.amountPence, 0),
+ totalCollected: s.pledges
+ .filter((p: QrPledge) => p.status === "paid")
+ .reduce((sum: number, p: QrPledge) => sum + p.amountPence, 0),
createdAt: s.createdAt,
}))
)
diff --git a/pledge-now-pay-later/src/app/api/events/public/[slug]/route.ts b/pledge-now-pay-later/src/app/api/events/public/[slug]/route.ts
new file mode 100644
index 0000000..7797ab6
--- /dev/null
+++ b/pledge-now-pay-later/src/app/api/events/public/[slug]/route.ts
@@ -0,0 +1,80 @@
+import { NextResponse } from "next/server"
+import prisma from "@/lib/prisma"
+
+export async function GET(
+ _req: Request,
+ { params }: { params: Promise<{ slug: string }> }
+) {
+ try {
+ const { slug } = await params
+ if (!prisma) {
+ return NextResponse.json({ error: "Database not configured" }, { status: 503 })
+ }
+
+ // Find event by slug (try both org-scoped and plain slug)
+ const event = await prisma.event.findFirst({
+ where: {
+ OR: [
+ { slug },
+ { slug: { contains: slug } },
+ ],
+ status: { in: ["active", "closed"] },
+ },
+ include: {
+ organization: { select: { name: true } },
+ qrSources: {
+ select: { code: true, label: true, volunteerName: true },
+ orderBy: { createdAt: "asc" },
+ },
+ pledges: {
+ select: {
+ donorName: true,
+ amountPence: true,
+ status: true,
+ giftAid: true,
+ createdAt: true,
+ },
+ orderBy: { createdAt: "desc" },
+ },
+ },
+ })
+
+ if (!event) {
+ return NextResponse.json({ error: "Event not found" }, { status: 404 })
+ }
+
+ const pledges = event.pledges
+ const totalPledged = pledges.reduce((s, p) => s + p.amountPence, 0)
+ const totalPaid = pledges
+ .filter((p) => p.status === "paid")
+ .reduce((s, p) => s + p.amountPence, 0)
+ const giftAidCount = pledges.filter((p) => p.giftAid).length
+
+ return NextResponse.json({
+ id: event.id,
+ name: event.name,
+ description: event.description,
+ eventDate: event.eventDate,
+ location: event.location,
+ goalAmount: event.goalAmount,
+ organizationName: event.organization.name,
+ stats: {
+ pledgeCount: pledges.length,
+ totalPledged,
+ totalPaid,
+ giftAidCount,
+ avgPledge: pledges.length > 0 ? Math.round(totalPledged / pledges.length) : 0,
+ },
+ recentPledges: pledges.slice(0, 10).map((p) => ({
+ donorName: p.donorName ? p.donorName.split(" ")[0] + " " + (p.donorName.split(" ")[1]?.[0] || "") + "." : null,
+ amountPence: p.amountPence,
+ createdAt: p.createdAt,
+ giftAid: p.giftAid,
+ })),
+ qrCodes: event.qrSources,
+ })
+ } catch (error) {
+ console.error("Public event error:", error)
+ return NextResponse.json({ error: "Internal error" }, { status: 500 })
+ }
+}
diff --git a/pledge-now-pay-later/src/app/api/exports/crm-pack/route.ts b/pledge-now-pay-later/src/app/api/exports/crm-pack/route.ts
index 6a04421..19eb374 100644
--- a/pledge-now-pay-later/src/app/api/exports/crm-pack/route.ts
+++ b/pledge-now-pay-later/src/app/api/exports/crm-pack/route.ts
@@ -30,11 +30,13 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: "Organization not found" }, { status: 404 })
}
const eventId = request.nextUrl.searchParams.get("eventId")
+ const giftAidOnly = request.nextUrl.searchParams.get("giftAidOnly") === "true"
const pledges = await prisma.pledge.findMany({
where: {
organizationId: orgId,
...(eventId ? { eventId } : {}),
+ ...(giftAidOnly ? { giftAid: true } : {}),
},
include: {
event: { select: { name: true } },
@@ -65,10 +67,14 @@ export async function GET(request: NextRequest) {
const csv = formatCrmExportCsv(rows)
+ const fileName = giftAidOnly
+ ? `gift-aid-declarations-${new Date().toISOString().slice(0, 10)}.csv`
+ : `crm-export-${new Date().toISOString().slice(0, 10)}.csv`
+
return new NextResponse(csv, {
headers: {
"Content-Type": "text/csv",
- "Content-Disposition": `attachment; filename="crm-export-${new Date().toISOString().slice(0, 10)}.csv"`,
+ "Content-Disposition": `attachment; filename="${fileName}"`,
},
})
} catch (error) {
diff --git a/pledge-now-pay-later/src/app/api/qr/[token]/volunteer/route.ts b/pledge-now-pay-later/src/app/api/qr/[token]/volunteer/route.ts
new file mode 100644
index 0000000..265e6f5
--- /dev/null
+++ b/pledge-now-pay-later/src/app/api/qr/[token]/volunteer/route.ts
@@ -0,0 +1,63 @@
+import { NextResponse } from "next/server"
+import { prisma } from "@/lib/prisma"
+
+export async function GET(
+ _req: Request,
+ { params }: { params: Promise<{ token: string }> }
+) {
+ const { token } = await params
+ const db = prisma
+
+ const qrSource = await db.qrSource.findUnique({
+ where: { code: token },
+ include: {
+ event: {
+ include: { organization: { select: { name: true } } },
+ },
+ pledges: {
+ orderBy: { createdAt: "desc" },
+ select: {
+ id: true,
+ reference: true,
+ amountPence: true,
+ status: true,
+ donorName: true,
+ giftAid: true,
+ createdAt: true,
+ },
+ },
+ },
+ })
+
+ if (!qrSource) {
+ return NextResponse.json({ error: "QR code not found" }, { status: 404 })
+ }
+
+ const pledges = qrSource.pledges
+ const totalPledgedPence = pledges.reduce((s, p) => s + p.amountPence, 0)
+ const totalPaidPence = pledges
+ .filter((p) => p.status === "paid")
+ .reduce((s, p) => s + p.amountPence, 0)
+
+ return NextResponse.json({
+ qrSource: {
+ label: qrSource.label,
+ volunteerName: qrSource.volunteerName,
+ code: qrSource.code,
+ scanCount: qrSource.scanCount,
+ },
+ event: {
+ name: qrSource.event.name,
+ organizationName: qrSource.event.organization.name,
+ },
+ pledges,
+ stats: {
+ totalPledges: pledges.length,
+ totalPledgedPence,
+ totalPaidPence,
+ conversionRate: qrSource.scanCount > 0
+ ? Math.round((pledges.length / qrSource.scanCount) * 100)
+ : 0,
+ },
+ })
+}
diff --git a/pledge-now-pay-later/src/app/dashboard/events/[id]/leaderboard/page.tsx b/pledge-now-pay-later/src/app/dashboard/events/[id]/leaderboard/page.tsx
new file mode 100644
index 0000000..d2d33ee
--- /dev/null
+++ b/pledge-now-pay-later/src/app/dashboard/events/[id]/leaderboard/page.tsx
@@ -0,0 +1,114 @@
+"use client"
+
+import { useState, useEffect } from "react"
+import { useParams } from "next/navigation"
+import { Card, CardContent } from "@/components/ui/card"
+import { Badge } from "@/components/ui/badge"
+import { Trophy, ArrowLeft, Loader2 } from "lucide-react"
+import Link from "next/link"
+
+interface LeaderEntry {
+ label: string
+ volunteerName: string | null
+ pledgeCount: number
+ totalPledged: number
+ totalPaid: number
+ scanCount: number
+ conversionRate: number
+}
+
+export default function LeaderboardPage() {
+ const params = useParams()
+ const eventId = params.id as string
+ const [entries, setEntries] = useState([])
+ const [loading, setLoading] = useState(true)
+
+ const formatPence = (p: number) => `£${(p / 100).toLocaleString("en-GB", { minimumFractionDigits: 0 })}`
+
+ useEffect(() => {
+ const load = () => {
+ fetch(`/api/events/${eventId}/qr`)
+ .then((r) => r.json())
+ .then((data) => {
+ if (Array.isArray(data)) {
+ const sorted = [...data].sort((a, b) => b.totalPledged - a.totalPledged)
+ setEntries(sorted.map((d) => ({
+ label: d.label,
+ volunteerName: d.volunteerName,
+ pledgeCount: d.pledgeCount,
+ totalPledged: d.totalPledged,
+ totalPaid: d.totalCollected || 0,
+ scanCount: d.scanCount,
+ conversionRate: d.scanCount > 0 ? Math.round((d.pledgeCount / d.scanCount) * 100) : 0,
+ })))
+ }
+ })
+ .catch(() => {})
+ .finally(() => setLoading(false))
+ }
+ load()
+ const interval = setInterval(load, 10000)
+ return () => clearInterval(interval)
+ }, [eventId])
+
+ const medals = ["🥇", "🥈", "🥉"]
+
+ if (loading) {
+ return (
+
+
+
+ )
+ }
+
+ return (
+
+
+
+
Back to Event
+
+
+ Fundraiser Leaderboard
+
+
Auto-refreshes every 10 seconds — perfect for live events
+
+
+
+ {entries.map((entry, i) => (
+
+
+
+
+ {i < 3 ? medals[i] : #{i + 1} }
+
+
+
{entry.volunteerName || entry.label}
+
+ {entry.pledgeCount} pledges
+ {entry.scanCount} scans
+ = 50 ? "success" : "secondary"} className="text-xs">
+ {entry.conversionRate}% conversion
+
+
+
+
+
{formatPence(entry.totalPledged)}
+
{formatPence(entry.totalPaid)} collected
+
+
+
+
+ ))}
+
+
+ {entries.length === 0 && (
+
+
+
+ No QR codes created yet. Create QR codes to see the leaderboard.
+
+
+ )}
+
+ )
+}
diff --git a/pledge-now-pay-later/src/app/dashboard/events/[id]/page.tsx b/pledge-now-pay-later/src/app/dashboard/events/[id]/page.tsx
index 3301c26..5f0bd72 100644
--- a/pledge-now-pay-later/src/app/dashboard/events/[id]/page.tsx
+++ b/pledge-now-pay-later/src/app/dashboard/events/[id]/page.tsx
@@ -8,7 +8,7 @@ import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Dialog, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { formatPence } from "@/lib/utils"
-import { Plus, Download, QrCode, ExternalLink, Copy, Check, Loader2, ArrowLeft } from "lucide-react"
+import { Plus, Download, QrCode, ExternalLink, Copy, Check, Loader2, ArrowLeft, Trophy, Users, MessageCircle } from "lucide-react"
import Link from "next/link"
import { QRCodeCanvas } from "@/components/qr-code"
@@ -104,9 +104,16 @@ export default function EventQRPage() {
{qrSources.length} QR code{qrSources.length !== 1 ? "s" : ""} · {totalScans} scans · {totalPledges} pledges · {formatPence(totalAmount)}
- setShowCreate(true)}>
- New QR Code
-
+
+
+
+ Leaderboard
+
+
+
setShowCreate(true)}>
+ New QR Code
+
+
{/* QR Grid */}
@@ -187,6 +194,26 @@ export default function EventQRPage() {
+ {/* Volunteer & share links */}
+
+
+
+ Volunteer View
+
+
+
{
+ const url = `${baseUrl}/p/${qr.code}`
+ const text = `Hi! Scan this to pledge: ${url}`
+ window.open(`https://wa.me/?text=${encodeURIComponent(text)}`, "_blank")
+ }}
+ >
+ Share
+
+
))}
diff --git a/pledge-now-pay-later/src/app/dashboard/events/page.tsx b/pledge-now-pay-later/src/app/dashboard/events/page.tsx
index e00275e..0e90e49 100644
--- a/pledge-now-pay-later/src/app/dashboard/events/page.tsx
+++ b/pledge-now-pay-later/src/app/dashboard/events/page.tsx
@@ -28,6 +28,7 @@ interface EventSummary {
export default function EventsPage() {
const [events, setEvents] = useState([])
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
const [loading, setLoading] = useState(true)
const [showCreate, setShowCreate] = useState(false)
diff --git a/pledge-now-pay-later/src/app/dashboard/exports/page.tsx b/pledge-now-pay-later/src/app/dashboard/exports/page.tsx
index 7050b33..666ad19 100644
--- a/pledge-now-pay-later/src/app/dashboard/exports/page.tsx
+++ b/pledge-now-pay-later/src/app/dashboard/exports/page.tsx
@@ -2,7 +2,7 @@
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
-import { Download, FileSpreadsheet, Webhook } from "lucide-react"
+import { Download, FileSpreadsheet, Webhook, Gift } from "lucide-react"
export default function ExportsPage() {
const handleCrmExport = () => {
@@ -12,27 +12,34 @@ export default function ExportsPage() {
a.click()
}
+ const handleGiftAidExport = () => {
+ const a = document.createElement("a")
+ a.href = "/api/exports/crm-pack?giftAidOnly=true"
+ a.download = `gift-aid-declarations-${new Date().toISOString().slice(0, 10)}.csv`
+ a.click()
+ }
+
return (
Exports
-
Export data for your CRM and automation tools
+
Export data for your CRM, HMRC, and automation tools
-
+
CRM Export Pack
- Download all pledges as CSV with full attribution data, ready to import into your CRM.
+ All pledges as CSV with full attribution data.
Includes:
-
+
Donor name, email, phone
Pledge amount and status
Payment method and reference
@@ -42,7 +49,37 @@ export default function ExportsPage() {
- Download CRM Pack (CSV)
+ Download CRM Pack
+
+
+
+
+
+
+
+ Gift Aid Report
+
+
+ HMRC-ready Gift Aid declarations for tax reclaim.
+
+
+
+
+
Includes only Gift Aid-eligible pledges:
+
+ Donor full name (required by HMRC)
+ Donation amount and date
+ Gift Aid declaration status
+ Event and reference for audit trail
+
+
+
+
+ 💷 Claim 25p for every £1 donated by a UK taxpayer
+
+
+
+ Download Gift Aid Report
@@ -50,23 +87,23 @@ export default function ExportsPage() {
- Webhook Events
+ Webhook / API
- Poll pending reminder events for external automation (Zapier, Make, n8n).
+ Connect to Zapier, Make, or n8n for automation.
-
Endpoint:
-
- GET /api/webhooks?since=2025-01-01T00:00:00Z
+ Reminder endpoint:
+
+ GET /api/webhooks?since=2025-01-01
- Returns pending reminders with donor contact info and pledge details.
+ Returns pending reminders with donor contact info for external email/SMS.
- 💡 Connect this to Zapier or Make to send emails/SMS automatically
+ 💡 Connect to Zapier or n8n to send automatic reminder emails and SMS
diff --git a/pledge-now-pay-later/src/app/dashboard/pledges/page.tsx b/pledge-now-pay-later/src/app/dashboard/pledges/page.tsx
index 49067d6..2eb750a 100644
--- a/pledge-now-pay-later/src/app/dashboard/pledges/page.tsx
+++ b/pledge-now-pay-later/src/app/dashboard/pledges/page.tsx
@@ -81,6 +81,7 @@ function PledgesContent() {
fetchPledges()
const interval = setInterval(fetchPledges, 15000)
return () => clearInterval(interval)
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, [eventId])
const handleStatusChange = async (pledgeId: string, newStatus: string) => {
diff --git a/pledge-now-pay-later/src/app/e/[slug]/page.tsx b/pledge-now-pay-later/src/app/e/[slug]/page.tsx
new file mode 100644
index 0000000..fc1998a
--- /dev/null
+++ b/pledge-now-pay-later/src/app/e/[slug]/page.tsx
@@ -0,0 +1,261 @@
+"use client"
+
+import { useState, useEffect } from "react"
+import { useParams } from "next/navigation"
+import { Card, CardContent } from "@/components/ui/card"
+import { Button } from "@/components/ui/button"
+import { MessageCircle, Share2, Heart, Users, Banknote, Gift, Loader2, QrCode, Calendar, MapPin } from "lucide-react"
+// Badge is available via @/components/ui/badge if needed
+
+interface EventData {
+ id: string
+ name: string
+ description: string | null
+ eventDate: string | null
+ location: string | null
+ goalAmount: number | null
+ organizationName: string
+ stats: {
+ pledgeCount: number
+ totalPledged: number
+ totalPaid: number
+ giftAidCount: number
+ avgPledge: number
+ }
+ recentPledges: Array<{
+ donorName: string | null
+ amountPence: number
+ createdAt: string
+ giftAid: boolean
+ }>
+ qrCodes: Array<{
+ code: string
+ label: string
+ volunteerName: string | null
+ }>
+}
+
+const formatPence = (p: number) => `£${(p / 100).toLocaleString("en-GB", { minimumFractionDigits: 0 })}`
+
+export default function PublicEventPage() {
+ const params = useParams()
+ const slug = params.slug as string
+ const [data, setData] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState("")
+
+ useEffect(() => {
+ fetch(`/api/events/public/${slug}`)
+ .then((r) => r.json())
+ .then((d) => {
+ if (d.error) setError(d.error)
+ else setData(d)
+ })
+ .catch(() => setError("Failed to load"))
+ .finally(() => setLoading(false))
+
+ const interval = setInterval(() => {
+ fetch(`/api/events/public/${slug}`)
+ .then((r) => r.json())
+ .then((d) => { if (!d.error) setData(d) })
+ .catch(() => {})
+ }, 15000)
+ return () => clearInterval(interval)
+ }, [slug])
+
+ const handleWhatsAppShare = () => {
+ if (!data) return
+ const url = `${window.location.origin}/e/${slug}`
+ const text = `🤲 ${data.name} is raising funds!\n\nPledge here — it takes 15 seconds:\n${url}`
+ window.open(`https://wa.me/?text=${encodeURIComponent(text)}`, "_blank")
+ }
+
+ const handleShare = async () => {
+ if (!data) return
+ const url = `${window.location.origin}/e/${slug}`
+ if (navigator.share) {
+ await navigator.share({ title: data.name, text: `Pledge to ${data.name}`, url })
+ } else {
+ handleWhatsAppShare()
+ }
+ }
+
+ if (loading) {
+ return (
+
+
+
+ )
+ }
+
+ if (error || !data) {
+ return (
+
+
+
+
Event not found
+
{error}
+
+
+ )
+ }
+
+ const progressPercent = data.goalAmount
+ ? Math.min(100, Math.round((data.stats.totalPledged / data.goalAmount) * 100))
+ : null
+ const giftAidBonus = Math.round(data.stats.totalPledged * 0.25 * (data.stats.giftAidCount / Math.max(1, data.stats.pledgeCount)))
+
+ return (
+
+
+ {/* Header */}
+
+
{data.organizationName}
+
{data.name}
+ {data.description &&
{data.description}
}
+
+ {data.eventDate && (
+
+
+ {new Date(data.eventDate).toLocaleDateString("en-GB", { day: "numeric", month: "long", year: "numeric" })}
+
+ )}
+ {data.location && (
+
+ {data.location}
+
+ )}
+
+
+
+ {/* Progress */}
+
+
+
+
{formatPence(data.stats.totalPledged)}
+
+ pledged by {data.stats.pledgeCount} {data.stats.pledgeCount === 1 ? "person" : "people"}
+
+
+
+ {progressPercent !== null && data.goalAmount && (
+
+
+ {progressPercent}% of goal
+ {formatPence(data.goalAmount)}
+
+
+
+ )}
+
+
+
+
+
{data.stats.pledgeCount}
+
Pledges
+
+
+
+
{formatPence(data.stats.totalPaid)}
+
Collected
+
+
+
+
{formatPence(giftAidBonus)}
+
Gift Aid
+
+
+
+
+
+ {/* Pledge CTA — link to first QR code */}
+ {data.qrCodes.length > 0 && (
+
window.location.href = `/p/${data.qrCodes[0].code}`}
+ >
+ Pledge Now
+
+ )}
+
+ {/* Share */}
+
+
+ WhatsApp
+
+
+ Share
+
+
+
+ {/* Recent pledges — social proof */}
+ {data.recentPledges.length > 0 && (
+
+
+ Recent Pledges
+
+ {data.recentPledges.map((p, i) => {
+ const name = p.donorName || "Anonymous"
+ const ago = formatTimeAgo(p.createdAt)
+ return (
+
+
+ {name[0].toUpperCase()}
+
+
+
{name}
+ {p.giftAid &&
🎁 }
+
{ago}
+
+
{formatPence(p.amountPence)}
+
+ )
+ })}
+
+ )}
+
+ {/* Volunteer QR codes */}
+ {data.qrCodes.length > 1 && (
+
+
+ Pledge via a Fundraiser
+
+
+ {data.qrCodes.map((qr) => (
+ window.location.href = `/p/${qr.code}`}
+ >
+
+ {qr.volunteerName || qr.label}
+
+ ))}
+
+
+ )}
+
+
+ Powered by Pledge Now, Pay Later
+
+
+
+ )
+}
+
+function formatTimeAgo(dateStr: string) {
+ const diff = Date.now() - new Date(dateStr).getTime()
+ const mins = Math.floor(diff / 60000)
+ if (mins < 1) return "Just now"
+ if (mins < 60) return `${mins}m ago`
+ const hrs = Math.floor(mins / 60)
+ if (hrs < 24) return `${hrs}h ago`
+ return `${Math.floor(hrs / 24)}d ago`
+}
diff --git a/pledge-now-pay-later/src/app/p/[token]/page.tsx b/pledge-now-pay-later/src/app/p/[token]/page.tsx
index f2280bb..c42e7ee 100644
--- a/pledge-now-pay-later/src/app/p/[token]/page.tsx
+++ b/pledge-now-pay-later/src/app/p/[token]/page.tsx
@@ -8,10 +8,9 @@ import { IdentityStep } from "./steps/identity-step"
import { ConfirmationStep } from "./steps/confirmation-step"
import { BankInstructionsStep } from "./steps/bank-instructions-step"
import { CardPaymentStep } from "./steps/card-payment-step"
-import { FpxPaymentStep } from "./steps/fpx-payment-step"
import { DirectDebitStep } from "./steps/direct-debit-step"
-export type Rail = "bank" | "gocardless" | "card" | "fpx"
+export type Rail = "bank" | "gocardless" | "card"
export interface PledgeData {
amountPence: number
@@ -30,18 +29,15 @@ interface EventInfo {
qrSourceLabel: string | null
}
-// Step indices:
-// 0 = Amount selection
-// 1 = Payment method selection
+// Steps:
+// 0 = Amount
+// 1 = Payment method
// 2 = Identity (for bank transfer)
// 3 = Bank instructions
-// 4 = Confirmation (generic — card, DD, FPX)
+// 4 = Confirmation (card, DD)
// 5 = Card payment step
-// 6 = FPX payment step
// 7 = Direct Debit step
-const STEP_TO_RAIL: Record = { 5: 1, 6: 1, 7: 1 } // maps back to payment selection
-
export default function PledgePage() {
const params = useParams()
const token = params.token as string
@@ -80,7 +76,6 @@ export default function PledgePage() {
setError("Unable to load pledge page")
setLoading(false)
})
- // Track pledge_start
fetch("/api/analytics", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -96,10 +91,9 @@ export default function PledgePage() {
const handleRailSelected = (rail: Rail) => {
setPledgeData((d) => ({ ...d, rail }))
const railStepMap: Record = {
- bank: 2, // → identity step → bank instructions
- card: 5, // → card payment step (combined identity + card)
- fpx: 6, // → FPX step (bank selection + identity + redirect)
- gocardless: 7, // → direct debit step (bank details + mandate)
+ bank: 2,
+ card: 5,
+ gocardless: 7,
}
setStep(railStepMap[rail])
}
@@ -119,12 +113,8 @@ export default function PledgePage() {
}),
})
const result = await res.json()
- if (result.error) {
- setError(result.error)
- return
- }
+ if (result.error) { setError(result.error); return }
setPledgeResult(result)
- // Bank rail shows bank instructions; everything else shows generic confirmation
setStep(finalData.rail === "bank" ? 3 : 4)
} catch {
setError("Something went wrong. Please try again.")
@@ -151,50 +141,38 @@ export default function PledgePage() {
)
}
+ const shareUrl = eventInfo?.qrSourceId ? `${typeof window !== "undefined" ? window.location.origin : ""}/p/${token}` : undefined
+
const steps: Record = {
0: ,
1: ,
- 2: ,
+ 2: ,
3: pledgeResult && ,
- 4: pledgeResult && ,
+ 4: pledgeResult && ,
5: ,
- 6: ,
7: ,
}
- // Determine which steps allow back navigation
- const backableSteps = new Set([1, 2, 5, 6, 7])
- const getBackStep = (current: number): number => {
- if (current in STEP_TO_RAIL) return STEP_TO_RAIL[current] // rail-specific steps → payment selection
- return current - 1
+ const backableSteps = new Set([1, 2, 5, 7])
+ const getBackStep = (s: number): number => {
+ if (s === 5 || s === 7) return 1
+ return s - 1
}
-
- // Progress calculation: steps 0-2 map linearly, 3+ means done
- const progressSteps = step >= 3 ? 3 : Math.min(step, 2) + 1
- const progressPercent = step >= 5 ? 66 : (progressSteps / 3) * 100 // rail steps show 2/3 progress
+ const progressPercent = step >= 3 ? 100 : step >= 2 ? 66 : step >= 1 ? 33 : 10
return (
- {/* Progress bar */}
- {/* Header */}
{eventInfo?.organizationName}
{eventInfo?.qrSourceLabel || ""}
- {/* Step content */}
-
- {steps[step]}
-
+
{steps[step]}
- {/* Back button */}
{backableSteps.has(step) && (
+ {/* Share CTA */}
+
+
🤲 Know someone who'd donate too?
+
+ {
+ const text = `I just pledged £${(amount / 100).toFixed(0)} to ${eventName}! 🤲\nPledge here: ${window.location.origin}`
+ window.open(`https://wa.me/?text=${encodeURIComponent(text)}`, "_blank")
+ }}
+ className="flex-1 bg-[#25D366] hover:bg-[#20BD5A] text-white"
+ size="sm"
+ >
+ WhatsApp
+
+ {
+ if (navigator.share) {
+ await navigator.share({ title: eventName, text: `Pledge to ${eventName}`, url: window.location.origin })
+ }
+ }}
+ variant="outline"
+ size="sm"
+ className="flex-1"
+ >
+ Share
+
+
+
+
Need help? Contact the charity directly.
diff --git a/pledge-now-pay-later/src/app/p/[token]/steps/confirmation-step.tsx b/pledge-now-pay-later/src/app/p/[token]/steps/confirmation-step.tsx
index 07b62ee..15fb64c 100644
--- a/pledge-now-pay-later/src/app/p/[token]/steps/confirmation-step.tsx
+++ b/pledge-now-pay-later/src/app/p/[token]/steps/confirmation-step.tsx
@@ -1,30 +1,42 @@
"use client"
-import { Check } from "lucide-react"
+import { Check, Share2, MessageCircle } from "lucide-react"
import { Card, CardContent } from "@/components/ui/card"
+import { Button } from "@/components/ui/button"
interface Props {
pledge: { id: string; reference: string }
amount: number
rail: string
eventName: string
+ shareUrl?: string
}
-export function ConfirmationStep({ pledge, amount, rail, eventName }: Props) {
+export function ConfirmationStep({ pledge, amount, rail, eventName, shareUrl }: Props) {
const railLabels: Record = {
bank: "Bank Transfer",
gocardless: "Direct Debit",
card: "Card Payment",
- fpx: "FPX Online Banking",
}
- const currencySymbol = rail === "fpx" ? "RM" : "£"
-
const nextStepMessages: Record = {
bank: "We've sent you payment instructions. Transfer at your convenience — we'll confirm once received.",
- gocardless: "Your Direct Debit mandate has been set up. The payment of " + currencySymbol + (amount / 100).toFixed(2) + " will be collected automatically in 3-5 working days. You'll receive email confirmation from GoCardless.",
- card: "Your card payment is being processed. You'll receive a confirmation email shortly.",
- fpx: "Your FPX payment has been received and is being verified. You'll receive a confirmation email once the payment is confirmed by your bank.",
+ gocardless: "Your Direct Debit mandate has been set up. The payment of £" + (amount / 100).toFixed(2) + " will be collected automatically in 3-5 working days. You'll receive email confirmation from GoCardless. Protected by the Direct Debit Guarantee.",
+ card: "Your card payment has been processed. You'll receive a confirmation email shortly.",
+ }
+
+ const handleWhatsAppShare = () => {
+ const text = `I just pledged £${(amount / 100).toFixed(0)} to ${eventName}! 🤲\n\nYou can pledge too: ${shareUrl || window.location.origin}`
+ window.open(`https://wa.me/?text=${encodeURIComponent(text)}`, "_blank")
+ }
+
+ const handleShare = async () => {
+ const text = `I just pledged £${(amount / 100).toFixed(0)} to ${eventName}!`
+ if (navigator.share) {
+ await navigator.share({ title: eventName, text, url: shareUrl || window.location.origin })
+ } else {
+ handleWhatsAppShare()
+ }
}
return (
@@ -35,10 +47,10 @@ export function ConfirmationStep({ pledge, amount, rail, eventName }: Props) {
- {rail === "fpx" ? "Payment Successful!" : rail === "gocardless" ? "Mandate Set Up!" : "Pledge Received!"}
+ {rail === "gocardless" ? "Mandate Set Up!" : rail === "card" ? "Payment Complete!" : "Pledge Received!"}
- Thank you for your generous {rail === "fpx" ? "donation" : "pledge"} to{" "}
+ Thank you for your generous {rail === "card" ? "donation" : "pledge"} to{" "}
{eventName}
@@ -47,7 +59,7 @@ export function ConfirmationStep({ pledge, amount, rail, eventName }: Props) {
Amount
- {currencySymbol}{(amount / 100).toFixed(2)}
+ £{(amount / 100).toFixed(2)}
Payment Method
@@ -63,7 +75,7 @@ export function ConfirmationStep({ pledge, amount, rail, eventName }: Props) {
3-5 working days
)}
- {rail === "fpx" && (
+ {rail === "card" && (
Status
Paid ✓
@@ -72,6 +84,7 @@ export function ConfirmationStep({ pledge, amount, rail, eventName }: Props) {
+ {/* What happens next */}
What happens next?
@@ -79,6 +92,33 @@ export function ConfirmationStep({ pledge, amount, rail, eventName }: Props) {
+ {/* Share / encourage others */}
+
+
+ 🤲 Spread the word — every pledge counts!
+
+
+ Share with friends and family so they can pledge too.
+
+
+
+
+ WhatsApp
+
+
+
+ Share
+
+
+
+
Need help? Contact the charity directly. Ref: {pledge.reference}
diff --git a/pledge-now-pay-later/src/app/p/[token]/steps/fpx-payment-step.tsx b/pledge-now-pay-later/src/app/p/[token]/steps/fpx-payment-step.tsx
deleted file mode 100644
index b10b7b7..0000000
--- a/pledge-now-pay-later/src/app/p/[token]/steps/fpx-payment-step.tsx
+++ /dev/null
@@ -1,329 +0,0 @@
-"use client"
-
-import { useState } from "react"
-import { Button } from "@/components/ui/button"
-import { Input } from "@/components/ui/input"
-import { Label } from "@/components/ui/label"
-import { Lock, Search, CheckCircle2 } from "lucide-react"
-
-interface Props {
- amount: number
- eventName: string
- onComplete: (identity: {
- donorName: string
- donorEmail: string
- donorPhone: string
- giftAid: boolean
- }) => void
-}
-
-interface Bank {
- code: string
- name: string
- shortName: string
- online: boolean
-}
-
-const FPX_BANKS: Bank[] = [
- { code: "MBB", name: "Maybank2u", shortName: "Maybank", online: true },
- { code: "CIMB", name: "CIMB Clicks", shortName: "CIMB", online: true },
- { code: "PBB", name: "PBe Bank", shortName: "Public Bank", online: true },
- { code: "RHB", name: "RHB Now", shortName: "RHB", online: true },
- { code: "HLB", name: "Hong Leong Connect", shortName: "Hong Leong", online: true },
- { code: "AMBB", name: "AmOnline", shortName: "AmBank", online: true },
- { code: "BIMB", name: "Bank Islam GO", shortName: "Bank Islam", online: true },
- { code: "BKRM", name: "i-Rakyat", shortName: "Bank Rakyat", online: true },
- { code: "BSN", name: "myBSN", shortName: "BSN", online: true },
- { code: "OCBC", name: "OCBC Online", shortName: "OCBC", online: true },
- { code: "UOB", name: "UOB Personal", shortName: "UOB", online: true },
- { code: "ABB", name: "Affin Online", shortName: "Affin Bank", online: true },
- { code: "ABMB", name: "Alliance Online", shortName: "Alliance Bank", online: true },
- { code: "BMMB", name: "Bank Muamalat", shortName: "Muamalat", online: true },
- { code: "SCB", name: "SC Online", shortName: "Standard Chartered", online: true },
- { code: "HSBC", name: "HSBC Online", shortName: "HSBC", online: true },
- { code: "AGR", name: "AGRONet", shortName: "Agrobank", online: true },
- { code: "KFH", name: "KFH Online", shortName: "KFH", online: true },
-]
-
-const BANK_COLORS: Record
= {
- MBB: "bg-yellow-500",
- CIMB: "bg-red-600",
- PBB: "bg-pink-700",
- RHB: "bg-blue-800",
- HLB: "bg-blue-600",
- AMBB: "bg-green-700",
- BIMB: "bg-emerald-700",
- BKRM: "bg-blue-900",
- BSN: "bg-orange-600",
- OCBC: "bg-red-700",
- UOB: "bg-blue-700",
- ABB: "bg-amber-700",
- ABMB: "bg-teal-700",
- BMMB: "bg-green-800",
- SCB: "bg-green-600",
- HSBC: "bg-red-500",
- AGR: "bg-green-900",
- KFH: "bg-yellow-700",
-}
-
-type Phase = "select" | "identity" | "redirecting" | "processing"
-
-export function FpxPaymentStep({ amount, eventName, onComplete }: Props) {
- const [phase, setPhase] = useState("select")
- const [selectedBank, setSelectedBank] = useState(null)
- const [search, setSearch] = useState("")
- const [name, setName] = useState("")
- const [email, setEmail] = useState("")
- const [phone, setPhone] = useState("")
- const [errors, setErrors] = useState>({})
-
- const ringgit = (amount / 100).toFixed(2)
-
- const filteredBanks = search
- ? FPX_BANKS.filter(
- (b) =>
- b.name.toLowerCase().includes(search.toLowerCase()) ||
- b.shortName.toLowerCase().includes(search.toLowerCase()) ||
- b.code.toLowerCase().includes(search.toLowerCase())
- )
- : FPX_BANKS
-
- const handleBankSelect = (bank: Bank) => {
- setSelectedBank(bank)
- }
-
- const handleContinueToIdentity = () => {
- if (!selectedBank) return
- setPhase("identity")
- }
-
- const handleSubmit = async () => {
- const errs: Record = {}
- if (!email.includes("@")) errs.email = "Valid email required"
- setErrors(errs)
- if (Object.keys(errs).length > 0) return
-
- setPhase("redirecting")
-
- // Simulate FPX redirect flow
- await new Promise((r) => setTimeout(r, 2000))
- setPhase("processing")
- await new Promise((r) => setTimeout(r, 1500))
-
- onComplete({
- donorName: name,
- donorEmail: email,
- donorPhone: phone,
- giftAid: false, // Gift Aid not applicable for MYR
- })
- }
-
- // Redirecting phase
- if (phase === "redirecting") {
- return (
-
-
-
-
- Redirecting to {selectedBank?.name}
-
-
- You'll be taken to your bank's secure login page to authorize the payment of RM{ringgit}
-
-
-
-
-
- {selectedBank?.code}
-
-
{selectedBank?.name}
-
-
-
- Do not close this window. You will be redirected back automatically.
-
-
- )
- }
-
- // Processing phase
- if (phase === "processing") {
- return (
-
-
-
-
- Processing Payment
-
-
- Verifying your payment with {selectedBank?.shortName}...
-
-
-
- )
- }
-
- // Identity phase
- if (phase === "identity") {
- return (
-
-
-
Your Details
-
- Before we redirect you to {selectedBank?.name}
-
-
-
- {/* Selected bank indicator */}
-
-
-
-
- {selectedBank?.code}
-
-
-
{selectedBank?.name}
-
FPX Online Banking
-
-
-
-
RM{ringgit}
-
{eventName}
-
-
-
-
-
-
- Full Name (optional)
- setName(e.target.value)}
- autoComplete="name"
- />
-
-
-
-
Email
-
setEmail(e.target.value)}
- autoComplete="email"
- inputMode="email"
- className={errors.email ? "border-red-500" : ""}
- />
- {errors.email &&
{errors.email}
}
-
We'll send your receipt here
-
-
-
- Phone (optional)
- setPhone(e.target.value)}
- autoComplete="tel"
- inputMode="tel"
- />
-
-
-
-
-
- Pay RM{ringgit} via {selectedBank?.shortName}
-
-
-
-
- Secured by FPX — Bank Negara Malaysia
-
-
- )
- }
-
- // Bank selection phase (default)
- return (
-
-
-
- FPX Online Banking
-
-
- Pay RM{ringgit} {" "}
- for {eventName}
-
-
-
- {/* Search */}
-
-
- setSearch(e.target.value)}
- className="pl-10"
- />
-
-
- {/* Bank list */}
-
- {filteredBanks.map((bank) => (
-
handleBankSelect(bank)}
- className={`
- text-left rounded-xl border-2 p-3 transition-all active:scale-[0.98]
- ${selectedBank?.code === bank.code
- ? "border-trust-blue bg-trust-blue/5 shadow-md shadow-trust-blue/10"
- : "border-gray-200 bg-white hover:border-gray-300"
- }
- `}
- >
-
-
- {bank.code}
-
-
-
{bank.shortName}
-
{bank.name}
-
- {selectedBank?.code === bank.code && (
-
- )}
-
-
- ))}
-
-
- {filteredBanks.length === 0 && (
-
No banks found matching "{search}"
- )}
-
- {/* Continue */}
-
- Continue with {selectedBank?.shortName || "selected bank"} →
-
-
-
-
- Powered by FPX — regulated by Bank Negara Malaysia
-
-
- )
-}
diff --git a/pledge-now-pay-later/src/app/p/[token]/steps/identity-step.tsx b/pledge-now-pay-later/src/app/p/[token]/steps/identity-step.tsx
index 663c4c6..888da29 100644
--- a/pledge-now-pay-later/src/app/p/[token]/steps/identity-step.tsx
+++ b/pledge-now-pay-later/src/app/p/[token]/steps/identity-step.tsx
@@ -4,6 +4,7 @@ import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
+import { Gift, Shield } from "lucide-react"
interface Props {
onSubmit: (data: {
@@ -12,9 +13,10 @@ interface Props {
donorPhone: string
giftAid: boolean
}) => void
+ amount: number
}
-export function IdentityStep({ onSubmit }: Props) {
+export function IdentityStep({ onSubmit, amount }: Props) {
const [name, setName] = useState("")
const [email, setEmail] = useState("")
const [phone, setPhone] = useState("")
@@ -23,6 +25,7 @@ export function IdentityStep({ onSubmit }: Props) {
const hasContact = email.includes("@") || phone.length >= 10
const isValid = hasContact
+ const giftAidBonus = Math.round(amount * 0.25)
const handleSubmit = async () => {
if (!isValid) return
@@ -47,10 +50,10 @@ export function IdentityStep({ onSubmit }: Props) {
-
Name (optional)
+
Full Name (for Gift Aid)
setName(e.target.value)}
autoComplete="name"
@@ -68,6 +71,9 @@ export function IdentityStep({ onSubmit }: Props) {
autoComplete="email"
inputMode="email"
/>
+
+ We'll send your payment instructions and receipt here
+
@@ -77,7 +83,7 @@ export function IdentityStep({ onSubmit }: Props) {
-
Phone
+
Mobile Number
+
+ We can send reminders via SMS if you prefer
+
- {/* Gift Aid */}
-
- setGiftAid(e.target.checked)}
- className="mt-1 h-5 w-5 rounded border-gray-300 text-trust-blue focus:ring-trust-blue"
- />
-
-
Add Gift Aid
-
- Boost your donation by 25% at no extra cost to you. You must be a UK taxpayer.
-
+ {/* Gift Aid — prominent UK-specific */}
+
setGiftAid(!giftAid)}
+ className={`rounded-2xl border-2 p-5 cursor-pointer transition-all ${
+ giftAid
+ ? "border-success-green bg-success-green/5 shadow-md shadow-success-green/10"
+ : "border-gray-200 bg-white hover:border-success-green/50"
+ }`}
+ >
+
+
+
+
+
+
+ {}}
+ className="h-5 w-5 rounded border-gray-300 text-success-green focus:ring-success-green"
+ />
+ Add Gift Aid
+ {giftAid && (
+
+ +£{(giftAidBonus / 100).toFixed(0)} free
+
+ )}
+
+
+ Boost your £{(amount / 100).toFixed(0)} pledge to{" "}
+ £{((amount + giftAidBonus) / 100).toFixed(0)} at no extra cost.
+ HMRC adds 25% — the charity claims it back.
+
+ {giftAid && (
+
+ I confirm I am a UK taxpayer and understand that if I pay less Income Tax and/or
+ Capital Gains Tax than the amount of Gift Aid claimed on all my donations in that
+ tax year it is my responsibility to pay any difference.
+
+ )}
+
-
+
-
- We'll only use this to send payment details and confirm receipt.
-
+
+
+ Your data is kept secure and only used for this pledge
+
)
}
diff --git a/pledge-now-pay-later/src/app/p/[token]/steps/payment-step.tsx b/pledge-now-pay-later/src/app/p/[token]/steps/payment-step.tsx
index 2984b8f..6e993ef 100644
--- a/pledge-now-pay-later/src/app/p/[token]/steps/payment-step.tsx
+++ b/pledge-now-pay-later/src/app/p/[token]/steps/payment-step.tsx
@@ -1,9 +1,9 @@
"use client"
-import { Building2, CreditCard, Landmark, Globe } from "lucide-react"
+import { Building2, CreditCard, Landmark } from "lucide-react"
interface Props {
- onSelect: (rail: "bank" | "gocardless" | "card" | "fpx") => void
+ onSelect: (rail: "bank" | "gocardless" | "card") => void
amount: number
}
@@ -18,34 +18,31 @@ export function PaymentStep({ onSelect, amount }: Props) {
subtitle: "Zero fees — 100% goes to charity",
tag: "Recommended",
tagColor: "bg-success-green text-white",
- detail: "Use your banking app to transfer directly",
+ detail: "Use your banking app to transfer directly. We'll give you the details.",
+ fee: "No fees",
+ feeColor: "text-success-green",
},
{
id: "gocardless" as const,
icon: Landmark,
title: "Direct Debit",
subtitle: "Automatic collection — set and forget",
- tag: "Low fees",
+ tag: "Set up once",
tagColor: "bg-trust-blue/10 text-trust-blue",
- detail: "We'll collect via GoCardless",
+ detail: "We'll collect via GoCardless. Protected by the Direct Debit Guarantee.",
+ fee: "1% + 20p",
+ feeColor: "text-muted-foreground",
},
{
id: "card" as const,
icon: CreditCard,
- title: "Card Payment via Stripe",
- subtitle: "Pay now by Visa, Mastercard, Amex",
- tag: "Stripe",
+ title: "Debit or Credit Card",
+ subtitle: "Pay instantly by Visa, Mastercard, or Amex",
+ tag: "Instant",
tagColor: "bg-purple-100 text-purple-700",
- detail: "Secure payment powered by Stripe",
- },
- {
- id: "fpx" as const,
- icon: Globe,
- title: "FPX Online Banking",
- subtitle: "Pay via Malaysian bank account",
- tag: "Malaysia",
- tagColor: "bg-amber-500/10 text-amber-700",
- detail: "Instant payment from 18 Malaysian banks",
+ detail: "Secure payment powered by Stripe. Receipt emailed immediately.",
+ fee: "1.4% + 20p",
+ feeColor: "text-muted-foreground",
},
]
@@ -56,7 +53,7 @@ export function PaymentStep({ onSelect, amount }: Props) {
How would you like to pay?
- Pledge: £{pounds}
+ Your pledge: £{pounds}
@@ -72,16 +69,17 @@ export function PaymentStep({ onSelect, amount }: Props) {
-
+
{opt.title}
- {opt.tag && (
-
- {opt.tag}
-
- )}
+
+ {opt.tag}
+
{opt.subtitle}
{opt.detail}
+
+ Fee: {opt.fee}
+
→
@@ -90,6 +88,10 @@ export function PaymentStep({ onSelect, amount }: Props) {
))}
+
+
+ All payments are secure. Bank transfers mean 100% reaches the charity.
+
)
}
diff --git a/pledge-now-pay-later/src/app/page.tsx b/pledge-now-pay-later/src/app/page.tsx
index 43d02d7..2cb02b2 100644
--- a/pledge-now-pay-later/src/app/page.tsx
+++ b/pledge-now-pay-later/src/app/page.tsx
@@ -1,6 +1,6 @@
import Link from "next/link"
import { Button } from "@/components/ui/button"
-import { CreditCard, Landmark, Building2, Globe, QrCode, BarChart3, Bell, Download } from "lucide-react"
+import { CreditCard, Landmark, Building2, QrCode, BarChart3, Bell, Download, Users, Gift, MessageCircle, Share2, Smartphone } from "lucide-react"
export default function Home() {
return (
@@ -17,7 +17,7 @@ export default function Home() {
Pay Later
- Turn "I'll donate later" into tracked pledges with automatic payment follow-up. Zero fees on bank transfers.
+ Turn "I'll donate later" into tracked pledges with automatic follow-up. Built for UK charity fundraising events.
@@ -38,25 +38,50 @@ export default function Home() {
Pledge time
-
85%+
-
Collection rate
+
+25%
+
Gift Aid boost
+ {/* Who is this for? */}
+
+
+
Built for everyone in your fundraising chain
+
From the charity manager to the donor's phone
+
+ {[
+ { icon: BarChart3, title: "Charity Managers", desc: "Live dashboard, bank reconciliation, Gift Aid reports. See every pound from pledge to collection.", color: "text-trust-blue" },
+ { icon: Users, title: "Volunteers", desc: "Personal QR codes, leaderboard, own pledge tracker. Know exactly who pledged at your table.", color: "text-warm-amber" },
+ { icon: Smartphone, title: "Donors", desc: "15-second pledge on your phone. Clear bank details, copy buttons, reminders until paid.", color: "text-success-green" },
+ { icon: Share2, title: "Personal Fundraisers", desc: "Share your pledge link on WhatsApp. Track friends and family pledges with a progress bar.", color: "text-purple-600" },
+ ].map((p, i) => (
+
+
+
{p.title}
+
{p.desc}
+
+ ))}
+
+
+
+
{/* Payment Methods */}
-
-
4 Payment Rails, One Platform
-
+
+
3 UK Payment Rails, One Platform
+
Every method a UK donor expects
+
{[
- { icon: Building2, title: "Bank Transfer", desc: "Zero fees — 100% to charity", color: "text-success-green" },
- { icon: Landmark, title: "Direct Debit", desc: "GoCardless auto-collection", color: "text-trust-blue" },
- { icon: CreditCard, title: "Card via Stripe", desc: "Visa, Mastercard, Amex", color: "text-purple-600" },
- { icon: Globe, title: "FPX Banking", desc: "Malaysian online banking", color: "text-amber-600" },
+ { icon: Building2, title: "Bank Transfer", desc: "Zero fees — 100% to charity. Unique reference for auto-matching.", color: "text-success-green", tag: "0% fees" },
+ { icon: Landmark, title: "Direct Debit", desc: "GoCardless auto-collection. Protected by the Direct Debit Guarantee.", color: "text-trust-blue", tag: "Set & forget" },
+ { icon: CreditCard, title: "Card via Stripe", desc: "Visa, Mastercard, Amex. Instant payment and receipt.", color: "text-purple-600", tag: "Instant" },
].map((m, i) => (
-
-
+
+
+
{m.tag}
{m.title}
{m.desc}
@@ -67,13 +92,17 @@ export default function Home() {
{/* Features */}
-
Everything You Need
+
Everything a UK charity needs
{[
- { icon: QrCode, title: "QR Codes", desc: "Per-table, per-volunteer attribution tracking" },
- { icon: BarChart3, title: "Live Dashboard", desc: "Real-time pledge pipeline with auto-refresh" },
- { icon: Bell, title: "Smart Reminders", desc: "4-step follow-up sequence via email/SMS" },
- { icon: Download, title: "Bank Reconciliation", desc: "CSV import, auto-match by reference" },
+ { icon: QrCode, title: "QR Attribution", desc: "Per-table, per-volunteer tracking. Know who raised what." },
+ { icon: Gift, title: "Gift Aid Built In", desc: "One-tap declaration. HMRC-ready export. +25% on every eligible pledge." },
+ { icon: Bell, title: "Smart Reminders", desc: "Automated follow-up via email and SMS until the pledge is paid." },
+ { icon: Download, title: "Bank Reconciliation", desc: "Upload your CSV statement. Auto-match by unique reference." },
+ { icon: MessageCircle, title: "WhatsApp Sharing", desc: "Donors share their pledge with friends. Viral fundraising built in." },
+ { icon: Users, title: "Volunteer Portal", desc: "Each volunteer sees their own pledges and conversion rate." },
+ { icon: BarChart3, title: "Live Dashboard", desc: "Real-time ticker during events. Pipeline from pledge to payment." },
+ { icon: Share2, title: "Fundraiser Pages", desc: "Shareable links with progress bars. Perfect for personal campaigns." },
].map((f, i) => (
@@ -86,8 +115,9 @@ export default function Home() {
{/* Footer */}
-
- Pledge Now, Pay Later — Built for UK charities.
+
+ Pledge Now, Pay Later — Built for UK charities by QuikCue .
+ Free forever. No hidden fees. No card required.
)
diff --git a/pledge-now-pay-later/src/app/v/[code]/page.tsx b/pledge-now-pay-later/src/app/v/[code]/page.tsx
new file mode 100644
index 0000000..25bdffd
--- /dev/null
+++ b/pledge-now-pay-later/src/app/v/[code]/page.tsx
@@ -0,0 +1,216 @@
+"use client"
+
+import { useState, useEffect } from "react"
+import { useParams } from "next/navigation"
+import { Card, CardContent } from "@/components/ui/card"
+import { Button } from "@/components/ui/button"
+import { Badge } from "@/components/ui/badge"
+import { MessageCircle, Share2, QrCode, TrendingUp, Users, Banknote, Loader2 } from "lucide-react"
+
+interface VolunteerData {
+ qrSource: {
+ label: string
+ volunteerName: string | null
+ code: string
+ scanCount: number
+ }
+ event: {
+ name: string
+ organizationName: string
+ }
+ pledges: Array<{
+ id: string
+ reference: string
+ amountPence: number
+ status: string
+ donorName: string | null
+ createdAt: string
+ giftAid: boolean
+ }>
+ stats: {
+ totalPledges: number
+ totalPledgedPence: number
+ totalPaidPence: number
+ conversionRate: number
+ }
+}
+
+const statusColors: Record
= {
+ new: "secondary",
+ initiated: "warning",
+ paid: "success",
+ overdue: "destructive",
+}
+
+export default function VolunteerPage() {
+ const params = useParams()
+ const code = params.code as string
+ const [data, setData] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState("")
+
+ useEffect(() => {
+ fetch(`/api/qr/${code}/volunteer`)
+ .then((r) => r.json())
+ .then((d) => {
+ if (d.error) setError(d.error)
+ else setData(d)
+ })
+ .catch(() => setError("Failed to load"))
+ .finally(() => setLoading(false))
+
+ const interval = setInterval(() => {
+ fetch(`/api/qr/${code}/volunteer`)
+ .then((r) => r.json())
+ .then((d) => { if (!d.error) setData(d) })
+ .catch(() => {})
+ }, 15000)
+ return () => clearInterval(interval)
+ }, [code])
+
+ const handleWhatsAppShare = () => {
+ if (!data) return
+ const url = `${window.location.origin}/p/${code}`
+ const text = `🤲 Pledge to ${data.event.name}!\n\nMake a pledge here — it only takes 15 seconds:\n${url}`
+ window.open(`https://wa.me/?text=${encodeURIComponent(text)}`, "_blank")
+ }
+
+ const handleShare = async () => {
+ if (!data) return
+ const url = `${window.location.origin}/p/${code}`
+ if (navigator.share) {
+ await navigator.share({ title: data.event.name, text: `Pledge to ${data.event.name}`, url })
+ } else {
+ handleWhatsAppShare()
+ }
+ }
+
+ const formatPence = (p: number) => `£${(p / 100).toLocaleString("en-GB", { minimumFractionDigits: 0 })}`
+
+ if (loading) {
+ return (
+
+
+
+ )
+ }
+
+ if (error || !data) {
+ return (
+
+
+
+
QR code not found
+
{error}
+
+
+ )
+ }
+
+ return (
+
+
+ {/* Header */}
+
+
{data.event.organizationName}
+
{data.event.name}
+
+ {data.qrSource.volunteerName || data.qrSource.label}'s Pledges
+
+
+
+ {/* Stats */}
+
+
+
+
+ {data.stats.totalPledges}
+ Pledges
+
+
+
+
+
+ {formatPence(data.stats.totalPledgedPence)}
+ Pledged
+
+
+
+
+
+ {formatPence(data.stats.totalPaidPence)}
+ Paid
+
+
+
+
+ {/* Progress bar */}
+ {data.stats.totalPledgedPence > 0 && (
+
+
+ Collection progress
+ {Math.round((data.stats.totalPaidPence / data.stats.totalPledgedPence) * 100)}%
+
+
+
+ )}
+
+ {/* Share buttons */}
+
+
+ Share on WhatsApp
+
+
+ Share Link
+
+
+
+ {/* Pledge list */}
+
+
+ My Pledges ({data.pledges.length})
+
+ {data.pledges.length === 0 ? (
+
+
+ No pledges yet. Share your link to start collecting!
+
+
+ ) : (
+ data.pledges.map((p) => (
+
+
+
+
+
{p.donorName || "Anonymous"}
+
+ {new Date(p.createdAt).toLocaleDateString("en-GB", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" })}
+ {p.giftAid && " · 🎁 Gift Aid"}
+
+
+
+ {formatPence(p.amountPence)}
+
+ {p.status === "paid" ? "Paid ✓" : p.status}
+
+
+
+
+
+ ))
+ )}
+
+
+ {/* Auto-refresh indicator */}
+
+ Updates automatically every 15 seconds
+
+
+
+ )
+}
diff --git a/pledge-now-pay-later/src/components/live-ticker.tsx b/pledge-now-pay-later/src/components/live-ticker.tsx
new file mode 100644
index 0000000..3b10f29
--- /dev/null
+++ b/pledge-now-pay-later/src/components/live-ticker.tsx
@@ -0,0 +1,120 @@
+"use client"
+
+import { useState, useEffect } from "react"
+import { Badge } from "@/components/ui/badge"
+import { Banknote, TrendingUp, Radio } from "lucide-react"
+
+interface TickerPledge {
+ id: string
+ donorName: string | null
+ amountPence: number
+ status: string
+ rail: string
+ createdAt: string
+ qrSourceLabel: string | null
+ giftAid: boolean
+}
+
+interface LiveTickerProps {
+ eventId: string
+}
+
+const formatPence = (p: number) => `£${(p / 100).toLocaleString("en-GB", { minimumFractionDigits: 0 })}`
+
+export function LiveTicker({ eventId }: LiveTickerProps) {
+ const [pledges, setPledges] = useState([])
+ const [isLive, setIsLive] = useState(false)
+
+ useEffect(() => {
+ const load = async () => {
+ try {
+ const res = await fetch(`/api/pledges?eventId=${eventId}&limit=10`)
+ const data = await res.json()
+ if (data.pledges) {
+ setPledges(data.pledges.map((p: Record) => ({
+ id: p.id,
+ donorName: p.donorName,
+ amountPence: p.amountPence,
+ status: p.status,
+ rail: p.rail,
+ createdAt: p.createdAt,
+ qrSourceLabel: p.qrSourceLabel,
+ giftAid: p.giftAid,
+ })))
+ setIsLive(true)
+ }
+ } catch {
+ setIsLive(false)
+ }
+ }
+ load()
+ const interval = setInterval(load, 8000)
+ return () => clearInterval(interval)
+ }, [eventId])
+
+ if (pledges.length === 0) return null
+
+ const totalToday = pledges.reduce((s, p) => s + p.amountPence, 0)
+
+ return (
+
+
+
+ Live Feed
+
+
+
+ {formatPence(totalToday)} recent
+
+ {isLive && (
+
+ Live
+
+ )}
+
+
+
+
+ {pledges.map((p, i) => {
+ const name = p.donorName || "Anonymous"
+ const initials = name.split(" ").map(w => w[0]).join("").slice(0, 2).toUpperCase()
+ const ago = formatTimeAgo(p.createdAt)
+
+ return (
+
+
+ {initials}
+
+
+
+ {name}
+ {p.giftAid && 🎁 }
+
+
+ {p.qrSourceLabel && `${p.qrSourceLabel} · `}{ago}
+
+
+
+
+ {formatPence(p.amountPence)}
+
+
+ )
+ })}
+
+
+ )
+}
+
+function formatTimeAgo(dateStr: string) {
+ const diff = Date.now() - new Date(dateStr).getTime()
+ const mins = Math.floor(diff / 60000)
+ if (mins < 1) return "Just now"
+ if (mins < 60) return `${mins}m ago`
+ const hrs = Math.floor(mins / 60)
+ if (hrs < 24) return `${hrs}h ago`
+ return `${Math.floor(hrs / 24)}d ago`
+}