feat: add improved pi agent with observatory, dashboard, and pledge-now-pay-later
This commit is contained in:
3
.pi/observatory/.gitignore
vendored
Normal file
3
.pi/observatory/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
events.jsonl
|
||||||
|
summary.json
|
||||||
|
report.md
|
||||||
0
.pi/observatory/.gitkeep
Normal file
0
.pi/observatory/.gitkeep
Normal 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.
|
|
||||||
|
|||||||
12
README.md
12
README.md
@@ -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
|
||||||
|
|||||||
13
bun.lock
13
bun.lock
@@ -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=="],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
971
extensions/agent-dashboard.ts
Normal file
971
extensions/agent-dashboard.ts
Normal 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 {}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
1100
extensions/observatory.ts
Normal file
File diff suppressed because it is too large
Load Diff
41
extensions/stop.ts
Normal file
41
extensions/stop.ts
Normal 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");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
16
justfile
16
justfile
@@ -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
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
7
pledge-now-pay-later/.env.example
Normal file
7
pledge-now-pay-later/.env.example
Normal 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
|
||||||
3
pledge-now-pay-later/.eslintrc.json
Normal file
3
pledge-now-pay-later/.eslintrc.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": ["next/core-web-vitals", "next/typescript"]
|
||||||
|
}
|
||||||
43
pledge-now-pay-later/.gitignore
vendored
Normal file
43
pledge-now-pay-later/.gitignore
vendored
Normal 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
|
||||||
27
pledge-now-pay-later/Dockerfile
Normal file
27
pledge-now-pay-later/Dockerfile
Normal 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"]
|
||||||
139
pledge-now-pay-later/README.md
Normal file
139
pledge-now-pay-later/README.md
Normal 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.
|
||||||
1242
pledge-now-pay-later/bun.lock
Normal file
1242
pledge-now-pay-later/bun.lock
Normal file
File diff suppressed because it is too large
Load Diff
23
pledge-now-pay-later/docker-compose.yml
Normal file
23
pledge-now-pay-later/docker-compose.yml
Normal 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:
|
||||||
134
pledge-now-pay-later/docs/EMBED_GUIDE.md
Normal file
134
pledge-now-pay-later/docs/EMBED_GUIDE.md
Normal 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
|
||||||
1364
pledge-now-pay-later/docs/PRODUCT_SPEC.md
Normal file
1364
pledge-now-pay-later/docs/PRODUCT_SPEC.md
Normal file
File diff suppressed because it is too large
Load Diff
6
pledge-now-pay-later/next.config.mjs
Normal file
6
pledge-now-pay-later/next.config.mjs
Normal 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
8166
pledge-now-pay-later/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
49
pledge-now-pay-later/package.json
Normal file
49
pledge-now-pay-later/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
8
pledge-now-pay-later/postcss.config.mjs
Normal file
8
pledge-now-pay-later/postcss.config.mjs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/** @type {import('postcss-load-config').Config} */
|
||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
15
pledge-now-pay-later/prisma.config.ts
Normal file
15
pledge-now-pay-later/prisma.config.ts
Normal 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"],
|
||||||
|
},
|
||||||
|
});
|
||||||
208
pledge-now-pay-later/prisma/schema.prisma
Normal file
208
pledge-now-pay-later/prisma/schema.prisma
Normal 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])
|
||||||
|
}
|
||||||
306
pledge-now-pay-later/prisma/seed.mts
Normal file
306
pledge-now-pay-later/prisma/seed.mts
Normal 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()
|
||||||
|
})
|
||||||
33
pledge-now-pay-later/src/app/api/analytics/route.ts
Normal file
33
pledge-now-pay-later/src/app/api/analytics/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
156
pledge-now-pay-later/src/app/api/dashboard/route.ts
Normal file
156
pledge-now-pay-later/src/app/api/dashboard/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
104
pledge-now-pay-later/src/app/api/events/[id]/qr/route.ts
Normal file
104
pledge-now-pay-later/src/app/api/events/[id]/qr/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
107
pledge-now-pay-later/src/app/api/events/route.ts
Normal file
107
pledge-now-pay-later/src/app/api/events/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
78
pledge-now-pay-later/src/app/api/exports/crm-pack/route.ts
Normal file
78
pledge-now-pay-later/src/app/api/exports/crm-pack/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
135
pledge-now-pay-later/src/app/api/gocardless/create-flow/route.ts
Normal file
135
pledge-now-pay-later/src/app/api/gocardless/create-flow/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
82
pledge-now-pay-later/src/app/api/gocardless/webhook/route.ts
Normal file
82
pledge-now-pay-later/src/app/api/gocardless/webhook/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
133
pledge-now-pay-later/src/app/api/imports/bank-statement/route.ts
Normal file
133
pledge-now-pay-later/src/app/api/imports/bank-statement/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
88
pledge-now-pay-later/src/app/api/pledges/[id]/route.ts
Normal file
88
pledge-now-pay-later/src/app/api/pledges/[id]/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
133
pledge-now-pay-later/src/app/api/pledges/route.ts
Normal file
133
pledge-now-pay-later/src/app/api/pledges/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
66
pledge-now-pay-later/src/app/api/qr/[token]/route.ts
Normal file
66
pledge-now-pay-later/src/app/api/qr/[token]/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
60
pledge-now-pay-later/src/app/api/settings/route.ts
Normal file
60
pledge-now-pay-later/src/app/api/settings/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
118
pledge-now-pay-later/src/app/api/stripe/checkout/route.ts
Normal file
118
pledge-now-pay-later/src/app/api/stripe/checkout/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
88
pledge-now-pay-later/src/app/api/stripe/webhook/route.ts
Normal file
88
pledge-now-pay-later/src/app/api/stripe/webhook/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
79
pledge-now-pay-later/src/app/api/webhooks/route.ts
Normal file
79
pledge-now-pay-later/src/app/api/webhooks/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
92
pledge-now-pay-later/src/app/dashboard/apply/page.tsx
Normal file
92
pledge-now-pay-later/src/app/dashboard/apply/page.tsx
Normal 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'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'll review your application and get back within 48 hours.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
241
pledge-now-pay-later/src/app/dashboard/events/[id]/page.tsx
Normal file
241
pledge-now-pay-later/src/app/dashboard/events/[id]/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
225
pledge-now-pay-later/src/app/dashboard/events/page.tsx
Normal file
225
pledge-now-pay-later/src/app/dashboard/events/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
77
pledge-now-pay-later/src/app/dashboard/exports/page.tsx
Normal file
77
pledge-now-pay-later/src/app/dashboard/exports/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
89
pledge-now-pay-later/src/app/dashboard/layout.tsx
Normal file
89
pledge-now-pay-later/src/app/dashboard/layout.tsx
Normal 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'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
23
pledge-now-pay-later/src/app/dashboard/loading.tsx
Normal file
23
pledge-now-pay-later/src/app/dashboard/loading.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
324
pledge-now-pay-later/src/app/dashboard/page.tsx
Normal file
324
pledge-now-pay-later/src/app/dashboard/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
293
pledge-now-pay-later/src/app/dashboard/pledges/page.tsx
Normal file
293
pledge-now-pay-later/src/app/dashboard/pledges/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
239
pledge-now-pay-later/src/app/dashboard/reconcile/page.tsx
Normal file
239
pledge-now-pay-later/src/app/dashboard/reconcile/page.tsx
Normal 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'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
266
pledge-now-pay-later/src/app/dashboard/settings/page.tsx
Normal file
266
pledge-now-pay-later/src/app/dashboard/settings/page.tsx
Normal 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'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
BIN
pledge-now-pay-later/src/app/favicon.ico
Normal file
BIN
pledge-now-pay-later/src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
BIN
pledge-now-pay-later/src/app/fonts/GeistMonoVF.woff
Normal file
BIN
pledge-now-pay-later/src/app/fonts/GeistMonoVF.woff
Normal file
Binary file not shown.
BIN
pledge-now-pay-later/src/app/fonts/GeistVF.woff
Normal file
BIN
pledge-now-pay-later/src/app/fonts/GeistVF.woff
Normal file
Binary file not shown.
45
pledge-now-pay-later/src/app/globals.css
Normal file
45
pledge-now-pay-later/src/app/globals.css
Normal 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];
|
||||||
|
}
|
||||||
|
}
|
||||||
20
pledge-now-pay-later/src/app/layout.tsx
Normal file
20
pledge-now-pay-later/src/app/layout.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
12
pledge-now-pay-later/src/app/p/[token]/loading.tsx
Normal file
12
pledge-now-pay-later/src/app/p/[token]/loading.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
210
pledge-now-pay-later/src/app/p/[token]/page.tsx
Normal file
210
pledge-now-pay-later/src/app/p/[token]/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
97
pledge-now-pay-later/src/app/p/[token]/steps/amount-step.tsx
Normal file
97
pledge-now-pay-later/src/app/p/[token]/steps/amount-step.tsx
Normal 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't be charged now. Choose how to pay next.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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'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 "new payment"
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* I've paid */}
|
||||||
|
<Button
|
||||||
|
size="xl"
|
||||||
|
variant="success"
|
||||||
|
className="w-full"
|
||||||
|
onClick={handleIPaid}
|
||||||
|
>
|
||||||
|
I've Sent the Payment ✓
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<p className="text-center text-xs text-muted-foreground">
|
||||||
|
Payments usually take 1-2 hours to arrive. We'll confirm once received.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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'll be taken to your bank'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'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 "{search}"</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
123
pledge-now-pay-later/src/app/p/[token]/steps/identity-step.tsx
Normal file
123
pledge-now-pay-later/src/app/p/[token]/steps/identity-step.tsx
Normal 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'll only use this to send payment details and confirm receipt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
152
pledge-now-pay-later/src/app/p/success/page.tsx
Normal file
152
pledge-now-pay-later/src/app/p/success/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
94
pledge-now-pay-later/src/app/page.tsx
Normal file
94
pledge-now-pay-later/src/app/page.tsx
Normal 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 "I'll donate later" 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
32
pledge-now-pay-later/src/components/qr-code.tsx
Normal file
32
pledge-now-pay-later/src/components/qr-code.tsx
Normal 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 }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
28
pledge-now-pay-later/src/components/ui/badge.tsx
Normal file
28
pledge-now-pay-later/src/components/ui/badge.tsx
Normal 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 }
|
||||||
38
pledge-now-pay-later/src/components/ui/button.tsx
Normal file
38
pledge-now-pay-later/src/components/ui/button.tsx
Normal 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 }
|
||||||
34
pledge-now-pay-later/src/components/ui/card.tsx
Normal file
34
pledge-now-pay-later/src/components/ui/card.tsx
Normal 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 }
|
||||||
35
pledge-now-pay-later/src/components/ui/dialog.tsx
Normal file
35
pledge-now-pay-later/src/components/ui/dialog.tsx
Normal 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} />
|
||||||
|
}
|
||||||
17
pledge-now-pay-later/src/components/ui/input.tsx
Normal file
17
pledge-now-pay-later/src/components/ui/input.tsx
Normal 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 }
|
||||||
9
pledge-now-pay-later/src/components/ui/label.tsx
Normal file
9
pledge-now-pay-later/src/components/ui/label.tsx
Normal 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 }
|
||||||
18
pledge-now-pay-later/src/components/ui/select.tsx
Normal file
18
pledge-now-pay-later/src/components/ui/select.tsx
Normal 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 }
|
||||||
7
pledge-now-pay-later/src/components/ui/skeleton.tsx
Normal file
7
pledge-now-pay-later/src/components/ui/skeleton.tsx
Normal 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 }
|
||||||
18
pledge-now-pay-later/src/components/ui/textarea.tsx
Normal file
18
pledge-now-pay-later/src/components/ui/textarea.tsx
Normal 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 }
|
||||||
49
pledge-now-pay-later/src/components/ui/toast.tsx
Normal file
49
pledge-now-pay-later/src/components/ui/toast.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
37
pledge-now-pay-later/src/lib/analytics.ts
Normal file
37
pledge-now-pay-later/src/lib/analytics.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
42
pledge-now-pay-later/src/lib/exports.ts
Normal file
42
pledge-now-pay-later/src/lib/exports.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
171
pledge-now-pay-later/src/lib/gocardless.ts
Normal file
171
pledge-now-pay-later/src/lib/gocardless.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
98
pledge-now-pay-later/src/lib/matching.ts
Normal file
98
pledge-now-pay-later/src/lib/matching.ts
Normal 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
|
||||||
|
}
|
||||||
29
pledge-now-pay-later/src/lib/org.ts
Normal file
29
pledge-now-pay-later/src/lib/org.ts
Normal 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
|
||||||
|
}
|
||||||
21
pledge-now-pay-later/src/lib/prisma.ts
Normal file
21
pledge-now-pay-later/src/lib/prisma.ts
Normal 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
|
||||||
48
pledge-now-pay-later/src/lib/qr.ts
Normal file
48
pledge-now-pay-later/src/lib/qr.ts
Normal 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',
|
||||||
|
})
|
||||||
|
}
|
||||||
39
pledge-now-pay-later/src/lib/reference.ts
Normal file
39
pledge-now-pay-later/src/lib/reference.ts
Normal 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)
|
||||||
|
}
|
||||||
100
pledge-now-pay-later/src/lib/reminders.ts
Normal file
100
pledge-now-pay-later/src/lib/reminders.ts
Normal 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
|
||||||
|
}
|
||||||
127
pledge-now-pay-later/src/lib/stripe.ts
Normal file
127
pledge-now-pay-later/src/lib/stripe.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
16
pledge-now-pay-later/src/lib/utils.ts
Normal file
16
pledge-now-pay-later/src/lib/utils.ts
Normal 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)}`
|
||||||
|
}
|
||||||
48
pledge-now-pay-later/src/lib/validators.ts
Normal file
48
pledge-now-pay-later/src/lib/validators.ts
Normal 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(),
|
||||||
|
})
|
||||||
50
pledge-now-pay-later/src/middleware.ts
Normal file
50
pledge-now-pay-later/src/middleware.ts
Normal 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).*)"],
|
||||||
|
}
|
||||||
62
pledge-now-pay-later/tailwind.config.ts
Normal file
62
pledge-now-pay-later/tailwind.config.ts
Normal 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;
|
||||||
26
pledge-now-pay-later/tsconfig.json
Normal file
26
pledge-now-pay-later/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user