feat: add improved pi agent with observatory, dashboard, and pledge-now-pay-later

This commit is contained in:
Azreen Jamal
2026-03-01 23:41:24 +08:00
parent ae242436c9
commit f832b913d5
99 changed files with 20949 additions and 74 deletions

3
.pi/observatory/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
events.jsonl
summary.json
report.md

0
.pi/observatory/.gitkeep Normal file
View File

View File

@@ -8,7 +8,15 @@ allowed-tools: Bash
## Purpose ## Purpose
Automate browsers using `playwright-cli` — a token-efficient CLI for Playwright. Runs headless by default, supports parallel sessions via named sessions (`-s=`), and doesn't load tool schemas into context. Automate browsers using `playwright-cli` (via `@playwright/cli`) — a token-efficient CLI for Playwright. Runs headless by default, supports parallel sessions via named sessions (`-s=`), and doesn't load tool schemas into context.
## Prerequisites
Ensure the package is installed in the project:
```bash
bun add -d @playwright/cli
bunx playwright install chromium
```
## Key Details ## Key Details
@@ -28,17 +36,17 @@ Automate browsers using `playwright-cli` — a token-efficient CLI for Playwrigh
# "scrape pricing from competitor.com" → -s=competitor-pricing # "scrape pricing from competitor.com" → -s=competitor-pricing
# "UI test the login page" → -s=login-ui-test # "UI test the login page" → -s=login-ui-test
playwright-cli -s=mystore-checkout open https://mystore.com --persistent bunx playwright-cli -s=mystore-checkout open https://mystore.com --persistent
playwright-cli -s=mystore-checkout snapshot bunx playwright-cli -s=mystore-checkout snapshot
playwright-cli -s=mystore-checkout click e12 bunx playwright-cli -s=mystore-checkout click e12
``` ```
Managing sessions: Managing sessions:
```bash ```bash
playwright-cli list # list all sessions bunx playwright-cli list # list all sessions
playwright-cli close-all # close all sessions bunx playwright-cli close-all # close all sessions
playwright-cli -s=<name> close # close specific session bunx playwright-cli -s=<name> close # close specific session
playwright-cli -s=<name> delete-data # wipe session profile bunx playwright-cli -s=<name> delete-data # wipe session profile
``` ```
## Quick Reference ## Quick Reference
@@ -61,35 +69,35 @@ Config: open --headed, open --browser=chrome, resize <w> <h>
1. Derive a session name from the user's prompt and open with `--persistent` to preserve cookies/state. Always set the viewport via env var at launch: 1. Derive a session name from the user's prompt and open with `--persistent` to preserve cookies/state. Always set the viewport via env var at launch:
```bash ```bash
PLAYWRIGHT_MCP_VIEWPORT_SIZE=1440x900 playwright-cli -s=<session-name> open <url> --persistent PLAYWRIGHT_MCP_VIEWPORT_SIZE=1440x900 bunx playwright-cli -s=<session-name> open <url> --persistent
# or headed: # or headed:
PLAYWRIGHT_MCP_VIEWPORT_SIZE=1440x900 playwright-cli -s=<session-name> open <url> --persistent --headed PLAYWRIGHT_MCP_VIEWPORT_SIZE=1440x900 bunx playwright-cli -s=<session-name> open <url> --persistent --headed
# or with vision (screenshots returned as image responses in context): # or with vision (screenshots returned as image responses in context):
PLAYWRIGHT_MCP_VIEWPORT_SIZE=1440x900 PLAYWRIGHT_MCP_CAPS=vision playwright-cli -s=<session-name> open <url> --persistent PLAYWRIGHT_MCP_VIEWPORT_SIZE=1440x900 PLAYWRIGHT_MCP_CAPS=vision bunx playwright-cli -s=<session-name> open <url> --persistent
``` ```
3. Get element references via snapshot: 3. Get element references via snapshot:
```bash ```bash
playwright-cli snapshot bunx playwright-cli snapshot
``` ```
4. Interact using refs from snapshot: 4. Interact using refs from snapshot:
```bash ```bash
playwright-cli click <ref> bunx playwright-cli click <ref>
playwright-cli fill <ref> "text" bunx playwright-cli fill <ref> "text"
playwright-cli type "text" bunx playwright-cli type "text"
playwright-cli press Enter bunx playwright-cli press Enter
``` ```
5. Capture results: 5. Capture results:
```bash ```bash
playwright-cli screenshot bunx playwright-cli screenshot
playwright-cli screenshot --filename=output.png bunx playwright-cli screenshot --filename=output.png
``` ```
6. **Always close the session when done.** This is not optional — close the named session after finishing your task: 6. **Always close the session when done.** This is not optional — close the named session after finishing your task:
```bash ```bash
playwright-cli -s=<session-name> close bunx playwright-cli -s=<session-name> close
``` ```
## Configuration ## Configuration
@@ -109,6 +117,4 @@ If a `playwright-cli.json` exists in the working directory, use it automatically
## Full Help ## Full Help
Run `playwright-cli --help` or `playwright-cli --help <command>` for detailed command usage. Run `bunx playwright-cli --help` or `bunx playwright-cli --help <command>` for detailed command usage.
See [docs/playwright-cli.md](docs/playwright-cli.md) for full documentation.

View File

@@ -85,6 +85,7 @@ bun install
| **system-select** | `extensions/system-select.ts` | `/system` command to interactively switch between agent personas/system prompts from `.pi/agents/`, `.claude/agents/`, `.gemini/agents/`, `.codex/agents/` | | **system-select** | `extensions/system-select.ts` | `/system` command to interactively switch between agent personas/system prompts from `.pi/agents/`, `.claude/agents/`, `.gemini/agents/`, `.codex/agents/` |
| **damage-control** | `extensions/damage-control.ts` | Real-time safety auditing — intercepts dangerous bash patterns and enforces path-based access controls from `.pi/damage-control-rules.yaml` | | **damage-control** | `extensions/damage-control.ts` | Real-time safety auditing — intercepts dangerous bash patterns and enforces path-based access controls from `.pi/damage-control-rules.yaml` |
| **agent-chain** | `extensions/agent-chain.ts` | Sequential pipeline orchestrator — chains multiple agents where each step's output feeds into the next step's prompt; use `/chain` to select and run | | **agent-chain** | `extensions/agent-chain.ts` | Sequential pipeline orchestrator — chains multiple agents where each step's output feeds into the next step's prompt; use `/chain` to select and run |
| **agent-dashboard** | `extensions/agent-dashboard.ts` | Unified agent observability — passively tracks `dispatch_agent`, `subagent_create`, and `run_chain` across all orchestration interfaces; compact widget + `/dashboard` overlay with live, history, interface, and stats views |
| **pi-pi** | `extensions/pi-pi.ts` | Meta-agent that builds Pi agents using parallel research experts for documentation | | **pi-pi** | `extensions/pi-pi.ts` | Meta-agent that builds Pi agents using parallel research experts for documentation |
| **session-replay** | `extensions/session-replay.ts` | Scrollable timeline overlay of session history - showcasing customizable dialog UI | | **session-replay** | `extensions/session-replay.ts` | Scrollable timeline overlay of session history - showcasing customizable dialog UI |
| **theme-cycler** | `extensions/theme-cycler.ts` | Keyboard shortcuts (Ctrl+X/Ctrl+Q) and `/theme` command to cycle/switch between custom themes | | **theme-cycler** | `extensions/theme-cycler.ts` | Keyboard shortcuts (Ctrl+X/Ctrl+Q) and `/theme` command to cycle/switch between custom themes |
@@ -132,6 +133,7 @@ just ext-agent-team # Multi-agent orchestration grid dashboard
just ext-system-select # Agent persona switcher via /system command just ext-system-select # Agent persona switcher via /system command
just ext-damage-control # Safety auditing + minimal footer just ext-damage-control # Safety auditing + minimal footer
just ext-agent-chain # Sequential pipeline orchestrator with step chaining just ext-agent-chain # Sequential pipeline orchestrator with step chaining
just ext-agent-dashboard # Unified agent monitoring across team, subagent, and chain
just ext-pi-pi # Meta-agent that builds Pi agents using parallel experts just ext-pi-pi # Meta-agent that builds Pi agents using parallel experts
just ext-session-replay # Scrollable timeline overlay of session history just ext-session-replay # Scrollable timeline overlay of session history
just ext-theme-cycler # Theme cycler + minimal footer just ext-theme-cycler # Theme cycler + minimal footer
@@ -192,6 +194,16 @@ Unlike the dynamic dispatcher, `agent-chain` acts as a sequential pipeline orche
- The `$INPUT` variable injects the previous step's output (or the user's initial prompt for the first step), and `$ORIGINAL` always contains the user's initial prompt. - The `$INPUT` variable injects the previous step's output (or the user's initial prompt for the first step), and `$ORIGINAL` always contains the user's initial prompt.
- Example: The `plan-build-review` pipeline feeds your prompt to the `planner`, passes the plan to the `builder`, and finally sends the code to the `reviewer`. - Example: The `plan-build-review` pipeline feeds your prompt to the `planner`, passes the plan to the `builder`, and finally sends the code to the `reviewer`.
### Agent Dashboard (`/dashboard`)
The `agent-dashboard` extension provides unified observability across all three orchestration interfaces. It passively intercepts `dispatch_agent`, `subagent_create`, `subagent_continue`, and `run_chain` tool calls and tracks every agent run. Stack it alongside any orchestration extension:
```bash
pi -e extensions/agent-team.ts -e extensions/agent-dashboard.ts
```
The compact widget shows active/done/error counts. Use `/dashboard` to open a full-screen overlay with four views: **Live** (active agent cards), **History** (completed runs table), **Interfaces** (grouped by team/subagent/chain), and **Stats** (aggregate metrics and per-agent durations).
--- ---
## Safety Auditing & Damage Control ## Safety Auditing & Damage Control

View File

@@ -7,9 +7,22 @@
"dependencies": { "dependencies": {
"yaml": "^2.8.0", "yaml": "^2.8.0",
}, },
"devDependencies": {
"@playwright/cli": "^0.1.1",
},
}, },
}, },
"packages": { "packages": {
"@playwright/cli": ["@playwright/cli@0.1.1", "", { "dependencies": { "minimist": "^1.2.5", "playwright": "1.59.0-alpha-1771104257000" }, "bin": { "playwright-cli": "playwright-cli.js" } }, "sha512-9k11ZfDwAfMVDDIuEVW1Wvs8SoDNXIY1dNQ+9C9/SS8ZmElkcxesu5eoL7vNa96ntibUGaq1TM2qQoqvdl/I9g=="],
"fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"playwright": ["playwright@1.59.0-alpha-1771104257000", "", { "dependencies": { "playwright-core": "1.59.0-alpha-1771104257000" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-6SCMMMJaDRsSqiKVLmb2nhtLES7iTYawTWWrQK6UdIGNzXi8lka4sLKRec3L4DnTWwddAvCuRn8035dhNiHzbg=="],
"playwright-core": ["playwright-core@1.59.0-alpha-1771104257000", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-YiXup3pnpQUCBMSIW5zx8CErwRx4K6O5Kojkw2BzJui8MazoMUDU6E3xGsb1kzFviEAE09LFQ+y1a0RhIJQ5SA=="],
"yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="],
} }
} }

View File

@@ -0,0 +1,971 @@
/**
* Agent Dashboard — Unified observability across all agent interfaces
*
* Passively tracks agent activity from team dispatches, subagent spawns,
* and chain pipeline runs. Provides a compact always-visible widget plus
* a full-screen overlay with four switchable views.
*
* Hooks into: dispatch_agent, subagent_create, subagent_continue, run_chain
* tool calls and their completions. Completely passive — never blocks.
*
* Commands:
* /dashboard — toggle full-screen overlay
* /dashboard clear — reset all tracked state
*
* Usage: pi -e extensions/agent-dashboard.ts
*/
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
import { DynamicBorder } from "@mariozechner/pi-coding-agent";
import { Container, matchesKey, Text, truncateToWidth } from "@mariozechner/pi-tui";
import { applyExtensionDefaults } from "./themeMap.ts";
// ── Data Types ─────────────────────────────────────────────────────────
type AgentInterface = "team" | "subagent" | "chain";
interface TrackedAgent {
id: string;
name: string;
iface: AgentInterface;
status: "running" | "done" | "error";
task: string;
startedAt: number;
endedAt?: number;
elapsed: number;
toolCount: number;
lastText: string;
turnCount: number;
chainStep?: number;
chainName?: string;
teamName?: string;
}
interface AgentRun {
id: string;
name: string;
iface: AgentInterface;
task: string;
status: "done" | "error";
startedAt: number;
endedAt: number;
duration: number;
toolCount: number;
resultPreview: string;
chainStep?: number;
chainName?: string;
teamName?: string;
}
interface DashboardStats {
totalRuns: number;
totalSuccess: number;
totalError: number;
totalDuration: number;
agentRunCounts: Record<string, number>;
ifaceCounts: Record<AgentInterface, number>;
}
// ── Helpers ────────────────────────────────────────────────────────────
function fmtDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
const secs = Math.floor(ms / 1000);
if (secs < 60) return `${secs}s`;
const mins = Math.floor(secs / 60);
const remSecs = secs % 60;
if (mins < 60) return `${mins}m ${remSecs}s`;
const hrs = Math.floor(mins / 60);
const remMins = mins % 60;
return `${hrs}h ${remMins}m`;
}
function shortId(): string {
return Math.random().toString(36).slice(2, 6);
}
function truncate(s: string, max: number): string {
return s.length > max ? s.slice(0, max - 1) + "…" : s;
}
function emptyStats(): DashboardStats {
return {
totalRuns: 0,
totalSuccess: 0,
totalError: 0,
totalDuration: 0,
agentRunCounts: {},
ifaceCounts: { team: 0, subagent: 0, chain: 0 },
};
}
// ── Extension ──────────────────────────────────────────────────────────
export default function (pi: ExtensionAPI) {
// ── State ──────────────────────────────────────────────────────────
const activeAgents: Map<string, TrackedAgent> = new Map();
let history: AgentRun[] = [];
let stats: DashboardStats = emptyStats();
let widgetCtx: ExtensionContext | null = null;
let tickTimer: ReturnType<typeof setInterval> | null = null;
// Mapping from toolCallId → tracked agent info (with timestamp for staleness)
const pendingCalls: Map<string, { agentId: string; ts: number }> = new Map();
// Staleness threshold: 10 minutes
const STALE_TIMEOUT_MS = 10 * 60 * 1000;
// Inactivity auto-stop: stop tick after 30s with no active agents
let lastActivityTs = Date.now();
// ── Tracked tool names ─────────────────────────────────────────────
const TRACKED_TOOLS = new Set([
"dispatch_agent",
"subagent_create",
"subagent_continue",
"run_chain",
]);
// ── State Management ───────────────────────────────────────────────
function clearState() {
activeAgents.clear();
history = [];
stats = emptyStats();
pendingCalls.clear();
lastActivityTs = Date.now();
}
function addToHistory(agent: TrackedAgent) {
const run: AgentRun = {
id: agent.id,
name: agent.name,
iface: agent.iface,
task: agent.task,
status: agent.status === "error" ? "error" : "done",
startedAt: agent.startedAt,
endedAt: agent.endedAt || Date.now(),
duration: agent.elapsed,
toolCount: agent.toolCount,
resultPreview: truncate(agent.lastText, 200),
chainStep: agent.chainStep,
chainName: agent.chainName,
teamName: agent.teamName,
};
history.push(run);
// Ring buffer capped at 200
if (history.length > 200) {
history = history.slice(-200);
}
// Update stats
stats.totalRuns++;
if (run.status === "done") stats.totalSuccess++;
else stats.totalError++;
stats.totalDuration += run.duration;
stats.agentRunCounts[run.name] = (stats.agentRunCounts[run.name] || 0) + 1;
stats.ifaceCounts[run.iface] = (stats.ifaceCounts[run.iface] || 0) + 1;
}
// ── Tick Timer ─────────────────────────────────────────────────────
function startTick() {
if (tickTimer) return;
tickTimer = setInterval(() => {
const now = Date.now();
// Update elapsed on running agents
for (const agent of activeAgents.values()) {
if (agent.status === "running") {
agent.elapsed = now - agent.startedAt;
}
}
// Staleness check: expire pending calls older than 10 minutes
for (const [callId, pending] of pendingCalls) {
if (now - pending.ts > STALE_TIMEOUT_MS) {
pendingCalls.delete(callId);
const agent = activeAgents.get(pending.agentId);
if (agent && agent.status === "running") {
agent.status = "error";
agent.endedAt = now;
agent.elapsed = now - agent.startedAt;
agent.lastText = "Timed out (no completion after 10m)";
addToHistory(agent);
activeAgents.delete(pending.agentId);
}
}
}
// Auto-stop tick after 30s of inactivity (no active agents, no pending calls)
if (activeAgents.size === 0 && pendingCalls.size === 0) {
if (now - lastActivityTs > 30_000) {
stopTick();
return;
}
} else {
lastActivityTs = now;
}
updateWidget();
}, 1000);
}
function stopTick() {
if (tickTimer) {
clearInterval(tickTimer);
tickTimer = null;
}
}
// ── Widget Rendering ───────────────────────────────────────────────
function updateWidget() {
if (!widgetCtx) return;
try {
widgetCtx.ui.setWidget("agent-dashboard", (_tui, theme) => {
const container = new Container();
const borderFn = (s: string) => theme.fg("accent", s);
container.addChild(new DynamicBorder(borderFn));
const headerText = new Text("", 1, 0);
container.addChild(headerText);
const agentLines: Text[] = [];
// Pre-allocate up to 4 lines for active agents
for (let i = 0; i < 4; i++) {
const t = new Text("", 1, 0);
agentLines.push(t);
container.addChild(t);
}
const hintText = new Text("", 1, 0);
container.addChild(hintText);
container.addChild(new DynamicBorder(borderFn));
return {
render(width: number): string[] {
const activeCount = activeAgents.size;
const doneCount = stats.totalSuccess;
const errorCount = stats.totalError;
// Line 1: summary bar
const line1 =
theme.fg("accent", " 📊 Dashboard") +
theme.fg("dim", " │ Active: ") + theme.fg(activeCount > 0 ? "accent" : "muted", `${activeCount}`) +
theme.fg("dim", " │ Done: ") + theme.fg("success", `${doneCount}`) +
theme.fg("dim", " │ Errors: ") + theme.fg(errorCount > 0 ? "error" : "muted", `${errorCount}`);
headerText.setText(truncateToWidth(line1, width - 4));
// Active agent lines
const agents = Array.from(activeAgents.values());
for (let i = 0; i < agentLines.length; i++) {
if (i < agents.length) {
const a = agents[i];
const icon = a.status === "running" ? "⟳"
: a.status === "done" ? "✓" : "✗";
const statusColor = a.status === "running" ? "accent"
: a.status === "done" ? "success" : "error";
const ifaceTag = theme.fg("dim", `[${a.iface}]`);
const elapsed = theme.fg("muted", fmtDuration(a.elapsed));
const tools = theme.fg("dim", `🔧${a.toolCount}`);
const lastText = a.lastText
? theme.fg("muted", truncate(a.lastText, Math.max(20, width - 60)))
: "";
const line =
" " + theme.fg(statusColor, icon) + " " +
theme.fg("accent", truncate(a.name, 16)) + " " +
ifaceTag + " " +
elapsed + " " +
tools +
(lastText ? theme.fg("dim", " │ ") + lastText : "");
agentLines[i].setText(truncateToWidth(line, width - 4));
} else {
agentLines[i].setText("");
}
}
// Hint line
const hintLine =
theme.fg("dim", " /dashboard") + theme.fg("muted", " — full view") +
theme.fg("dim", " │ ") +
theme.fg("muted", `${stats.totalRuns} total runs`) +
(stats.totalDuration > 0
? theme.fg("dim", " │ avg ") + theme.fg("muted", fmtDuration(Math.round(stats.totalDuration / Math.max(1, stats.totalRuns))))
: "");
hintText.setText(truncateToWidth(hintLine, width - 4));
return container.render(width);
},
invalidate() {
container.invalidate();
},
};
});
} catch {}
}
// ── Overlay ────────────────────────────────────────────────────────
async function openOverlay(ctx: ExtensionContext) {
if (!ctx.hasUI) return;
let currentView = 0; // 0=Live, 1=History, 2=Interfaces, 3=Stats
let scrollOffset = 0;
const viewNames = ["1:Live", "2:History", "3:Interfaces", "4:Stats"];
await ctx.ui.custom((_tui, theme, _kb, done) => {
return {
render(width: number): string[] {
const lines: string[] = [];
// ── Header ──
lines.push("");
const tabs = viewNames.map((name, i) =>
i === currentView
? theme.fg("accent", theme.bold(`[${name}]`))
: theme.fg("dim", `[${name}]`)
).join(" ");
lines.push(truncateToWidth(
" " + theme.fg("accent", theme.bold("📊 Agent Dashboard")) +
" ".repeat(Math.max(1, width - 20 - viewNames.join(" ").length - 2)) +
tabs,
width,
));
lines.push(theme.fg("dim", "─".repeat(width)));
// ── View content ──
const contentLines = renderView(currentView, width, theme, scrollOffset);
lines.push(...contentLines);
// ── Footer controls ──
lines.push("");
lines.push(theme.fg("dim", "─".repeat(width)));
lines.push(truncateToWidth(
" " + theme.fg("dim", "1-4/Tab: views │ j/k: scroll │ c: clear │ q/Esc: close"),
width,
));
lines.push("");
return lines;
},
handleInput(data: string) {
if (matchesKey(data, "escape") || data === "q") {
done(undefined);
return;
}
if (data === "1") { currentView = 0; scrollOffset = 0; }
else if (data === "2") { currentView = 1; scrollOffset = 0; }
else if (data === "3") { currentView = 2; scrollOffset = 0; }
else if (data === "4") { currentView = 3; scrollOffset = 0; }
else if (data === "\t") { currentView = (currentView + 1) % 4; scrollOffset = 0; }
else if (matchesKey(data, "up") || data === "k") { scrollOffset = Math.max(0, scrollOffset - 1); }
else if (matchesKey(data, "down") || data === "j") { scrollOffset++; }
else if (matchesKey(data, "pageUp")) { scrollOffset = Math.max(0, scrollOffset - 20); }
else if (matchesKey(data, "pageDown")) { scrollOffset += 20; }
else if (data === "c") {
clearState();
scrollOffset = 0;
}
_tui.requestRender();
},
invalidate() {},
};
}, {
overlay: true,
overlayOptions: { width: "90%", anchor: "center" },
});
}
// ── View Renderers ─────────────────────────────────────────────────
function renderView(view: number, width: number, theme: any, offset: number): string[] {
switch (view) {
case 0: return renderLiveView(width, theme, offset);
case 1: return renderHistoryView(width, theme, offset);
case 2: return renderInterfacesView(width, theme, offset);
case 3: return renderStatsView(width, theme, offset);
default: return [];
}
}
// ── View 1: Live ───────────────────────────────────────────────────
function renderLiveView(width: number, theme: any, offset: number): string[] {
const lines: string[] = [];
const agents = Array.from(activeAgents.values());
lines.push(truncateToWidth(
" " + theme.fg("accent", theme.bold("Active Agents")) +
theme.fg("dim", ` (${agents.length} running)`),
width,
));
lines.push("");
if (agents.length === 0) {
lines.push(truncateToWidth(
" " + theme.fg("dim", "No agents currently running. Activity will appear here when"),
width,
));
lines.push(truncateToWidth(
" " + theme.fg("dim", "dispatch_agent, subagent_create, subagent_continue, or run_chain is called."),
width,
));
lines.push("");
// Show recent completions as context
if (history.length > 0) {
lines.push(truncateToWidth(
" " + theme.fg("muted", `Last completed: ${history.length} agents`),
width,
));
const recent = history.slice(-3).reverse();
for (const run of recent) {
const icon = run.status === "done" ? "✓" : "✗";
const color = run.status === "done" ? "success" : "error";
lines.push(truncateToWidth(
" " + theme.fg(color, `${icon} ${run.name}`) +
theme.fg("dim", ` [${run.iface}] `) +
theme.fg("muted", fmtDuration(run.duration)) +
theme.fg("dim", " — ") +
theme.fg("muted", truncate(run.task, 50)),
width,
));
}
}
return lines;
}
const allLines: string[] = [];
for (const agent of agents) {
const icon = agent.status === "running" ? "●"
: agent.status === "done" ? "✓" : "✗";
const statusColor = agent.status === "running" ? "accent"
: agent.status === "done" ? "success" : "error";
// Card top
allLines.push(truncateToWidth(
" " + theme.fg("dim", "┌─ ") +
theme.fg(statusColor, `${icon} ${agent.name}`) +
theme.fg("dim", ` [${agent.iface}]`) +
(agent.chainName ? theme.fg("dim", ` chain:${agent.chainName}`) : "") +
(agent.teamName ? theme.fg("dim", ` team:${agent.teamName}`) : "") +
(agent.chainStep !== undefined ? theme.fg("dim", ` step:${agent.chainStep}`) : "") +
theme.fg("dim", " ─".repeat(Math.max(0, Math.floor((width - 50) / 2)))),
width,
));
// Task
allLines.push(truncateToWidth(
" " + theme.fg("dim", "│ ") +
theme.fg("muted", "Task: ") +
theme.fg("accent", truncate(agent.task, width - 20)),
width,
));
// Metrics
allLines.push(truncateToWidth(
" " + theme.fg("dim", "│ ") +
theme.fg("muted", "Elapsed: ") + theme.fg("success", fmtDuration(agent.elapsed)) +
theme.fg("dim", " │ ") +
theme.fg("muted", "Tools: ") + theme.fg("accent", `${agent.toolCount}`) +
theme.fg("dim", " │ ") +
theme.fg("muted", "Turns: ") + theme.fg("accent", `${agent.turnCount}`),
width,
));
// Streaming text
if (agent.lastText) {
allLines.push(truncateToWidth(
" " + theme.fg("dim", "│ ") +
theme.fg("muted", truncate(agent.lastText, width - 10)),
width,
));
}
// Card bottom
allLines.push(truncateToWidth(
" " + theme.fg("dim", "└" + "─".repeat(Math.max(0, width - 5))),
width,
));
allLines.push("");
}
const visible = allLines.slice(offset);
lines.push(...visible);
return lines;
}
// ── View 2: History ────────────────────────────────────────────────
function renderHistoryView(width: number, theme: any, offset: number): string[] {
const lines: string[] = [];
lines.push(truncateToWidth(
" " + theme.fg("accent", theme.bold("Completed Runs")) +
theme.fg("dim", ` (${history.length} total)`),
width,
));
lines.push("");
if (history.length === 0) {
lines.push(truncateToWidth(" " + theme.fg("dim", "No completed runs yet."), width));
return lines;
}
// Table header
const hdr =
theme.fg("accent", " Status") +
theme.fg("accent", " │ Name ") +
theme.fg("accent", " │ Interface ") +
theme.fg("accent", " │ Duration ") +
theme.fg("accent", " │ Tools ") +
theme.fg("accent", " │ Task");
lines.push(truncateToWidth(hdr, width));
lines.push(truncateToWidth(" " + theme.fg("dim", "─".repeat(Math.min(80, width - 4))), width));
// Show newest first
const rows: string[] = [];
const reversed = [...history].reverse();
for (const run of reversed) {
const icon = run.status === "done" ? "✓" : "✗";
const color = run.status === "done" ? "success" : "error";
const ifaceLabel = run.iface.padEnd(9);
const nameLabel = truncate(run.name, 14).padEnd(14);
const durLabel = fmtDuration(run.duration).padEnd(8);
const toolLabel = String(run.toolCount).padStart(5);
const taskPreview = truncate(run.task, Math.max(10, width - 70));
const row =
" " + theme.fg(color, ` ${icon} `) +
theme.fg("dim", " │ ") + theme.fg("accent", nameLabel) +
theme.fg("dim", " │ ") + theme.fg("muted", ifaceLabel) +
theme.fg("dim", " │ ") + theme.fg("success", durLabel) +
theme.fg("dim", " │ ") + theme.fg("accent", toolLabel) +
theme.fg("dim", " │ ") + theme.fg("muted", taskPreview);
rows.push(row);
}
const visible = rows.slice(offset);
for (const row of visible) {
lines.push(truncateToWidth(row, width));
}
return lines;
}
// ── View 3: Interfaces ─────────────────────────────────────────────
function renderInterfacesView(width: number, theme: any, offset: number): string[] {
const lines: string[] = [];
lines.push(truncateToWidth(
" " + theme.fg("accent", theme.bold("Agents by Interface")),
width,
));
lines.push("");
const ifaceLabels: Record<AgentInterface, string> = {
team: "🏢 Team (dispatch_agent)",
subagent: "🤖 Subagent (subagent_create/continue)",
chain: "🔗 Chain (run_chain)",
};
const allLines: string[] = [];
for (const iface of ["team", "subagent", "chain"] as AgentInterface[]) {
const activeForIface = Array.from(activeAgents.values()).filter(a => a.iface === iface);
const historyForIface = history.filter(r => r.iface === iface);
const totalCount = stats.ifaceCounts[iface] || 0;
allLines.push(truncateToWidth(
" " + theme.fg("accent", theme.bold(ifaceLabels[iface])) +
theme.fg("dim", `${activeForIface.length} active, ${totalCount} completed`),
width,
));
allLines.push(truncateToWidth(" " + theme.fg("dim", "─".repeat(Math.min(60, width - 6))), width));
// Active
if (activeForIface.length > 0) {
for (const agent of activeForIface) {
allLines.push(truncateToWidth(
" " + theme.fg("accent", "● ") +
theme.fg("accent", agent.name) +
theme.fg("dim", " — ") +
theme.fg("success", fmtDuration(agent.elapsed)) +
theme.fg("dim", " │ 🔧") + theme.fg("muted", `${agent.toolCount}`) +
theme.fg("dim", " │ ") +
theme.fg("muted", truncate(agent.task, 40)),
width,
));
}
}
// Recent completed (last 5)
const recent = historyForIface.slice(-5).reverse();
if (recent.length > 0) {
for (const run of recent) {
const icon = run.status === "done" ? "✓" : "✗";
const color = run.status === "done" ? "success" : "error";
allLines.push(truncateToWidth(
" " + theme.fg(color, `${icon} `) +
theme.fg("muted", run.name) +
theme.fg("dim", " — ") +
theme.fg("muted", fmtDuration(run.duration)) +
theme.fg("dim", " │ ") +
theme.fg("muted", truncate(run.task, 40)),
width,
));
}
}
if (activeForIface.length === 0 && recent.length === 0) {
allLines.push(truncateToWidth(" " + theme.fg("dim", "No activity recorded."), width));
}
allLines.push("");
}
const visible = allLines.slice(offset);
lines.push(...visible);
return lines;
}
// ── View 4: Stats ──────────────────────────────────────────────────
function renderStatsView(width: number, theme: any, offset: number): string[] {
const lines: string[] = [];
lines.push(truncateToWidth(
" " + theme.fg("accent", theme.bold("Aggregate Statistics")),
width,
));
lines.push("");
const avgDur = stats.totalRuns > 0
? fmtDuration(Math.round(stats.totalDuration / stats.totalRuns))
: "—";
const successRate = stats.totalRuns > 0
? `${Math.round((stats.totalSuccess / stats.totalRuns) * 100)}%`
: "—";
const allLines: string[] = [];
// Summary cards
allLines.push(truncateToWidth(
" " +
theme.fg("muted", "Total Runs: ") + theme.fg("accent", `${stats.totalRuns}`) +
theme.fg("dim", " │ ") +
theme.fg("muted", "Success: ") + theme.fg("success", `${stats.totalSuccess}`) +
theme.fg("dim", " │ ") +
theme.fg("muted", "Errors: ") + theme.fg(stats.totalError > 0 ? "error" : "muted", `${stats.totalError}`) +
theme.fg("dim", " │ ") +
theme.fg("muted", "Success Rate: ") + theme.fg("success", successRate),
width,
));
allLines.push(truncateToWidth(
" " +
theme.fg("muted", "Total Duration: ") + theme.fg("success", fmtDuration(stats.totalDuration)) +
theme.fg("dim", " │ ") +
theme.fg("muted", "Avg Duration: ") + theme.fg("accent", avgDur),
width,
));
allLines.push("");
// Interface breakdown
allLines.push(truncateToWidth(
" " + theme.fg("accent", theme.bold("Interface Breakdown")),
width,
));
allLines.push("");
const ifaceTotal = Math.max(1, stats.ifaceCounts.team + stats.ifaceCounts.subagent + stats.ifaceCounts.chain);
const barWidth = Math.min(30, Math.floor(width * 0.3));
for (const [iface, label] of [["team", "Team "], ["subagent", "Subagent "], ["chain", "Chain "]] as [AgentInterface, string][]) {
const count = stats.ifaceCounts[iface] || 0;
const ratio = count / ifaceTotal;
const filled = Math.round(ratio * barWidth);
const bar = "█".repeat(filled) + "░".repeat(barWidth - filled);
allLines.push(truncateToWidth(
" " +
theme.fg("accent", label) + " " +
theme.fg("success", bar) + " " +
theme.fg("muted", `${count}`) +
theme.fg("dim", ` (${Math.round(ratio * 100)}%)`),
width,
));
}
allLines.push("");
// Most-used agents bar chart
allLines.push(truncateToWidth(
" " + theme.fg("accent", theme.bold("Most-Used Agents")),
width,
));
allLines.push("");
const agentEntries = Object.entries(stats.agentRunCounts).sort((a, b) => b[1] - a[1]);
if (agentEntries.length === 0) {
allLines.push(truncateToWidth(" " + theme.fg("dim", "No agent runs recorded yet."), width));
} else {
const maxCount = agentEntries[0][1];
for (const [name, count] of agentEntries.slice(0, 15)) {
const ratio = maxCount > 0 ? count / maxCount : 0;
const filled = Math.round(ratio * barWidth);
const bar = "█".repeat(filled) + "░".repeat(barWidth - filled);
allLines.push(truncateToWidth(
" " +
theme.fg("accent", name.padEnd(16)) + " " +
theme.fg("success", bar) + " " +
theme.fg("muted", `${count}`),
width,
));
}
}
allLines.push("");
// Per-agent average durations
if (history.length > 0) {
allLines.push(truncateToWidth(
" " + theme.fg("accent", theme.bold("Average Duration by Agent")),
width,
));
allLines.push("");
const durByAgent: Record<string, number[]> = {};
for (const run of history) {
if (!durByAgent[run.name]) durByAgent[run.name] = [];
durByAgent[run.name].push(run.duration);
}
const durEntries = Object.entries(durByAgent).sort((a, b) => {
const avgA = a[1].reduce((s, v) => s + v, 0) / a[1].length;
const avgB = b[1].reduce((s, v) => s + v, 0) / b[1].length;
return avgB - avgA;
});
for (const [name, durations] of durEntries) {
const avg = durations.reduce((s, v) => s + v, 0) / durations.length;
const min = Math.min(...durations);
const max = Math.max(...durations);
allLines.push(truncateToWidth(
" " +
theme.fg("accent", name.padEnd(16)) +
theme.fg("dim", " avg: ") + theme.fg("success", fmtDuration(Math.round(avg)).padEnd(8)) +
theme.fg("dim", " min: ") + theme.fg("muted", fmtDuration(min).padEnd(8)) +
theme.fg("dim", " max: ") + theme.fg("muted", fmtDuration(max).padEnd(8)) +
theme.fg("dim", " runs: ") + theme.fg("muted", `${durations.length}`),
width,
));
}
}
const visible = allLines.slice(offset);
lines.push(...visible);
return lines;
}
// ── Commands ───────────────────────────────────────────────────────
pi.registerCommand("dashboard", {
description: "Open Agent Dashboard overlay. Args: clear",
handler: async (args, ctx) => {
widgetCtx = ctx;
const arg = (args || "").trim().toLowerCase();
if (arg === "clear") {
stopTick();
clearState();
startTick();
ctx.ui.notify("📊 Dashboard: All data cleared.", "info");
updateWidget();
return;
}
await openOverlay(ctx);
},
});
// ── Event Handlers ─────────────────────────────────────────────────
pi.on("session_start", async (_event, ctx) => {
applyExtensionDefaults(import.meta.url, ctx);
stopTick();
widgetCtx = ctx;
clearState();
startTick();
updateWidget();
});
pi.on("before_agent_start", async (_event, ctx) => {
widgetCtx = ctx;
return undefined;
});
pi.on("agent_end", async (_event, ctx) => {
widgetCtx = ctx;
updateWidget();
});
pi.on("tool_call", async (event, _ctx) => {
try {
const toolName = event.toolName;
if (!TRACKED_TOOLS.has(toolName)) return undefined;
const input = event.input;
const now = Date.now();
const callId = event.toolCallId;
lastActivityTs = now;
if (toolName === "dispatch_agent") {
const agentName = (input.agent as string) || "unknown";
const task = (input.task as string) || "";
const id = `team:${agentName}:${shortId()}`;
const tracked: TrackedAgent = {
id,
name: agentName,
iface: "team",
status: "running",
task,
startedAt: now,
elapsed: 0,
toolCount: 0,
lastText: "",
turnCount: 1,
teamName: agentName,
};
activeAgents.set(id, tracked);
pendingCalls.set(callId, { agentId: id, ts: now });
} else if (toolName === "subagent_create") {
const task = (input.task as string) || "";
const id = `sub:create:${shortId()}`;
const tracked: TrackedAgent = {
id,
name: "Subagent",
iface: "subagent",
status: "running",
task,
startedAt: now,
elapsed: 0,
toolCount: 0,
lastText: "",
turnCount: 1,
};
activeAgents.set(id, tracked);
pendingCalls.set(callId, { agentId: id, ts: now });
} else if (toolName === "subagent_continue") {
// Always create a new tracking entry using the widget's ID from input
const subId = input.id;
const prompt = (input.prompt as string) || "";
const id = `sub:cont:${subId}:${shortId()}`;
const tracked: TrackedAgent = {
id,
name: `Subagent #${subId}`,
iface: "subagent",
status: "running",
task: prompt,
startedAt: now,
elapsed: 0,
toolCount: 0,
lastText: "",
turnCount: 1,
};
activeAgents.set(id, tracked);
pendingCalls.set(callId, { agentId: id, ts: now });
} else if (toolName === "run_chain") {
const task = (input.task as string) || "";
const id = `chain:${shortId()}`;
const tracked: TrackedAgent = {
id,
name: "chain",
iface: "chain",
status: "running",
task,
startedAt: now,
elapsed: 0,
toolCount: 0,
lastText: "",
turnCount: 1,
chainName: "pipeline",
};
activeAgents.set(id, tracked);
pendingCalls.set(callId, { agentId: id, ts: now });
}
// Ensure tick is running when we have active agents
startTick();
updateWidget();
} catch {}
return undefined;
});
pi.on("tool_execution_end", async (event) => {
try {
const toolName = event.toolName;
if (!TRACKED_TOOLS.has(toolName)) return;
const now = Date.now();
const callId = event.toolCallId;
lastActivityTs = now;
const pending = pendingCalls.get(callId);
if (pending) {
pendingCalls.delete(callId);
const agent = activeAgents.get(pending.agentId);
if (agent) {
agent.status = event.isError ? "error" : "done";
agent.endedAt = now;
agent.elapsed = now - agent.startedAt;
// Extract result preview if available
try {
const result = event.result;
if (result?.content) {
for (const block of result.content) {
if (block.type === "text" && block.text) {
agent.lastText = block.text.slice(0, 200);
break;
}
}
}
} catch {}
// Move to history
addToHistory(agent);
activeAgents.delete(pending.agentId);
}
}
updateWidget();
} catch {}
});
}

View File

@@ -2,8 +2,9 @@
* Agent Team — Dispatcher-only orchestrator with grid dashboard * Agent Team — Dispatcher-only orchestrator with grid dashboard
* *
* The primary Pi agent has NO codebase tools. It can ONLY delegate work * The primary Pi agent has NO codebase tools. It can ONLY delegate work
* to specialist agents via the `dispatch_agent` tool. Each specialist * to specialist agents via the `dispatch_agent` tool (single) or
* maintains its own Pi session for cross-invocation memory. * `dispatch_agents` tool (parallel batch). Each specialist maintains
* its own Pi session for cross-invocation memory.
* *
* Loads agent definitions from agents/*.md, .claude/agents/*.md, .pi/agents/*.md. * Loads agent definitions from agents/*.md, .claude/agents/*.md, .pi/agents/*.md.
* Teams are defined in .pi/agents/teams.yaml — on boot a select dialog lets * Teams are defined in .pi/agents/teams.yaml — on boot a select dialog lets
@@ -20,11 +21,16 @@
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Type } from "@sinclair/typebox"; import { Type } from "@sinclair/typebox";
import { Text, type AutocompleteItem, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; import { Text, type AutocompleteItem, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
import { spawn } from "child_process"; import { spawn, type ChildProcess } from "child_process";
import { readdirSync, readFileSync, existsSync, mkdirSync, unlinkSync } from "fs"; import { readdirSync, readFileSync, existsSync, mkdirSync, unlinkSync } from "fs";
import { join, resolve } from "path"; import { join, resolve } from "path";
import { applyExtensionDefaults } from "./themeMap.ts"; import { applyExtensionDefaults } from "./themeMap.ts";
// ── Constants ────────────────────────────────────
const AGENT_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes per dispatch
const WIDGET_THROTTLE_MS = 500; // max widget refresh rate
// ── Types ──────────────────────────────────────── // ── Types ────────────────────────────────────────
interface AgentDef { interface AgentDef {
@@ -46,6 +52,7 @@ interface AgentState {
sessionFile: string | null; sessionFile: string | null;
runCount: number; runCount: number;
timer?: ReturnType<typeof setInterval>; timer?: ReturnType<typeof setInterval>;
proc?: ChildProcess;
} }
// ── Display Name Helper ────────────────────────── // ── Display Name Helper ──────────────────────────
@@ -136,6 +143,7 @@ function scanAgentDirs(cwd: string): AgentDef[] {
export default function (pi: ExtensionAPI) { export default function (pi: ExtensionAPI) {
const agentStates: Map<string, AgentState> = new Map(); const agentStates: Map<string, AgentState> = new Map();
const activeProcesses: Set<ChildProcess> = new Set();
let allAgentDefs: AgentDef[] = []; let allAgentDefs: AgentDef[] = [];
let teams: Record<string, string[]> = {}; let teams: Record<string, string[]> = {};
let activeTeamName = ""; let activeTeamName = "";
@@ -144,17 +152,40 @@ export default function (pi: ExtensionAPI) {
let sessionDir = ""; let sessionDir = "";
let contextWindow = 0; let contextWindow = 0;
// ── Throttled Widget Update ──────────────────
let widgetDirty = false;
let widgetTimer: ReturnType<typeof setTimeout> | null = null;
function scheduleWidgetUpdate() {
widgetDirty = true;
if (widgetTimer) return; // already scheduled
widgetTimer = setTimeout(() => {
widgetTimer = null;
if (widgetDirty) {
widgetDirty = false;
doUpdateWidget();
}
}, WIDGET_THROTTLE_MS);
}
function flushWidgetUpdate() {
if (widgetTimer) {
clearTimeout(widgetTimer);
widgetTimer = null;
}
widgetDirty = false;
doUpdateWidget();
}
function loadAgents(cwd: string) { function loadAgents(cwd: string) {
// Create session storage dir
sessionDir = join(cwd, ".pi", "agent-sessions"); sessionDir = join(cwd, ".pi", "agent-sessions");
if (!existsSync(sessionDir)) { if (!existsSync(sessionDir)) {
mkdirSync(sessionDir, { recursive: true }); mkdirSync(sessionDir, { recursive: true });
} }
// Load all agent definitions
allAgentDefs = scanAgentDirs(cwd); allAgentDefs = scanAgentDirs(cwd);
// Load teams from .pi/agents/teams.yaml
const teamsPath = join(cwd, ".pi", "agents", "teams.yaml"); const teamsPath = join(cwd, ".pi", "agents", "teams.yaml");
if (existsSync(teamsPath)) { if (existsSync(teamsPath)) {
try { try {
@@ -166,7 +197,6 @@ export default function (pi: ExtensionAPI) {
teams = {}; teams = {};
} }
// If no teams defined, create a default "all" team
if (Object.keys(teams).length === 0) { if (Object.keys(teams).length === 0) {
teams = { all: allAgentDefs.map(d => d.name) }; teams = { all: allAgentDefs.map(d => d.name) };
} }
@@ -196,11 +226,24 @@ export default function (pi: ExtensionAPI) {
}); });
} }
// Auto-size grid columns based on team size
const size = agentStates.size; const size = agentStates.size;
gridCols = size <= 3 ? size : size === 4 ? 2 : 3; gridCols = size <= 3 ? size : size === 4 ? 2 : 3;
} }
// ── Kill all tracked child processes ─────────
function killAllAgents() {
for (const proc of activeProcesses) {
try { proc.kill("SIGTERM"); } catch {}
}
// Force kill after 3s
setTimeout(() => {
for (const proc of activeProcesses) {
try { proc.kill("SIGKILL"); } catch {}
}
}, 3000);
}
// ── Grid Rendering ─────────────────────────── // ── Grid Rendering ───────────────────────────
function renderCard(state: AgentState, colWidth: number, theme: any): string[] { function renderCard(state: AgentState, colWidth: number, theme: any): string[] {
@@ -223,7 +266,6 @@ export default function (pi: ExtensionAPI) {
const statusLine = theme.fg(statusColor, statusStr + timeStr); const statusLine = theme.fg(statusColor, statusStr + timeStr);
const statusVisible = statusStr.length + timeStr.length; const statusVisible = statusStr.length + timeStr.length;
// Context bar: 5 blocks + percent
const filled = Math.ceil(state.contextPct / 20); const filled = Math.ceil(state.contextPct / 20);
const bar = "#".repeat(filled) + "-".repeat(5 - filled); const bar = "#".repeat(filled) + "-".repeat(5 - filled);
const ctxStr = `[${bar}] ${Math.ceil(state.contextPct)}%`; const ctxStr = `[${bar}] ${Math.ceil(state.contextPct)}%`;
@@ -252,7 +294,7 @@ export default function (pi: ExtensionAPI) {
]; ];
} }
function updateWidget() { function doUpdateWidget() {
if (!widgetCtx) return; if (!widgetCtx) return;
widgetCtx.ui.setWidget("agent-team", (_tui: any, theme: any) => { widgetCtx.ui.setWidget("agent-team", (_tui: any, theme: any) => {
@@ -302,6 +344,7 @@ export default function (pi: ExtensionAPI) {
agentName: string, agentName: string,
task: string, task: string,
ctx: any, ctx: any,
signal?: AbortSignal,
): Promise<{ output: string; exitCode: number; elapsed: number }> { ): Promise<{ output: string; exitCode: number; elapsed: number }> {
const key = agentName.toLowerCase(); const key = agentName.toLowerCase();
const state = agentStates.get(key); const state = agentStates.get(key);
@@ -321,29 +364,29 @@ export default function (pi: ExtensionAPI) {
}); });
} }
// Reset state for new run
state.status = "running"; state.status = "running";
state.task = task; state.task = task;
state.toolCount = 0; state.toolCount = 0;
state.elapsed = 0; state.elapsed = 0;
state.lastWork = ""; state.lastWork = "";
state.contextPct = 0;
state.runCount++; state.runCount++;
updateWidget(); scheduleWidgetUpdate();
const startTime = Date.now(); const startTime = Date.now();
state.timer = setInterval(() => { state.timer = setInterval(() => {
state.elapsed = Date.now() - startTime; state.elapsed = Date.now() - startTime;
updateWidget(); scheduleWidgetUpdate();
}, 1000); }, 1000);
const model = ctx.model const model = ctx.model
? `${ctx.model.provider}/${ctx.model.id}` ? `${ctx.model.provider}/${ctx.model.id}`
: "openrouter/google/gemini-3-flash-preview"; : "openrouter/google/gemini-3-flash-preview";
// Session file for this agent
const agentKey = state.def.name.toLowerCase().replace(/\s+/g, "-"); const agentKey = state.def.name.toLowerCase().replace(/\s+/g, "-");
const agentSessionFile = join(sessionDir, `${agentKey}.json`); const agentSessionFile = join(sessionDir, `${agentKey}.json`);
// Build args — first run creates session, subsequent runs resume
const args = [ const args = [
"--mode", "json", "--mode", "json",
"-p", "-p",
@@ -355,7 +398,6 @@ export default function (pi: ExtensionAPI) {
"--session", agentSessionFile, "--session", agentSessionFile,
]; ];
// Continue existing session if we have one
if (state.sessionFile) { if (state.sessionFile) {
args.push("-c"); args.push("-c");
} }
@@ -363,13 +405,44 @@ export default function (pi: ExtensionAPI) {
args.push(task); args.push(task);
const textChunks: string[] = []; const textChunks: string[] = [];
let resolved = false;
return new Promise((promiseResolve) => {
// Guard against double-resolve
const safeResolve = (val: { output: string; exitCode: number; elapsed: number }) => {
if (resolved) return;
resolved = true;
promiseResolve(val);
};
return new Promise((resolve) => {
const proc = spawn("pi", args, { const proc = spawn("pi", args, {
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env }, env: { ...process.env },
}); });
state.proc = proc;
activeProcesses.add(proc);
// ── Timeout guard ──
const timeout = setTimeout(() => {
try { proc.kill("SIGTERM"); } catch {}
// Force kill after 3s if still alive
setTimeout(() => { try { proc.kill("SIGKILL"); } catch {} }, 3000);
}, AGENT_TIMEOUT_MS);
// ── AbortSignal support ──
const onAbort = () => {
try { proc.kill("SIGTERM"); } catch {}
setTimeout(() => { try { proc.kill("SIGKILL"); } catch {} }, 3000);
};
if (signal) {
if (signal.aborted) {
onAbort();
} else {
signal.addEventListener("abort", onAbort, { once: true });
}
}
let buffer = ""; let buffer = "";
proc.stdout!.setEncoding("utf-8"); proc.stdout!.setEncoding("utf-8");
@@ -388,23 +461,23 @@ export default function (pi: ExtensionAPI) {
const full = textChunks.join(""); const full = textChunks.join("");
const last = full.split("\n").filter((l: string) => l.trim()).pop() || ""; const last = full.split("\n").filter((l: string) => l.trim()).pop() || "";
state.lastWork = last; state.lastWork = last;
updateWidget(); scheduleWidgetUpdate();
} }
} else if (event.type === "tool_execution_start") { } else if (event.type === "tool_execution_start") {
state.toolCount++; state.toolCount++;
updateWidget(); scheduleWidgetUpdate();
} else if (event.type === "message_end") { } else if (event.type === "message_end") {
const msg = event.message; const msg = event.message;
if (msg?.usage && contextWindow > 0) { if (msg?.usage && contextWindow > 0) {
state.contextPct = ((msg.usage.input || 0) / contextWindow) * 100; state.contextPct = ((msg.usage.input || 0) / contextWindow) * 100;
updateWidget(); scheduleWidgetUpdate();
} }
} else if (event.type === "agent_end") { } else if (event.type === "agent_end") {
const msgs = event.messages || []; const msgs = event.messages || [];
const last = [...msgs].reverse().find((m: any) => m.role === "assistant"); const last = [...msgs].reverse().find((m: any) => m.role === "assistant");
if (last?.usage && contextWindow > 0) { if (last?.usage && contextWindow > 0) {
state.contextPct = ((last.usage.input || 0) / contextWindow) * 100; state.contextPct = ((last.usage.input || 0) / contextWindow) * 100;
updateWidget(); scheduleWidgetUpdate();
} }
} }
} catch {} } catch {}
@@ -415,6 +488,12 @@ export default function (pi: ExtensionAPI) {
proc.stderr!.on("data", () => {}); proc.stderr!.on("data", () => {});
proc.on("close", (code) => { proc.on("close", (code) => {
clearTimeout(timeout);
if (signal) signal.removeEventListener?.("abort", onAbort);
activeProcesses.delete(proc);
state.proc = undefined;
// Process any remaining buffer
if (buffer.trim()) { if (buffer.trim()) {
try { try {
const event = JSON.parse(buffer); const event = JSON.parse(buffer);
@@ -427,35 +506,45 @@ export default function (pi: ExtensionAPI) {
clearInterval(state.timer); clearInterval(state.timer);
state.elapsed = Date.now() - startTime; state.elapsed = Date.now() - startTime;
state.status = code === 0 ? "done" : "error";
// Mark session file as available for resume const timedOut = state.elapsed >= AGENT_TIMEOUT_MS;
state.status = timedOut ? "error" : (code === 0 ? "done" : "error");
if (code === 0) { if (code === 0) {
state.sessionFile = agentSessionFile; state.sessionFile = agentSessionFile;
} }
const full = textChunks.join(""); const full = textChunks.join("");
state.lastWork = full.split("\n").filter((l: string) => l.trim()).pop() || ""; state.lastWork = full.split("\n").filter((l: string) => l.trim()).pop() || "";
updateWidget(); flushWidgetUpdate();
ctx.ui.notify( const statusMsg = timedOut
`${displayName(state.def.name)} ${state.status} in ${Math.round(state.elapsed / 1000)}s`, ? `${displayName(state.def.name)} timed out after ${Math.round(AGENT_TIMEOUT_MS / 1000)}s`
state.status === "done" ? "success" : "error" : `${displayName(state.def.name)} ${state.status} in ${Math.round(state.elapsed / 1000)}s`;
);
resolve({ ctx.ui.notify(statusMsg, state.status === "done" ? "success" : "error");
output: full,
const output = timedOut
? full + "\n\n[TIMED OUT after " + Math.round(AGENT_TIMEOUT_MS / 1000) + "s]"
: full;
safeResolve({
output,
exitCode: code ?? 1, exitCode: code ?? 1,
elapsed: state.elapsed, elapsed: state.elapsed,
}); });
}); });
proc.on("error", (err) => { proc.on("error", (err) => {
clearTimeout(timeout);
if (signal) signal.removeEventListener?.("abort", onAbort);
activeProcesses.delete(proc);
state.proc = undefined;
clearInterval(state.timer); clearInterval(state.timer);
state.status = "error"; state.status = "error";
state.lastWork = `Error: ${err.message}`; state.lastWork = `Error: ${err.message}`;
updateWidget(); flushWidgetUpdate();
resolve({ safeResolve({
output: `Error spawning agent: ${err.message}`, output: `Error spawning agent: ${err.message}`,
exitCode: 1, exitCode: 1,
elapsed: Date.now() - startTime, elapsed: Date.now() - startTime,
@@ -464,18 +553,18 @@ export default function (pi: ExtensionAPI) {
}); });
} }
// ── dispatch_agent Tool (registered at top level) ── // ── dispatch_agent Tool (single) ─────────────
pi.registerTool({ pi.registerTool({
name: "dispatch_agent", name: "dispatch_agent",
label: "Dispatch Agent", label: "Dispatch Agent",
description: "Dispatch a task to a specialist agent. The agent will execute the task and return the result. Use the system prompt to see available agent names.", description: "Dispatch a task to a single specialist agent. The agent executes the task and returns the result. For dispatching multiple agents in parallel, use dispatch_agents instead.",
parameters: Type.Object({ parameters: Type.Object({
agent: Type.String({ description: "Agent name (case-insensitive)" }), agent: Type.String({ description: "Agent name (case-insensitive)" }),
task: Type.String({ description: "Task description for the agent to execute" }), task: Type.String({ description: "Task description for the agent to execute" }),
}), }),
async execute(_toolCallId, params, _signal, onUpdate, ctx) { async execute(_toolCallId, params, signal, onUpdate, ctx) {
const { agent, task } = params as { agent: string; task: string }; const { agent, task } = params as { agent: string; task: string };
try { try {
@@ -486,7 +575,7 @@ export default function (pi: ExtensionAPI) {
}); });
} }
const result = await dispatchAgent(agent, task, ctx); const result = await dispatchAgent(agent, task, ctx, signal);
const truncated = result.output.length > 8000 const truncated = result.output.length > 8000
? result.output.slice(0, 8000) + "\n\n... [truncated]" ? result.output.slice(0, 8000) + "\n\n... [truncated]"
@@ -534,7 +623,6 @@ export default function (pi: ExtensionAPI) {
return new Text(text?.type === "text" ? text.text : "", 0, 0); return new Text(text?.type === "text" ? text.text : "", 0, 0);
} }
// Streaming/partial result while agent is still running
if (options.isPartial || details.status === "dispatching") { if (options.isPartial || details.status === "dispatching") {
return new Text( return new Text(
theme.fg("accent", `${details.agent || "?"}`) + theme.fg("accent", `${details.agent || "?"}`) +
@@ -560,6 +648,120 @@ export default function (pi: ExtensionAPI) {
}, },
}); });
// ── dispatch_agents Tool (parallel batch) ────
pi.registerTool({
name: "dispatch_agents",
label: "Dispatch Agents (Parallel)",
description: "Dispatch tasks to multiple specialist agents in parallel. All agents run simultaneously and results are returned together. Much faster than sequential dispatch_agent calls when tasks are independent.",
parameters: Type.Object({
dispatches: Type.Array(
Type.Object({
agent: Type.String({ description: "Agent name (case-insensitive)" }),
task: Type.String({ description: "Task description for the agent" }),
}),
{ description: "Array of {agent, task} pairs to dispatch in parallel", minItems: 1 },
),
}),
async execute(_toolCallId, params, signal, onUpdate, ctx) {
const { dispatches } = params as { dispatches: { agent: string; task: string }[] };
const agentNames = dispatches.map(d => d.agent).join(", ");
if (onUpdate) {
onUpdate({
content: [{ type: "text", text: `Dispatching ${dispatches.length} agents in parallel: ${agentNames}` }],
details: { dispatches, status: "dispatching", count: dispatches.length },
});
}
// Launch all in parallel
const promises = dispatches.map(({ agent, task }) =>
dispatchAgent(agent, task, ctx, signal).then(result => ({
agent,
task,
...result,
}))
);
const results = await Promise.all(promises);
const summaryParts: string[] = [];
const allDetails: any[] = [];
for (const r of results) {
const status = r.exitCode === 0 ? "done" : "error";
const truncated = r.output.length > 4000
? r.output.slice(0, 4000) + "\n... [truncated]"
: r.output;
summaryParts.push(`## [${r.agent}] ${status} in ${Math.round(r.elapsed / 1000)}s\n\n${truncated}`);
allDetails.push({
agent: r.agent,
task: r.task,
status,
elapsed: r.elapsed,
exitCode: r.exitCode,
fullOutput: r.output,
});
}
const doneCount = results.filter(r => r.exitCode === 0).length;
const header = `Parallel dispatch complete: ${doneCount}/${results.length} succeeded`;
return {
content: [{ type: "text", text: `${header}\n\n${summaryParts.join("\n\n---\n\n")}` }],
details: {
dispatches: allDetails,
status: "complete",
count: results.length,
succeeded: doneCount,
},
};
},
renderCall(args, theme) {
const dispatches = (args as any).dispatches || [];
const names = dispatches.map((d: any) => d.agent || "?").join(", ");
return new Text(
theme.fg("toolTitle", theme.bold("dispatch_agents ")) +
theme.fg("accent", `[${dispatches.length}] `) +
theme.fg("muted", names),
0, 0,
);
},
renderResult(result, options, theme) {
const details = result.details as any;
if (!details) {
const text = result.content[0];
return new Text(text?.type === "text" ? text.text : "", 0, 0);
}
if (options.isPartial || details.status === "dispatching") {
return new Text(
theme.fg("accent", `● Parallel dispatch`) +
theme.fg("dim", ` ${details.count || "?"} agents working...`),
0, 0,
);
}
const header = theme.fg("success", `${details.succeeded}`) +
theme.fg("dim", `/${details.count} agents completed`);
if (options.expanded && Array.isArray(details.dispatches)) {
const lines = details.dispatches.map((d: any) => {
const icon = d.status === "done" ? "✓" : "✗";
const color = d.status === "done" ? "success" : "error";
return theme.fg(color, ` ${icon} ${d.agent}`) +
theme.fg("dim", ` ${Math.round(d.elapsed / 1000)}s`);
});
return new Text(header + "\n" + lines.join("\n"), 0, 0);
}
return new Text(header, 0, 0);
},
});
// ── Commands ───────────────────────────────── // ── Commands ─────────────────────────────────
pi.registerCommand("agents-team", { pi.registerCommand("agents-team", {
@@ -583,7 +785,7 @@ export default function (pi: ExtensionAPI) {
const idx = options.indexOf(choice); const idx = options.indexOf(choice);
const name = teamNames[idx]; const name = teamNames[idx];
activateTeam(name); activateTeam(name);
updateWidget(); flushWidgetUpdate();
ctx.ui.setStatus("agent-team", `Team: ${name} (${agentStates.size})`); ctx.ui.setStatus("agent-team", `Team: ${name} (${agentStates.size})`);
ctx.ui.notify(`Team: ${name}${Array.from(agentStates.values()).map(s => displayName(s.def.name)).join(", ")}`, "info"); ctx.ui.notify(`Team: ${name}${Array.from(agentStates.values()).map(s => displayName(s.def.name)).join(", ")}`, "info");
}, },
@@ -619,7 +821,7 @@ export default function (pi: ExtensionAPI) {
if (n >= 1 && n <= 6) { if (n >= 1 && n <= 6) {
gridCols = n; gridCols = n;
_ctx.ui.notify(`Grid set to ${gridCols} columns`, "info"); _ctx.ui.notify(`Grid set to ${gridCols} columns`, "info");
updateWidget(); flushWidgetUpdate();
} else { } else {
_ctx.ui.notify("Usage: /agents-grid <1-6>", "error"); _ctx.ui.notify("Usage: /agents-grid <1-6>", "error");
} }
@@ -629,7 +831,6 @@ export default function (pi: ExtensionAPI) {
// ── System Prompt Override ─────────────────── // ── System Prompt Override ───────────────────
pi.on("before_agent_start", async (_event, _ctx) => { pi.on("before_agent_start", async (_event, _ctx) => {
// Build dynamic agent catalog from active team only
const agentCatalog = Array.from(agentStates.values()) const agentCatalog = Array.from(agentStates.values())
.map(s => `### ${displayName(s.def.name)}\n**Dispatch as:** \`${s.def.name}\`\n${s.def.description}\n**Tools:** ${s.def.tools}`) .map(s => `### ${displayName(s.def.name)}\n**Dispatch as:** \`${s.def.name}\`\n${s.def.description}\n**Tools:** ${s.def.tools}`)
.join("\n\n"); .join("\n\n");
@@ -639,7 +840,7 @@ export default function (pi: ExtensionAPI) {
return { return {
systemPrompt: `You are a dispatcher agent. You coordinate specialist agents to accomplish tasks. systemPrompt: `You are a dispatcher agent. You coordinate specialist agents to accomplish tasks.
You do NOT have direct access to the codebase. You MUST delegate all work through You do NOT have direct access to the codebase. You MUST delegate all work through
agents using the dispatch_agent tool. agents using the dispatch_agent or dispatch_agents tools.
## Active Team: ${activeTeamName} ## Active Team: ${activeTeamName}
Members: ${teamMembers} Members: ${teamMembers}
@@ -648,17 +849,20 @@ You can ONLY dispatch to agents listed below. Do not attempt to dispatch to agen
## How to Work ## How to Work
- Analyze the user's request and break it into clear sub-tasks - Analyze the user's request and break it into clear sub-tasks
- Choose the right agent(s) for each sub-task - Choose the right agent(s) for each sub-task
- Dispatch tasks using the dispatch_agent tool - **Use dispatch_agents for independent parallel tasks** — this is much faster
- Use dispatch_agent for sequential tasks where order matters
- Review results and dispatch follow-up agents if needed - Review results and dispatch follow-up agents if needed
- If a task fails, try a different agent or adjust the task description - If a task fails, try a different agent or adjust the task description
- Summarize the outcome for the user - Summarize the outcome for the user
## Rules ## Rules
- NEVER try to read, write, or execute code directly — you have no such tools - NEVER try to read, write, or execute code directly — you have no such tools
- ALWAYS use dispatch_agent to get work done - ALWAYS use dispatch_agent or dispatch_agents to get work done
- **Prefer dispatch_agents when tasks are independent** — parallelism saves time
- You can chain agents: use scout to explore, then builder to implement - You can chain agents: use scout to explore, then builder to implement
- You can dispatch the same agent multiple times with different tasks - You can dispatch the same agent multiple times with different tasks
- Keep tasks focused — one clear objective per dispatch - Keep tasks focused — one clear objective per dispatch
- Each agent has a ${Math.round(AGENT_TIMEOUT_MS / 1000)}s timeout — break large tasks into smaller ones
## Agents ## Agents
@@ -670,7 +874,6 @@ ${agentCatalog}`,
pi.on("session_start", async (_event, _ctx) => { pi.on("session_start", async (_event, _ctx) => {
applyExtensionDefaults(import.meta.url, _ctx); applyExtensionDefaults(import.meta.url, _ctx);
// Clear widgets from previous session
if (widgetCtx) { if (widgetCtx) {
widgetCtx.ui.setWidget("agent-team", undefined); widgetCtx.ui.setWidget("agent-team", undefined);
} }
@@ -689,14 +892,12 @@ ${agentCatalog}`,
loadAgents(_ctx.cwd); loadAgents(_ctx.cwd);
// Default to first team — use /agents-team to switch
const teamNames = Object.keys(teams); const teamNames = Object.keys(teams);
if (teamNames.length > 0) { if (teamNames.length > 0) {
activateTeam(teamNames[0]); activateTeam(teamNames[0]);
} }
// Lock down to dispatcher-only (tool already registered at top level) pi.setActiveTools(["dispatch_agent", "dispatch_agents"]);
pi.setActiveTools(["dispatch_agent"]);
_ctx.ui.setStatus("agent-team", `Team: ${activeTeamName} (${agentStates.size})`); _ctx.ui.setStatus("agent-team", `Team: ${activeTeamName} (${agentStates.size})`);
const members = Array.from(agentStates.values()).map(s => displayName(s.def.name)).join(", "); const members = Array.from(agentStates.values()).map(s => displayName(s.def.name)).join(", ");
@@ -708,9 +909,8 @@ ${agentCatalog}`,
`/agents-grid <1-6> Set grid column count`, `/agents-grid <1-6> Set grid column count`,
"info", "info",
); );
updateWidget(); flushWidgetUpdate();
// Footer: model | team | context bar
_ctx.ui.setFooter((_tui, theme, _footerData) => ({ _ctx.ui.setFooter((_tui, theme, _footerData) => ({
dispose: () => {}, dispose: () => {},
invalidate() {}, invalidate() {},
@@ -721,9 +921,13 @@ ${agentCatalog}`,
const filled = Math.round(pct / 10); const filled = Math.round(pct / 10);
const bar = "#".repeat(filled) + "-".repeat(10 - filled); const bar = "#".repeat(filled) + "-".repeat(10 - filled);
const running = Array.from(agentStates.values()).filter(s => s.status === "running").length;
const runningStr = running > 0 ? theme.fg("accent", `${running} running`) : "";
const left = theme.fg("dim", ` ${model}`) + const left = theme.fg("dim", ` ${model}`) +
theme.fg("muted", " · ") + theme.fg("muted", " · ") +
theme.fg("accent", activeTeamName); theme.fg("accent", activeTeamName) +
runningStr;
const right = theme.fg("dim", `[${bar}] ${Math.round(pct)}% `); const right = theme.fg("dim", `[${bar}] ${Math.round(pct)}% `);
const pad = " ".repeat(Math.max(1, width - visibleWidth(left) - visibleWidth(right))); const pad = " ".repeat(Math.max(1, width - visibleWidth(left) - visibleWidth(right)));
@@ -731,4 +935,10 @@ ${agentCatalog}`,
}, },
})); }));
}); });
// ── Cleanup on exit ──────────────────────────
process.on("exit", () => killAllAgents());
process.on("SIGINT", () => { killAllAgents(); process.exit(0); });
process.on("SIGTERM", () => { killAllAgents(); process.exit(0); });
} }

1100
extensions/observatory.ts Normal file

File diff suppressed because it is too large Load Diff

41
extensions/stop.ts Normal file
View File

@@ -0,0 +1,41 @@
/**
* Stop — Immediately interrupt the active chat session
*
* Registers a /stop slash command that aborts the current agent turn.
* Also supports /stop with a reason message for logging clarity.
*
* Usage: pi -e extensions/stop.ts
*
* Commands:
* /stop — abort the current agent turn immediately
* /stop <reason> — abort with a logged reason
*/
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
import { applyExtensionDefaults } from "./themeMap.ts";
export default function (pi: ExtensionAPI) {
let activeCtx: ExtensionContext | undefined;
pi.on("session_start", async (_event, ctx) => {
applyExtensionDefaults(import.meta.url, ctx);
activeCtx = ctx;
});
pi.on("session_switch", async (_event, ctx) => {
activeCtx = ctx;
});
pi.registerCommand("stop", {
description: "Immediately interrupt the active agent turn. Usage: /stop [reason]",
handler: async (args, ctx) => {
activeCtx = ctx;
const reason = (args || "").trim();
ctx.abort();
const msg = reason
? `🛑 Session aborted: ${reason}`
: "🛑 Session aborted.";
ctx.ui.notify(msg, "warning");
},
});
}

View File

@@ -22,10 +22,12 @@ import { fileURLToPath } from "url";
// //
export const THEME_MAP: Record<string, string> = { export const THEME_MAP: Record<string, string> = {
"agent-chain": "midnight-ocean", // deep sequential pipeline "agent-chain": "midnight-ocean", // deep sequential pipeline
"agent-dashboard": "tokyo-night", // unified monitoring hub
"agent-team": "dracula", // rich orchestration palette "agent-team": "dracula", // rich orchestration palette
"cross-agent": "ocean-breeze", // cross-boundary, connecting "cross-agent": "ocean-breeze", // cross-boundary, connecting
"damage-control": "gruvbox", // grounded, earthy safety "damage-control": "gruvbox", // grounded, earthy safety
"minimal": "synthwave", // synthwave by default now! "minimal": "synthwave", // synthwave by default now!
"observatory": "cyberpunk", // futuristic observation deck
"pi-pi": "rose-pine", // warm creative meta-agent "pi-pi": "rose-pine", // warm creative meta-agent
"pure-focus": "everforest", // calm, distraction-free "pure-focus": "everforest", // calm, distraction-free
"purpose-gate": "tokyo-night", // intentional, sharp focus "purpose-gate": "tokyo-night", // intentional, sharp focus

View File

@@ -67,11 +67,19 @@ ext-pi-pi:
#ext #ext
# 15. Session Replay: scrollable timeline overlay of session history (legit) # 15. Observatory: comprehensive observability dashboard with live widget, overlay, and export
ext-observatory:
pi -e extensions/observatory.ts -e extensions/theme-cycler.ts
# 16. Agent Dashboard: unified observability across team, subagent, and chain interfaces
ext-agent-dashboard:
pi -e extensions/agent-dashboard.ts -e extensions/theme-cycler.ts
# 17. Session Replay: scrollable timeline overlay of session history (legit)
ext-session-replay: ext-session-replay:
pi -e extensions/session-replay.ts -e extensions/minimal.ts pi -e extensions/session-replay.ts -e extensions/minimal.ts
# 16. Theme cycler: Ctrl+X forward, Ctrl+Q backward, /theme picker # 18. Theme cycler: Ctrl+X forward, Ctrl+Q backward, /theme picker
ext-theme-cycler: ext-theme-cycler:
pi -e extensions/theme-cycler.ts -e extensions/minimal.ts pi -e extensions/theme-cycler.ts -e extensions/minimal.ts
@@ -104,4 +112,6 @@ all:
just open system-select minimal theme-cycler just open system-select minimal theme-cycler
just open damage-control minimal theme-cycler just open damage-control minimal theme-cycler
just open agent-chain theme-cycler just open agent-chain theme-cycler
just open pi-pi theme-cycler just open pi-pi theme-cycler
just open observatory theme-cycler
just open agent-dashboard theme-cycler

View File

@@ -5,5 +5,8 @@
"description": "Pi Coding Agent extension playground", "description": "Pi Coding Agent extension playground",
"dependencies": { "dependencies": {
"yaml": "^2.8.0" "yaml": "^2.8.0"
},
"devDependencies": {
"@playwright/cli": "^0.1.1"
} }
} }

View File

@@ -0,0 +1,7 @@
DATABASE_URL=postgresql://user:password@localhost:5432/dbname
NEXTAUTH_SECRET=your-secret-here
NEXTAUTH_URL=http://localhost:3000
GOCARDLESS_ACCESS_TOKEN=your-gocardless-token
GOCARDLESS_ENVIRONMENT=sandbox
REDIS_URL=redis://localhost:6379
BASE_URL=http://localhost:3000

View File

@@ -0,0 +1,3 @@
{
"extends": ["next/core-web-vitals", "next/typescript"]
}

43
pledge-now-pay-later/.gitignore vendored Normal file
View File

@@ -0,0 +1,43 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
/src/generated/prisma
# SQLite dev database
*.db
*.db-journal

View File

@@ -0,0 +1,27 @@
FROM node:20-alpine AS base
FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npx prisma generate
RUN npm run build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder /app/prisma ./prisma
USER nextjs
EXPOSE 3000
ENV PORT=3000
CMD ["node", "server.js"]

View File

@@ -0,0 +1,139 @@
# Pledge Now, Pay Later
> Convert "I'll donate later" into tracked pledges with automatic payment follow-up. Free forever for UK charities.
## Features
- **15-Second Pledge Flow**: Mobile-first, 3-screen donor experience
- **QR Code Attribution**: Every pledge tied to event + volunteer/table
- **Pay by Bank Transfer**: Zero fees — unique reference for matching
- **Direct Debit**: GoCardless integration for automatic collection
- **Automated Reminders**: 4-step follow-up sequence (export/webhook)
- **Bank Statement Reconciliation**: Upload CSV, auto-match payments
- **CRM Export**: Full attribution data ready for import
- **Pipeline Dashboard**: Track pledges from new → paid
## Quick Start
### Prerequisites
- Node.js 18+
- Docker & Docker Compose
- npm or pnpm
### Setup
```bash
# 1. Clone and install
cd pledge-now-pay-later
npm install
# 2. Start database
docker compose up -d
# 3. Run migrations
npx prisma migrate dev --name init
# 4. Seed demo data
npx prisma db seed
# 5. Start dev server
npm run dev
```
Visit [http://localhost:3000](http://localhost:3000)
### Demo URLs
- **Landing**: http://localhost:3000
- **Donor Flow**: http://localhost:3000/p/demo
- **Dashboard**: http://localhost:3000/dashboard
## Tech Stack
| Layer | Technology |
|-------|-----------|
| Frontend | Next.js 14 (App Router) |
| Language | TypeScript |
| Styling | Tailwind CSS + shadcn/ui |
| Database | PostgreSQL 16 |
| ORM | Prisma |
| QR Codes | qrcode (node) |
| CSV Parsing | PapaParse |
| Icons | Lucide React |
| Auth | NextAuth.js (ready) |
## Architecture
```
src/
├── app/
│ ├── api/ # API routes
│ │ ├── analytics/ # Event tracking
│ │ ├── dashboard/ # Stats & pipeline
│ │ ├── events/ # CRUD + QR management
│ │ ├── exports/ # CRM pack CSV
│ │ ├── imports/ # Bank statement matching
│ │ ├── pledges/ # Create, update, mark paid
│ │ ├── qr/ # Resolve QR tokens
│ │ └── webhooks/ # Reminder event polling
│ ├── dashboard/ # Staff UI
│ │ ├── events/ # Event management + QR codes
│ │ ├── pledges/ # Pledge pipeline
│ │ ├── reconcile/ # Bank CSV import
│ │ ├── exports/ # Download CRM data
│ │ ├── settings/ # Org config
│ │ └── apply/ # Fractional CTO upsell
│ └── p/[token]/ # Donor pledge flow
│ └── steps/ # Amount → Payment → Identity → Instructions
├── components/ui/ # Reusable UI components
└── lib/ # Core utilities
├── prisma.ts # DB client
├── reference.ts # Bank-safe ref generator
├── qr.ts # QR code generation
├── matching.ts # Bank statement matching
├── reminders.ts # Reminder sequences
├── analytics.ts # Event tracking
├── exports.ts # CRM export formatting
└── validators.ts # Zod schemas
```
## API Reference
| Endpoint | Method | Purpose |
|----------|--------|---------|
| `/api/qr/{token}` | GET | Resolve QR code → event info |
| `/api/pledges` | POST | Create pledge |
| `/api/pledges/{id}` | PATCH | Update pledge status |
| `/api/pledges/{id}/mark-initiated` | POST | Donor "I've paid" |
| `/api/events` | GET/POST | List & create events |
| `/api/events/{id}/qr` | GET/POST | Manage QR sources |
| `/api/events/{id}/qr/{qrId}/download` | GET | Download QR PNG |
| `/api/dashboard` | GET | Dashboard stats |
| `/api/imports/bank-statement` | POST | Upload & match CSV |
| `/api/exports/crm-pack` | GET | Download CRM CSV |
| `/api/webhooks` | GET | Poll pending reminders |
| `/api/analytics` | POST | Track events |
## Payment Reference Format
References follow format: `PREFIX-XXXX-NN`
- **PREFIX**: Configurable per org (default: PNPL), max 4 chars
- **XXXX**: 4-char alphanumeric (human-safe: no 0/O, 1/I/l)
- **NN**: Amount in pounds (helps manual matching)
- Total max 18 chars (UK BACS limit)
Example: `PNPL-7K4P-50` (£50 pledge)
## Reminder Sequence
| Step | Delay | Message |
|------|-------|---------|
| 0 | T+0 | Payment instructions with bank details |
| 1 | T+2 days | Gentle nudge |
| 2 | T+7 days | Impact story + urgency |
| 3 | T+14 days | Final reminder + easy cancel |
Reminders auto-stop when pledge is marked paid.
## License
Proprietary — © Omair. All rights reserved.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,23 @@
version: "3.8"
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
ports:
- "5432:5432"
environment:
POSTGRES_DB: pnpl
POSTGRES_USER: pnpl
POSTGRES_PASSWORD: pnpl_dev
volumes:
- pgdata:/var/lib/postgresql/data
redis:
image: redis:7-alpine
restart: unless-stopped
ports:
- "6379:6379"
volumes:
pgdata:

View File

@@ -0,0 +1,134 @@
# Embed Guide — Add Pledge Now, Pay Later to Your Website
## Option 1: QR Code + Direct Link (Recommended)
The simplest way — just share your pledge page URL or print the QR code.
### Get Your Link
Every QR source generates a unique link:
```
https://your-domain.com/p/{code}
```
### Add a Button to Your Website
```html
<a href="https://your-domain.com/p/YOUR_CODE"
style="display:inline-block; background:#1e40af; color:#fff; padding:16px 32px;
border-radius:12px; font-weight:700; font-size:18px; text-decoration:none;">
Pledge Now →
</a>
```
### Embed as Full-Page iframe
```html
<iframe
src="https://your-domain.com/p/YOUR_CODE"
width="100%"
height="700"
style="border:none; border-radius:16px; max-width:480px;"
title="Make a Pledge"
></iframe>
```
## Option 2: QR Code for Print Materials
1. Go to **Dashboard → Events → [Your Event] → QR Codes**
2. Click **Download PNG** for each QR code
3. Print on table cards, flyers, or banners
### Recommended Sizes
- **Table cards**: 5cm × 5cm QR with label below
- **Banners**: 15cm × 15cm QR
- **Flyers**: 3cm × 3cm QR (still scannable)
### Print Template (HTML)
```html
<div style="text-align:center; padding:20px; border:2px solid #1e40af; border-radius:16px; width:200px;">
<img src="/api/events/EVENT_ID/qr/QR_ID/download?code=CODE" width="150" height="150" />
<p style="font-weight:700; margin-top:8px;">Scan to Pledge</p>
<p style="font-size:12px; color:#666;">Table 5 · Ramadan Gala 2025</p>
</div>
```
## Option 3: Webhook Integration (Advanced)
Connect reminders to your existing email/SMS tools:
### Poll for Due Reminders
```bash
curl -X GET "https://your-domain.com/api/webhooks?since=2025-01-01T00:00:00Z" \
-H "x-org-id: YOUR_ORG_ID"
```
### Response Format
```json
{
"events": [
{
"event": "reminder.due",
"timestamp": "2025-03-17T10:00:00Z",
"data": {
"reminderId": "clx...",
"pledgeId": "clx...",
"step": 1,
"channel": "email",
"donor": {
"name": "Sarah Khan",
"email": "sarah@example.com",
"phone": "07700900001"
},
"pledge": {
"reference": "PNPL-7K4P-50",
"amount": 5000,
"rail": "bank"
},
"event": "Ramadan Gala 2025"
}
}
]
}
```
### Zapier / Make Setup
1. Create a scheduled trigger (every 15 minutes)
2. HTTP GET to `/api/webhooks?since={{last_run}}`
3. For each event, send email/SMS via your provider
4. Templates available in Dashboard → Exports
## Option 4: CRM Integration
### Export Pledge Data
```bash
curl -X GET "https://your-domain.com/api/exports/crm-pack" \
-H "x-org-id: YOUR_ORG_ID" \
-o pledges.csv
```
### CSV Fields
| Field | Description |
|-------|-------------|
| pledge_reference | Unique ref (PNPL-XXXX-NN) |
| donor_name | Donor's name |
| donor_email | Email address |
| donor_phone | Phone number |
| amount_gbp | Amount in pounds |
| payment_method | bank / gocardless / card |
| status | new / initiated / paid / overdue / cancelled |
| event_name | Source event |
| source_label | QR source label |
| volunteer_name | Assigned volunteer |
| table_name | Table assignment |
| gift_aid | Yes / No |
| pledged_at | ISO timestamp |
| paid_at | ISO timestamp (if paid) |
| days_to_collect | Number of days to payment |
### Salesforce Import
1. Download CRM pack CSV
2. Salesforce → Setup → Data Import Wizard
3. Map fields: pledge_reference → External ID, amount_gbp → Amount, etc.
### Beacon CRM Import
1. Download CRM pack CSV
2. Beacon → Contacts → Import
3. Map donor fields and donation amount

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone",
};
export default nextConfig;

8166
pledge-now-pay-later/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,49 @@
{
"name": "pledge-now-pay-later",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@auth/prisma-adapter": "^2.11.1",
"@prisma/adapter-pg": "^7.4.2",
"@prisma/client": "^7.4.2",
"@stripe/stripe-js": "^8.8.0",
"@types/bcryptjs": "^2.4.6",
"@types/qrcode": "^1.5.6",
"bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.575.0",
"nanoid": "^5.1.6",
"next": "14.2.35",
"next-auth": "^4.24.13",
"papaparse": "^5.5.3",
"pg": "^8.19.0",
"prisma": "^7.4.2",
"qrcode": "^1.5.4",
"react": "^18",
"react-dom": "^18",
"stripe": "^20.4.0",
"tailwind-merge": "^3.5.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/node": "^20",
"@types/papaparse": "^5.5.2",
"@types/pg": "^8.18.0",
"@types/react": "^18",
"@types/react-dom": "^18",
"eslint": "^8",
"eslint-config-next": "14.2.35",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"tsx": "^4.21.0",
"typescript": "^5"
}
}

View File

@@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;

View File

@@ -0,0 +1,15 @@
// This file was generated by Prisma, and assumes you have installed the following:
// npm install --save-dev prisma dotenv
import "dotenv/config";
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
seed: "bun prisma/seed.mts",
},
datasource: {
url: process.env["DATABASE_URL"],
},
});

View File

@@ -0,0 +1,208 @@
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
datasource db {
provider = "postgresql"
}
model Organization {
id String @id @default(cuid())
name String
slug String @unique
country String @default("UK")
timezone String @default("Europe/London")
bankName String?
bankSortCode String?
bankAccountNo String?
bankAccountName String?
refPrefix String @default("PNPL")
logo String?
primaryColor String @default("#1e40af")
gcAccessToken String?
gcEnvironment String @default("sandbox")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
users User[]
events Event[]
pledges Pledge[]
imports Import[]
@@index([slug])
}
model User {
id String @id @default(cuid())
email String @unique
name String?
hashedPassword String?
role String @default("staff") // super_admin, org_admin, staff, volunteer
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([organizationId])
}
model Event {
id String @id @default(cuid())
name String
slug String
description String?
eventDate DateTime?
location String?
goalAmount Int? // in pence
currency String @default("GBP")
status String @default("active") // draft, active, closed, archived
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
qrSources QrSource[]
pledges Pledge[]
@@unique([organizationId, slug])
@@index([organizationId, status])
}
model QrSource {
id String @id @default(cuid())
label String // "Table 5", "Volunteer: Ahmed"
code String @unique // short token for URL
volunteerName String?
tableName String?
eventId String
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
scanCount Int @default(0)
createdAt DateTime @default(now())
pledges Pledge[]
@@index([eventId])
@@index([code])
}
model Pledge {
id String @id @default(cuid())
reference String @unique // human-safe bank ref e.g. "PNPL-7K4P-50"
amountPence Int
currency String @default("GBP")
rail String // bank, gocardless, card
status String @default("new") // new, initiated, paid, overdue, cancelled
donorName String?
donorEmail String?
donorPhone String?
giftAid Boolean @default(false)
iPaidClickedAt DateTime?
notes String?
eventId String
event Event @relation(fields: [eventId], references: [id])
qrSourceId String?
qrSource QrSource? @relation(fields: [qrSourceId], references: [id])
organizationId String
organization Organization @relation(fields: [organizationId], references: [id])
paymentInstruction PaymentInstruction?
payments Payment[]
reminders Reminder[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
paidAt DateTime?
cancelledAt DateTime?
@@index([organizationId, status])
@@index([reference])
@@index([eventId, status])
@@index([donorEmail])
@@index([donorPhone])
}
model PaymentInstruction {
id String @id @default(cuid())
pledgeId String @unique
pledge Pledge @relation(fields: [pledgeId], references: [id], onDelete: Cascade)
bankReference String // the unique ref to use
bankDetails Json // {sortCode, accountNo, accountName, bankName}
gcMandateId String?
gcMandateUrl String?
sentAt DateTime?
createdAt DateTime @default(now())
@@index([bankReference])
}
model Payment {
id String @id @default(cuid())
pledgeId String
pledge Pledge @relation(fields: [pledgeId], references: [id], onDelete: Cascade)
provider String // bank, gocardless, stripe
providerRef String? // external ID
amountPence Int
status String @default("pending") // pending, confirmed, failed
matchedBy String? // auto, manual
receivedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
importId String?
import Import? @relation(fields: [importId], references: [id])
@@index([pledgeId])
@@index([providerRef])
}
model Reminder {
id String @id @default(cuid())
pledgeId String
pledge Pledge @relation(fields: [pledgeId], references: [id], onDelete: Cascade)
step Int // 0=instructions, 1=nudge, 2=urgency, 3=final
channel String @default("email") // email, sms, whatsapp
scheduledAt DateTime
sentAt DateTime?
status String @default("pending") // pending, sent, skipped, failed
payload Json?
createdAt DateTime @default(now())
@@index([pledgeId])
@@index([scheduledAt, status])
}
model Import {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id])
kind String // bank_statement, gocardless_export, crm_export
fileName String?
rowCount Int @default(0)
matchedCount Int @default(0)
unmatchedCount Int @default(0)
mappingConfig Json?
stats Json?
status String @default("pending") // pending, processing, completed, failed
uploadedAt DateTime @default(now())
payments Payment[]
@@index([organizationId])
}
model AnalyticsEvent {
id String @id @default(cuid())
eventType String // pledge_start, amount_selected, rail_selected, identity_submitted, pledge_completed, instruction_copy_clicked, i_paid_clicked, payment_matched
pledgeId String?
eventId String?
qrSourceId String?
metadata Json?
createdAt DateTime @default(now())
@@index([eventType])
@@index([pledgeId])
@@index([eventId])
@@index([createdAt])
}

View File

@@ -0,0 +1,306 @@
import "dotenv/config"
import pg from "pg"
import { PrismaPg } from "@prisma/adapter-pg"
import { PrismaClient } from "../src/generated/prisma/client.ts"
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL })
const adapter = new PrismaPg(pool)
const prisma = new PrismaClient({ adapter })
function daysFromNow(days: number): Date {
return new Date(Date.now() + days * 86400000)
}
function daysAgo(days: number): Date {
return new Date(Date.now() - days * 86400000)
}
async function main() {
// ── Organisation ──
const org = await prisma.organization.upsert({
where: { slug: "demo-charity" },
update: {
bankName: "Barclays",
bankSortCode: "20-00-00",
bankAccountNo: "12345678",
bankAccountName: "Charity Right",
},
create: {
name: "Charity Right",
slug: "demo-charity",
country: "UK",
timezone: "Europe/London",
bankName: "Barclays",
bankSortCode: "20-00-00",
bankAccountNo: "12345678",
bankAccountName: "Charity Right",
refPrefix: "DEMO",
primaryColor: "#1e40af",
},
})
// ── Admin user ──
await prisma.user.upsert({
where: { email: "admin@charityright.org" },
update: {},
create: {
email: "admin@charityright.org",
name: "Azreen Jamal",
role: "org_admin",
organizationId: org.id,
},
})
// ── Events ──
const galaEvent = await prisma.event.upsert({
where: { organizationId_slug: { organizationId: org.id, slug: "ramadan-gala-2026" } },
update: { name: "Ramadan Gala 2026", eventDate: daysFromNow(14), goalAmount: 5000000 },
create: {
name: "Ramadan Gala 2026",
slug: "ramadan-gala-2026",
description: "Annual fundraising gala dinner — all proceeds support orphan education in Bangladesh, Pakistan, and Syria.",
eventDate: daysFromNow(14),
location: "Bradford Hilton, Hall Lane, BD1 4QR",
goalAmount: 5000000, // £50,000
currency: "GBP",
status: "active",
organizationId: org.id,
},
})
const eidEvent = await prisma.event.upsert({
where: { organizationId_slug: { organizationId: org.id, slug: "eid-community-lunch-2026" } },
update: {},
create: {
name: "Eid Community Lunch 2026",
slug: "eid-community-lunch-2026",
description: "Community lunch and fundraiser for local food bank programme.",
eventDate: daysFromNow(45),
location: "East London Mosque, Whitechapel Road, E1 1JX",
goalAmount: 1500000, // £15,000
currency: "GBP",
status: "active",
organizationId: org.id,
},
})
// ── QR Sources for Gala ──
const qrCodes = [
{ label: "Table 1 - Ahmed", volunteerName: "Ahmed Khan", tableName: "Table 1", code: "gala-tbl1" },
{ label: "Table 2 - Fatima", volunteerName: "Fatima Patel", tableName: "Table 2", code: "gala-tbl2" },
{ label: "Table 3 - Yusuf", volunteerName: "Yusuf Ali", tableName: "Table 3", code: "gala-tbl3" },
{ label: "Table 4 - Khadijah", volunteerName: "Khadijah Begum", tableName: "Table 4", code: "gala-tbl4" },
{ label: "Table 5 - Omar", volunteerName: "Omar Malik", tableName: "Table 5", code: "gala-tbl5" },
{ label: "Main Entrance", volunteerName: null, tableName: null, code: "gala-entrance" },
{ label: "Stage Banner", volunteerName: null, tableName: null, code: "gala-stage" },
{ label: "Online Link", volunteerName: null, tableName: null, code: "gala-online" },
]
const qrSourceIds: Record<string, string> = {}
for (const qr of qrCodes) {
const source = await prisma.qrSource.upsert({
where: { code: qr.code },
update: { label: qr.label, volunteerName: qr.volunteerName, scanCount: Math.floor(Math.random() * 40) + 5 },
create: {
label: qr.label,
code: qr.code,
volunteerName: qr.volunteerName,
tableName: qr.tableName,
eventId: galaEvent.id,
scanCount: Math.floor(Math.random() * 40) + 5,
},
})
qrSourceIds[qr.code] = source.id
}
// ── QR Sources for Eid ──
const eidQrs = [
{ label: "Registration Desk", volunteerName: "Ibrahim Hassan", tableName: null, code: "eid-reg" },
{ label: "Online Link", volunteerName: null, tableName: null, code: "eid-online" },
]
for (const qr of eidQrs) {
await prisma.qrSource.upsert({
where: { code: qr.code },
update: {},
create: {
label: qr.label,
code: qr.code,
volunteerName: qr.volunteerName,
tableName: qr.tableName,
eventId: eidEvent.id,
scanCount: Math.floor(Math.random() * 10) + 2,
},
})
}
// ── Sample Pledges ──
const samplePledges = [
// Paid pledges
{ name: "Sarah Khan", email: "sarah@example.com", phone: "07700900001", amount: 10000, rail: "bank", status: "paid", giftAid: true, qr: "gala-tbl1", daysAgo: 5 },
{ name: "Ali Hassan", email: "ali.hassan@gmail.com", phone: "07700900002", amount: 25000, rail: "bank", status: "paid", giftAid: false, qr: "gala-tbl1", daysAgo: 4 },
{ name: "Amina Begum", email: "amina.b@hotmail.com", phone: "", amount: 5000, rail: "card", status: "paid", giftAid: true, qr: "gala-tbl2", daysAgo: 3 },
{ name: "Mohammed Raza", email: "m.raza@outlook.com", phone: "07700900004", amount: 50000, rail: "gocardless", status: "paid", giftAid: true, qr: "gala-stage", daysAgo: 6 },
{ name: "Zainab Ahmed", email: "zainab@example.com", phone: "", amount: 10000, rail: "bank", status: "paid", giftAid: false, qr: "gala-tbl3", daysAgo: 7 },
{ name: "Hassan Malik", email: "hassan.malik@gmail.com", phone: "07700900006", amount: 20000, rail: "card", status: "paid", giftAid: true, qr: "gala-entrance", daysAgo: 2 },
// Initiated (payment in progress)
{ name: "Ruqayyah Patel", email: "ruqayyah@example.com", phone: "07700900007", amount: 15000, rail: "bank", status: "initiated", giftAid: true, qr: "gala-tbl4", daysAgo: 1 },
{ name: "Ibrahim Shah", email: "ibrahim.shah@gmail.com", phone: "", amount: 10000, rail: "gocardless", status: "initiated", giftAid: false, qr: "gala-tbl5", daysAgo: 1 },
// New pledges (just created)
{ name: "Maryam Siddiqui", email: "maryam.s@yahoo.com", phone: "07700900009", amount: 5000, rail: "bank", status: "new", giftAid: false, qr: "gala-tbl2", daysAgo: 0 },
{ name: "Usman Chaudhry", email: "usman.c@gmail.com", phone: "", amount: 100000, rail: "bank", status: "new", giftAid: true, qr: "gala-entrance", daysAgo: 0 },
{ name: "Aisha Rahman", email: "aisha.r@hotmail.com", phone: "07700900011", amount: 7500, rail: "card", status: "new", giftAid: true, qr: "gala-online", daysAgo: 0 },
{ name: null, email: "anon.donor@gmail.com", phone: "", amount: 20000, rail: "bank", status: "new", giftAid: false, qr: "gala-tbl3", daysAgo: 0 },
// Overdue
{ name: "Tariq Hussain", email: "tariq.h@example.com", phone: "07700900013", amount: 25000, rail: "bank", status: "overdue", giftAid: true, qr: "gala-tbl1", daysAgo: 12 },
{ name: "Nadia Akhtar", email: "nadia.a@outlook.com", phone: "", amount: 10000, rail: "bank", status: "overdue", giftAid: false, qr: "gala-tbl5", daysAgo: 10 },
// Cancelled
{ name: "Omar Farooq", email: "omar.f@gmail.com", phone: "07700900015", amount: 5000, rail: "card", status: "cancelled", giftAid: false, qr: "gala-tbl4", daysAgo: 8 },
// FPX pledge (Malaysian donor)
{ name: "Ahmad bin Abdullah", email: "ahmad@example.my", phone: "+60123456789", amount: 50000, rail: "fpx", status: "paid", giftAid: false, qr: "gala-online", daysAgo: 3 },
// Eid event pledges
{ name: "Hafsa Nawaz", email: "hafsa@example.com", phone: "07700900017", amount: 5000, rail: "bank", status: "new", giftAid: true, qr: null, daysAgo: 1 },
{ name: "Bilal Iqbal", email: "bilal.i@gmail.com", phone: "", amount: 10000, rail: "gocardless", status: "paid", giftAid: false, qr: null, daysAgo: 5 },
]
let pledgeIndex = 0
for (const p of samplePledges) {
pledgeIndex++
const ref = `DEMO-SEED${String(pledgeIndex).padStart(2, "0")}-${Math.floor(p.amount / 100)}`
const isEid = p.qr === null
const eventId = isEid ? eidEvent.id : galaEvent.id
const createdAt = daysAgo(p.daysAgo)
const paidAt = p.status === "paid" ? daysAgo(Math.max(p.daysAgo - 1, 0)) : null
// Skip if reference already exists
const existing = await prisma.pledge.findUnique({ where: { reference: ref } })
if (existing) continue
const pledge = await prisma.pledge.create({
data: {
reference: ref,
amountPence: p.amount,
currency: "GBP",
rail: p.rail,
status: p.status,
donorName: p.name,
donorEmail: p.email || null,
donorPhone: p.phone || null,
giftAid: p.giftAid,
eventId,
qrSourceId: p.qr ? qrSourceIds[p.qr] || null : null,
organizationId: org.id,
createdAt,
paidAt,
cancelledAt: p.status === "cancelled" ? daysAgo(p.daysAgo - 1) : null,
},
})
// Payment instruction for bank transfers
if (p.rail === "bank") {
await prisma.paymentInstruction.create({
data: {
pledgeId: pledge.id,
bankReference: ref,
bankDetails: {
bankName: "Barclays",
sortCode: "20-00-00",
accountNo: "12345678",
accountName: "Charity Right",
},
},
})
}
// Payment record for paid pledges
if (p.status === "paid") {
await prisma.payment.create({
data: {
pledgeId: pledge.id,
provider: p.rail === "gocardless" ? "gocardless" : p.rail === "card" || p.rail === "fpx" ? "stripe" : "bank",
providerRef: p.rail === "bank" ? null : `sim_${pledge.id.slice(0, 8)}`,
amountPence: p.amount,
status: "confirmed",
matchedBy: p.rail === "bank" ? "auto" : "webhook",
receivedAt: paidAt,
},
})
}
// Reminders for non-paid pledges
if (["new", "initiated", "overdue"].includes(p.status)) {
const steps = [
{ step: 0, delayDays: 0, key: "instructions" },
{ step: 1, delayDays: 2, key: "gentle_nudge" },
{ step: 2, delayDays: 7, key: "urgency_impact" },
{ step: 3, delayDays: 14, key: "final_reminder" },
]
for (const s of steps) {
const scheduledAt = new Date(createdAt.getTime() + s.delayDays * 86400000)
const isSent = scheduledAt < new Date() && p.status !== "new"
await prisma.reminder.create({
data: {
pledgeId: pledge.id,
step: s.step,
channel: "email",
scheduledAt,
status: p.status === "overdue" && s.step <= 2 ? "sent" : isSent ? "sent" : "pending",
sentAt: isSent ? scheduledAt : null,
payload: { templateKey: s.key },
},
})
}
}
// Analytics events
await prisma.analyticsEvent.create({
data: {
eventType: "pledge_completed",
pledgeId: pledge.id,
eventId,
qrSourceId: p.qr ? qrSourceIds[p.qr] || null : null,
metadata: { amountPence: p.amount, rail: p.rail },
createdAt,
},
})
}
// ── Funnel analytics (scans → starts → completions) ──
const funnelEvents = [
...Array.from({ length: 45 }, () => ({ eventType: "pledge_start", eventId: galaEvent.id })),
...Array.from({ length: 8 }, () => ({ eventType: "pledge_start", eventId: eidEvent.id })),
...Array.from({ length: 12 }, () => ({ eventType: "instruction_copy_clicked", eventId: galaEvent.id })),
...Array.from({ length: 6 }, () => ({ eventType: "i_paid_clicked", eventId: galaEvent.id })),
]
for (const fe of funnelEvents) {
await prisma.analyticsEvent.create({
data: {
eventType: fe.eventType,
eventId: fe.eventId,
createdAt: daysAgo(Math.floor(Math.random() * 7)),
},
})
}
// Count totals
const pledgeCount = await prisma.pledge.count({ where: { organizationId: org.id } })
const totalAmount = await prisma.pledge.aggregate({ where: { organizationId: org.id }, _sum: { amountPence: true } })
console.log("✅ Seed data created")
console.log(` Org: ${org.name} (${org.slug})`)
console.log(` Events: ${galaEvent.name}, ${eidEvent.name}`)
console.log(` QR Codes: ${qrCodes.length + eidQrs.length}`)
console.log(` Pledges: ${pledgeCount}${((totalAmount._sum.amountPence || 0) / 100).toLocaleString()})`)
}
main()
.catch(console.error)
.finally(async () => {
await prisma.$disconnect()
await pool.end()
})

View File

@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from "next/server"
import prisma from "@/lib/prisma"
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { eventType, pledgeId, eventId, qrSourceId, metadata } = body
// Fire and forget - don't block on errors
if (pledgeId?.startsWith("demo-")) {
return NextResponse.json({ ok: true })
}
if (!prisma) {
return NextResponse.json({ ok: true })
}
await prisma.analyticsEvent.create({
data: {
eventType: eventType || "unknown",
pledgeId: pledgeId || null,
eventId: eventId || null,
qrSourceId: qrSourceId || null,
metadata: metadata || {},
},
})
return NextResponse.json({ ok: true })
} catch {
// Never fail analytics
return NextResponse.json({ ok: true })
}
}

View File

@@ -0,0 +1,156 @@
import { NextRequest, NextResponse } from "next/server"
import prisma from "@/lib/prisma"
import { resolveOrgId } from "@/lib/org"
interface PledgeRow {
id: string
reference: string
amountPence: number
status: string
rail: string
donorName: string | null
donorEmail: string | null
donorPhone: string | null
giftAid: boolean
createdAt: Date
paidAt: Date | null
event: { name: string }
qrSource: { label: string; volunteerName: string | null; tableName: string | null } | null
reminders: Array<{ step: number; status: string; scheduledAt: Date }>
}
interface AnalyticsRow {
eventType: string
_count: number
}
interface ReminderRow {
step: number
status: string
scheduledAt: Date
}
export async function GET(request: NextRequest) {
try {
if (!prisma) {
return NextResponse.json({
summary: {
totalPledges: 12,
totalPledgedPence: 2450000,
totalCollectedPence: 1820000,
collectionRate: 74,
overdueRate: 8,
},
byStatus: { paid: 8, pending: 2, overdue: 1, cancelled: 1 },
byRail: { bank_transfer: 10, card: 2 },
topSources: [
{ label: "Table 1 - Ahmed", count: 4, amount: 850000 },
{ label: "Table 2 - Fatima", count: 3, amount: 620000 },
],
funnel: { qr_scan: 45, pledge_started: 32, pledge_completed: 12 },
pledges: [],
})
}
const orgId = await resolveOrgId(request.headers.get("x-org-id"))
if (!orgId) {
return NextResponse.json({ error: "Organization not found" }, { status: 404 })
}
const eventId = request.nextUrl.searchParams.get("eventId")
const where = {
organizationId: orgId,
...(eventId ? { eventId } : {}),
}
const [pledges, analytics] = await Promise.all([
prisma.pledge.findMany({
where,
include: {
event: { select: { name: true } },
qrSource: { select: { label: true, volunteerName: true, tableName: true } },
reminders: { select: { step: true, status: true, scheduledAt: true } },
},
orderBy: { createdAt: "desc" },
}),
prisma.analyticsEvent.groupBy({
by: ["eventType"],
where: eventId ? { eventId } : {},
_count: true,
}),
]) as [PledgeRow[], AnalyticsRow[]]
const totalPledged = pledges.reduce((s: number, p: PledgeRow) => s + p.amountPence, 0)
const totalCollected = pledges
.filter((p: PledgeRow) => p.status === "paid")
.reduce((s: number, p: PledgeRow) => s + p.amountPence, 0)
const collectionRate = totalPledged > 0 ? totalCollected / totalPledged : 0
const overdueCount = pledges.filter((p: PledgeRow) => p.status === "overdue").length
const overdueRate = pledges.length > 0 ? overdueCount / pledges.length : 0
// Status breakdown
const byStatus: Record<string, number> = {}
pledges.forEach((p: PledgeRow) => {
byStatus[p.status] = (byStatus[p.status] || 0) + 1
})
// Rail breakdown
const byRail: Record<string, number> = {}
pledges.forEach((p: PledgeRow) => {
byRail[p.rail] = (byRail[p.rail] || 0) + 1
})
// Top QR sources
const qrStats: Record<string, { label: string; count: number; amount: number }> = {}
pledges.forEach((p: PledgeRow) => {
if (p.qrSource) {
const key = p.qrSource.label
if (!qrStats[key]) qrStats[key] = { label: key, count: 0, amount: 0 }
qrStats[key].count++
qrStats[key].amount += p.amountPence
}
})
// Funnel from analytics
const funnel = Object.fromEntries(analytics.map((a: AnalyticsRow) => [a.eventType, a._count]))
return NextResponse.json({
summary: {
totalPledges: pledges.length,
totalPledgedPence: totalPledged,
totalCollectedPence: totalCollected,
collectionRate: Math.round(collectionRate * 100),
overdueRate: Math.round(overdueRate * 100),
},
byStatus,
byRail,
topSources: Object.values(qrStats).sort((a: { amount: number }, b: { amount: number }) => b.amount - a.amount).slice(0, 10),
funnel,
pledges: pledges.map((p: PledgeRow) => ({
id: p.id,
reference: p.reference,
amountPence: p.amountPence,
status: p.status,
rail: p.rail,
donorName: p.donorName,
donorEmail: p.donorEmail,
donorPhone: p.donorPhone,
eventName: p.event.name,
source: p.qrSource?.label || null,
volunteerName: p.qrSource?.volunteerName || null,
giftAid: p.giftAid,
createdAt: p.createdAt,
paidAt: p.paidAt,
nextReminder: p.reminders
.filter((r: ReminderRow) => r.status === "pending")
.sort((a: ReminderRow, b: ReminderRow) => a.scheduledAt.getTime() - b.scheduledAt.getTime())[0]?.scheduledAt || null,
lastTouch: p.reminders
.filter((r: ReminderRow) => r.status === "sent")
.sort((a: ReminderRow, b: ReminderRow) => b.scheduledAt.getTime() - a.scheduledAt.getTime())[0]?.scheduledAt || null,
})),
})
} catch (error) {
console.error("Dashboard error:", error)
return NextResponse.json({ error: "Internal error" }, { status: 500 })
}
}

View File

@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from "next/server"
import { generateQrBuffer } from "@/lib/qr"
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string; qrId: string }> }
) {
try {
const { qrId } = await params
const baseUrl = process.env.BASE_URL || "http://localhost:3000"
// qrId is actually used to look up the code, but for simplicity use the code from query
const code = request.nextUrl.searchParams.get("code") || qrId
const buffer = await generateQrBuffer({
baseUrl,
code,
width: 800,
margin: 2,
})
return new NextResponse(new Uint8Array(buffer), {
headers: {
"Content-Type": "image/png",
"Content-Disposition": `attachment; filename="qr-${code}.png"`,
},
})
} catch (error) {
console.error("QR download error:", error)
return NextResponse.json({ error: "Internal error" }, { status: 500 })
}
}

View File

@@ -0,0 +1,104 @@
import { NextRequest, NextResponse } from "next/server"
import prisma from "@/lib/prisma"
import { createQrSourceSchema } from "@/lib/validators"
import { customAlphabet } from "nanoid"
const generateCode = customAlphabet("23456789abcdefghjkmnpqrstuvwxyz", 8)
interface QrPledge {
amountPence: number
status: string
}
interface QrRow {
id: string
label: string
code: string
volunteerName: string | null
tableName: string | null
scanCount: number
createdAt: Date
_count: { pledges: number }
pledges: QrPledge[]
}
// GET QR sources for event
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
if (!prisma) {
return NextResponse.json([])
}
const sources = await prisma.qrSource.findMany({
where: { eventId: id },
include: {
_count: { select: { pledges: true } },
pledges: { select: { amountPence: true, status: true } },
},
orderBy: { createdAt: "desc" },
}) as QrRow[]
return NextResponse.json(
sources.map((s: QrRow) => ({
id: s.id,
label: s.label,
code: s.code,
volunteerName: s.volunteerName,
tableName: s.tableName,
scanCount: s.scanCount,
pledgeCount: s._count.pledges,
totalPledged: s.pledges.reduce((sum: number, p: QrPledge) => sum + p.amountPence, 0),
createdAt: s.createdAt,
}))
)
} catch (error) {
console.error("QR sources GET error:", error)
return NextResponse.json({ error: "Internal error" }, { status: 500 })
}
}
// POST create QR source
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
if (!prisma) {
return NextResponse.json({ error: "Database not configured" }, { status: 503 })
}
const body = await request.json()
const parsed = createQrSourceSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json({ error: "Invalid data", details: parsed.error.flatten() }, { status: 400 })
}
const event = await prisma.event.findUnique({
where: { id },
select: { id: true },
})
if (!event) {
return NextResponse.json({ error: "Event not found" }, { status: 404 })
}
const code = generateCode()
const qrSource = await prisma.qrSource.create({
data: {
...parsed.data,
code,
eventId: id,
},
})
return NextResponse.json(qrSource, { status: 201 })
} catch (error) {
console.error("QR source creation error:", error)
return NextResponse.json({ error: "Internal error" }, { status: 500 })
}
}

View File

@@ -0,0 +1,107 @@
import { NextRequest, NextResponse } from "next/server"
import prisma from "@/lib/prisma"
import { createEventSchema } from "@/lib/validators"
import { resolveOrgId } from "@/lib/org"
interface PledgeSummary {
amountPence: number
status: string
}
interface EventRow {
id: string
name: string
slug: string
eventDate: Date | null
location: string | null
goalAmount: number | null
status: string
createdAt: Date
_count: { pledges: number; qrSources: number }
pledges: PledgeSummary[]
}
// GET all events for org (TODO: auth middleware)
export async function GET(request: NextRequest) {
try {
if (!prisma) {
return NextResponse.json([])
}
const orgId = await resolveOrgId(request.headers.get("x-org-id"))
if (!orgId) {
return NextResponse.json({ error: "Organization not found" }, { status: 404 })
}
const events = await prisma.event.findMany({
where: { organizationId: orgId },
include: {
_count: { select: { pledges: true, qrSources: true } },
pledges: {
select: { amountPence: true, status: true },
},
},
orderBy: { createdAt: "desc" },
}) as EventRow[]
const formatted = events.map((e: EventRow) => ({
id: e.id,
name: e.name,
slug: e.slug,
eventDate: e.eventDate,
location: e.location,
goalAmount: e.goalAmount,
status: e.status,
pledgeCount: e._count.pledges,
qrSourceCount: e._count.qrSources,
totalPledged: e.pledges.reduce((sum: number, p: PledgeSummary) => sum + p.amountPence, 0),
totalCollected: e.pledges
.filter((p: PledgeSummary) => p.status === "paid")
.reduce((sum: number, p: PledgeSummary) => sum + p.amountPence, 0),
createdAt: e.createdAt,
}))
return NextResponse.json(formatted)
} catch (error) {
console.error("Events GET error:", error)
return NextResponse.json({ error: "Internal error" }, { status: 500 })
}
}
// POST create event
export async function POST(request: NextRequest) {
try {
if (!prisma) {
return NextResponse.json({ error: "Database not configured" }, { status: 503 })
}
const orgId = await resolveOrgId(request.headers.get("x-org-id"))
if (!orgId) {
return NextResponse.json({ error: "Organization not found" }, { status: 404 })
}
const body = await request.json()
const parsed = createEventSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json({ error: "Invalid data", details: parsed.error.flatten() }, { status: 400 })
}
const slug = parsed.data.name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 50)
const event = await prisma.event.create({
data: {
...parsed.data,
slug: slug + "-" + Date.now().toString(36),
eventDate: parsed.data.eventDate ? new Date(parsed.data.eventDate) : null,
organizationId: orgId,
},
})
return NextResponse.json(event, { status: 201 })
} catch (error) {
console.error("Event creation error:", error)
return NextResponse.json({ error: "Internal error" }, { status: 500 })
}
}

View File

@@ -0,0 +1,78 @@
import { NextRequest, NextResponse } from "next/server"
import prisma from "@/lib/prisma"
import { formatCrmExportCsv, type CrmExportRow } from "@/lib/exports"
import { resolveOrgId } from "@/lib/org"
interface ExportPledge {
reference: string
donorName: string | null
donorEmail: string | null
donorPhone: string | null
amountPence: number
rail: string
status: string
giftAid: boolean
createdAt: Date
paidAt: Date | null
event: { name: string }
qrSource: { label: string; volunteerName: string | null; tableName: string | null } | null
}
export async function GET(request: NextRequest) {
try {
if (!prisma) {
return NextResponse.json({ error: "Database not configured" }, { status: 503 })
}
const orgId = await resolveOrgId(
request.headers.get("x-org-id") || request.nextUrl.searchParams.get("orgId") || "demo"
)
if (!orgId) {
return NextResponse.json({ error: "Organization not found" }, { status: 404 })
}
const eventId = request.nextUrl.searchParams.get("eventId")
const pledges = await prisma.pledge.findMany({
where: {
organizationId: orgId,
...(eventId ? { eventId } : {}),
},
include: {
event: { select: { name: true } },
qrSource: { select: { label: true, volunteerName: true, tableName: true } },
},
orderBy: { createdAt: "desc" },
}) as ExportPledge[]
const rows: CrmExportRow[] = pledges.map((p: ExportPledge) => ({
pledge_reference: p.reference,
donor_name: p.donorName || "",
donor_email: p.donorEmail || "",
donor_phone: p.donorPhone || "",
amount_gbp: (p.amountPence / 100).toFixed(2),
payment_method: p.rail,
status: p.status,
event_name: p.event.name,
source_label: p.qrSource?.label || "",
volunteer_name: p.qrSource?.volunteerName || "",
table_name: p.qrSource?.tableName || "",
gift_aid: p.giftAid ? "Yes" : "No",
pledged_at: p.createdAt.toISOString(),
paid_at: p.paidAt?.toISOString() || "",
days_to_collect: p.paidAt
? Math.ceil((p.paidAt.getTime() - p.createdAt.getTime()) / (1000 * 60 * 60 * 24)).toString()
: "",
}))
const csv = formatCrmExportCsv(rows)
return new NextResponse(csv, {
headers: {
"Content-Type": "text/csv",
"Content-Disposition": `attachment; filename="crm-export-${new Date().toISOString().slice(0, 10)}.csv"`,
},
})
} catch (error) {
console.error("CRM export error:", error)
return NextResponse.json({ error: "Internal error" }, { status: 500 })
}
}

View File

@@ -0,0 +1,74 @@
import { NextRequest, NextResponse } from "next/server"
import prisma from "@/lib/prisma"
import { completeRedirectFlow, createPayment } from "@/lib/gocardless"
export async function GET(request: NextRequest) {
try {
const pledgeId = request.nextUrl.searchParams.get("pledge_id")
const redirectFlowId = request.nextUrl.searchParams.get("redirect_flow_id")
if (!pledgeId) {
return NextResponse.redirect(new URL("/", request.url))
}
const pledge = await prisma.pledge.findUnique({
where: { id: pledgeId },
include: { event: true, paymentInstruction: true },
})
if (!pledge) {
return NextResponse.redirect(new URL("/", request.url))
}
// If we have a redirect flow ID, complete the GoCardless flow
if (redirectFlowId) {
const result = await completeRedirectFlow(redirectFlowId, pledgeId)
if (result) {
// Save mandate ID
if (pledge.paymentInstruction) {
await prisma.paymentInstruction.update({
where: { id: pledge.paymentInstruction.id },
data: { gcMandateId: result.mandateId },
})
}
// Create the payment against the mandate
const payment = await createPayment({
amountPence: pledge.amountPence,
mandateId: result.mandateId,
reference: pledge.reference,
pledgeId: pledge.id,
description: `${pledge.event.name}${pledge.reference}`,
})
if (payment) {
// Update pledge status
await prisma.pledge.update({
where: { id: pledgeId },
data: { status: "initiated" },
})
// Record payment
await prisma.payment.create({
data: {
pledgeId: pledge.id,
provider: "gocardless",
providerRef: payment.paymentId,
amountPence: pledge.amountPence,
status: "pending",
matchedBy: "auto",
},
})
}
}
}
// Redirect to success page
const successUrl = `/p/success?pledge_id=${pledgeId}&rail=gocardless`
return NextResponse.redirect(new URL(successUrl, request.url))
} catch (error) {
console.error("GoCardless callback error:", error)
return NextResponse.redirect(new URL("/", request.url))
}
}

View File

@@ -0,0 +1,135 @@
import { NextRequest, NextResponse } from "next/server"
import prisma from "@/lib/prisma"
import { createRedirectFlow } from "@/lib/gocardless"
import { generateReference } from "@/lib/reference"
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { amountPence, donorName, donorEmail, donorPhone, giftAid, eventId, qrSourceId } = body
if (!prisma) {
return NextResponse.json({ error: "Database not configured" }, { status: 503 })
}
// Get event + org
const event = await prisma.event.findUnique({
where: { id: eventId },
include: { organization: true },
})
if (!event) {
return NextResponse.json({ error: "Event not found" }, { status: 404 })
}
const org = event.organization
// Generate reference
let reference = ""
let attempts = 0
while (attempts < 10) {
reference = generateReference(org.refPrefix || "PNPL", amountPence)
const exists = await prisma.pledge.findUnique({ where: { reference } })
if (!exists) break
attempts++
}
// Create pledge in DB
const pledge = await prisma.pledge.create({
data: {
reference,
amountPence,
currency: "GBP",
rail: "gocardless",
status: "new",
donorName: donorName || null,
donorEmail: donorEmail || null,
donorPhone: donorPhone || null,
giftAid: giftAid || false,
eventId,
qrSourceId: qrSourceId || null,
organizationId: org.id,
},
})
// Create reminder schedule
const { calculateReminderSchedule } = await import("@/lib/reminders")
const schedule = calculateReminderSchedule(new Date())
await prisma.reminder.createMany({
data: schedule.map((s) => ({
pledgeId: pledge.id,
step: s.step,
channel: s.channel,
scheduledAt: s.scheduledAt,
status: "pending",
payload: { templateKey: s.templateKey, subject: s.subject },
})),
})
// Track analytics
await prisma.analyticsEvent.create({
data: {
eventType: "pledge_completed",
pledgeId: pledge.id,
eventId,
qrSourceId: qrSourceId || null,
metadata: { amountPence, rail: "gocardless" },
},
})
// Try real GoCardless flow
// GoCardless live mode requires HTTPS redirect URLs
const baseUrl = process.env.BASE_URL || "http://localhost:3000"
const isHttps = baseUrl.startsWith("https://")
const redirectUrl = `${baseUrl}/api/gocardless/callback?pledge_id=${pledge.id}`
if (!isHttps && process.env.GOCARDLESS_ENVIRONMENT === "live") {
// Can't use GC live with HTTP — return simulated mode
// Set BASE_URL to your HTTPS domain to enable live GoCardless
console.warn("GoCardless live mode requires HTTPS BASE_URL. Falling back to simulated.")
return NextResponse.json({
mode: "simulated",
pledgeId: pledge.id,
reference,
id: pledge.id,
})
}
const flow = await createRedirectFlow({
description: `${event.name}${reference}`,
reference,
pledgeId: pledge.id,
successRedirectUrl: redirectUrl,
})
if (flow) {
// Save the redirect flow ID for completion
await prisma.paymentInstruction.create({
data: {
pledgeId: pledge.id,
bankReference: reference,
bankDetails: {},
gcMandateUrl: flow.redirectUrl,
},
})
return NextResponse.json({
mode: "live",
pledgeId: pledge.id,
reference,
redirectUrl: flow.redirectUrl,
redirectFlowId: flow.redirectFlowId,
})
}
// Fallback: no GoCardless configured — return pledge for simulated flow
return NextResponse.json({
mode: "simulated",
pledgeId: pledge.id,
reference,
id: pledge.id,
})
} catch (error) {
console.error("GoCardless create flow error:", error)
return NextResponse.json({ error: "Internal error" }, { status: 500 })
}
}

View File

@@ -0,0 +1,82 @@
import { NextRequest, NextResponse } from "next/server"
import prisma from "@/lib/prisma"
// GoCardless sends webhook events for payment status changes
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const events = body.events || []
for (const event of events) {
const { resource_type, action, links } = event
if (resource_type === "payments") {
const paymentId = links?.payment
if (!paymentId) continue
// Find our payment record
const payment = await prisma.payment.findFirst({
where: { providerRef: paymentId, provider: "gocardless" },
include: { pledge: true },
})
if (!payment) continue
switch (action) {
case "confirmed":
case "paid_out":
await prisma.pledge.update({
where: { id: payment.pledgeId },
data: { status: "paid", paidAt: new Date() },
})
await prisma.payment.update({
where: { id: payment.id },
data: { status: "confirmed", receivedAt: new Date() },
})
await prisma.analyticsEvent.create({
data: {
eventType: "payment_matched",
pledgeId: payment.pledgeId,
metadata: { provider: "gocardless", action, paymentId },
},
})
break
case "failed":
case "cancelled":
await prisma.pledge.update({
where: { id: payment.pledgeId },
data: { status: action === "cancelled" ? "cancelled" : "overdue" },
})
await prisma.payment.update({
where: { id: payment.id },
data: { status: "failed" },
})
break
}
}
if (resource_type === "mandates" && action === "cancelled") {
// Mandate cancelled by bank/customer
const mandateId = links?.mandate
if (mandateId) {
const instruction = await prisma.paymentInstruction.findFirst({
where: { gcMandateId: mandateId },
})
if (instruction) {
await prisma.pledge.update({
where: { id: instruction.pledgeId },
data: { status: "cancelled", cancelledAt: new Date() },
})
}
}
}
}
return NextResponse.json({ received: true })
} catch (error) {
console.error("GoCardless webhook error:", error)
return NextResponse.json({ error: "Webhook handler failed" }, { status: 500 })
}
}

View File

@@ -0,0 +1,133 @@
import { NextRequest, NextResponse } from "next/server"
import prisma from "@/lib/prisma"
import Papa from "papaparse"
import { matchBankRow } from "@/lib/matching"
import { resolveOrgId } from "@/lib/org"
export async function POST(request: NextRequest) {
try {
if (!prisma) {
return NextResponse.json({ error: "Database not configured" }, { status: 503 })
}
const orgId = await resolveOrgId(request.headers.get("x-org-id"))
if (!orgId) {
return NextResponse.json({ error: "Organization not found" }, { status: 404 })
}
const formData = await request.formData()
const file = formData.get("file") as File
const mappingJson = formData.get("mapping") as string
if (!file) {
return NextResponse.json({ error: "No file uploaded" }, { status: 400 })
}
let mapping: Record<string, string> = {}
try {
mapping = mappingJson ? JSON.parse(mappingJson) : {}
} catch {
return NextResponse.json({ error: "Invalid column mapping JSON" }, { status: 400 })
}
const csvText = await file.text()
const parsed = Papa.parse(csvText, { header: true, skipEmptyLines: true })
if (parsed.errors.length > 0 && parsed.data.length === 0) {
return NextResponse.json({ error: "CSV parse error", details: parsed.errors }, { status: 400 })
}
// Get all unmatched pledges for this org
const openPledges = await prisma.pledge.findMany({
where: {
organizationId: orgId,
status: { in: ["new", "initiated", "overdue"] },
},
select: { id: true, reference: true, amountPence: true },
})
const pledgeMap = new Map<string, { id: string; amountPence: number }>(
openPledges.map((p: { id: string; reference: string; amountPence: number }) => [p.reference, { id: p.id, amountPence: p.amountPence }])
)
// Convert rows and match
const rows = (parsed.data as Record<string, string>[]).map((raw) => ({
date: raw[mapping.dateCol || "Date"] || "",
description: raw[mapping.descriptionCol || "Description"] || "",
amount: parseFloat(raw[mapping.creditCol || mapping.amountCol || "Amount"] || "0"),
reference: raw[mapping.referenceCol || "Reference"] || "",
raw,
}))
const results = rows
.filter((r) => r.amount > 0) // only credits
.map((r) => matchBankRow(r, pledgeMap))
// Create import record
const importRecord = await prisma.import.create({
data: {
organizationId: orgId,
kind: "bank_statement",
fileName: file.name,
rowCount: rows.length,
matchedCount: results.filter((r) => r.confidence === "exact").length,
unmatchedCount: results.filter((r) => r.confidence === "none").length,
mappingConfig: mapping,
status: "completed",
stats: {
totalRows: rows.length,
credits: rows.filter((r) => r.amount > 0).length,
exactMatches: results.filter((r) => r.confidence === "exact").length,
partialMatches: results.filter((r) => r.confidence === "partial").length,
unmatched: results.filter((r) => r.confidence === "none").length,
},
},
})
// Auto-confirm exact matches
const confirmed: string[] = []
for (const result of results) {
if (result.confidence === "exact" && result.pledgeId) {
await prisma.$transaction([
prisma.pledge.update({
where: { id: result.pledgeId },
data: { status: "paid", paidAt: new Date() },
}),
prisma.payment.create({
data: {
pledgeId: result.pledgeId,
provider: "bank",
amountPence: Math.round(result.matchedAmount * 100),
status: "confirmed",
matchedBy: "auto",
receivedAt: new Date(result.bankRow.date) || new Date(),
importId: importRecord.id,
},
}),
// Skip remaining reminders
prisma.reminder.updateMany({
where: { pledgeId: result.pledgeId, status: "pending" },
data: { status: "skipped" },
}),
])
confirmed.push(result.pledgeId)
}
}
return NextResponse.json({
importId: importRecord.id,
summary: {
totalRows: rows.length,
credits: rows.filter((r) => r.amount > 0).length,
exactMatches: results.filter((r) => r.confidence === "exact").length,
partialMatches: results.filter((r) => r.confidence === "partial").length,
unmatched: results.filter((r) => r.confidence === "none").length,
autoConfirmed: confirmed.length,
},
matches: results.map((r) => ({
...r,
autoConfirmed: r.pledgeId ? confirmed.includes(r.pledgeId) : false,
})),
})
} catch (error) {
console.error("Bank import error:", error)
return NextResponse.json({ error: "Internal error" }, { status: 500 })
}
}

View File

@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from "next/server"
import prisma from "@/lib/prisma"
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
if (!prisma) {
return NextResponse.json({ error: "Database not configured" }, { status: 503 })
}
const { id } = await params
if (id.startsWith("demo-")) {
return NextResponse.json({ ok: true })
}
await prisma.pledge.update({
where: { id },
data: {
status: "initiated",
iPaidClickedAt: new Date(),
},
})
return NextResponse.json({ ok: true })
} catch (error) {
console.error("Mark initiated error:", error)
return NextResponse.json({ error: "Internal error" }, { status: 500 })
}
}

View File

@@ -0,0 +1,88 @@
import { NextRequest, NextResponse } from "next/server"
import prisma from "@/lib/prisma"
import { updatePledgeStatusSchema } from "@/lib/validators"
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
if (!prisma) {
return NextResponse.json({ error: "Database not configured" }, { status: 503 })
}
const { id } = await params
const pledge = await prisma.pledge.findUnique({
where: { id },
include: { event: { select: { name: true } } },
})
if (!pledge) {
return NextResponse.json({ error: "Not found" }, { status: 404 })
}
return NextResponse.json({
id: pledge.id,
reference: pledge.reference,
amountPence: pledge.amountPence,
rail: pledge.rail,
status: pledge.status,
donorName: pledge.donorName,
donorEmail: pledge.donorEmail,
eventName: pledge.event.name,
})
} catch (error) {
console.error("Pledge GET error:", error)
return NextResponse.json({ error: "Internal error" }, { status: 500 })
}
}
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
if (!prisma) {
return NextResponse.json({ error: "Database not configured" }, { status: 503 })
}
const { id } = await params
const body = await request.json()
const parsed = updatePledgeStatusSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json({ error: "Invalid data" }, { status: 400 })
}
const existing = await prisma.pledge.findUnique({ where: { id } })
if (!existing) {
return NextResponse.json({ error: "Pledge not found" }, { status: 404 })
}
const updateData: Record<string, unknown> = {
status: parsed.data.status,
notes: parsed.data.notes,
}
if (parsed.data.status === "paid") {
updateData.paidAt = new Date()
}
if (parsed.data.status === "cancelled") {
updateData.cancelledAt = new Date()
}
const pledge = await prisma.pledge.update({
where: { id },
data: updateData,
})
// If paid or cancelled, skip remaining reminders
if (["paid", "cancelled"].includes(parsed.data.status)) {
await prisma.reminder.updateMany({
where: { pledgeId: id, status: "pending" },
data: { status: "skipped" },
})
}
return NextResponse.json(pledge)
} catch (error) {
console.error("Pledge update error:", error)
return NextResponse.json({ error: "Internal error" }, { status: 500 })
}
}

View File

@@ -0,0 +1,133 @@
import { NextRequest, NextResponse } from "next/server"
import prisma from "@/lib/prisma"
import { createPledgeSchema } from "@/lib/validators"
import { generateReference } from "@/lib/reference"
import { calculateReminderSchedule } from "@/lib/reminders"
export async function POST(request: NextRequest) {
try {
const body = await request.json()
if (!prisma) {
return NextResponse.json({ error: "Database not configured" }, { status: 503 })
}
const parsed = createPledgeSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid data", details: parsed.error.flatten() },
{ status: 400 }
)
}
const { amountPence, rail, donorName, donorEmail, donorPhone, giftAid, eventId, qrSourceId } = parsed.data
// Get event + org
const event = await prisma.event.findUnique({
where: { id: eventId },
include: { organization: true },
})
if (!event) {
return NextResponse.json({ error: "Event not found" }, { status: 404 })
}
const org = event.organization
// Generate unique reference (retry on collision)
let reference = ""
let attempts = 0
while (attempts < 10) {
reference = generateReference(org.refPrefix || "PNPL", amountPence)
const exists = await prisma.pledge.findUnique({ where: { reference } })
if (!exists) break
attempts++
}
if (attempts >= 10) {
return NextResponse.json({ error: "Could not generate unique reference" }, { status: 500 })
}
// Create pledge + payment instruction + reminder schedule in transaction
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pledge = await prisma.$transaction(async (tx: any) => {
const p = await tx.pledge.create({
data: {
reference,
amountPence,
currency: "GBP",
rail,
status: "new",
donorName: donorName || null,
donorEmail: donorEmail || null,
donorPhone: donorPhone || null,
giftAid,
eventId,
qrSourceId: qrSourceId || null,
organizationId: org.id,
},
})
// Create payment instruction for bank transfers
if (rail === "bank" && org.bankSortCode && org.bankAccountNo) {
await tx.paymentInstruction.create({
data: {
pledgeId: p.id,
bankReference: reference,
bankDetails: {
bankName: org.bankName || "",
sortCode: org.bankSortCode,
accountNo: org.bankAccountNo,
accountName: org.bankAccountName || org.name,
},
},
})
}
// Create reminder schedule
const schedule = calculateReminderSchedule(new Date())
await tx.reminder.createMany({
data: schedule.map((s) => ({
pledgeId: p.id,
step: s.step,
channel: s.channel,
scheduledAt: s.scheduledAt,
status: "pending",
payload: { templateKey: s.templateKey, subject: s.subject },
})),
})
// Track analytics
await tx.analyticsEvent.create({
data: {
eventType: "pledge_completed",
pledgeId: p.id,
eventId,
qrSourceId: qrSourceId || null,
metadata: { amountPence, rail },
},
})
return p
})
// Build response
const response: Record<string, unknown> = {
id: pledge.id,
reference: pledge.reference,
}
if (rail === "bank" && org.bankSortCode) {
response.bankDetails = {
bankName: org.bankName || "",
sortCode: org.bankSortCode,
accountNo: org.bankAccountNo || "",
accountName: org.bankAccountName || org.name,
}
}
return NextResponse.json(response, { status: 201 })
} catch (error) {
console.error("Pledge creation error:", error)
return NextResponse.json({ error: "Internal error" }, { status: 500 })
}
}

View File

@@ -0,0 +1,66 @@
import { NextRequest, NextResponse } from "next/server"
import prisma from "@/lib/prisma"
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ token: string }> }
) {
try {
const { token } = await params
if (!prisma) {
return NextResponse.json({ error: "Database not configured" }, { status: 503 })
}
// Handle "demo" token — resolve to the first active event
if (token === "demo") {
const event = await prisma.event.findFirst({
where: { status: "active" },
include: { organization: { select: { name: true } } },
orderBy: { createdAt: "asc" },
})
if (!event) {
return NextResponse.json({ error: "No active events found" }, { status: 404 })
}
return NextResponse.json({
id: event.id,
name: event.name,
organizationName: event.organization.name,
qrSourceId: null,
qrSourceLabel: null,
})
}
const qrSource = await prisma.qrSource.findUnique({
where: { code: token },
include: {
event: {
include: {
organization: { select: { name: true } },
},
},
},
})
if (!qrSource || qrSource.event.status !== "active") {
return NextResponse.json({ error: "This pledge link is no longer active" }, { status: 404 })
}
// Increment scan count
await prisma.qrSource.update({
where: { id: qrSource.id },
data: { scanCount: { increment: 1 } },
})
return NextResponse.json({
id: qrSource.event.id,
name: qrSource.event.name,
organizationName: qrSource.event.organization.name,
qrSourceId: qrSource.id,
qrSourceLabel: qrSource.label,
})
} catch (error) {
console.error("QR resolve error:", error)
return NextResponse.json({ error: "Internal error" }, { status: 500 })
}
}

View File

@@ -0,0 +1,60 @@
import { NextRequest, NextResponse } from "next/server"
import prisma from "@/lib/prisma"
import { resolveOrgId } from "@/lib/org"
export async function GET(request: NextRequest) {
try {
if (!prisma) return NextResponse.json({ error: "DB not configured" }, { status: 503 })
const orgId = await resolveOrgId(request.headers.get("x-org-id") || "demo")
if (!orgId) return NextResponse.json({ error: "Org not found" }, { status: 404 })
const org = await prisma.organization.findUnique({ where: { id: orgId } })
if (!org) return NextResponse.json({ error: "Org not found" }, { status: 404 })
return NextResponse.json({
id: org.id,
name: org.name,
slug: org.slug,
country: org.country,
bankName: org.bankName || "",
bankSortCode: org.bankSortCode || "",
bankAccountNo: org.bankAccountNo || "",
bankAccountName: org.bankAccountName || "",
refPrefix: org.refPrefix,
logo: org.logo,
primaryColor: org.primaryColor,
gcAccessToken: org.gcAccessToken ? "••••••••" : "",
gcEnvironment: org.gcEnvironment,
})
} catch (error) {
console.error("Settings GET error:", error)
return NextResponse.json({ error: "Internal error" }, { status: 500 })
}
}
export async function PATCH(request: NextRequest) {
try {
if (!prisma) return NextResponse.json({ error: "DB not configured" }, { status: 503 })
const orgId = await resolveOrgId(request.headers.get("x-org-id") || "demo")
if (!orgId) return NextResponse.json({ error: "Org not found" }, { status: 404 })
const body = await request.json()
const allowed = ["name", "bankName", "bankSortCode", "bankAccountNo", "bankAccountName", "refPrefix", "primaryColor", "logo", "gcAccessToken", "gcEnvironment"]
const data: Record<string, string> = {}
for (const key of allowed) {
if (key in body && body[key] !== undefined && body[key] !== "••••••••") {
data[key] = body[key]
}
}
const org = await prisma.organization.update({
where: { id: orgId },
data,
})
return NextResponse.json({ success: true, name: org.name })
} catch (error) {
console.error("Settings PATCH error:", error)
return NextResponse.json({ error: "Internal error" }, { status: 500 })
}
}

View File

@@ -0,0 +1,118 @@
import { NextRequest, NextResponse } from "next/server"
import prisma from "@/lib/prisma"
import { createCheckoutSession } from "@/lib/stripe"
import { generateReference } from "@/lib/reference"
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { amountPence, donorName, donorEmail, donorPhone, giftAid, eventId, qrSourceId } = body
if (!prisma) {
return NextResponse.json({ error: "Database not configured" }, { status: 503 })
}
// Get event + org
const event = await prisma.event.findUnique({
where: { id: eventId },
include: { organization: true },
})
if (!event) {
return NextResponse.json({ error: "Event not found" }, { status: 404 })
}
const org = event.organization
// Generate reference
let reference = ""
let attempts = 0
while (attempts < 10) {
reference = generateReference(org.refPrefix || "PNPL", amountPence)
const exists = await prisma.pledge.findUnique({ where: { reference } })
if (!exists) break
attempts++
}
// Create pledge in DB
const pledge = await prisma.pledge.create({
data: {
reference,
amountPence,
currency: "GBP",
rail: "card",
status: "new",
donorName: donorName || null,
donorEmail: donorEmail || null,
donorPhone: donorPhone || null,
giftAid: giftAid || false,
eventId,
qrSourceId: qrSourceId || null,
organizationId: org.id,
},
})
// Track analytics
await prisma.analyticsEvent.create({
data: {
eventType: "pledge_completed",
pledgeId: pledge.id,
eventId,
qrSourceId: qrSourceId || null,
metadata: { amountPence, rail: "card" },
},
})
// Try real Stripe checkout
const baseUrl = process.env.BASE_URL || "http://localhost:3000"
const session = await createCheckoutSession({
amountPence,
currency: "GBP",
pledgeId: pledge.id,
reference,
eventName: event.name,
organizationName: org.name,
donorEmail: donorEmail || undefined,
successUrl: `${baseUrl}/p/success?pledge_id=${pledge.id}&rail=card&session_id={CHECKOUT_SESSION_ID}`,
cancelUrl: `${baseUrl}/p/success?pledge_id=${pledge.id}&rail=card&cancelled=true`,
})
if (session) {
// Save Stripe session reference
await prisma.payment.create({
data: {
pledgeId: pledge.id,
provider: "stripe",
providerRef: session.sessionId,
amountPence,
status: "pending",
matchedBy: "auto",
},
})
await prisma.pledge.update({
where: { id: pledge.id },
data: { status: "initiated" },
})
return NextResponse.json({
mode: "live",
pledgeId: pledge.id,
reference,
checkoutUrl: session.checkoutUrl,
sessionId: session.sessionId,
})
}
// Fallback: no Stripe configured — return pledge for simulated flow
return NextResponse.json({
mode: "simulated",
pledgeId: pledge.id,
reference,
id: pledge.id,
})
} catch (error) {
console.error("Stripe checkout error:", error)
return NextResponse.json({ error: "Internal error" }, { status: 500 })
}
}

View File

@@ -0,0 +1,88 @@
import { NextRequest, NextResponse } from "next/server"
import prisma from "@/lib/prisma"
import { constructWebhookEvent } from "@/lib/stripe"
export async function POST(request: NextRequest) {
try {
const body = await request.text()
const signature = request.headers.get("stripe-signature") || ""
const event = constructWebhookEvent(body, signature)
if (!event) {
return NextResponse.json({ error: "Invalid signature" }, { status: 400 })
}
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as { id: string; metadata: Record<string, string>; payment_status: string }
const pledgeId = session.metadata?.pledge_id
if (pledgeId && session.payment_status === "paid") {
await prisma.pledge.update({
where: { id: pledgeId },
data: {
status: "paid",
paidAt: new Date(),
},
})
// Update payment record
await prisma.payment.updateMany({
where: {
pledgeId,
providerRef: session.id,
},
data: {
status: "confirmed",
receivedAt: new Date(),
},
})
// Track analytics
await prisma.analyticsEvent.create({
data: {
eventType: "payment_matched",
pledgeId,
metadata: { provider: "stripe", sessionId: session.id },
},
})
}
break
}
case "payment_intent.succeeded": {
const pi = event.data.object as { id: string; metadata: Record<string, string> }
const pledgeId = pi.metadata?.pledge_id
if (pledgeId) {
await prisma.pledge.update({
where: { id: pledgeId },
data: {
status: "paid",
paidAt: new Date(),
},
})
}
break
}
case "payment_intent.payment_failed": {
const pi = event.data.object as { id: string; metadata: Record<string, string> }
const pledgeId = pi.metadata?.pledge_id
if (pledgeId) {
await prisma.pledge.update({
where: { id: pledgeId },
data: { status: "overdue" },
})
}
break
}
}
return NextResponse.json({ received: true })
} catch (error) {
console.error("Stripe webhook error:", error)
return NextResponse.json({ error: "Webhook handler failed" }, { status: 500 })
}
}

View File

@@ -0,0 +1,79 @@
import { NextRequest, NextResponse } from "next/server"
import prisma from "@/lib/prisma"
import { formatWebhookPayload } from "@/lib/exports"
interface ReminderWithPledge {
id: string
pledgeId: string
step: number
channel: string
scheduledAt: Date
payload: unknown
pledge: {
donorName: string | null
donorEmail: string | null
donorPhone: string | null
reference: string
amountPence: number
rail: string
event: { name: string }
organization: { name: string }
}
}
// GET pending webhook events (for external polling)
export async function GET(request: NextRequest) {
try {
if (!prisma) {
return NextResponse.json([])
}
const since = request.nextUrl.searchParams.get("since")
const limit = parseInt(request.nextUrl.searchParams.get("limit") || "50")
const reminders = await prisma.reminder.findMany({
where: {
status: "pending",
scheduledAt: { lte: new Date() },
...(since ? { scheduledAt: { gte: new Date(since) } } : {}),
},
include: {
pledge: {
include: {
event: { select: { name: true } },
organization: { select: { name: true } },
},
},
},
take: limit,
orderBy: { scheduledAt: "asc" },
}) as ReminderWithPledge[]
const events = reminders.map((r: ReminderWithPledge) =>
formatWebhookPayload("reminder.due", {
reminderId: r.id,
pledgeId: r.pledgeId,
step: r.step,
channel: r.channel,
scheduledAt: r.scheduledAt,
donor: {
name: r.pledge.donorName,
email: r.pledge.donorEmail,
phone: r.pledge.donorPhone,
},
pledge: {
reference: r.pledge.reference,
amount: r.pledge.amountPence,
rail: r.pledge.rail,
},
event: r.pledge.event.name,
organization: r.pledge.organization.name,
payload: r.payload,
})
)
return NextResponse.json({ events, count: events.length })
} catch (error) {
console.error("Webhooks error:", error)
return NextResponse.json({ error: "Internal error" }, { status: 500 })
}
}

View File

@@ -0,0 +1,92 @@
"use client"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { Select } from "@/components/ui/select"
import { TrendingUp, Shield, Zap } from "lucide-react"
export default function ApplyPage() {
return (
<div className="space-y-8 max-w-3xl">
<div className="text-center space-y-4">
<h1 className="text-3xl font-extrabold text-gray-900">
Fractional Head of Technology
</h1>
<p className="text-lg text-muted-foreground max-w-xl mx-auto">
Get expert technology leadership for your charity without the full-time cost.
</p>
</div>
{/* Benefits */}
<div className="grid sm:grid-cols-3 gap-4">
{[
{ icon: TrendingUp, title: "Optimise Your Stack", desc: "Reduce costs, improve donor experience, integrate tools" },
{ icon: Shield, title: "Data & Compliance", desc: "GDPR, consent management, security best practices" },
{ icon: Zap, title: "Automate Everything", desc: "Connect your CRM, comms, payments, and reporting" },
].map((b, i) => (
<Card key={i}>
<CardContent className="pt-6 text-center space-y-2">
<b.icon className="h-8 w-8 text-trust-blue mx-auto" />
<h3 className="font-bold">{b.title}</h3>
<p className="text-xs text-muted-foreground">{b.desc}</p>
</CardContent>
</Card>
))}
</div>
{/* Application form */}
<Card>
<CardHeader>
<CardTitle>Apply</CardTitle>
<CardDescription>Tell us about your charity&apos;s tech needs</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Your Name</Label>
<Input placeholder="Full name" />
</div>
<div className="space-y-2">
<Label>Email</Label>
<Input type="email" placeholder="you@charity.org" />
</div>
</div>
<div className="grid sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Organisation</Label>
<Input placeholder="Charity name" />
</div>
<div className="space-y-2">
<Label>Annual Budget (approx)</Label>
<Select>
<option value="">Select...</option>
<option>Under £100k</option>
<option>£100k - £500k</option>
<option>£500k - £1M</option>
<option>£1M - £5M</option>
<option>Over £5M</option>
</Select>
</div>
</div>
<div className="space-y-2">
<Label>Current Tech Stack</Label>
<Textarea placeholder="e.g. Salesforce CRM, Mailchimp, WordPress, manual spreadsheets..." />
</div>
<div className="space-y-2">
<Label>Biggest Tech Challenge</Label>
<Textarea placeholder="What's the biggest technology pain point you're facing?" />
</div>
<Button size="lg" className="w-full">
Submit Application
</Button>
<p className="text-xs text-muted-foreground text-center">
We&apos;ll review your application and get back within 48 hours.
</p>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,241 @@
"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 { 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 Link from "next/link"
import { QRCodeCanvas } from "@/components/qr-code"
interface QrSourceInfo {
id: string
label: string
code: string
volunteerName: string | null
tableName: string | null
scanCount: number
pledgeCount: number
totalPledged: number
}
export default function EventQRPage() {
const params = useParams()
const eventId = params.id as string
const [qrSources, setQrSources] = useState<QrSourceInfo[]>([])
const [loading, setLoading] = useState(true)
const [showCreate, setShowCreate] = useState(false)
const [copiedCode, setCopiedCode] = useState<string | null>(null)
const [form, setForm] = useState({ label: "", volunteerName: "", tableName: "" })
const [creating, setCreating] = useState(false)
const baseUrl = typeof window !== "undefined" ? window.location.origin : ""
useEffect(() => {
fetch(`/api/events/${eventId}/qr`)
.then((r) => r.json())
.then((data) => {
if (Array.isArray(data)) setQrSources(data)
})
.catch(() => {})
.finally(() => setLoading(false))
}, [eventId])
const copyLink = async (code: string) => {
await navigator.clipboard.writeText(`${baseUrl}/p/${code}`)
setCopiedCode(code)
setTimeout(() => setCopiedCode(null), 2000)
}
const handleCreate = async () => {
setCreating(true)
try {
const res = await fetch(`/api/events/${eventId}/qr`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
})
if (res.ok) {
const qr = await res.json()
setQrSources((prev) => [{ ...qr, scanCount: 0, pledgeCount: 0, totalPledged: 0 }, ...prev])
setShowCreate(false)
setForm({ label: "", volunteerName: "", tableName: "" })
}
} catch {}
setCreating(false)
}
// Auto-generate label
useEffect(() => {
if (form.volunteerName || form.tableName) {
const parts = [form.tableName, form.volunteerName].filter(Boolean)
setForm((f) => ({ ...f, label: parts.join(" - ") }))
}
}, [form.volunteerName, form.tableName])
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-8 w-8 text-trust-blue animate-spin" />
</div>
)
}
const totalScans = qrSources.reduce((s, q) => s + q.scanCount, 0)
const totalPledges = qrSources.reduce((s, q) => s + q.pledgeCount, 0)
const totalAmount = qrSources.reduce((s, q) => s + q.totalPledged, 0)
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<Link
href="/dashboard/events"
className="text-sm text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-1 mb-2"
>
<ArrowLeft className="h-3 w-3" /> Back to Events
</Link>
<h1 className="text-3xl font-extrabold text-gray-900">QR Codes</h1>
<p className="text-muted-foreground mt-1">
{qrSources.length} QR code{qrSources.length !== 1 ? "s" : ""} · {totalScans} scans · {totalPledges} pledges · {formatPence(totalAmount)}
</p>
</div>
<Button onClick={() => setShowCreate(true)}>
<Plus className="h-4 w-4 mr-2" /> New QR Code
</Button>
</div>
{/* QR Grid */}
{qrSources.length === 0 ? (
<Card>
<CardContent className="py-12 text-center space-y-4">
<QrCode className="h-12 w-12 text-muted-foreground mx-auto" />
<p className="text-muted-foreground">No QR codes yet. Create one to start collecting pledges!</p>
<Button onClick={() => setShowCreate(true)}>
<Plus className="h-4 w-4 mr-2" /> Create First QR Code
</Button>
</CardContent>
</Card>
) : (
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
{qrSources.map((qr) => (
<Card key={qr.id} className="hover:shadow-md transition-shadow">
<CardContent className="pt-6 space-y-4">
{/* QR Code */}
<div className="max-w-[180px] mx-auto bg-white rounded-2xl flex items-center justify-center p-2">
<QRCodeCanvas url={`${baseUrl}/p/${qr.code}`} size={164} />
</div>
<div className="text-center">
<h3 className="font-bold">{qr.label}</h3>
{qr.volunteerName && (
<p className="text-xs text-muted-foreground">Volunteer: {qr.volunteerName}</p>
)}
</div>
{/* Stats */}
<div className="grid grid-cols-3 gap-2 text-center text-xs">
<div className="rounded-lg bg-gray-50 p-2">
<p className="font-bold text-sm">{qr.scanCount}</p>
<p className="text-muted-foreground">Scans</p>
</div>
<div className="rounded-lg bg-gray-50 p-2">
<p className="font-bold text-sm">{qr.pledgeCount}</p>
<p className="text-muted-foreground">Pledges</p>
</div>
<div className="rounded-lg bg-gray-50 p-2">
<p className="font-bold text-sm">{formatPence(qr.totalPledged)}</p>
<p className="text-muted-foreground">Total</p>
</div>
</div>
{/* Conversion rate */}
{qr.scanCount > 0 && (
<div className="text-center">
<span className="text-xs text-muted-foreground">
Conversion: <span className="font-semibold text-foreground">{Math.round((qr.pledgeCount / qr.scanCount) * 100)}%</span>
</span>
</div>
)}
{/* Actions */}
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
className="flex-1"
onClick={() => copyLink(qr.code)}
>
{copiedCode === qr.code ? (
<><Check className="h-3 w-3 mr-1" /> Copied</>
) : (
<><Copy className="h-3 w-3 mr-1" /> Link</>
)}
</Button>
<a href={`/api/events/${eventId}/qr/${qr.id}/download?code=${qr.code}`} download>
<Button variant="outline" size="sm">
<Download className="h-3 w-3 mr-1" /> PNG
</Button>
</a>
<a href={`/p/${qr.code}`} target="_blank">
<Button variant="outline" size="sm">
<ExternalLink className="h-3 w-3" />
</Button>
</a>
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* Create dialog */}
<Dialog open={showCreate} onOpenChange={setShowCreate}>
<DialogHeader>
<DialogTitle>Create QR Code</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Table Name</Label>
<Input
placeholder="e.g. Table 5"
value={form.tableName}
onChange={(e) => setForm((f) => ({ ...f, tableName: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label>Volunteer Name</Label>
<Input
placeholder="e.g. Ahmed"
value={form.volunteerName}
onChange={(e) => setForm((f) => ({ ...f, volunteerName: e.target.value }))}
/>
</div>
</div>
<div className="space-y-2">
<Label>Label *</Label>
<Input
placeholder="e.g. Table 5 - Ahmed"
value={form.label}
onChange={(e) => setForm((f) => ({ ...f, label: e.target.value }))}
/>
<p className="text-xs text-muted-foreground">Auto-generated from table + volunteer, or enter custom</p>
</div>
<div className="flex gap-3 pt-2">
<Button variant="outline" onClick={() => setShowCreate(false)} className="flex-1">
Cancel
</Button>
<Button onClick={handleCreate} disabled={!form.label || creating} className="flex-1">
{creating ? "Creating..." : "Create QR Code"}
</Button>
</div>
</div>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,225 @@
"use client"
import { useState, useEffect } from "react"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { Badge } from "@/components/ui/badge"
import { Dialog, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { formatPence } from "@/lib/utils"
import { Plus, QrCode, Calendar, MapPin, Target } from "lucide-react"
import Link from "next/link"
interface EventSummary {
id: string
name: string
slug: string
eventDate: string | null
location: string | null
goalAmount: number | null
status: string
pledgeCount: number
qrSourceCount: number
totalPledged: number
totalCollected: number
}
export default function EventsPage() {
const [events, setEvents] = useState<EventSummary[]>([])
const [loading, setLoading] = useState(true)
const [showCreate, setShowCreate] = useState(false)
useEffect(() => {
fetch("/api/events", { headers: { "x-org-id": "demo" } })
.then((r) => r.json())
.then((data) => {
if (Array.isArray(data)) setEvents(data)
})
.catch(() => {})
.finally(() => setLoading(false))
}, [])
const [creating, setCreating] = useState(false)
const [form, setForm] = useState({ name: "", description: "", location: "", eventDate: "", goalAmount: "" })
const handleCreate = async () => {
setCreating(true)
try {
const res = await fetch("/api/events", {
method: "POST",
headers: { "Content-Type": "application/json", "x-org-id": "demo" },
body: JSON.stringify({
...form,
goalAmount: form.goalAmount ? Math.round(parseFloat(form.goalAmount) * 100) : undefined,
eventDate: form.eventDate ? new Date(form.eventDate).toISOString() : undefined,
}),
})
if (res.ok) {
const event = await res.json()
setEvents((prev) => [{ ...event, pledgeCount: 0, qrSourceCount: 0, totalPledged: 0, totalCollected: 0 }, ...prev])
setShowCreate(false)
setForm({ name: "", description: "", location: "", eventDate: "", goalAmount: "" })
}
} catch {
// handle error
}
setCreating(false)
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-extrabold text-gray-900">Events</h1>
<p className="text-muted-foreground mt-1">Manage your fundraising events and QR codes</p>
</div>
<Button onClick={() => setShowCreate(true)}>
<Plus className="h-4 w-4 mr-2" /> New Event
</Button>
</div>
{/* Event cards */}
<div className="grid md:grid-cols-2 gap-4">
{events.map((event) => {
const progress = event.goalAmount ? Math.round((event.totalPledged / event.goalAmount) * 100) : 0
return (
<Card key={event.id} className="hover:shadow-md transition-shadow">
<CardHeader>
<div className="flex items-start justify-between">
<div>
<CardTitle className="text-lg">{event.name}</CardTitle>
<CardDescription className="flex items-center gap-3 mt-1">
{event.eventDate && (
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
{new Date(event.eventDate).toLocaleDateString("en-GB")}
</span>
)}
{event.location && (
<span className="flex items-center gap-1">
<MapPin className="h-3 w-3" />
{event.location}
</span>
)}
</CardDescription>
</div>
<Badge variant={event.status === "active" ? "success" : "secondary"}>
{event.status}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-3 gap-4 text-center">
<div>
<p className="text-xl font-bold">{event.pledgeCount}</p>
<p className="text-xs text-muted-foreground">Pledges</p>
</div>
<div>
<p className="text-xl font-bold">{formatPence(event.totalPledged)}</p>
<p className="text-xs text-muted-foreground">Pledged</p>
</div>
<div>
<p className="text-xl font-bold text-success-green">{formatPence(event.totalCollected)}</p>
<p className="text-xs text-muted-foreground">Collected</p>
</div>
</div>
{event.goalAmount && (
<div>
<div className="flex justify-between text-xs text-muted-foreground mb-1">
<span>{progress}% of goal</span>
<span className="flex items-center gap-1">
<Target className="h-3 w-3" /> {formatPence(event.goalAmount)}
</span>
</div>
<div className="h-2 rounded-full bg-gray-100 overflow-hidden">
<div
className="h-full rounded-full bg-trust-blue transition-all"
style={{ width: `${Math.min(progress, 100)}%` }}
/>
</div>
</div>
)}
<div className="flex gap-2">
<Link href={`/dashboard/events/${event.id}`} className="flex-1">
<Button variant="outline" size="sm" className="w-full">
<QrCode className="h-4 w-4 mr-1" /> QR Codes ({event.qrSourceCount})
</Button>
</Link>
<Link href={`/dashboard/pledges?event=${event.id}`} className="flex-1">
<Button variant="outline" size="sm" className="w-full">
View Pledges
</Button>
</Link>
</div>
</CardContent>
</Card>
)
})}
</div>
{/* Create dialog */}
<Dialog open={showCreate} onOpenChange={setShowCreate}>
<DialogHeader>
<DialogTitle>Create Event</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Event Name *</Label>
<Input
placeholder="e.g. Ramadan Gala 2025"
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label>Description</Label>
<Textarea
placeholder="Brief description..."
value={form.description}
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Date</Label>
<Input
type="datetime-local"
value={form.eventDate}
onChange={(e) => setForm((f) => ({ ...f, eventDate: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label>Goal (£)</Label>
<Input
type="number"
placeholder="50000"
value={form.goalAmount}
onChange={(e) => setForm((f) => ({ ...f, goalAmount: e.target.value }))}
/>
</div>
</div>
<div className="space-y-2">
<Label>Location</Label>
<Input
placeholder="Venue name and address"
value={form.location}
onChange={(e) => setForm((f) => ({ ...f, location: e.target.value }))}
/>
</div>
<div className="flex gap-3 pt-2">
<Button variant="outline" onClick={() => setShowCreate(false)} className="flex-1">
Cancel
</Button>
<Button onClick={handleCreate} disabled={!form.name || creating} className="flex-1">
{creating ? "Creating..." : "Create Event"}
</Button>
</div>
</div>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,77 @@
"use client"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Download, FileSpreadsheet, Webhook } from "lucide-react"
export default function ExportsPage() {
const handleCrmExport = () => {
const a = document.createElement("a")
a.href = "/api/exports/crm-pack"
a.download = `pledges-export-${new Date().toISOString().slice(0, 10)}.csv`
a.click()
}
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-extrabold text-gray-900">Exports</h1>
<p className="text-muted-foreground mt-1">Export data for your CRM and automation tools</p>
</div>
<div className="grid md:grid-cols-2 gap-4">
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<FileSpreadsheet className="h-5 w-5" /> CRM Export Pack
</CardTitle>
<CardDescription>
Download all pledges as CSV with full attribution data, ready to import into your CRM.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="text-sm text-muted-foreground space-y-1">
<p>Includes:</p>
<ul className="list-disc list-inside space-y-0.5">
<li>Donor name, email, phone</li>
<li>Pledge amount and status</li>
<li>Payment method and reference</li>
<li>Event name and source attribution</li>
<li>Gift Aid flag</li>
<li>Days to collect</li>
</ul>
</div>
<Button onClick={handleCrmExport} className="w-full">
<Download className="h-4 w-4 mr-2" /> Download CRM Pack (CSV)
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Webhook className="h-5 w-5" /> Webhook Events
</CardTitle>
<CardDescription>
Poll pending reminder events for external automation (Zapier, Make, n8n).
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="text-sm text-muted-foreground space-y-2">
<p>Endpoint:</p>
<code className="block bg-gray-100 rounded-lg p-3 text-xs font-mono">
GET /api/webhooks?since=2025-01-01T00:00:00Z
</code>
<p>Returns pending reminders with donor contact info and pledge details.</p>
</div>
<div className="rounded-xl bg-trust-blue/5 border border-trust-blue/20 p-3">
<p className="text-xs text-trust-blue font-medium">
💡 Connect this to Zapier or Make to send emails/SMS automatically
</p>
</div>
</CardContent>
</Card>
</div>
</div>
)
}

View File

@@ -0,0 +1,89 @@
import Link from "next/link"
import { LayoutDashboard, Calendar, FileBarChart, Upload, Download, Settings } from "lucide-react"
const navItems = [
{ href: "/dashboard", label: "Overview", icon: LayoutDashboard },
{ href: "/dashboard/events", label: "Events", icon: Calendar },
{ href: "/dashboard/pledges", label: "Pledges", icon: FileBarChart },
{ href: "/dashboard/reconcile", label: "Reconcile", icon: Upload },
{ href: "/dashboard/exports", label: "Exports", icon: Download },
{ href: "/dashboard/settings", label: "Settings", icon: Settings },
]
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen bg-gray-50/50">
{/* Top bar */}
<header className="sticky top-0 z-40 border-b bg-white/80 backdrop-blur-xl">
<div className="flex h-16 items-center gap-4 px-6">
<Link href="/dashboard" className="flex items-center gap-2">
<div className="h-8 w-8 rounded-lg bg-trust-blue flex items-center justify-center">
<span className="text-white font-bold text-sm">P</span>
</div>
<span className="font-bold text-lg hidden sm:block">Pledge Now, Pay Later</span>
</Link>
<div className="flex-1" />
<Link
href="/"
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
View Public Site
</Link>
</div>
</header>
<div className="flex">
{/* Sidebar */}
<aside className="hidden md:flex w-64 flex-col border-r bg-white min-h-[calc(100vh-4rem)] p-4">
<nav className="space-y-1">
{navItems.map((item) => (
<Link
key={item.href}
href={item.href}
className="flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium text-muted-foreground hover:bg-gray-100 hover:text-foreground transition-colors"
>
<item.icon className="h-4 w-4" />
{item.label}
</Link>
))}
</nav>
{/* Upsell CTA */}
<div className="mt-auto pt-4">
<div className="rounded-2xl bg-gradient-to-br from-trust-blue/5 to-warm-amber/5 border p-4 space-y-2">
<p className="text-sm font-semibold">Need tech leadership?</p>
<p className="text-xs text-muted-foreground">
Get a fractional Head of Technology to optimise your charity&apos;s digital stack.
</p>
<Link
href="/dashboard/apply"
className="inline-block text-xs font-semibold text-trust-blue hover:underline"
>
Learn more
</Link>
</div>
</div>
</aside>
{/* Mobile bottom nav */}
<nav className="md:hidden fixed bottom-0 left-0 right-0 z-40 border-t bg-white/80 backdrop-blur-xl flex justify-around py-2">
{navItems.slice(0, 5).map((item) => (
<Link
key={item.href}
href={item.href}
className="flex flex-col items-center gap-1 p-2 text-muted-foreground hover:text-trust-blue transition-colors"
>
<item.icon className="h-5 w-5" />
<span className="text-[10px] font-medium">{item.label}</span>
</Link>
))}
</nav>
{/* Main content */}
<main className="flex-1 p-4 md:p-8 pb-20 md:pb-8">
{children}
</main>
</div>
</div>
)
}

View File

@@ -0,0 +1,23 @@
import { Skeleton } from "@/components/ui/skeleton"
export default function DashboardLoading() {
return (
<div className="space-y-8">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-64" />
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[...Array(4)].map((_, i) => (
<Skeleton key={i} className="h-24 rounded-2xl" />
))}
</div>
<Skeleton className="h-16 rounded-2xl" />
<div className="grid md:grid-cols-2 gap-4">
<Skeleton className="h-64 rounded-2xl" />
<Skeleton className="h-64 rounded-2xl" />
</div>
<Skeleton className="h-96 rounded-2xl" />
</div>
)
}

View File

@@ -0,0 +1,324 @@
"use client"
import { useState, useEffect } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { formatPence } from "@/lib/utils"
import { TrendingUp, Users, Banknote, AlertTriangle } from "lucide-react"
interface DashboardData {
summary: {
totalPledges: number
totalPledgedPence: number
totalCollectedPence: number
collectionRate: number
overdueRate: number
}
byStatus: Record<string, number>
byRail: Record<string, number>
topSources: Array<{ label: string; count: number; amount: number }>
pledges: Array<{
id: string
reference: string
amountPence: number
status: string
rail: string
donorName: string | null
donorEmail: string | null
eventName: string
source: string | null
createdAt: string
paidAt: string | null
nextReminder: string | null
}>
}
const statusColors: Record<string, "default" | "secondary" | "success" | "warning" | "destructive" | "outline"> = {
new: "secondary",
initiated: "warning",
paid: "success",
overdue: "destructive",
cancelled: "outline",
}
const statusLabels: Record<string, string> = {
new: "New",
initiated: "Payment Initiated",
paid: "Paid",
overdue: "Overdue",
cancelled: "Cancelled",
}
const EMPTY_DATA: DashboardData = {
summary: { totalPledges: 0, totalPledgedPence: 0, totalCollectedPence: 0, collectionRate: 0, overdueRate: 0 },
byStatus: {},
byRail: {},
topSources: [],
pledges: [],
}
export default function DashboardPage() {
const [data, setData] = useState<DashboardData>(EMPTY_DATA)
const [loading, setLoading] = useState(true)
const [filter, setFilter] = useState<string>("all")
const fetchData = () => {
fetch("/api/dashboard", {
headers: { "x-org-id": "demo" },
})
.then((r) => r.json())
.then((d) => {
if (d.summary) setData(d)
})
.catch(() => {})
.finally(() => setLoading(false))
}
useEffect(() => {
fetchData()
// Auto-refresh every 10 seconds
const interval = setInterval(fetchData, 10000)
return () => clearInterval(interval)
}, [])
const filteredPledges = filter === "all" ? data.pledges : data.pledges.filter((p) => p.status === filter)
const handleStatusChange = async (pledgeId: string, newStatus: string) => {
try {
await fetch(`/api/pledges/${pledgeId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: newStatus }),
})
setData((prev) => ({
...prev,
pledges: prev.pledges.map((p) =>
p.id === pledgeId ? { ...p, status: newStatus } : p
),
}))
} catch {
// handle error
}
}
if (loading) {
return (
<div className="space-y-8">
<div>
<h1 className="text-3xl font-extrabold text-gray-900">Dashboard</h1>
<p className="text-muted-foreground mt-1">Loading...</p>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[1, 2, 3, 4].map((i) => (
<Card key={i}><CardContent className="pt-6"><div className="h-16 animate-pulse bg-gray-100 rounded-lg" /></CardContent></Card>
))}
</div>
</div>
)
}
return (
<div className="space-y-8">
{/* Header */}
<div>
<h1 className="text-3xl font-extrabold text-gray-900">Dashboard</h1>
<p className="text-muted-foreground mt-1">Overview of all pledge activity</p>
</div>
{/* Stats cards */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<div className="rounded-xl bg-trust-blue/10 p-2.5">
<Users className="h-5 w-5 text-trust-blue" />
</div>
<div>
<p className="text-2xl font-bold">{data.summary.totalPledges}</p>
<p className="text-xs text-muted-foreground">Total Pledges</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<div className="rounded-xl bg-warm-amber/10 p-2.5">
<Banknote className="h-5 w-5 text-warm-amber" />
</div>
<div>
<p className="text-2xl font-bold">{formatPence(data.summary.totalPledgedPence)}</p>
<p className="text-xs text-muted-foreground">Total Pledged</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<div className="rounded-xl bg-success-green/10 p-2.5">
<TrendingUp className="h-5 w-5 text-success-green" />
</div>
<div>
<p className="text-2xl font-bold">{data.summary.collectionRate}%</p>
<p className="text-xs text-muted-foreground">Collection Rate</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<div className="rounded-xl bg-danger-red/10 p-2.5">
<AlertTriangle className="h-5 w-5 text-danger-red" />
</div>
<div>
<p className="text-2xl font-bold">{data.summary.overdueRate}%</p>
<p className="text-xs text-muted-foreground">Overdue Rate</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Collection progress bar */}
<Card>
<CardContent className="pt-6">
<div className="flex justify-between items-center mb-2">
<span className="text-sm font-medium">Pledged vs Collected</span>
<span className="text-sm text-muted-foreground">
{formatPence(data.summary.totalCollectedPence)} of {formatPence(data.summary.totalPledgedPence)}
</span>
</div>
<div className="h-4 rounded-full bg-gray-100 overflow-hidden">
<div
className="h-full rounded-full bg-gradient-to-r from-trust-blue to-success-green transition-all duration-1000"
style={{ width: `${data.summary.collectionRate}%` }}
/>
</div>
</CardContent>
</Card>
{/* Two-column: Sources + Status */}
<div className="grid md:grid-cols-2 gap-4">
{/* Top QR sources */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Top Sources</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{data.topSources.map((src, i) => (
<div key={i} className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-sm font-bold text-muted-foreground w-5">{i + 1}</span>
<div>
<p className="text-sm font-medium">{src.label}</p>
<p className="text-xs text-muted-foreground">{src.count} pledges</p>
</div>
</div>
<span className="text-sm font-bold">{formatPence(src.amount)}</span>
</div>
))}
</CardContent>
</Card>
{/* Status breakdown */}
<Card>
<CardHeader>
<CardTitle className="text-lg">By Status</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{Object.entries(data.byStatus).map(([status, count]) => (
<div key={status} className="flex items-center justify-between">
<Badge variant={statusColors[status]}>
{statusLabels[status] || status}
</Badge>
<span className="text-sm font-bold">{count}</span>
</div>
))}
</CardContent>
</Card>
</div>
{/* Pledge pipeline */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-lg">Pledge Pipeline</CardTitle>
<div className="flex gap-2 flex-wrap">
{["all", "new", "initiated", "paid", "overdue", "cancelled"].map((s) => (
<button
key={s}
onClick={() => setFilter(s)}
className={`text-xs px-3 py-1.5 rounded-full font-medium transition-colors ${
filter === s
? "bg-trust-blue text-white"
: "bg-gray-100 text-muted-foreground hover:bg-gray-200"
}`}
>
{s === "all" ? "All" : statusLabels[s] || s}
</button>
))}
</div>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left">
<th className="pb-3 font-medium text-muted-foreground">Reference</th>
<th className="pb-3 font-medium text-muted-foreground">Donor</th>
<th className="pb-3 font-medium text-muted-foreground">Amount</th>
<th className="pb-3 font-medium text-muted-foreground">Rail</th>
<th className="pb-3 font-medium text-muted-foreground">Source</th>
<th className="pb-3 font-medium text-muted-foreground">Status</th>
<th className="pb-3 font-medium text-muted-foreground">Actions</th>
</tr>
</thead>
<tbody className="divide-y">
{filteredPledges.map((pledge) => (
<tr key={pledge.id} className="hover:bg-gray-50/50">
<td className="py-3 font-mono font-bold text-trust-blue">{pledge.reference}</td>
<td className="py-3">
<div>
<p className="font-medium">{pledge.donorName || "Anonymous"}</p>
<p className="text-xs text-muted-foreground">{pledge.donorEmail || ""}</p>
</div>
</td>
<td className="py-3 font-bold">{formatPence(pledge.amountPence)}</td>
<td className="py-3 capitalize">{pledge.rail}</td>
<td className="py-3 text-xs">{pledge.source || "—"}</td>
<td className="py-3">
<Badge variant={statusColors[pledge.status]}>
{statusLabels[pledge.status]}
</Badge>
</td>
<td className="py-3">
<div className="flex gap-1">
{pledge.status !== "paid" && pledge.status !== "cancelled" && (
<>
<button
onClick={() => handleStatusChange(pledge.id, "paid")}
className="text-xs px-2 py-1 rounded-lg bg-success-green/10 text-success-green hover:bg-success-green/20 font-medium"
>
Mark Paid
</button>
<button
onClick={() => handleStatusChange(pledge.id, "cancelled")}
className="text-xs px-2 py-1 rounded-lg bg-danger-red/10 text-danger-red hover:bg-danger-red/20 font-medium"
>
Cancel
</button>
</>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,293 @@
"use client"
import { useState, useEffect, Suspense } from "react"
import { useSearchParams } from "next/navigation"
import { Card, CardContent } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { formatPence } from "@/lib/utils"
import { Search, Loader2, Download, RefreshCw, ArrowLeft } from "lucide-react"
import Link from "next/link"
const statusColors: Record<string, "default" | "secondary" | "success" | "warning" | "destructive" | "outline"> = {
new: "secondary",
initiated: "warning",
paid: "success",
overdue: "destructive",
cancelled: "outline",
}
const statusLabels: Record<string, string> = {
new: "New",
initiated: "Initiated",
paid: "Paid",
overdue: "Overdue",
cancelled: "Cancelled",
}
const railLabels: Record<string, string> = {
bank: "Bank Transfer",
gocardless: "Direct Debit",
card: "Card",
fpx: "FPX",
}
interface PledgeRow {
id: string
reference: string
amountPence: number
status: string
rail: string
donorName: string | null
donorEmail: string | null
donorPhone: string | null
giftAid: boolean
eventName: string
source: string | null
createdAt: string
paidAt: string | null
}
function PledgesContent() {
const searchParams = useSearchParams()
const eventId = searchParams.get("event")
const [pledges, setPledges] = useState<PledgeRow[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState("")
const [statusFilter, setStatusFilter] = useState<string>("all")
const [updating, setUpdating] = useState<string | null>(null)
const [eventName, setEventName] = useState<string | null>(null)
const fetchPledges = () => {
const url = eventId
? `/api/dashboard?eventId=${eventId}`
: "/api/dashboard"
fetch(url, { headers: { "x-org-id": "demo" } })
.then((r) => r.json())
.then((data) => {
if (data.pledges) {
setPledges(data.pledges)
if (eventId && data.pledges.length > 0) {
setEventName(data.pledges[0].eventName)
}
}
})
.catch(() => {})
.finally(() => setLoading(false))
}
useEffect(() => {
fetchPledges()
const interval = setInterval(fetchPledges, 15000)
return () => clearInterval(interval)
}, [eventId])
const handleStatusChange = async (pledgeId: string, newStatus: string) => {
setUpdating(pledgeId)
try {
const res = await fetch(`/api/pledges/${pledgeId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: newStatus }),
})
if (res.ok) {
setPledges((prev) =>
prev.map((p) =>
p.id === pledgeId
? { ...p, status: newStatus, paidAt: newStatus === "paid" ? new Date().toISOString() : p.paidAt }
: p
)
)
}
} catch {}
setUpdating(null)
}
const filtered = pledges.filter((p) => {
const matchSearch =
!search ||
p.reference.toLowerCase().includes(search.toLowerCase()) ||
p.donorName?.toLowerCase().includes(search.toLowerCase()) ||
p.donorEmail?.toLowerCase().includes(search.toLowerCase()) ||
p.donorPhone?.includes(search)
const matchStatus = statusFilter === "all" || p.status === statusFilter
return matchSearch && matchStatus
})
const statusCounts = pledges.reduce((acc, p) => {
acc[p.status] = (acc[p.status] || 0) + 1
return acc
}, {} as Record<string, number>)
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-8 w-8 text-trust-blue animate-spin" />
</div>
)
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
{eventId && (
<Link
href="/dashboard/events"
className="text-sm text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-1 mb-2"
>
<ArrowLeft className="h-3 w-3" /> Back to Events
</Link>
)}
<h1 className="text-3xl font-extrabold text-gray-900">
{eventName ? `${eventName} — Pledges` : "All Pledges"}
</h1>
<p className="text-muted-foreground mt-1">
{pledges.length} pledge{pledges.length !== 1 ? "s" : ""} totalling{" "}
{formatPence(pledges.reduce((s, p) => s + p.amountPence, 0))}
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={fetchPledges}>
<RefreshCw className="h-4 w-4 mr-1" /> Refresh
</Button>
<a href={`/api/exports/crm-pack${eventId ? `?eventId=${eventId}` : ""}`} download>
<Button variant="outline" size="sm">
<Download className="h-4 w-4 mr-1" /> Export CSV
</Button>
</a>
</div>
</div>
{/* Filters */}
<div className="flex flex-col sm:flex-row gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search by reference, name, email, or phone..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10"
/>
</div>
<div className="flex gap-2 flex-wrap">
{["all", "new", "initiated", "paid", "overdue", "cancelled"].map((s) => (
<button
key={s}
onClick={() => setStatusFilter(s)}
className={`text-xs px-3 py-2 rounded-xl font-medium transition-colors whitespace-nowrap ${
statusFilter === s ? "bg-trust-blue text-white" : "bg-gray-100 text-muted-foreground hover:bg-gray-200"
}`}
>
{s === "all" ? `All (${pledges.length})` : `${statusLabels[s]} (${statusCounts[s] || 0})`}
</button>
))}
</div>
</div>
{/* Pledges list */}
{filtered.length === 0 ? (
<Card>
<CardContent className="py-12 text-center">
<p className="text-muted-foreground">
{search ? "No pledges match your search." : "No pledges yet. Share a QR code to get started!"}
</p>
</CardContent>
</Card>
) : (
<div className="space-y-3">
{filtered.map((p) => (
<Card key={p.id} className="hover:shadow-sm transition-shadow">
<CardContent className="pt-4">
<div className="flex items-start justify-between">
<div className="space-y-1.5">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-mono font-bold text-trust-blue">{p.reference}</span>
<Badge variant={statusColors[p.status]}>{statusLabels[p.status]}</Badge>
<span className="text-xs px-2 py-0.5 rounded-full bg-gray-100 text-muted-foreground">
{railLabels[p.rail] || p.rail}
</span>
{p.giftAid && (
<span className="text-xs px-2 py-0.5 rounded-full bg-green-50 text-green-700 font-medium">
Gift Aid
</span>
)}
</div>
<p className="font-semibold">{p.donorName || "Anonymous"}</p>
<p className="text-xs text-muted-foreground">
{[p.donorEmail, p.donorPhone].filter(Boolean).join(" · ") || "No contact info"}
</p>
<p className="text-xs text-muted-foreground">
{p.eventName}{p.source ? ` · ${p.source}` : ""}
</p>
</div>
<div className="text-right shrink-0">
<p className="text-xl font-bold">{formatPence(p.amountPence)}</p>
<p className="text-xs text-muted-foreground">
{new Date(p.createdAt).toLocaleDateString("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
})}
</p>
{p.paidAt && (
<p className="text-xs text-success-green font-medium">
Paid {new Date(p.paidAt).toLocaleDateString("en-GB", { day: "numeric", month: "short" })}
</p>
)}
</div>
</div>
{p.status !== "paid" && p.status !== "cancelled" && (
<div className="flex gap-2 mt-3 pt-3 border-t">
<Button
size="sm"
variant="success"
className="text-xs"
disabled={updating === p.id}
onClick={() => handleStatusChange(p.id, "paid")}
>
{updating === p.id ? "..." : "Mark Paid"}
</Button>
{p.status === "new" && (
<Button
size="sm"
variant="outline"
className="text-xs"
disabled={updating === p.id}
onClick={() => handleStatusChange(p.id, "initiated")}
>
Mark Initiated
</Button>
)}
<Button
size="sm"
variant="ghost"
className="text-xs text-danger-red ml-auto"
disabled={updating === p.id}
onClick={() => handleStatusChange(p.id, "cancelled")}
>
Cancel
</Button>
</div>
)}
</CardContent>
</Card>
))}
</div>
)}
</div>
)
}
export default function PledgesPage() {
return (
<Suspense fallback={
<div className="flex items-center justify-center py-20">
<Loader2 className="h-8 w-8 text-trust-blue animate-spin" />
</div>
}>
<PledgesContent />
</Suspense>
)
}

View File

@@ -0,0 +1,239 @@
"use client"
import { useState } from "react"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Badge } from "@/components/ui/badge"
import { Upload, CheckCircle2, AlertCircle, HelpCircle, FileSpreadsheet } from "lucide-react"
interface MatchResult {
bankRow: {
date: string
description: string
amount: number
reference: string
}
pledgeId: string | null
pledgeReference: string | null
confidence: "exact" | "partial" | "amount_only" | "none"
matchedAmount: number
autoConfirmed: boolean
}
export default function ReconcilePage() {
const [file, setFile] = useState<File | null>(null)
const [uploading, setUploading] = useState(false)
const [results, setResults] = useState<{
summary: {
totalRows: number
credits: number
exactMatches: number
partialMatches: number
unmatched: number
autoConfirmed: number
}
matches: MatchResult[]
} | null>(null)
const [mapping, setMapping] = useState({
dateCol: "Date",
descriptionCol: "Description",
creditCol: "Credit",
referenceCol: "Reference",
})
const handleUpload = async () => {
if (!file) return
setUploading(true)
try {
const formData = new FormData()
formData.append("file", file)
formData.append("mapping", JSON.stringify(mapping))
const res = await fetch("/api/imports/bank-statement", {
method: "POST",
headers: { "x-org-id": "demo" },
body: formData,
})
const data = await res.json()
if (data.summary) {
setResults(data)
}
} catch {
// handle error
}
setUploading(false)
}
const confidenceIcon = (c: string) => {
switch (c) {
case "exact": return <CheckCircle2 className="h-4 w-4 text-success-green" />
case "partial": return <AlertCircle className="h-4 w-4 text-warm-amber" />
default: return <HelpCircle className="h-4 w-4 text-muted-foreground" />
}
}
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-extrabold text-gray-900">Reconcile Payments</h1>
<p className="text-muted-foreground mt-1">Upload a bank statement CSV to automatically match payments to pledges</p>
</div>
{/* Upload card */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<FileSpreadsheet className="h-5 w-5" /> Bank Statement Import
</CardTitle>
<CardDescription>
Export your bank statement as CSV and upload it here. We&apos;ll match payment references automatically.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Column mapping */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="space-y-1">
<Label className="text-xs">Date Column</Label>
<Input
value={mapping.dateCol}
onChange={(e) => setMapping((m) => ({ ...m, dateCol: e.target.value }))}
className="h-9 text-sm"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Description Column</Label>
<Input
value={mapping.descriptionCol}
onChange={(e) => setMapping((m) => ({ ...m, descriptionCol: e.target.value }))}
className="h-9 text-sm"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Credit/Amount Column</Label>
<Input
value={mapping.creditCol}
onChange={(e) => setMapping((m) => ({ ...m, creditCol: e.target.value }))}
className="h-9 text-sm"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Reference Column</Label>
<Input
value={mapping.referenceCol}
onChange={(e) => setMapping((m) => ({ ...m, referenceCol: e.target.value }))}
className="h-9 text-sm"
/>
</div>
</div>
{/* File upload */}
<div className="border-2 border-dashed border-gray-200 rounded-2xl p-8 text-center hover:border-trust-blue/50 transition-colors">
<Upload className="h-10 w-10 text-muted-foreground mx-auto mb-3" />
<input
type="file"
accept=".csv"
onChange={(e) => setFile(e.target.files?.[0] || null)}
className="hidden"
id="csv-upload"
/>
<label htmlFor="csv-upload" className="cursor-pointer">
<p className="font-medium">{file ? file.name : "Click to upload CSV"}</p>
<p className="text-xs text-muted-foreground mt-1">CSV file from your bank</p>
</label>
</div>
<Button onClick={handleUpload} disabled={!file || uploading} className="w-full">
{uploading ? "Processing..." : "Upload & Match"}
</Button>
</CardContent>
</Card>
{/* Results */}
{results && (
<>
{/* Summary */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
<Card>
<CardContent className="pt-6 text-center">
<p className="text-2xl font-bold">{results.summary.totalRows}</p>
<p className="text-xs text-muted-foreground">Total Rows</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6 text-center">
<p className="text-2xl font-bold">{results.summary.credits}</p>
<p className="text-xs text-muted-foreground">Credits</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6 text-center">
<p className="text-2xl font-bold text-success-green">{results.summary.exactMatches}</p>
<p className="text-xs text-muted-foreground">Exact Matches</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6 text-center">
<p className="text-2xl font-bold text-warm-amber">{results.summary.partialMatches}</p>
<p className="text-xs text-muted-foreground">Partial</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6 text-center">
<p className="text-2xl font-bold text-success-green">{results.summary.autoConfirmed}</p>
<p className="text-xs text-muted-foreground">Auto-confirmed</p>
</CardContent>
</Card>
</div>
{/* Match table */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Match Results</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left">
<th className="pb-3 font-medium text-muted-foreground">Confidence</th>
<th className="pb-3 font-medium text-muted-foreground">Date</th>
<th className="pb-3 font-medium text-muted-foreground">Description</th>
<th className="pb-3 font-medium text-muted-foreground">Amount</th>
<th className="pb-3 font-medium text-muted-foreground">Matched Pledge</th>
<th className="pb-3 font-medium text-muted-foreground">Status</th>
</tr>
</thead>
<tbody className="divide-y">
{results.matches.map((m, i) => (
<tr key={i} className="hover:bg-gray-50/50">
<td className="py-3">{confidenceIcon(m.confidence)}</td>
<td className="py-3">{m.bankRow.date}</td>
<td className="py-3 max-w-[200px] truncate">{m.bankRow.description}</td>
<td className="py-3 font-bold">£{m.matchedAmount.toFixed(2)}</td>
<td className="py-3 font-mono">{m.pledgeReference || "—"}</td>
<td className="py-3">
{m.autoConfirmed ? (
<Badge variant="success">Auto-confirmed</Badge>
) : m.confidence === "partial" ? (
<Badge variant="warning">Review needed</Badge>
) : m.confidence === "none" ? (
<Badge variant="outline">No match</Badge>
) : (
<Badge variant="success">Matched</Badge>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
</>
)}
</div>
)
}

View File

@@ -0,0 +1,266 @@
"use client"
import { useState, useEffect } from "react"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Building2, CreditCard, Palette, Check, Loader2, AlertCircle } from "lucide-react"
interface OrgSettings {
name: string
bankName: string
bankSortCode: string
bankAccountNo: string
bankAccountName: string
refPrefix: string
primaryColor: string
gcAccessToken: string
gcEnvironment: string
}
export default function SettingsPage() {
const [settings, setSettings] = useState<OrgSettings | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState<string | null>(null)
const [saved, setSaved] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
fetch("/api/settings", { headers: { "x-org-id": "demo" } })
.then((r) => r.json())
.then((data) => {
if (data.name) setSettings(data)
})
.catch(() => setError("Failed to load settings"))
.finally(() => setLoading(false))
}, [])
const save = async (section: string, data: Record<string, string>) => {
setSaving(section)
setError(null)
try {
const res = await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json", "x-org-id": "demo" },
body: JSON.stringify(data),
})
if (res.ok) {
setSaved(section)
setTimeout(() => setSaved(null), 2000)
} else {
setError("Failed to save")
}
} catch {
setError("Failed to save")
}
setSaving(null)
}
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-8 w-8 text-trust-blue animate-spin" />
</div>
)
}
if (!settings) {
return (
<div className="text-center py-20">
<AlertCircle className="h-8 w-8 text-danger-red mx-auto mb-2" />
<p className="text-muted-foreground">Failed to load settings</p>
</div>
)
}
const update = (key: keyof OrgSettings, value: string) => {
setSettings((s) => s ? { ...s, [key]: value } : s)
}
return (
<div className="space-y-6 max-w-2xl">
<div>
<h1 className="text-3xl font-extrabold text-gray-900">Settings</h1>
<p className="text-muted-foreground mt-1">Configure your organisation&apos;s payment details</p>
</div>
{error && (
<div className="rounded-xl bg-danger-red/10 border border-danger-red/20 p-3 text-sm text-danger-red">
{error}
</div>
)}
{/* Bank Details */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Building2 className="h-5 w-5" /> Bank Account Details
</CardTitle>
<CardDescription>
These details are shown to donors when they choose bank transfer.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Bank Name</Label>
<Input value={settings.bankName} onChange={(e) => update("bankName", e.target.value)} />
</div>
<div className="space-y-2">
<Label>Account Name</Label>
<Input value={settings.bankAccountName} onChange={(e) => update("bankAccountName", e.target.value)} />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Sort Code</Label>
<Input value={settings.bankSortCode} onChange={(e) => update("bankSortCode", e.target.value)} placeholder="XX-XX-XX" />
</div>
<div className="space-y-2">
<Label>Account Number</Label>
<Input value={settings.bankAccountNo} onChange={(e) => update("bankAccountNo", e.target.value)} placeholder="XXXXXXXX" />
</div>
</div>
<div className="space-y-2">
<Label>Reference Prefix</Label>
<Input value={settings.refPrefix} onChange={(e) => update("refPrefix", e.target.value)} maxLength={4} />
<p className="text-xs text-muted-foreground">Max 4 chars. Used in payment references, e.g. {settings.refPrefix}-XXXX-50</p>
</div>
<Button
onClick={() => save("bank", {
bankName: settings.bankName,
bankSortCode: settings.bankSortCode,
bankAccountNo: settings.bankAccountNo,
bankAccountName: settings.bankAccountName,
refPrefix: settings.refPrefix,
})}
disabled={saving === "bank"}
>
{saving === "bank" ? (
<><Loader2 className="h-4 w-4 mr-2 animate-spin" /> Saving...</>
) : saved === "bank" ? (
<><Check className="h-4 w-4 mr-2" /> Saved!</>
) : (
"Save Bank Details"
)}
</Button>
</CardContent>
</Card>
{/* GoCardless */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<CreditCard className="h-5 w-5" /> GoCardless (Direct Debit)
</CardTitle>
<CardDescription>
Connect GoCardless to enable Direct Debit collection.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Access Token</Label>
<Input
type="password"
value={settings.gcAccessToken}
onChange={(e) => update("gcAccessToken", e.target.value)}
placeholder="sandbox_xxxxx or live_xxxxx"
/>
</div>
<div className="space-y-2">
<Label>Environment</Label>
<div className="flex gap-3">
{["sandbox", "live"].map((env) => (
<button
key={env}
onClick={() => update("gcEnvironment", env)}
className={`px-4 py-2 rounded-xl text-sm font-medium border-2 transition-colors ${
settings.gcEnvironment === env
? env === "live"
? "border-danger-red bg-danger-red/5 text-danger-red"
: "border-trust-blue bg-trust-blue/5 text-trust-blue"
: "border-gray-200 text-muted-foreground hover:border-gray-300"
}`}
>
{env.charAt(0).toUpperCase() + env.slice(1)}
{env === "live" && settings.gcEnvironment === "live" && " ⚠️"}
</button>
))}
</div>
{settings.gcEnvironment === "live" && (
<p className="text-xs text-danger-red font-medium"> Live mode will create real Direct Debit mandates</p>
)}
</div>
<Button
onClick={() => save("gc", {
gcAccessToken: settings.gcAccessToken,
gcEnvironment: settings.gcEnvironment,
})}
disabled={saving === "gc"}
>
{saving === "gc" ? (
<><Loader2 className="h-4 w-4 mr-2 animate-spin" /> Saving...</>
) : saved === "gc" ? (
<><Check className="h-4 w-4 mr-2" /> Connected!</>
) : (
"Save GoCardless Settings"
)}
</Button>
</CardContent>
</Card>
{/* Branding */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Palette className="h-5 w-5" /> Branding
</CardTitle>
<CardDescription>
Customise the look of your pledge pages.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Organisation Name</Label>
<Input
value={settings.name}
onChange={(e) => update("name", e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Primary Colour</Label>
<div className="flex gap-2">
<Input
type="color"
value={settings.primaryColor}
onChange={(e) => update("primaryColor", e.target.value)}
className="w-14 h-11 p-1"
/>
<Input
value={settings.primaryColor}
onChange={(e) => update("primaryColor", e.target.value)}
className="flex-1"
/>
</div>
</div>
<Button
onClick={() => save("brand", {
name: settings.name,
primaryColor: settings.primaryColor,
})}
disabled={saving === "brand"}
>
{saving === "brand" ? (
<><Loader2 className="h-4 w-4 mr-2 animate-spin" /> Saving...</>
) : saved === "brand" ? (
<><Check className="h-4 w-4 mr-2" /> Saved!</>
) : (
"Save Branding"
)}
</Button>
</CardContent>
</Card>
</div>
)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,45 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 221.2 83.2% 53.3%;
--radius: 0.75rem;
}
* {
@apply border-border;
}
body {
@apply bg-background text-foreground antialiased;
font-family: 'Inter', system-ui, -apple-system, sans-serif;
}
}
@layer utilities {
.tap-target {
@apply min-h-[48px] min-w-[48px];
}
}

View File

@@ -0,0 +1,20 @@
import type { Metadata } from "next"
import { ToastProvider } from "@/components/ui/toast"
import "./globals.css"
export const metadata: Metadata = {
title: "Pledge Now, Pay Later",
description: "Convert pledges into collected donations for UK charities",
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<ToastProvider>
{children}
</ToastProvider>
</body>
</html>
)
}

View File

@@ -0,0 +1,12 @@
export default function PledgeLoading() {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-trust-blue/5 via-white to-warm-amber/5">
<div className="text-center space-y-4">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-trust-blue/10 animate-pulse">
<div className="w-8 h-8 rounded-full bg-trust-blue/30" />
</div>
<p className="text-muted-foreground animate-pulse">Loading pledge page...</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,210 @@
"use client"
import { useState, useEffect } from "react"
import { useParams } from "next/navigation"
import { AmountStep } from "./steps/amount-step"
import { PaymentStep } from "./steps/payment-step"
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 interface PledgeData {
amountPence: number
rail: Rail
donorName: string
donorEmail: string
donorPhone: string
giftAid: boolean
}
interface EventInfo {
id: string
name: string
organizationName: string
qrSourceId: string | null
qrSourceLabel: string | null
}
// Step indices:
// 0 = Amount selection
// 1 = Payment method selection
// 2 = Identity (for bank transfer)
// 3 = Bank instructions
// 4 = Confirmation (generic — card, DD, FPX)
// 5 = Card payment step
// 6 = FPX payment step
// 7 = Direct Debit step
const STEP_TO_RAIL: Record<number, number> = { 5: 1, 6: 1, 7: 1 } // maps back to payment selection
export default function PledgePage() {
const params = useParams()
const token = params.token as string
const [step, setStep] = useState(0)
const [eventInfo, setEventInfo] = useState<EventInfo | null>(null)
const [pledgeData, setPledgeData] = useState<PledgeData>({
amountPence: 0,
rail: "bank",
donorName: "",
donorEmail: "",
donorPhone: "",
giftAid: false,
})
const [pledgeResult, setPledgeResult] = useState<{
id: string
reference: string
bankDetails?: {
bankName: string
sortCode: string
accountNo: string
accountName: string
}
} | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState("")
useEffect(() => {
fetch(`/api/qr/${token}`)
.then((r) => r.json())
.then((data) => {
if (data.error) setError(data.error)
else setEventInfo(data)
setLoading(false)
})
.catch(() => {
setError("Unable to load pledge page")
setLoading(false)
})
// Track pledge_start
fetch("/api/analytics", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ eventType: "pledge_start", metadata: { token } }),
}).catch(() => {})
}, [token])
const handleAmountSelected = (amountPence: number) => {
setPledgeData((d) => ({ ...d, amountPence }))
setStep(1)
}
const handleRailSelected = (rail: Rail) => {
setPledgeData((d) => ({ ...d, rail }))
const railStepMap: Record<Rail, number> = {
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)
}
setStep(railStepMap[rail])
}
const submitPledge = async (identity: { donorName: string; donorEmail: string; donorPhone: string; giftAid: boolean }) => {
const finalData = { ...pledgeData, ...identity }
setPledgeData(finalData)
try {
const res = await fetch("/api/pledges", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...finalData,
eventId: eventInfo?.id,
qrSourceId: eventInfo?.qrSourceId,
}),
})
const result = await res.json()
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.")
}
}
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-trust-blue/5 via-white to-warm-amber/5">
<div className="animate-pulse text-trust-blue text-lg font-medium">Loading...</div>
</div>
)
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-trust-blue/5 via-white to-warm-amber/5 p-4">
<div className="text-center space-y-4">
<div className="text-6xl">😔</div>
<h1 className="text-xl font-bold text-gray-900">Something went wrong</h1>
<p className="text-muted-foreground">{error}</p>
</div>
</div>
)
}
const steps: Record<number, React.ReactNode> = {
0: <AmountStep onSelect={handleAmountSelected} eventName={eventInfo?.name || ""} />,
1: <PaymentStep onSelect={handleRailSelected} amount={pledgeData.amountPence} />,
2: <IdentityStep onSubmit={submitPledge} />,
3: pledgeResult && <BankInstructionsStep pledge={pledgeResult} amount={pledgeData.amountPence} eventName={eventInfo?.name || ""} />,
4: pledgeResult && <ConfirmationStep pledge={pledgeResult} amount={pledgeData.amountPence} rail={pledgeData.rail} eventName={eventInfo?.name || ""} />,
5: <CardPaymentStep amount={pledgeData.amountPence} eventName={eventInfo?.name || ""} eventId={eventInfo?.id || ""} qrSourceId={eventInfo?.qrSourceId || null} onComplete={submitPledge} />,
6: <FpxPaymentStep amount={pledgeData.amountPence} eventName={eventInfo?.name || ""} onComplete={submitPledge} />,
7: <DirectDebitStep amount={pledgeData.amountPence} eventName={eventInfo?.name || ""} organizationName={eventInfo?.organizationName || ""} eventId={eventInfo?.id || ""} qrSourceId={eventInfo?.qrSourceId || null} onComplete={submitPledge} />,
}
// 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
}
// 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
return (
<div className="min-h-screen bg-gradient-to-br from-trust-blue/5 via-white to-warm-amber/5">
{/* Progress bar */}
<div className="fixed top-0 left-0 right-0 h-1 bg-gray-100 z-50">
<div
className="h-full bg-trust-blue transition-all duration-500 ease-out"
style={{ width: `${progressPercent}%` }}
/>
</div>
{/* Header */}
<div className="pt-6 pb-2 px-4 text-center">
<p className="text-sm text-muted-foreground">{eventInfo?.organizationName}</p>
<p className="text-xs text-muted-foreground/60">{eventInfo?.qrSourceLabel || ""}</p>
</div>
{/* Step content */}
<div className="px-4 pb-8">
{steps[step]}
</div>
{/* Back button */}
{backableSteps.has(step) && (
<div className="fixed bottom-6 left-4">
<button
onClick={() => setStep(getBackStep(step))}
className="text-sm text-muted-foreground hover:text-foreground transition-colors tap-target flex items-center gap-1"
>
Back
</button>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,97 @@
"use client"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
const PRESETS = [1000, 2000, 5000, 10000, 25000, 50000] // pence
interface Props {
onSelect: (amountPence: number) => void
eventName: string
}
export function AmountStep({ onSelect, eventName }: Props) {
const [custom, setCustom] = useState("")
const [selected, setSelected] = useState<number | null>(null)
const handlePreset = (amount: number) => {
setSelected(amount)
setCustom("")
}
const handleCustomChange = (value: string) => {
const clean = value.replace(/[^0-9.]/g, "")
setCustom(clean)
setSelected(null)
}
const handleContinue = () => {
const amount = selected || Math.round(parseFloat(custom) * 100)
if (amount >= 100) onSelect(amount)
}
const isValid = selected || (custom && parseFloat(custom) >= 1)
return (
<div className="max-w-md mx-auto pt-4 space-y-8">
<div className="text-center space-y-2">
<h1 className="text-3xl font-extrabold text-gray-900">
Make a Pledge
</h1>
<p className="text-muted-foreground">
for <span className="font-semibold text-foreground">{eventName}</span>
</p>
</div>
{/* Presets */}
<div className="grid grid-cols-3 gap-3">
{PRESETS.map((amount) => (
<button
key={amount}
onClick={() => handlePreset(amount)}
className={`
tap-target rounded-2xl border-2 py-4 text-center font-bold text-lg transition-all
${selected === amount
? "border-trust-blue bg-trust-blue text-white shadow-lg shadow-trust-blue/25 scale-[1.02]"
: "border-gray-200 bg-white text-gray-900 hover:border-trust-blue/50 active:scale-[0.98]"
}
`}
>
£{amount / 100}
</button>
))}
</div>
{/* Custom */}
<div className="space-y-2">
<label className="text-sm font-medium text-muted-foreground">Or enter a custom amount</label>
<div className="relative">
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-2xl font-bold text-gray-400">£</span>
<Input
type="text"
inputMode="decimal"
placeholder="0.00"
value={custom}
onChange={(e) => handleCustomChange(e.target.value)}
className="pl-10 h-16 text-2xl font-bold text-center rounded-2xl"
/>
</div>
</div>
{/* Continue */}
<Button
size="xl"
className="w-full"
disabled={!isValid}
onClick={handleContinue}
>
Continue
</Button>
<p className="text-center text-xs text-muted-foreground">
You won&apos;t be charged now. Choose how to pay next.
</p>
</div>
)
}

View File

@@ -0,0 +1,165 @@
"use client"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { Check, Copy, ExternalLink } from "lucide-react"
interface Props {
pledge: {
id: string
reference: string
bankDetails?: {
bankName: string
sortCode: string
accountNo: string
accountName: string
}
}
amount: number
eventName: string
}
export function BankInstructionsStep({ pledge, amount, eventName }: Props) {
const [copied, setCopied] = useState(false)
const [markedPaid, setMarkedPaid] = useState(false)
const copyReference = async () => {
await navigator.clipboard.writeText(pledge.reference)
setCopied(true)
setTimeout(() => setCopied(false), 3000)
// Track
fetch("/api/analytics", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ eventType: "instruction_copy_clicked", pledgeId: pledge.id }),
}).catch(() => {})
}
const handleIPaid = async () => {
setMarkedPaid(true)
fetch(`/api/pledges/${pledge.id}/mark-initiated`, { method: "POST" }).catch(() => {})
fetch("/api/analytics", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ eventType: "i_paid_clicked", pledgeId: pledge.id }),
}).catch(() => {})
}
const bd = pledge.bankDetails
if (markedPaid) {
return (
<div className="max-w-md mx-auto pt-8 text-center space-y-6">
<div className="inline-flex items-center justify-center w-20 h-20 rounded-full bg-success-green/10">
<Check className="h-10 w-10 text-success-green" />
</div>
<h1 className="text-2xl font-extrabold text-gray-900">Thank you!</h1>
<p className="text-muted-foreground">
We&apos;ll confirm once your payment of <span className="font-bold text-foreground">£{(amount / 100).toFixed(2)}</span> is received.
</p>
<Card className="text-left">
<CardContent className="pt-4 space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Reference</span>
<span className="font-mono font-bold">{pledge.reference}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Event</span>
<span>{eventName}</span>
</div>
</CardContent>
</Card>
<p className="text-xs text-muted-foreground">
Need help? Contact the charity directly.
</p>
</div>
)
}
return (
<div className="max-w-md mx-auto pt-4 space-y-6">
<div className="text-center space-y-2">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-trust-blue/10 mb-2">
<span className="text-3xl">🏦</span>
</div>
<h1 className="text-2xl font-extrabold text-gray-900">
Transfer £{(amount / 100).toFixed(2)}
</h1>
<p className="text-muted-foreground">
Use these details in your banking app
</p>
</div>
{/* Bank details card */}
{bd && (
<Card>
<CardContent className="pt-6 space-y-4">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-muted-foreground text-xs uppercase tracking-wider">Sort Code</p>
<p className="font-mono font-bold text-lg">{bd.sortCode}</p>
</div>
<div>
<p className="text-muted-foreground text-xs uppercase tracking-wider">Account No</p>
<p className="font-mono font-bold text-lg">{bd.accountNo}</p>
</div>
</div>
<div className="text-sm">
<p className="text-muted-foreground text-xs uppercase tracking-wider">Account Name</p>
<p className="font-semibold">{bd.accountName}</p>
</div>
</CardContent>
</Card>
)}
{/* Reference - THE KEY */}
<div className="rounded-2xl border-2 border-trust-blue bg-trust-blue/5 p-6 text-center space-y-3">
<p className="text-xs font-semibold text-trust-blue uppercase tracking-wider">
Payment Reference use exactly:
</p>
<p className="text-3xl font-mono font-extrabold text-trust-blue tracking-wider">
{pledge.reference}
</p>
<Button
onClick={copyReference}
variant={copied ? "success" : "default"}
size="lg"
className="w-full"
>
{copied ? (
<>
<Check className="h-5 w-5 mr-2" /> Copied!
</>
) : (
<>
<Copy className="h-5 w-5 mr-2" /> Copy Reference
</>
)}
</Button>
</div>
{/* Suggestion */}
<div className="rounded-2xl bg-warm-amber/5 border border-warm-amber/20 p-4 text-center">
<p className="text-sm font-medium text-warm-amber">
<ExternalLink className="h-4 w-4 inline mr-1" />
Open your banking app now and search for &quot;new payment&quot;
</p>
</div>
{/* I've paid */}
<Button
size="xl"
variant="success"
className="w-full"
onClick={handleIPaid}
>
I&apos;ve Sent the Payment
</Button>
<p className="text-center text-xs text-muted-foreground">
Payments usually take 1-2 hours to arrive. We&apos;ll confirm once received.
</p>
</div>
)
}

View File

@@ -0,0 +1,305 @@
"use client"
import { useState, useRef } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { CreditCard, Lock } from "lucide-react"
interface Props {
amount: number
eventName: string
eventId: string
qrSourceId: string | null
onComplete: (identity: {
donorName: string
donorEmail: string
donorPhone: string
giftAid: boolean
}) => void
}
function formatCardNumber(value: string): string {
const digits = value.replace(/\D/g, "").slice(0, 16)
return digits.replace(/(\d{4})(?=\d)/g, "$1 ")
}
function formatExpiry(value: string): string {
const digits = value.replace(/\D/g, "").slice(0, 4)
if (digits.length >= 3) return digits.slice(0, 2) + "/" + digits.slice(2)
return digits
}
function luhnCheck(num: string): boolean {
const digits = num.replace(/\D/g, "")
if (digits.length < 13) return false
let sum = 0
let alt = false
for (let i = digits.length - 1; i >= 0; i--) {
let n = parseInt(digits[i], 10)
if (alt) {
n *= 2
if (n > 9) n -= 9
}
sum += n
alt = !alt
}
return sum % 10 === 0
}
function getCardBrand(num: string): string {
const d = num.replace(/\D/g, "")
if (/^4/.test(d)) return "Visa"
if (/^5[1-5]/.test(d) || /^2[2-7]/.test(d)) return "Mastercard"
if (/^3[47]/.test(d)) return "Amex"
if (/^6(?:011|5)/.test(d)) return "Discover"
return ""
}
export function CardPaymentStep({ amount, eventName, eventId, qrSourceId, onComplete }: Props) {
const [cardNumber, setCardNumber] = useState("")
const [expiry, setExpiry] = useState("")
const [cvc, setCvc] = useState("")
const [name, setName] = useState("")
const [email, setEmail] = useState("")
const [giftAid, setGiftAid] = useState(false)
const [processing, setProcessing] = useState(false)
const [errors, setErrors] = useState<Record<string, string>>({})
const expiryRef = useRef<HTMLInputElement>(null)
const cvcRef = useRef<HTMLInputElement>(null)
const pounds = (amount / 100).toFixed(2)
const brand = getCardBrand(cardNumber)
const validate = (): boolean => {
const errs: Record<string, string> = {}
const digits = cardNumber.replace(/\D/g, "")
if (!luhnCheck(digits)) errs.card = "Invalid card number"
if (digits.length < 13) errs.card = "Card number too short"
const expiryDigits = expiry.replace(/\D/g, "")
if (expiryDigits.length < 4) {
errs.expiry = "Invalid expiry"
} else {
const month = parseInt(expiryDigits.slice(0, 2), 10)
const year = parseInt("20" + expiryDigits.slice(2, 4), 10)
const now = new Date()
if (month < 1 || month > 12) errs.expiry = "Invalid month"
else if (year < now.getFullYear() || (year === now.getFullYear() && month < now.getMonth() + 1))
errs.expiry = "Card expired"
}
if (cvc.length < 3) errs.cvc = "Invalid CVC"
if (!name.trim()) errs.name = "Name required"
if (!email.includes("@")) errs.email = "Valid email required"
setErrors(errs)
return Object.keys(errs).length === 0
}
const handleSubmit = async () => {
if (!validate()) return
setProcessing(true)
// Try real Stripe Checkout first
try {
const res = await fetch("/api/stripe/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
amountPence: amount,
donorName: name,
donorEmail: email,
donorPhone: "",
giftAid,
eventId,
qrSourceId,
}),
})
const data = await res.json()
if (data.mode === "live" && data.checkoutUrl) {
// Redirect to Stripe Checkout
window.location.href = data.checkoutUrl
return
}
// Simulated mode — fall through to onComplete
} catch {
// Fall through to simulated
}
// Simulated fallback
await new Promise((r) => setTimeout(r, 1500))
onComplete({
donorName: name,
donorEmail: email,
donorPhone: "",
giftAid,
})
}
const handleCardNumberChange = (value: string) => {
const formatted = formatCardNumber(value)
setCardNumber(formatted)
// Auto-advance to expiry when complete
if (formatted.replace(/\s/g, "").length === 16) {
expiryRef.current?.focus()
}
}
const handleExpiryChange = (value: string) => {
const formatted = formatExpiry(value)
setExpiry(formatted)
if (formatted.length === 5) {
cvcRef.current?.focus()
}
}
const isReady = cardNumber.replace(/\D/g, "").length >= 13 && expiry.length === 5 && cvc.length >= 3 && name.trim() && email.includes("@")
return (
<div className="max-w-md mx-auto pt-4 space-y-6">
<div className="text-center space-y-2">
<h1 className="text-2xl font-extrabold text-gray-900">Pay by Card</h1>
<p className="text-lg text-muted-foreground">
Pledge: <span className="font-bold text-foreground">£{pounds}</span>{" "}
for <span className="font-semibold text-foreground">{eventName}</span>
</p>
</div>
{/* Card form */}
<div className="rounded-2xl border-2 border-gray-200 bg-white p-5 space-y-4">
{/* Card number */}
<div className="space-y-2">
<Label htmlFor="card-number">Card Number</Label>
<div className="relative">
<CreditCard className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
<Input
id="card-number"
placeholder="1234 5678 9012 3456"
value={cardNumber}
onChange={(e) => handleCardNumberChange(e.target.value)}
inputMode="numeric"
autoComplete="cc-number"
className={`pl-11 font-mono text-base ${errors.card ? "border-red-500" : ""}`}
/>
{brand && (
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs font-semibold text-trust-blue bg-trust-blue/10 px-2 py-0.5 rounded-full">
{brand}
</span>
)}
</div>
{errors.card && <p className="text-xs text-red-500">{errors.card}</p>}
</div>
{/* Expiry + CVC row */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="expiry">Expiry</Label>
<Input
id="expiry"
ref={expiryRef}
placeholder="MM/YY"
value={expiry}
onChange={(e) => handleExpiryChange(e.target.value)}
inputMode="numeric"
autoComplete="cc-exp"
maxLength={5}
className={`font-mono text-base ${errors.expiry ? "border-red-500" : ""}`}
/>
{errors.expiry && <p className="text-xs text-red-500">{errors.expiry}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="cvc">CVC</Label>
<Input
id="cvc"
ref={cvcRef}
placeholder="123"
value={cvc}
onChange={(e) => setCvc(e.target.value.replace(/\D/g, "").slice(0, 4))}
inputMode="numeric"
autoComplete="cc-csc"
maxLength={4}
className={`font-mono text-base ${errors.cvc ? "border-red-500" : ""}`}
/>
{errors.cvc && <p className="text-xs text-red-500">{errors.cvc}</p>}
</div>
</div>
{/* Cardholder name */}
<div className="space-y-2">
<Label htmlFor="card-name">Name on Card</Label>
<Input
id="card-name"
placeholder="J. Smith"
value={name}
onChange={(e) => setName(e.target.value)}
autoComplete="cc-name"
className={errors.name ? "border-red-500" : ""}
/>
{errors.name && <p className="text-xs text-red-500">{errors.name}</p>}
</div>
{/* Email */}
<div className="space-y-2">
<Label htmlFor="card-email">Email</Label>
<Input
id="card-email"
type="email"
placeholder="your@email.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
inputMode="email"
className={errors.email ? "border-red-500" : ""}
/>
{errors.email && <p className="text-xs text-red-500">{errors.email}</p>}
</div>
</div>
{/* Gift Aid */}
<label className="flex items-start gap-3 rounded-2xl border-2 border-gray-200 bg-white p-4 cursor-pointer hover:border-trust-blue/50 transition-colors">
<input
type="checkbox"
checked={giftAid}
onChange={(e) => setGiftAid(e.target.checked)}
className="mt-1 h-5 w-5 rounded border-gray-300 text-trust-blue focus:ring-trust-blue"
/>
<div>
<span className="font-semibold text-gray-900">Add Gift Aid</span>
<p className="text-sm text-muted-foreground mt-0.5">
Boost your donation by 25% at no extra cost. You must be a UK taxpayer.
</p>
</div>
</label>
{/* Pay button */}
<Button
size="xl"
className="w-full"
disabled={!isReady || processing}
onClick={handleSubmit}
>
{processing ? (
<span className="flex items-center gap-2">
<span className="h-5 w-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
Processing...
</span>
) : (
<>
<Lock className="h-5 w-5 mr-2" />
Pay £{pounds}
</>
)}
</Button>
<div className="flex items-center justify-center gap-2 text-xs text-muted-foreground">
<Lock className="h-3 w-3" />
<span>Secured with 256-bit encryption</span>
</div>
</div>
)
}

View File

@@ -0,0 +1,87 @@
"use client"
import { Check } from "lucide-react"
import { Card, CardContent } from "@/components/ui/card"
interface Props {
pledge: { id: string; reference: string }
amount: number
rail: string
eventName: string
}
export function ConfirmationStep({ pledge, amount, rail, eventName }: Props) {
const railLabels: Record<string, string> = {
bank: "Bank Transfer",
gocardless: "Direct Debit",
card: "Card Payment",
fpx: "FPX Online Banking",
}
const currencySymbol = rail === "fpx" ? "RM" : "£"
const nextStepMessages: Record<string, string> = {
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.",
}
return (
<div className="max-w-md mx-auto pt-8 text-center space-y-6">
<div className="inline-flex items-center justify-center w-20 h-20 rounded-full bg-success-green/10">
<Check className="h-10 w-10 text-success-green" />
</div>
<div className="space-y-2">
<h1 className="text-2xl font-extrabold text-gray-900">
{rail === "fpx" ? "Payment Successful!" : rail === "gocardless" ? "Mandate Set Up!" : "Pledge Received!"}
</h1>
<p className="text-muted-foreground">
Thank you for your generous {rail === "fpx" ? "donation" : "pledge"} to{" "}
<span className="font-semibold text-foreground">{eventName}</span>
</p>
</div>
<Card>
<CardContent className="pt-6 space-y-3 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Amount</span>
<span className="font-bold">{currencySymbol}{(amount / 100).toFixed(2)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Payment Method</span>
<span>{railLabels[rail] || rail}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Reference</span>
<span className="font-mono font-bold">{pledge.reference}</span>
</div>
{rail === "gocardless" && (
<div className="flex justify-between">
<span className="text-muted-foreground">Collection</span>
<span className="text-sm">3-5 working days</span>
</div>
)}
{rail === "fpx" && (
<div className="flex justify-between">
<span className="text-muted-foreground">Status</span>
<span className="text-success-green font-semibold">Paid </span>
</div>
)}
</CardContent>
</Card>
<div className="rounded-2xl bg-trust-blue/5 border border-trust-blue/20 p-4 space-y-2">
<p className="text-sm font-medium text-trust-blue">What happens next?</p>
<p className="text-sm text-muted-foreground">
{nextStepMessages[rail] || nextStepMessages.bank}
</p>
</div>
<p className="text-xs text-muted-foreground">
Need help? Contact the charity directly. Ref: {pledge.reference}
</p>
</div>
)
}

View File

@@ -0,0 +1,365 @@
"use client"
import { useState, useRef } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Lock, ShieldCheck, Landmark } from "lucide-react"
interface Props {
amount: number
eventName: string
organizationName: string
eventId: string
qrSourceId: string | null
onComplete: (identity: {
donorName: string
donorEmail: string
donorPhone: string
giftAid: boolean
}) => void
}
function formatSortCode(value: string): string {
const digits = value.replace(/\D/g, "").slice(0, 6)
if (digits.length > 4) return digits.slice(0, 2) + "-" + digits.slice(2, 4) + "-" + digits.slice(4)
if (digits.length > 2) return digits.slice(0, 2) + "-" + digits.slice(2)
return digits
}
type Phase = "form" | "reviewing" | "processing"
export function DirectDebitStep({ amount, eventName, organizationName, eventId, qrSourceId, onComplete }: Props) {
const [phase, setPhase] = useState<Phase>("form")
const [accountName, setAccountName] = useState("")
const [email, setEmail] = useState("")
const [sortCode, setSortCode] = useState("")
const [accountNumber, setAccountNumber] = useState("")
const [giftAid, setGiftAid] = useState(false)
const [mandateAgreed, setMandateAgreed] = useState(false)
const [errors, setErrors] = useState<Record<string, string>>({})
const accountNumberRef = useRef<HTMLInputElement>(null)
const pounds = (amount / 100).toFixed(2)
const validate = (): boolean => {
const errs: Record<string, string> = {}
if (!accountName.trim()) errs.accountName = "Account holder name required"
if (!email.includes("@")) errs.email = "Valid email required"
const scDigits = sortCode.replace(/\D/g, "")
if (scDigits.length !== 6) errs.sortCode = "Sort code must be 6 digits"
const anDigits = accountNumber.replace(/\D/g, "")
if (anDigits.length < 7 || anDigits.length > 8) errs.accountNumber = "Account number must be 7-8 digits"
if (!mandateAgreed) errs.mandate = "You must agree to the Direct Debit mandate"
setErrors(errs)
return Object.keys(errs).length === 0
}
const handleSortCodeChange = (value: string) => {
const formatted = formatSortCode(value)
setSortCode(formatted)
// Auto-advance when complete
if (formatted.replace(/\D/g, "").length === 6) {
accountNumberRef.current?.focus()
}
}
const handleReview = () => {
if (!validate()) return
setPhase("reviewing")
}
const handleConfirm = async () => {
setPhase("processing")
// Try real GoCardless flow first
try {
const res = await fetch("/api/gocardless/create-flow", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
amountPence: amount,
donorName: accountName,
donorEmail: email,
donorPhone: "",
giftAid,
eventId,
qrSourceId,
}),
})
const data = await res.json()
if (data.mode === "live" && data.redirectUrl) {
// Redirect to GoCardless hosted page
window.location.href = data.redirectUrl
return
}
// Simulated mode — fall through to onComplete
} catch {
// Fall through to simulated
}
// Simulated fallback
await new Promise((r) => setTimeout(r, 1000))
onComplete({
donorName: accountName,
donorEmail: email,
donorPhone: "",
giftAid,
})
}
// Processing phase
if (phase === "processing") {
return (
<div className="max-w-md mx-auto pt-16 text-center space-y-6">
<div className="inline-flex items-center justify-center w-20 h-20 rounded-full bg-trust-blue/10">
<div className="h-10 w-10 border-4 border-trust-blue border-t-transparent rounded-full animate-spin" />
</div>
<div className="space-y-2">
<h1 className="text-2xl font-extrabold text-gray-900">
Setting Up Direct Debit
</h1>
<p className="text-muted-foreground">
Creating your mandate with GoCardless...
</p>
</div>
<p className="text-xs text-muted-foreground">
This may take a few seconds.
</p>
</div>
)
}
// Review phase
if (phase === "reviewing") {
return (
<div className="max-w-md mx-auto pt-4 space-y-6">
<div className="text-center space-y-2">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-trust-blue/10 mb-2">
<ShieldCheck className="h-8 w-8 text-trust-blue" />
</div>
<h1 className="text-2xl font-extrabold text-gray-900">
Confirm Direct Debit
</h1>
<p className="text-muted-foreground">
Please review your mandate details
</p>
</div>
{/* Summary card */}
<div className="rounded-2xl border-2 border-gray-200 bg-white p-5 space-y-4">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wider">Account Holder</p>
<p className="font-semibold">{accountName}</p>
</div>
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wider">Email</p>
<p className="font-semibold truncate">{email}</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wider">Sort Code</p>
<p className="font-mono font-bold text-lg">{sortCode}</p>
</div>
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wider">Account Number</p>
<p className="font-mono font-bold text-lg">{accountNumber.slice(-4)}</p>
</div>
</div>
<div className="border-t pt-4">
<div className="flex justify-between items-center">
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wider">Amount</p>
<p className="font-bold text-xl">£{pounds}</p>
</div>
<div className="text-right">
<p className="text-xs text-muted-foreground uppercase tracking-wider">Payee</p>
<p className="font-semibold">{organizationName}</p>
</div>
</div>
</div>
</div>
{/* Guarantee box */}
<div className="rounded-2xl bg-emerald-50 border border-emerald-200 p-4 space-y-2">
<div className="flex items-center gap-2">
<ShieldCheck className="h-5 w-5 text-emerald-600" />
<p className="text-sm font-semibold text-emerald-800">Direct Debit Guarantee</p>
</div>
<p className="text-xs text-emerald-700 leading-relaxed">
This Guarantee is offered by all banks and building societies that accept instructions to pay Direct Debits.
If there are any changes to the amount, date, or frequency of your Direct Debit, you will be notified in advance.
If an error is made in the payment, you are entitled to a full and immediate refund from your bank.
You can cancel a Direct Debit at any time by contacting your bank.
</p>
</div>
<div className="flex gap-3">
<Button variant="outline" size="xl" className="flex-1" onClick={() => setPhase("form")}>
Edit
</Button>
<Button size="xl" className="flex-1" onClick={handleConfirm}>
<Lock className="h-5 w-5 mr-2" />
Confirm Mandate
</Button>
</div>
<div className="flex items-center justify-center gap-2 text-xs text-muted-foreground">
<Lock className="h-3 w-3" />
<span>Processed securely by GoCardless</span>
</div>
</div>
)
}
// Form phase (default)
return (
<div className="max-w-md mx-auto pt-4 space-y-6">
<div className="text-center space-y-2">
<h1 className="text-2xl font-extrabold text-gray-900">
Set Up Direct Debit
</h1>
<p className="text-lg text-muted-foreground">
Pledge: <span className="font-bold text-foreground">£{pounds}</span>{" "}
for <span className="font-semibold text-foreground">{eventName}</span>
</p>
</div>
{/* Info banner */}
<div className="rounded-2xl bg-trust-blue/5 border border-trust-blue/20 p-4 flex items-start gap-3">
<Landmark className="h-5 w-5 text-trust-blue flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-trust-blue">How it works</p>
<p className="text-xs text-muted-foreground mt-1">
We&apos;ll set up a Direct Debit mandate via GoCardless. The payment will be collected automatically in 3-5 working days. You can cancel anytime.
</p>
</div>
</div>
{/* Bank details form */}
<div className="rounded-2xl border-2 border-gray-200 bg-white p-5 space-y-4">
<div className="space-y-2">
<Label htmlFor="dd-name">Account Holder Name</Label>
<Input
id="dd-name"
placeholder="As shown on your bank statement"
value={accountName}
onChange={(e) => setAccountName(e.target.value)}
autoComplete="name"
className={errors.accountName ? "border-red-500" : ""}
/>
{errors.accountName && <p className="text-xs text-red-500">{errors.accountName}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="dd-email">Email</Label>
<Input
id="dd-email"
type="email"
placeholder="your@email.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
inputMode="email"
className={errors.email ? "border-red-500" : ""}
/>
{errors.email && <p className="text-xs text-red-500">{errors.email}</p>}
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="dd-sort">Sort Code</Label>
<Input
id="dd-sort"
placeholder="00-00-00"
value={sortCode}
onChange={(e) => handleSortCodeChange(e.target.value)}
inputMode="numeric"
maxLength={8}
className={`font-mono text-base ${errors.sortCode ? "border-red-500" : ""}`}
/>
{errors.sortCode && <p className="text-xs text-red-500">{errors.sortCode}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="dd-account">Account Number</Label>
<Input
id="dd-account"
ref={accountNumberRef}
placeholder="12345678"
value={accountNumber}
onChange={(e) => setAccountNumber(e.target.value.replace(/\D/g, "").slice(0, 8))}
inputMode="numeric"
maxLength={8}
className={`font-mono text-base ${errors.accountNumber ? "border-red-500" : ""}`}
/>
{errors.accountNumber && <p className="text-xs text-red-500">{errors.accountNumber}</p>}
</div>
</div>
</div>
{/* Gift Aid */}
<label className="flex items-start gap-3 rounded-2xl border-2 border-gray-200 bg-white p-4 cursor-pointer hover:border-trust-blue/50 transition-colors">
<input
type="checkbox"
checked={giftAid}
onChange={(e) => setGiftAid(e.target.checked)}
className="mt-1 h-5 w-5 rounded border-gray-300 text-trust-blue focus:ring-trust-blue"
/>
<div>
<span className="font-semibold text-gray-900">Add Gift Aid</span>
<p className="text-sm text-muted-foreground mt-0.5">
Boost your donation by 25% at no extra cost. You must be a UK taxpayer.
</p>
</div>
</label>
{/* Direct Debit mandate agreement */}
<label className={`flex items-start gap-3 rounded-2xl border-2 p-4 cursor-pointer transition-colors ${
errors.mandate ? "border-red-300 bg-red-50" : "border-gray-200 bg-white hover:border-trust-blue/50"
}`}>
<input
type="checkbox"
checked={mandateAgreed}
onChange={(e) => setMandateAgreed(e.target.checked)}
className="mt-1 h-5 w-5 rounded border-gray-300 text-trust-blue focus:ring-trust-blue"
/>
<div>
<span className="font-semibold text-gray-900">I authorise this Direct Debit</span>
<p className="text-sm text-muted-foreground mt-0.5">
I confirm that I am the account holder and authorise <span className="font-medium">{organizationName}</span> to collect <span className="font-medium">£{pounds}</span> from my account via Direct Debit, subject to the Direct Debit Guarantee.
</p>
</div>
</label>
{errors.mandate && <p className="text-xs text-red-500 -mt-4 ml-1">{errors.mandate}</p>}
<Button
size="xl"
className="w-full"
onClick={handleReview}
>
Review Mandate
</Button>
{/* DD Guarantee mini */}
<div className="rounded-xl bg-emerald-50 border border-emerald-200 p-3 flex items-start gap-2">
<ShieldCheck className="h-4 w-4 text-emerald-600 flex-shrink-0 mt-0.5" />
<p className="text-xs text-emerald-700">
Protected by the <span className="font-semibold">Direct Debit Guarantee</span>. You can cancel anytime by contacting your bank. Full refund if any errors occur.
</p>
</div>
<div className="flex items-center justify-center gap-2 text-xs text-muted-foreground">
<Lock className="h-3 w-3" />
<span>Processed securely by GoCardless</span>
</div>
</div>
)
}

View File

@@ -0,0 +1,329 @@
"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<string, string> = {
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<Phase>("select")
const [selectedBank, setSelectedBank] = useState<Bank | null>(null)
const [search, setSearch] = useState("")
const [name, setName] = useState("")
const [email, setEmail] = useState("")
const [phone, setPhone] = useState("")
const [errors, setErrors] = useState<Record<string, string>>({})
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<string, string> = {}
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 (
<div className="max-w-md mx-auto pt-16 text-center space-y-6">
<div className="inline-flex items-center justify-center w-20 h-20 rounded-full bg-trust-blue/10">
<div className="h-10 w-10 border-4 border-trust-blue border-t-transparent rounded-full animate-spin" />
</div>
<div className="space-y-2">
<h1 className="text-2xl font-extrabold text-gray-900">
Redirecting to {selectedBank?.name}
</h1>
<p className="text-muted-foreground">
You&apos;ll be taken to your bank&apos;s secure login page to authorize the payment of <span className="font-bold text-foreground">RM{ringgit}</span>
</p>
</div>
<div className="rounded-2xl bg-gray-50 border p-4">
<div className="flex items-center justify-center gap-3">
<div className={`w-10 h-10 rounded-lg ${BANK_COLORS[selectedBank?.code || ""] || "bg-gray-500"} flex items-center justify-center`}>
<span className="text-white font-bold text-xs">{selectedBank?.code}</span>
</div>
<span className="font-semibold">{selectedBank?.name}</span>
</div>
</div>
<p className="text-xs text-muted-foreground">
Do not close this window. You will be redirected back automatically.
</p>
</div>
)
}
// Processing phase
if (phase === "processing") {
return (
<div className="max-w-md mx-auto pt-16 text-center space-y-6">
<div className="inline-flex items-center justify-center w-20 h-20 rounded-full bg-success-green/10">
<div className="h-10 w-10 border-4 border-success-green border-t-transparent rounded-full animate-spin" />
</div>
<div className="space-y-2">
<h1 className="text-2xl font-extrabold text-gray-900">
Processing Payment
</h1>
<p className="text-muted-foreground">
Verifying your payment with {selectedBank?.shortName}...
</p>
</div>
</div>
)
}
// Identity phase
if (phase === "identity") {
return (
<div className="max-w-md mx-auto pt-4 space-y-6">
<div className="text-center space-y-2">
<h1 className="text-2xl font-extrabold text-gray-900">Your Details</h1>
<p className="text-muted-foreground">
Before we redirect you to <span className="font-semibold text-foreground">{selectedBank?.name}</span>
</p>
</div>
{/* Selected bank indicator */}
<div className="rounded-2xl border-2 border-trust-blue/20 bg-trust-blue/5 p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-lg ${BANK_COLORS[selectedBank?.code || ""] || "bg-gray-500"} flex items-center justify-center`}>
<span className="text-white font-bold text-xs">{selectedBank?.code}</span>
</div>
<div>
<p className="font-semibold text-sm">{selectedBank?.name}</p>
<p className="text-xs text-muted-foreground">FPX Online Banking</p>
</div>
</div>
<div className="text-right">
<p className="font-bold text-lg">RM{ringgit}</p>
<p className="text-xs text-muted-foreground">{eventName}</p>
</div>
</div>
</div>
<div className="rounded-2xl border-2 border-gray-200 bg-white p-5 space-y-4">
<div className="space-y-2">
<Label htmlFor="fpx-name">Full Name <span className="text-muted-foreground font-normal">(optional)</span></Label>
<Input
id="fpx-name"
placeholder="Your full name"
value={name}
onChange={(e) => setName(e.target.value)}
autoComplete="name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="fpx-email">Email</Label>
<Input
id="fpx-email"
type="email"
placeholder="your@email.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
inputMode="email"
className={errors.email ? "border-red-500" : ""}
/>
{errors.email && <p className="text-xs text-red-500">{errors.email}</p>}
<p className="text-xs text-muted-foreground">We&apos;ll send your receipt here</p>
</div>
<div className="space-y-2">
<Label htmlFor="fpx-phone">Phone <span className="text-muted-foreground font-normal">(optional)</span></Label>
<Input
id="fpx-phone"
type="tel"
placeholder="+60 12-345 6789"
value={phone}
onChange={(e) => setPhone(e.target.value)}
autoComplete="tel"
inputMode="tel"
/>
</div>
</div>
<Button size="xl" className="w-full" onClick={handleSubmit}>
<Lock className="h-5 w-5 mr-2" />
Pay RM{ringgit} via {selectedBank?.shortName}
</Button>
<div className="flex items-center justify-center gap-2 text-xs text-muted-foreground">
<Lock className="h-3 w-3" />
<span>Secured by FPX Bank Negara Malaysia</span>
</div>
</div>
)
}
// Bank selection phase (default)
return (
<div className="max-w-md mx-auto pt-4 space-y-5">
<div className="text-center space-y-2">
<h1 className="text-2xl font-extrabold text-gray-900">
FPX Online Banking
</h1>
<p className="text-lg text-muted-foreground">
Pay <span className="font-bold text-foreground">RM{ringgit}</span>{" "}
for <span className="font-semibold text-foreground">{eventName}</span>
</p>
</div>
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
placeholder="Search your bank..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10"
/>
</div>
{/* Bank list */}
<div className="grid grid-cols-2 gap-2 max-h-[400px] overflow-y-auto pr-1">
{filteredBanks.map((bank) => (
<button
key={bank.code}
onClick={() => 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"
}
`}
>
<div className="flex items-center gap-2.5">
<div className={`w-9 h-9 rounded-lg ${BANK_COLORS[bank.code] || "bg-gray-500"} flex items-center justify-center flex-shrink-0`}>
<span className="text-white font-bold text-[10px] leading-none">{bank.code}</span>
</div>
<div className="min-w-0 flex-1">
<p className="font-semibold text-xs text-gray-900 truncate">{bank.shortName}</p>
<p className="text-[10px] text-muted-foreground truncate">{bank.name}</p>
</div>
{selectedBank?.code === bank.code && (
<CheckCircle2 className="h-4 w-4 text-trust-blue flex-shrink-0" />
)}
</div>
</button>
))}
</div>
{filteredBanks.length === 0 && (
<p className="text-center text-sm text-muted-foreground py-4">No banks found matching &quot;{search}&quot;</p>
)}
{/* Continue */}
<Button
size="xl"
className="w-full"
disabled={!selectedBank}
onClick={handleContinueToIdentity}
>
Continue with {selectedBank?.shortName || "selected bank"}
</Button>
<div className="flex items-center justify-center gap-2 text-xs text-muted-foreground">
<Lock className="h-3 w-3" />
<span>Powered by FPX regulated by Bank Negara Malaysia</span>
</div>
</div>
)
}

View File

@@ -0,0 +1,123 @@
"use client"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
interface Props {
onSubmit: (data: {
donorName: string
donorEmail: string
donorPhone: string
giftAid: boolean
}) => void
}
export function IdentityStep({ onSubmit }: Props) {
const [name, setName] = useState("")
const [email, setEmail] = useState("")
const [phone, setPhone] = useState("")
const [giftAid, setGiftAid] = useState(false)
const [submitting, setSubmitting] = useState(false)
const hasContact = email.includes("@") || phone.length >= 10
const isValid = hasContact
const handleSubmit = async () => {
if (!isValid) return
setSubmitting(true)
try {
await onSubmit({ donorName: name, donorEmail: email, donorPhone: phone, giftAid })
} catch {
setSubmitting(false)
}
}
return (
<div className="max-w-md mx-auto pt-4 space-y-6">
<div className="text-center space-y-2">
<h1 className="text-2xl font-extrabold text-gray-900">
Almost there!
</h1>
<p className="text-muted-foreground">
We need a way to send you payment details
</p>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Name <span className="text-muted-foreground font-normal">(optional)</span></Label>
<Input
id="name"
placeholder="Your name"
value={name}
onChange={(e) => setName(e.target.value)}
autoComplete="name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="your@email.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
inputMode="email"
/>
</div>
<div className="relative flex items-center">
<div className="flex-grow border-t border-gray-200" />
<span className="flex-shrink mx-3 text-xs text-muted-foreground">or</span>
<div className="flex-grow border-t border-gray-200" />
</div>
<div className="space-y-2">
<Label htmlFor="phone">Phone</Label>
<Input
id="phone"
type="tel"
placeholder="07700 900000"
value={phone}
onChange={(e) => setPhone(e.target.value)}
autoComplete="tel"
inputMode="tel"
/>
</div>
{/* Gift Aid */}
<label className="flex items-start gap-3 rounded-2xl border-2 border-gray-200 bg-white p-4 cursor-pointer hover:border-trust-blue/50 transition-colors">
<input
type="checkbox"
checked={giftAid}
onChange={(e) => setGiftAid(e.target.checked)}
className="mt-1 h-5 w-5 rounded border-gray-300 text-trust-blue focus:ring-trust-blue"
/>
<div>
<span className="font-semibold text-gray-900">Add Gift Aid</span>
<p className="text-sm text-muted-foreground mt-0.5">
Boost your donation by 25% at no extra cost to you. You must be a UK taxpayer.
</p>
</div>
</label>
</div>
<Button
size="xl"
className="w-full"
disabled={!isValid || submitting}
onClick={handleSubmit}
>
{submitting ? "Submitting..." : "Complete Pledge ✓"}
</Button>
<p className="text-center text-xs text-muted-foreground">
We&apos;ll only use this to send payment details and confirm receipt.
</p>
</div>
)
}

View File

@@ -0,0 +1,95 @@
"use client"
import { Building2, CreditCard, Landmark, Globe } from "lucide-react"
interface Props {
onSelect: (rail: "bank" | "gocardless" | "card" | "fpx") => void
amount: number
}
export function PaymentStep({ onSelect, amount }: Props) {
const pounds = (amount / 100).toFixed(2)
const options = [
{
id: "bank" as const,
icon: Building2,
title: "Bank Transfer",
subtitle: "Zero fees — 100% goes to charity",
tag: "Recommended",
tagColor: "bg-success-green text-white",
detail: "Use your banking app to transfer directly",
},
{
id: "gocardless" as const,
icon: Landmark,
title: "Direct Debit",
subtitle: "Automatic collection — set and forget",
tag: "Low fees",
tagColor: "bg-trust-blue/10 text-trust-blue",
detail: "We'll collect via GoCardless",
},
{
id: "card" as const,
icon: CreditCard,
title: "Card Payment via Stripe",
subtitle: "Pay now by Visa, Mastercard, Amex",
tag: "Stripe",
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",
},
]
return (
<div className="max-w-md mx-auto pt-4 space-y-6">
<div className="text-center space-y-2">
<h1 className="text-2xl font-extrabold text-gray-900">
How would you like to pay?
</h1>
<p className="text-lg text-muted-foreground">
Pledge: <span className="font-bold text-foreground">£{pounds}</span>
</p>
</div>
<div className="space-y-3">
{options.map((opt) => (
<button
key={opt.id}
onClick={() => onSelect(opt.id)}
className="w-full text-left tap-target rounded-2xl border-2 border-gray-200 bg-white p-5 hover:border-trust-blue/50 active:scale-[0.99] transition-all group"
>
<div className="flex items-start gap-4">
<div className="rounded-xl bg-trust-blue/5 p-3 group-hover:bg-trust-blue/10 transition-colors">
<opt.icon className="h-6 w-6 text-trust-blue" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-bold text-gray-900">{opt.title}</span>
{opt.tag && (
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${opt.tagColor}`}>
{opt.tag}
</span>
)}
</div>
<p className="text-sm text-muted-foreground mt-0.5">{opt.subtitle}</p>
<p className="text-xs text-muted-foreground/70 mt-1">{opt.detail}</p>
</div>
<div className="text-muted-foreground/40 group-hover:text-trust-blue transition-colors text-xl">
</div>
</div>
</button>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,152 @@
"use client"
import { Suspense, useEffect, useState } from "react"
import { useSearchParams } from "next/navigation"
import { Check, X, Loader2 } from "lucide-react"
import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import Link from "next/link"
interface PledgeInfo {
reference: string
amountPence: number
rail: string
donorName: string | null
eventName: string
status: string
}
function SuccessContent() {
const searchParams = useSearchParams()
const pledgeId = searchParams.get("pledge_id")
const rail = searchParams.get("rail") || "card"
const cancelled = searchParams.get("cancelled") === "true"
const [pledge, setPledge] = useState<PledgeInfo | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
if (!pledgeId) { setLoading(false); return }
fetch(`/api/pledges/${pledgeId}`)
.then((r) => r.json())
.then((data) => { if (data.reference) setPledge(data) })
.catch(() => {})
.finally(() => setLoading(false))
}, [pledgeId])
if (loading) {
return (
<div className="text-center space-y-4">
<Loader2 className="h-10 w-10 text-trust-blue animate-spin mx-auto" />
<p className="text-muted-foreground">Confirming your payment...</p>
</div>
)
}
if (cancelled) {
return (
<div className="max-w-md mx-auto text-center space-y-6">
<div className="inline-flex items-center justify-center w-20 h-20 rounded-full bg-amber-100">
<X className="h-10 w-10 text-amber-600" />
</div>
<h1 className="text-2xl font-extrabold text-gray-900">Payment Cancelled</h1>
<p className="text-muted-foreground">
Your payment was not completed. Your pledge has been saved you can return to complete it anytime.
</p>
{pledge && (
<Card>
<CardContent className="pt-6 space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Reference</span>
<span className="font-mono font-bold">{pledge.reference}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Amount</span>
<span className="font-bold">£{(pledge.amountPence / 100).toFixed(2)}</span>
</div>
</CardContent>
</Card>
)}
<Link href="/">
<Button variant="outline" size="lg">Return Home</Button>
</Link>
</div>
)
}
const railLabels: Record<string, string> = {
card: "Card Payment",
gocardless: "Direct Debit",
fpx: "FPX Online Banking",
bank: "Bank Transfer",
}
const nextStepMessages: Record<string, string> = {
card: "Your card payment has been processed. You'll receive a confirmation email shortly.",
gocardless: "Your Direct Debit mandate has been set up. The payment will be collected automatically in 3-5 working days.",
fpx: "Your FPX payment has been received and verified.",
bank: "Please complete the bank transfer using the reference provided.",
}
return (
<div className="max-w-md mx-auto text-center space-y-6">
<div className="inline-flex items-center justify-center w-20 h-20 rounded-full bg-success-green/10">
<Check className="h-10 w-10 text-success-green" />
</div>
<div className="space-y-2">
<h1 className="text-2xl font-extrabold text-gray-900">
{rail === "gocardless" ? "Mandate Set Up!" : "Payment Successful!"}
</h1>
<p className="text-muted-foreground">
Thank you for your generous donation
{pledge?.eventName && <> to <span className="font-semibold text-foreground">{pledge.eventName}</span></>}
</p>
</div>
{pledge && (
<Card>
<CardContent className="pt-6 space-y-3 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Amount</span>
<span className="font-bold">£{(pledge.amountPence / 100).toFixed(2)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Method</span>
<span>{railLabels[rail] || rail}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Reference</span>
<span className="font-mono font-bold">{pledge.reference}</span>
</div>
{pledge.donorName && (
<div className="flex justify-between">
<span className="text-muted-foreground">Donor</span>
<span>{pledge.donorName}</span>
</div>
)}
</CardContent>
</Card>
)}
<div className="rounded-2xl bg-trust-blue/5 border border-trust-blue/20 p-4 space-y-2">
<p className="text-sm font-medium text-trust-blue">What happens next?</p>
<p className="text-sm text-muted-foreground">{nextStepMessages[rail] || nextStepMessages.card}</p>
</div>
<p className="text-xs text-muted-foreground">
Need help? Contact the charity directly.{pledge && <> Ref: {pledge.reference}</>}
</p>
</div>
)
}
export default function SuccessPage() {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-trust-blue/5 via-white to-warm-amber/5 p-4">
<Suspense fallback={
<div className="text-center space-y-4">
<Loader2 className="h-10 w-10 text-trust-blue animate-spin mx-auto" />
<p className="text-muted-foreground">Loading...</p>
</div>
}>
<SuccessContent />
</Suspense>
</div>
)
}

View File

@@ -0,0 +1,94 @@
import Link from "next/link"
import { Button } from "@/components/ui/button"
import { CreditCard, Landmark, Building2, Globe, QrCode, BarChart3, Bell, Download } from "lucide-react"
export default function Home() {
return (
<div className="min-h-screen bg-gradient-to-br from-trust-blue/5 via-white to-warm-amber/5">
{/* Hero */}
<div className="flex flex-col items-center justify-center px-4 pt-20 pb-16">
<div className="text-center max-w-2xl mx-auto space-y-8">
<div className="space-y-4">
<div className="inline-flex items-center gap-2 rounded-full bg-trust-blue/10 px-4 py-2 text-sm font-medium text-trust-blue">
Free forever for UK charities
</div>
<h1 className="text-5xl md:text-6xl font-extrabold tracking-tight text-gray-900">
Pledge Now,{" "}
<span className="text-trust-blue">Pay Later</span>
</h1>
<p className="text-xl text-muted-foreground max-w-lg mx-auto">
Turn &quot;I&apos;ll donate later&quot; into tracked pledges with automatic payment follow-up. Zero fees on bank transfers.
</p>
</div>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Link href="/dashboard">
<Button size="xl">Open Dashboard </Button>
</Link>
<Link href="/p/demo">
<Button size="xl" variant="outline">Try Donor Flow </Button>
</Link>
</div>
<div className="grid grid-cols-3 gap-6 pt-8 border-t max-w-md mx-auto">
<div className="text-center">
<div className="text-2xl font-bold text-trust-blue">0%</div>
<div className="text-xs text-muted-foreground">Bank transfer fees</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-success-green">15s</div>
<div className="text-xs text-muted-foreground">Pledge time</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-warm-amber">85%+</div>
<div className="text-xs text-muted-foreground">Collection rate</div>
</div>
</div>
</div>
</div>
{/* Payment Methods */}
<div className="max-w-4xl mx-auto px-4 pb-16">
<h2 className="text-2xl font-bold text-center mb-8">4 Payment Rails, One Platform</h2>
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
{[
{ 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" },
].map((m, i) => (
<div key={i} className="rounded-2xl border bg-white p-5 text-center space-y-2 hover:shadow-md transition-shadow">
<m.icon className={`h-8 w-8 mx-auto ${m.color}`} />
<h3 className="font-bold">{m.title}</h3>
<p className="text-xs text-muted-foreground">{m.desc}</p>
</div>
))}
</div>
</div>
{/* Features */}
<div className="bg-white border-y py-16">
<div className="max-w-4xl mx-auto px-4">
<h2 className="text-2xl font-bold text-center mb-8">Everything You Need</h2>
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-6">
{[
{ 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" },
].map((f, i) => (
<div key={i} className="space-y-2">
<f.icon className="h-6 w-6 text-trust-blue" />
<h3 className="font-bold text-sm">{f.title}</h3>
<p className="text-xs text-muted-foreground">{f.desc}</p>
</div>
))}
</div>
</div>
</div>
{/* Footer */}
<footer className="py-8 px-4 text-center text-xs text-muted-foreground">
<p>Pledge Now, Pay Later Built for UK charities.</p>
</footer>
</div>
)
}

View File

@@ -0,0 +1,32 @@
"use client"
import { useEffect, useRef } from "react"
import QRCode from "qrcode"
interface Props {
url: string
size?: number
className?: string
}
export function QRCodeCanvas({ url, size = 180, className }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => {
if (!canvasRef.current) return
QRCode.toCanvas(canvasRef.current, url, {
width: size,
margin: 2,
color: { dark: "#1e40af", light: "#ffffff" },
errorCorrectionLevel: "M",
}).catch(() => {})
}, [url, size])
return (
<canvas
ref={canvasRef}
className={className}
style={{ width: size, height: size }}
/>
)
}

View File

@@ -0,0 +1,28 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors",
{
variants: {
variant: {
default: "border-transparent bg-trust-blue text-white",
secondary: "border-transparent bg-secondary text-secondary-foreground",
success: "border-transparent bg-success-green/10 text-success-green",
warning: "border-transparent bg-warm-amber/10 text-warm-amber",
destructive: "border-transparent bg-danger-red/10 text-danger-red",
outline: "text-foreground",
},
},
defaultVariants: { variant: "default" },
}
)
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,38 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-xl text-sm font-semibold ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 active:scale-[0.98]",
{
variants: {
variant: {
default: "bg-trust-blue text-white hover:bg-trust-blue/90 shadow-lg shadow-trust-blue/25",
destructive: "bg-danger-red text-white hover:bg-danger-red/90",
outline: "border-2 border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-trust-blue underline-offset-4 hover:underline",
success: "bg-success-green text-white hover:bg-success-green/90 shadow-lg shadow-success-green/25",
amber: "bg-warm-amber text-white hover:bg-warm-amber/90 shadow-lg shadow-warm-amber/25",
},
size: {
default: "h-11 px-6 py-2",
sm: "h-9 rounded-lg px-4",
lg: "h-14 rounded-2xl px-8 text-base",
xl: "h-16 rounded-2xl px-10 text-lg",
icon: "h-10 w-10",
},
},
defaultVariants: { variant: "default", size: "default" },
}
)
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(({ className, variant, size, ...props }, ref) => (
<button className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
))
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@@ -0,0 +1,34 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("rounded-2xl border bg-card text-card-foreground shadow-sm", className)} {...props} />
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(({ className, ...props }, ref) => (
<h3 ref={ref} className={cn("text-2xl font-bold leading-none tracking-tight", className)} {...props} />
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(({ className, ...props }, ref) => (
<p ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,35 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
interface DialogProps {
open: boolean
onOpenChange: (open: boolean) => void
children: React.ReactNode
}
export function Dialog({ open, onOpenChange, children }: DialogProps) {
if (!open) return null
return (
<div className="fixed inset-0 z-50">
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm" onClick={() => onOpenChange(false)} />
<div className="fixed inset-0 flex items-center justify-center p-4">
<div className="relative bg-background rounded-2xl shadow-xl max-w-lg w-full max-h-[90vh] overflow-y-auto p-6 animate-in fade-in-0 zoom-in-95">
{children}
</div>
</div>
</div>
)
}
export function DialogHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("flex flex-col space-y-2 mb-4", className)} {...props} />
}
export function DialogTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
return <h2 className={cn("text-xl font-bold", className)} {...props} />
}
export function DialogDescription({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
return <p className={cn("text-sm text-muted-foreground", className)} {...props} />
}

View File

@@ -0,0 +1,17 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(({ className, type, ...props }, ref) => (
<input
type={type}
className={cn(
"flex h-11 w-full rounded-xl border border-input bg-background px-4 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-trust-blue focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
))
Input.displayName = "Input"
export { Input }

View File

@@ -0,0 +1,9 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Label = React.forwardRef<HTMLLabelElement, React.LabelHTMLAttributes<HTMLLabelElement>>(({ className, ...props }, ref) => (
<label ref={ref} className={cn("text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70", className)} {...props} />
))
Label.displayName = "Label"
export { Label }

View File

@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Select = React.forwardRef<HTMLSelectElement, React.SelectHTMLAttributes<HTMLSelectElement>>(({ className, children, ...props }, ref) => (
<select
ref={ref}
className={cn(
"flex h-11 w-full rounded-xl border border-input bg-background px-4 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-trust-blue focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
{children}
</select>
))
Select.displayName = "Select"
export { Select }

View File

@@ -0,0 +1,7 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("animate-pulse rounded-xl bg-muted", className)} {...props} />
}
export { Skeleton }

View File

@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Textarea = React.forwardRef<HTMLTextAreaElement, React.TextareaHTMLAttributes<HTMLTextAreaElement>>(
({ className, ...props }, ref) => (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-xl border border-input bg-background px-4 py-3 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-trust-blue focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
)
Textarea.displayName = "Textarea"
export { Textarea }

View File

@@ -0,0 +1,49 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
interface Toast {
id: string
message: string
type?: 'success' | 'error' | 'info'
}
const ToastContext = React.createContext<{
toast: (message: string, type?: Toast['type']) => void
}>({ toast: () => {} })
export function useToast() {
return React.useContext(ToastContext)
}
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = React.useState<Toast[]>([])
const toast = React.useCallback((message: string, type: Toast['type'] = 'info') => {
const id = Math.random().toString(36).slice(2)
setToasts((prev) => [...prev, { id, message, type }])
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 4000)
}, [])
return (
<ToastContext.Provider value={{ toast }}>
{children}
<div className="fixed bottom-4 right-4 z-[100] flex flex-col gap-2">
{toasts.map((t) => (
<div
key={t.id}
className={cn(
"rounded-xl px-4 py-3 text-sm font-medium text-white shadow-lg animate-in slide-in-from-right",
t.type === 'success' && 'bg-success-green',
t.type === 'error' && 'bg-danger-red',
t.type === 'info' && 'bg-trust-blue',
!t.type && 'bg-trust-blue'
)}
>
{t.message}
</div>
))}
</div>
</ToastContext.Provider>
)
}

View File

@@ -0,0 +1,37 @@
import prisma from './prisma'
import { Prisma } from '@/generated/prisma/client'
export type AnalyticsEventType =
| 'pledge_start'
| 'amount_selected'
| 'rail_selected'
| 'identity_submitted'
| 'pledge_completed'
| 'instruction_copy_clicked'
| 'i_paid_clicked'
| 'payment_matched'
export async function trackEvent(
eventType: AnalyticsEventType,
data: {
pledgeId?: string
eventId?: string
qrSourceId?: string
metadata?: Record<string, unknown>
}
) {
try {
await prisma.analyticsEvent.create({
data: {
eventType,
pledgeId: data.pledgeId,
eventId: data.eventId,
qrSourceId: data.qrSourceId,
metadata: (data.metadata || {}) as Prisma.InputJsonValue,
},
})
} catch (error) {
console.error('Analytics tracking error:', error)
// Never throw - analytics should not break the flow
}
}

View File

@@ -0,0 +1,42 @@
export interface CrmExportRow {
pledge_reference: string
donor_name: string
donor_email: string
donor_phone: string
amount_gbp: string
payment_method: string
status: string
event_name: string
source_label: string
volunteer_name: string
table_name: string
gift_aid: string
pledged_at: string
paid_at: string
days_to_collect: string
}
export function formatCrmExportCsv(rows: CrmExportRow[]): string {
if (rows.length === 0) return ''
const headers = Object.keys(rows[0])
const lines = [
headers.join(','),
...rows.map((row) =>
headers
.map((h) => {
const val = (row as unknown as Record<string, string>)[h] || ''
return val.includes(',') || val.includes('"') ? `"${val.replace(/"/g, '""')}"` : val
})
.join(',')
),
]
return lines.join('\n')
}
export function formatWebhookPayload(event: string, data: Record<string, unknown>) {
return {
event,
timestamp: new Date().toISOString(),
data,
}
}

View File

@@ -0,0 +1,171 @@
/**
* GoCardless API client using direct REST calls.
* Supports Sandbox and Live environments.
*/
const GC_URLS = {
sandbox: "https://api-sandbox.gocardless.com",
live: "https://api.gocardless.com",
}
function getBaseUrl(): string {
return process.env.GOCARDLESS_ENVIRONMENT === "live"
? GC_URLS.live
: GC_URLS.sandbox
}
function getToken(): string | null {
const token = process.env.GOCARDLESS_ACCESS_TOKEN
if (!token || token === "sandbox_token" || token === "REPLACE_ME") return null
return token
}
async function gcFetch(path: string, body: Record<string, unknown>): Promise<Record<string, unknown> | null> {
const token = getToken()
if (!token) return null
const res = await fetch(`${getBaseUrl()}${path}`, {
method: "POST",
headers: {
"Authorization": `Bearer ${token}`,
"GoCardless-Version": "2015-07-06",
"Content-Type": "application/json",
},
body: JSON.stringify(body),
})
if (!res.ok) {
const err = await res.text()
console.error(`GoCardless API error [${res.status}] ${path}:`, err)
return null
}
return res.json()
}
async function gcGet(path: string): Promise<Record<string, unknown> | null> {
const token = getToken()
if (!token) return null
const res = await fetch(`${getBaseUrl()}${path}`, {
headers: {
"Authorization": `Bearer ${token}`,
"GoCardless-Version": "2015-07-06",
},
})
if (!res.ok) {
const err = await res.text()
console.error(`GoCardless API error [${res.status}] ${path}:`, err)
return null
}
return res.json()
}
/**
* Check if GoCardless is configured and accessible.
*/
export function isGoCardlessConfigured(): boolean {
return getToken() !== null
}
/**
* Create a Redirect Flow — the simplest way to set up a Direct Debit mandate.
* The donor is redirected to GoCardless-hosted pages to enter bank details.
*/
export async function createRedirectFlow(opts: {
description: string
reference: string
pledgeId: string
successRedirectUrl: string
}): Promise<{ redirectUrl: string; redirectFlowId: string } | null> {
const data = await gcFetch("/redirect_flows", {
redirect_flows: {
description: opts.description,
session_token: opts.pledgeId,
success_redirect_url: opts.successRedirectUrl,
scheme: "bacs",
},
})
if (!data) return null
const flow = (data as { redirect_flows: { id: string; redirect_url: string } }).redirect_flows
return {
redirectUrl: flow.redirect_url,
redirectFlowId: flow.id,
}
}
/**
* Complete a Redirect Flow after the donor returns from GoCardless.
* This creates the mandate and returns its ID.
*/
export async function completeRedirectFlow(
redirectFlowId: string,
sessionToken: string
): Promise<{ mandateId: string; customerId: string } | null> {
const data = await gcFetch(`/redirect_flows/${redirectFlowId}/actions/complete`, {
data: {
session_token: sessionToken,
},
})
if (!data) return null
const flow = (data as { redirect_flows: { links: { mandate: string; customer: string } } }).redirect_flows
return {
mandateId: flow.links.mandate,
customerId: flow.links.customer,
}
}
/**
* Create a payment against an existing mandate.
*/
export async function createPayment(opts: {
amountPence: number
mandateId: string
reference: string
pledgeId: string
description: string
}): Promise<{ paymentId: string; status: string; chargeDate: string } | null> {
const data = await gcFetch("/payments", {
payments: {
amount: opts.amountPence,
currency: "GBP",
description: opts.description,
reference: opts.reference,
metadata: {
pledge_id: opts.pledgeId,
},
links: {
mandate: opts.mandateId,
},
},
})
if (!data) return null
const payment = (data as { payments: { id: string; status: string; charge_date: string } }).payments
return {
paymentId: payment.id,
status: payment.status,
chargeDate: payment.charge_date,
}
}
/**
* Get a payment's current status.
*/
export async function getPayment(paymentId: string): Promise<{ status: string; chargeDate: string } | null> {
const data = await gcGet(`/payments/${paymentId}`)
if (!data) return null
const payment = (data as { payments: { status: string; charge_date: string } }).payments
return {
status: payment.status,
chargeDate: payment.charge_date,
}
}

View File

@@ -0,0 +1,98 @@
import { normalizeReference } from './reference'
export interface BankRow {
date: string
description: string
amount: number // in pounds, positive for credits
reference?: string
raw: Record<string, string>
}
export interface MatchResult {
bankRow: BankRow
pledgeId: string | null
pledgeReference: string | null
confidence: 'exact' | 'partial' | 'amount_only' | 'none'
matchedAmount: number
}
/**
* Attempt to match a bank statement row against known pledge references
*/
export function matchBankRow(
row: BankRow,
pledgeRefs: Map<string, { id: string; amountPence: number }>
): MatchResult {
const entries = Array.from(pledgeRefs.entries())
// Strategy 1: exact reference match in ref field
if (row.reference) {
const normalized = normalizeReference(row.reference)
for (const [ref, pledge] of entries) {
if (normalizeReference(ref) === normalized) {
return {
bankRow: row,
pledgeId: pledge.id,
pledgeReference: ref,
confidence: 'exact',
matchedAmount: row.amount,
}
}
}
}
// Strategy 2: reference found in description (not in dedicated ref field)
const descNormalized = row.description.replace(/[\s]/g, '').toUpperCase()
for (const [ref, pledge] of entries) {
const refNorm = normalizeReference(ref)
if (descNormalized.includes(refNorm)) {
return {
bankRow: row,
pledgeId: pledge.id,
pledgeReference: ref,
confidence: 'partial',
matchedAmount: row.amount,
}
}
}
// Strategy 3: partial reference match (at least the 4-char code portion)
for (const [ref, pledge] of entries) {
const parts = ref.split('-')
const codePart = parts.length >= 2 ? parts[1] : ''
if (codePart.length >= 4 && descNormalized.includes(codePart)) {
return {
bankRow: row,
pledgeId: pledge.id,
pledgeReference: ref,
confidence: 'partial',
matchedAmount: row.amount,
}
}
}
return {
bankRow: row,
pledgeId: null,
pledgeReference: null,
confidence: 'none',
matchedAmount: row.amount,
}
}
/**
* Parse a CSV bank statement into BankRow[]
* Handles common UK bank export formats
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export function parseBankCsv(_csvData: string, _columnMapping?: {
dateCol?: string
descriptionCol?: string
amountCol?: string
creditCol?: string
referenceCol?: string
}): BankRow[] {
// Dynamic import handled at call site; this returns parsed rows
// Caller should use papaparse to get rows, then pass here
return [] // implemented in the API route with papaparse
}

View File

@@ -0,0 +1,29 @@
import prisma from "@/lib/prisma"
/**
* Resolve organization ID from x-org-id header.
* The header may contain a slug or a direct ID — we try slug first, then ID.
*/
export async function resolveOrgId(headerValue: string | null): Promise<string | null> {
const val = headerValue?.trim()
if (!val) return null
// Try by slug first (most common from frontend)
const bySlug = await prisma.organization.findFirst({
where: {
OR: [
{ slug: val },
{ slug: { startsWith: val } },
],
},
select: { id: true },
})
if (bySlug) return bySlug.id
// Try direct ID
const byId = await prisma.organization.findUnique({
where: { id: val },
select: { id: true },
})
return byId?.id ?? null
}

View File

@@ -0,0 +1,21 @@
import pg from 'pg'
import { PrismaPg } from '@prisma/adapter-pg'
import { PrismaClient } from '@/generated/prisma/client'
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
function createPrismaClient(): PrismaClient {
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL })
const adapter = new PrismaPg(pool)
return new PrismaClient({ adapter })
}
export const prisma = globalForPrisma.prisma ?? createPrismaClient()
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma
}
export default prisma

View File

@@ -0,0 +1,48 @@
import QRCode from 'qrcode'
export interface QrGenerateOptions {
baseUrl: string
code: string
width?: number
margin?: number
}
/**
* Generate QR code as data URL (for display)
*/
export async function generateQrDataUrl(opts: QrGenerateOptions): Promise<string> {
const url = `${opts.baseUrl}/p/${opts.code}`
return QRCode.toDataURL(url, {
width: opts.width || 400,
margin: opts.margin || 2,
color: { dark: '#1e40af', light: '#ffffff' },
errorCorrectionLevel: 'M',
})
}
/**
* Generate QR code as SVG string (for print)
*/
export async function generateQrSvg(opts: QrGenerateOptions): Promise<string> {
const url = `${opts.baseUrl}/p/${opts.code}`
return QRCode.toString(url, {
type: 'svg',
width: opts.width || 400,
margin: opts.margin || 2,
color: { dark: '#1e40af', light: '#ffffff' },
errorCorrectionLevel: 'M',
})
}
/**
* Generate QR code as PNG buffer (for download)
*/
export async function generateQrBuffer(opts: QrGenerateOptions): Promise<Buffer> {
const url = `${opts.baseUrl}/p/${opts.code}`
return QRCode.toBuffer(url, {
width: opts.width || 800,
margin: opts.margin || 2,
color: { dark: '#1e40af', light: '#ffffff' },
errorCorrectionLevel: 'M',
})
}

View File

@@ -0,0 +1,39 @@
import { customAlphabet } from 'nanoid'
// Human-safe alphabet: no 0/O, 1/I/l confusion
const safeAlphabet = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ'
const generateCode = customAlphabet(safeAlphabet, 6)
/**
* Generate a unique, human-safe, bank-compatible payment reference.
* Format: PREFIX-XXXXXX-NN where:
* - PREFIX is org configurable (default "PNPL")
* - XXXXXX is 6-char alphanumeric (safe chars only)
* - NN is amount pounds (helps manual matching)
* Total max 18 chars (UK bank ref limit)
*/
export function generateReference(prefix: string = 'PNPL', amountPence?: number): string {
const code = generateCode()
const amountSuffix = amountPence ? Math.floor(amountPence / 100).toString().slice(-3) : ''
const ref = amountSuffix ? `${prefix}-${code}-${amountSuffix}` : `${prefix}-${code}`
// UK BACS reference max 18 chars
if (ref.length > 18) {
return `${prefix.slice(0, 4)}-${code}`
}
return ref
}
/**
* Normalize a reference for matching (strip spaces, dashes, uppercase)
*/
export function normalizeReference(ref: string): string {
return ref.replace(/[\s\-]/g, '').toUpperCase()
}
/**
* Check if two references match (fuzzy matching for bank statements)
*/
export function referencesMatch(ref1: string, ref2: string): boolean {
return normalizeReference(ref1) === normalizeReference(ref2)
}

View File

@@ -0,0 +1,100 @@
export interface ReminderStep {
step: number
delayDays: number
channel: 'email' | 'sms' | 'whatsapp'
subject: string
templateKey: string
description: string
}
export const DEFAULT_REMINDER_SEQUENCE: ReminderStep[] = [
{
step: 0,
delayDays: 0,
channel: 'email',
subject: 'Your pledge payment details',
templateKey: 'instructions',
description: 'Payment instructions with bank details and reference',
},
{
step: 1,
delayDays: 2,
channel: 'email',
subject: 'Quick reminder about your pledge',
templateKey: 'gentle_nudge',
description: 'Gentle nudge - "We noticed your pledge hasn\'t been received yet"',
},
{
step: 2,
delayDays: 7,
channel: 'email',
subject: 'Your pledge is making a difference',
templateKey: 'urgency_impact',
description: 'Impact story + urgency - "Your £X helps fund..."',
},
{
step: 3,
delayDays: 14,
channel: 'email',
subject: 'Final reminder about your pledge',
templateKey: 'final_reminder',
description: 'Final reminder with easy cancel option',
},
]
/**
* Calculate reminder schedule dates from pledge creation
*/
export function calculateReminderSchedule(
pledgeCreatedAt: Date,
sequence: ReminderStep[] = DEFAULT_REMINDER_SEQUENCE
): Array<{ step: number; scheduledAt: Date; channel: string; templateKey: string; subject: string }> {
return sequence.map((s) => ({
step: s.step,
scheduledAt: new Date(pledgeCreatedAt.getTime() + s.delayDays * 24 * 60 * 60 * 1000),
channel: s.channel,
templateKey: s.templateKey,
subject: s.subject,
}))
}
/**
* Generate reminder message content from template
*/
export function generateReminderContent(
templateKey: string,
variables: {
donorName?: string
amount: string
reference: string
bankName?: string
sortCode?: string
accountNo?: string
accountName?: string
eventName: string
pledgeUrl: string
cancelUrl: string
}
): { subject: string; body: string } {
const name = variables.donorName || 'there'
const templates: Record<string, { subject: string; body: string }> = {
instructions: {
subject: `Payment details for your £${variables.amount} pledge`,
body: `Hi ${name},\n\nThank you for pledging £${variables.amount} at ${variables.eventName}!\n\nTo complete your donation, please transfer to:\n\nBank: ${variables.bankName || 'See details'}\nSort Code: ${variables.sortCode || 'N/A'}\nAccount: ${variables.accountNo || 'N/A'}\nName: ${variables.accountName || 'N/A'}\nReference: ${variables.reference}\n\n⚠ Please use the exact reference above so we can match your payment.\n\nView your pledge: ${variables.pledgeUrl}\n\nThank you for your generosity!`,
},
gentle_nudge: {
subject: `Quick reminder: your £${variables.amount} pledge`,
body: `Hi ${name},\n\nJust a friendly reminder about your £${variables.amount} pledge at ${variables.eventName}.\n\nIf you've already sent the payment, thank you! It can take a few days to appear.\n\nIf not, here's your reference: ${variables.reference}\n\nView details: ${variables.pledgeUrl}\n\nNo longer wish to donate? ${variables.cancelUrl}`,
},
urgency_impact: {
subject: `Your £${variables.amount} pledge is making a difference`,
body: `Hi ${name},\n\nYour £${variables.amount} pledge from ${variables.eventName} is still outstanding.\n\nEvery donation makes a real impact. Your contribution helps us continue our vital work.\n\nPayment reference: ${variables.reference}\nView details: ${variables.pledgeUrl}\n\nNeed help? Just reply to this email.\nCancel pledge: ${variables.cancelUrl}`,
},
final_reminder: {
subject: `Final reminder: £${variables.amount} pledge`,
body: `Hi ${name},\n\nThis is our final reminder about your £${variables.amount} pledge from ${variables.eventName}.\n\nWe understand circumstances change. If you'd like to:\n✅ Pay now - use reference: ${variables.reference}\n❌ Cancel - ${variables.cancelUrl}\n\nView details: ${variables.pledgeUrl}\n\nThank you for considering us.`,
},
}
return templates[templateKey] || templates.instructions
}

View File

@@ -0,0 +1,127 @@
import Stripe from "stripe"
let stripeClient: Stripe | null = null
export function getStripe(): Stripe | null {
if (stripeClient) return stripeClient
const key = process.env.STRIPE_SECRET_KEY
if (!key || key === "sk_test_REPLACE_ME") return null
stripeClient = new Stripe(key, {
apiVersion: "2025-01-27.acacia" as Stripe.LatestApiVersion,
typescript: true,
})
return stripeClient
}
/**
* Create a Stripe Checkout Session for a card payment.
* Returns the checkout URL to redirect the donor to.
*/
export async function createCheckoutSession(opts: {
amountPence: number
currency: string
pledgeId: string
reference: string
eventName: string
organizationName: string
donorEmail?: string
successUrl: string
cancelUrl: string
}): Promise<{ sessionId: string; checkoutUrl: string } | null> {
const stripe = getStripe()
if (!stripe) return null
try {
const session = await stripe.checkout.sessions.create({
mode: "payment",
payment_method_types: ["card"],
line_items: [
{
price_data: {
currency: opts.currency.toLowerCase(),
unit_amount: opts.amountPence,
product_data: {
name: `Donation — ${opts.eventName}`,
description: `Pledge ref: ${opts.reference} to ${opts.organizationName}`,
},
},
quantity: 1,
},
],
customer_email: opts.donorEmail || undefined,
metadata: {
pledge_id: opts.pledgeId,
reference: opts.reference,
},
success_url: opts.successUrl,
cancel_url: opts.cancelUrl,
})
return {
sessionId: session.id,
checkoutUrl: session.url!,
}
} catch (error) {
console.error("Stripe checkout session error:", error)
return null
}
}
/**
* Create a Stripe Payment Intent for embedded payment (Stripe Elements).
* Returns client secret for frontend confirmation.
*/
export async function createPaymentIntent(opts: {
amountPence: number
currency: string
pledgeId: string
reference: string
donorEmail?: string
}): Promise<{ clientSecret: string; paymentIntentId: string } | null> {
const stripe = getStripe()
if (!stripe) return null
try {
const pi = await stripe.paymentIntents.create({
amount: opts.amountPence,
currency: opts.currency.toLowerCase(),
metadata: {
pledge_id: opts.pledgeId,
reference: opts.reference,
},
receipt_email: opts.donorEmail || undefined,
automatic_payment_methods: {
enabled: true,
},
})
return {
clientSecret: pi.client_secret!,
paymentIntentId: pi.id,
}
} catch (error) {
console.error("Stripe payment intent error:", error)
return null
}
}
/**
* Verify a Stripe webhook signature.
*/
export function constructWebhookEvent(
body: string | Buffer,
signature: string
): Stripe.Event | null {
const stripe = getStripe()
const secret = process.env.STRIPE_WEBHOOK_SECRET
if (!stripe || !secret || secret === "whsec_REPLACE_ME") return null
try {
return stripe.webhooks.constructEvent(body, signature, secret)
} catch (error) {
console.error("Stripe webhook signature verification failed:", error)
return null
}
}

View File

@@ -0,0 +1,16 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function formatPence(pence: number): string {
return `£${(pence / 100).toFixed(2)}`
}
export function formatPenceShort(pence: number): string {
const pounds = pence / 100
if (pounds >= 1000) return `£${(pounds / 1000).toFixed(pounds % 1000 === 0 ? 0 : 1)}k`
return `£${pounds.toFixed(0)}`
}

View File

@@ -0,0 +1,48 @@
import { z } from 'zod'
export const createEventSchema = z.object({
name: z.string().min(1).max(200),
description: z.string().max(2000).optional(),
eventDate: z.string().datetime().optional(),
location: z.string().max(500).optional(),
goalAmount: z.number().int().positive().optional(), // pence
currency: z.string().default('GBP'),
})
export const createQrSourceSchema = z.object({
label: z.string().min(1).max(100),
volunteerName: z.string().max(100).optional(),
tableName: z.string().max(100).optional(),
})
export const createPledgeSchema = z.object({
amountPence: z.number().int().min(100).max(100000000), // £1 to £1M
rail: z.enum(['bank', 'gocardless', 'card', 'fpx']),
donorName: z.string().max(200).optional().default(''),
donorEmail: z.string().max(200).optional().default(''),
donorPhone: z.string().max(20).optional().default(''),
giftAid: z.boolean().default(false),
eventId: z.string(),
qrSourceId: z.string().nullable().optional(),
}).transform((data) => ({
...data,
donorEmail: data.donorEmail && data.donorEmail.includes('@') ? data.donorEmail : undefined,
donorPhone: data.donorPhone && data.donorPhone.length >= 10 ? data.donorPhone : undefined,
donorName: data.donorName || undefined,
qrSourceId: data.qrSourceId || undefined,
}))
export const importBankStatementSchema = z.object({
columnMapping: z.object({
dateCol: z.string(),
descriptionCol: z.string(),
amountCol: z.string().optional(),
creditCol: z.string().optional(),
referenceCol: z.string().optional(),
}),
})
export const updatePledgeStatusSchema = z.object({
status: z.enum(['new', 'initiated', 'paid', 'overdue', 'cancelled']),
notes: z.string().max(1000).optional(),
})

View File

@@ -0,0 +1,50 @@
import { NextResponse } from "next/server"
import type { NextRequest } from "next/server"
// Simple in-memory rate limiter (use Redis in production)
const rateLimit = new Map<string, { count: number; resetAt: number }>()
function checkRateLimit(ip: string, limit: number = 60, windowMs: number = 60000): boolean {
const now = Date.now()
const entry = rateLimit.get(ip)
if (!entry || entry.resetAt < now) {
rateLimit.set(ip, { count: 1, resetAt: now + windowMs })
return true
}
if (entry.count >= limit) return false
entry.count++
return true
}
export function middleware(request: NextRequest) {
const response = NextResponse.next()
// Rate limit API routes
if (request.nextUrl.pathname.startsWith("/api/")) {
const ip = request.headers.get("x-forwarded-for") || request.headers.get("x-real-ip") || "unknown"
if (!checkRateLimit(ip)) {
return NextResponse.json(
{ error: "Too many requests" },
{ status: 429 }
)
}
}
// Add security headers
response.headers.set("X-Frame-Options", "SAMEORIGIN")
response.headers.set("X-Content-Type-Options", "nosniff")
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin")
// Allow iframe embedding for pledge pages
if (request.nextUrl.pathname.startsWith("/p/")) {
response.headers.delete("X-Frame-Options")
}
return response
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
}

View File

@@ -0,0 +1,62 @@
import type { Config } from "tailwindcss";
const config: Config = {
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
"trust-blue": "#1e40af",
"warm-amber": "#f59e0b",
"success-green": "#16a34a",
"danger-red": "#dc2626",
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
fontFamily: {
sans: ["Inter", "system-ui", "sans-serif"],
},
},
},
plugins: [],
};
export default config;

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}