diff --git a/.gitignore b/.gitignore index 063aec0..107a1f1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,16 +1,7 @@ node_modules/ -__pycache__/ -*.pyc -.DS_Store -*.swp -*.swo - -# API keys — never commit real credentials -.env - .pi/agent-sessions/ - - +calvana.tar.gz +*.tmp +nul +.env .playwright-cli/ - -tmp/ \ No newline at end of file diff --git a/.pi/extensions/calvana-shiplog.ts b/.pi/extensions/calvana-shiplog.ts new file mode 100644 index 0000000..c2b8cca --- /dev/null +++ b/.pi/extensions/calvana-shiplog.ts @@ -0,0 +1,719 @@ +/** + * Calvana Ship Log Extension + * + * Automatically tracks what you're shipping and updates the live Calvana site. + * + * Tools (LLM-callable): + * - calvana_ship: Add/update/complete shipping log entries + * - calvana_oops: Log mistakes and fixes + * - calvana_deploy: Push changes to the live site + * + * Commands (user): + * /ships — View current shipping log + * /ship-deploy — Force deploy to calvana.quikcue.com + * + * How it works: + * 1. When you work on tasks, the LLM uses calvana_ship to track progress + * 2. If something breaks, calvana_oops logs it + * 3. calvana_deploy rebuilds the /live page HTML and pushes it to the server + * 4. The extension auto-injects context so the LLM knows to track ships + * + * Edit the SSH/deploy config in the DEPLOY_CONFIG section below. + */ + +import { StringEnum } from "@mariozechner/pi-ai"; +import type { ExtensionAPI, ExtensionContext, Theme } from "@mariozechner/pi-coding-agent"; +import { Text, truncateToWidth, matchesKey } from "@mariozechner/pi-tui"; +import { Type } from "@sinclair/typebox"; + +// ════════════════════════════════════════════════════════════════════ +// CONFIGURATION — Edit these to change deploy target, copy, links +// ════════════════════════════════════════════════════════════════════ + +const DEPLOY_CONFIG = { + sshHost: "root@159.195.60.33", + sshPort: "22", + container: "qc-server-new", + sitePath: "/opt/calvana/html", + domain: "calvana.quikcue.com", +}; + +const SITE_CONFIG = { + title: "Calvana", + tagline: "I break rules. Not production.", + email: "omair@quikcue.com", + referralLine: "PS — Umar pointed me here. If this turns into a hire, I want him to get paid.", +}; + +// ════════════════════════════════════════════════════════════════════ +// TYPES +// ════════════════════════════════════════════════════════════════════ + +type ShipStatus = "planned" | "shipping" | "shipped"; + +interface ShipEntry { + id: number; + title: string; + status: ShipStatus; + timestamp: string; + metric: string; + prLink: string; + deployLink: string; + loomLink: string; +} + +interface OopsEntry { + id: number; + description: string; + fixTime: string; + commitLink: string; + timestamp: string; +} + +interface ShipLogState { + ships: ShipEntry[]; + oops: OopsEntry[]; + nextShipId: number; + nextOopsId: number; + lastDeployed: string | null; +} + +// ════════════════════════════════════════════════════════════════════ +// TOOL SCHEMAS +// ════════════════════════════════════════════════════════════════════ + +const ShipParams = Type.Object({ + action: StringEnum(["add", "update", "list"] as const), + title: Type.Optional(Type.String({ description: "Ship title (for add)" })), + id: Type.Optional(Type.Number({ description: "Ship ID (for update)" })), + status: Type.Optional(StringEnum(["planned", "shipping", "shipped"] as const)), + metric: Type.Optional(Type.String({ description: "What moved — metric line" })), + prLink: Type.Optional(Type.String({ description: "PR link" })), + deployLink: Type.Optional(Type.String({ description: "Deploy link" })), + loomLink: Type.Optional(Type.String({ description: "Loom clip link" })), +}); + +const OopsParams = Type.Object({ + action: StringEnum(["add", "list"] as const), + description: Type.Optional(Type.String({ description: "What broke and how it was fixed" })), + fixTime: Type.Optional(Type.String({ description: "Time to fix, e.g. '3 min'" })), + commitLink: Type.Optional(Type.String({ description: "Link to the fix commit" })), +}); + +const DeployParams = Type.Object({ + dryRun: Type.Optional(Type.Boolean({ description: "If true, generate HTML but don't deploy" })), +}); + +// ════════════════════════════════════════════════════════════════════ +// EXTENSION +// ════════════════════════════════════════════════════════════════════ + +export default function (pi: ExtensionAPI) { + // ── State ── + let state: ShipLogState = { + ships: [], + oops: [], + nextShipId: 1, + nextOopsId: 1, + lastDeployed: null, + }; + + // ── State reconstruction from session ── + const reconstructState = (ctx: ExtensionContext) => { + state = { ships: [], oops: [], nextShipId: 1, nextOopsId: 1, lastDeployed: null }; + + for (const entry of ctx.sessionManager.getBranch()) { + if (entry.type !== "message") continue; + const msg = entry.message; + if (msg.role !== "toolResult") continue; + if (msg.toolName === "calvana_ship" || msg.toolName === "calvana_oops" || msg.toolName === "calvana_deploy") { + const details = msg.details as { state?: ShipLogState } | undefined; + if (details?.state) { + state = details.state; + } + } + } + }; + + pi.on("session_start", async (_event, ctx) => { + reconstructState(ctx); + if (ctx.hasUI) { + const theme = ctx.ui.theme; + const shipCount = state.ships.length; + const shipped = state.ships.filter(s => s.status === "shipped").length; + ctx.ui.setStatus("calvana", theme.fg("dim", `🚀 ${shipped}/${shipCount} shipped`)); + } + }); + pi.on("session_switch", async (_event, ctx) => reconstructState(ctx)); + pi.on("session_fork", async (_event, ctx) => reconstructState(ctx)); + pi.on("session_tree", async (_event, ctx) => reconstructState(ctx)); + + // ── Inject context so LLM knows about ship tracking ── + pi.on("before_agent_start", async (event, _ctx) => { + const shipContext = ` +[Calvana Ship Log Extension Active] +You have access to these tools for tracking work: +- calvana_ship: Track shipping progress (add/update/list entries) +- calvana_oops: Log mistakes and fixes +- calvana_deploy: Push updates to the live site at https://${DEPLOY_CONFIG.domain}/live + +When you START working on a task, use calvana_ship to add or update it to "shipping". +When you COMPLETE a task, update it to "shipped" with a metric. +If something BREAKS, log it with calvana_oops. +After significant changes, use calvana_deploy to push updates live. + +Current ships: ${state.ships.length} (${state.ships.filter(s => s.status === "shipped").length} shipped) +Current oops: ${state.oops.length} +`; + return { + systemPrompt: event.systemPrompt + shipContext, + }; + }); + + // ── Update status bar on turn end ── + pi.on("turn_end", async (_event, ctx) => { + if (ctx.hasUI) { + const theme = ctx.ui.theme; + const shipped = state.ships.filter(s => s.status === "shipped").length; + const shipping = state.ships.filter(s => s.status === "shipping").length; + const total = state.ships.length; + let statusText = `🚀 ${shipped}/${total} shipped`; + if (shipping > 0) statusText += ` · ${shipping} in flight`; + if (state.lastDeployed) statusText += ` · last deploy ${state.lastDeployed}`; + ctx.ui.setStatus("calvana", theme.fg("dim", statusText)); + } + }); + + // ════════════════════════════════════════════════════════════════ + // TOOL: calvana_ship + // ════════════════════════════════════════════════════════════════ + + pi.registerTool({ + name: "calvana_ship", + label: "Ship Log", + description: "Track shipping progress. Actions: add (new entry), update (change status/links), list (show all). Use this whenever you start, progress, or finish a task.", + parameters: ShipParams, + + async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { + const now = new Date().toISOString().replace("T", " ").slice(0, 19) + " GMT+8"; + + switch (params.action) { + case "add": { + if (!params.title) { + return { + content: [{ type: "text", text: "Error: title required" }], + details: { state: { ...state }, error: "title required" }, + }; + } + const entry: ShipEntry = { + id: state.nextShipId++, + title: params.title, + status: (params.status as ShipStatus) || "planned", + timestamp: now, + metric: params.metric || "—", + prLink: params.prLink || "#pr", + deployLink: params.deployLink || "#deploy", + loomLink: params.loomLink || "#loomclip", + }; + state.ships.push(entry); + return { + content: [{ type: "text", text: `Ship #${entry.id} added: "${entry.title}" [${entry.status}]` }], + details: { state: { ...state, ships: [...state.ships] } }, + }; + } + + case "update": { + if (params.id === undefined) { + return { + content: [{ type: "text", text: "Error: id required for update" }], + details: { state: { ...state }, error: "id required" }, + }; + } + const ship = state.ships.find(s => s.id === params.id); + if (!ship) { + return { + content: [{ type: "text", text: `Ship #${params.id} not found` }], + details: { state: { ...state }, error: `#${params.id} not found` }, + }; + } + if (params.status) ship.status = params.status as ShipStatus; + if (params.metric) ship.metric = params.metric; + if (params.prLink) ship.prLink = params.prLink; + if (params.deployLink) ship.deployLink = params.deployLink; + if (params.loomLink) ship.loomLink = params.loomLink; + ship.timestamp = now; + return { + content: [{ type: "text", text: `Ship #${ship.id} updated: "${ship.title}" [${ship.status}]` }], + details: { state: { ...state, ships: [...state.ships] } }, + }; + } + + case "list": { + if (state.ships.length === 0) { + return { + content: [{ type: "text", text: "No ships logged yet." }], + details: { state: { ...state } }, + }; + } + const lines = state.ships.map(s => + `#${s.id} [${s.status.toUpperCase()}] ${s.title} (${s.timestamp}) — ${s.metric}` + ); + return { + content: [{ type: "text", text: lines.join("\n") }], + details: { state: { ...state } }, + }; + } + + default: + return { + content: [{ type: "text", text: `Unknown action: ${params.action}` }], + details: { state: { ...state } }, + }; + } + }, + + renderCall(args, theme) { + let text = theme.fg("toolTitle", theme.bold("🚀 ship ")); + text += theme.fg("muted", args.action || ""); + if (args.title) text += " " + theme.fg("dim", `"${args.title}"`); + if (args.id !== undefined) text += " " + theme.fg("accent", `#${args.id}`); + if (args.status) text += " → " + theme.fg("accent", args.status); + return new Text(text, 0, 0); + }, + + renderResult(result, { expanded }, theme) { + const details = result.details as { state?: ShipLogState; error?: string } | undefined; + if (details?.error) return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0); + + const st = details?.state; + if (!st || st.ships.length === 0) return new Text(theme.fg("dim", "No ships"), 0, 0); + + const shipped = st.ships.filter(s => s.status === "shipped").length; + const total = st.ships.length; + let text = theme.fg("success", `${shipped}/${total} shipped`); + + if (expanded) { + for (const s of st.ships) { + const badge = s.status === "shipped" ? theme.fg("success", "✓") + : s.status === "shipping" ? theme.fg("warning", "●") + : theme.fg("dim", "○"); + text += `\n ${badge} ${theme.fg("accent", `#${s.id}`)} ${theme.fg("muted", s.title)}`; + } + } + return new Text(text, 0, 0); + }, + }); + + // ════════════════════════════════════════════════════════════════ + // TOOL: calvana_oops + // ════════════════════════════════════════════════════════════════ + + pi.registerTool({ + name: "calvana_oops", + label: "Oops Log", + description: "Log mistakes and fixes. Actions: add (new oops entry), list (show all). Use when something breaks during a task.", + parameters: OopsParams, + + async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { + const now = new Date().toISOString().replace("T", " ").slice(0, 19) + " GMT+8"; + + switch (params.action) { + case "add": { + if (!params.description) { + return { + content: [{ type: "text", text: "Error: description required" }], + details: { state: { ...state }, error: "description required" }, + }; + } + const entry: OopsEntry = { + id: state.nextOopsId++, + description: params.description, + fixTime: params.fixTime || "—", + commitLink: params.commitLink || "#commit", + timestamp: now, + }; + state.oops.push(entry); + return { + content: [{ type: "text", text: `Oops #${entry.id}: "${entry.description}" (fixed in ${entry.fixTime})` }], + details: { state: { ...state, oops: [...state.oops] } }, + }; + } + + case "list": { + if (state.oops.length === 0) { + return { + content: [{ type: "text", text: "No oops entries. Clean run so far." }], + details: { state: { ...state } }, + }; + } + const lines = state.oops.map(o => + `#${o.id} ${o.description} — fixed in ${o.fixTime}` + ); + return { + content: [{ type: "text", text: lines.join("\n") }], + details: { state: { ...state } }, + }; + } + + default: + return { + content: [{ type: "text", text: `Unknown action: ${params.action}` }], + details: { state: { ...state } }, + }; + } + }, + + renderCall(args, theme) { + let text = theme.fg("toolTitle", theme.bold("💥 oops ")); + text += theme.fg("muted", args.action || ""); + if (args.description) text += " " + theme.fg("dim", `"${args.description}"`); + return new Text(text, 0, 0); + }, + + renderResult(result, _options, theme) { + const details = result.details as { state?: ShipLogState; error?: string } | undefined; + if (details?.error) return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0); + const text = result.content[0]; + return new Text(theme.fg("warning", text?.type === "text" ? text.text : ""), 0, 0); + }, + }); + + // ════════════════════════════════════════════════════════════════ + // TOOL: calvana_deploy + // ════════════════════════════════════════════════════════════════ + + pi.registerTool({ + name: "calvana_deploy", + label: "Deploy Calvana", + description: `Regenerate the /live page with current ship log and deploy to https://${DEPLOY_CONFIG.domain}. Call this after adding/updating ships or oops entries to push changes live.`, + parameters: DeployParams, + + async execute(_toolCallId, params, signal, onUpdate, _ctx) { + onUpdate?.({ content: [{ type: "text", text: "Generating HTML..." }] }); + + const liveHtml = generateLivePageHtml(state); + + if (params.dryRun) { + return { + content: [{ type: "text", text: `Dry run — generated ${liveHtml.length} bytes of HTML.\n\n${liveHtml.slice(0, 500)}...` }], + details: { state: { ...state }, dryRun: true }, + }; + } + + onUpdate?.({ content: [{ type: "text", text: "Deploying to server..." }] }); + + try { + // Write HTML to server via SSH + incus exec + const escapedHtml = liveHtml.replace(/'/g, "'\\''"); + const sshCmd = `ssh -o ConnectTimeout=10 -p ${DEPLOY_CONFIG.sshPort} ${DEPLOY_CONFIG.sshHost}`; + const writeCmd = `${sshCmd} "incus exec ${DEPLOY_CONFIG.container} -- bash -c 'cat > ${DEPLOY_CONFIG.sitePath}/live/index.html << '\\''HTMLEOF'\\'' +${liveHtml} +HTMLEOF +'"`; + + // Use base64 to avoid all escaping nightmares + const b64Html = Buffer.from(liveHtml).toString("base64"); + const deployResult = await pi.exec("bash", ["-c", + `ssh -o ConnectTimeout=10 -p ${DEPLOY_CONFIG.sshPort} ${DEPLOY_CONFIG.sshHost} "incus exec ${DEPLOY_CONFIG.container} -- bash -c 'echo ${b64Html} | base64 -d > ${DEPLOY_CONFIG.sitePath}/live/index.html'"` + ], { signal, timeout: 30000 }); + + if (deployResult.code !== 0) { + return { + content: [{ type: "text", text: `Deploy failed: ${deployResult.stderr}` }], + details: { state: { ...state }, error: deployResult.stderr }, + isError: true, + }; + } + + // Rebuild and update docker service + const rebuildResult = await pi.exec("bash", ["-c", + `ssh -o ConnectTimeout=10 -p ${DEPLOY_CONFIG.sshPort} ${DEPLOY_CONFIG.sshHost} "incus exec ${DEPLOY_CONFIG.container} -- bash -c 'cd /opt/calvana && docker build -t calvana:latest . 2>&1 | tail -2 && docker service update --force calvana 2>&1 | tail -2'"` + ], { signal, timeout: 60000 }); + + const now = new Date().toISOString().replace("T", " ").slice(0, 19); + state.lastDeployed = now; + + return { + content: [{ type: "text", text: `✓ Deployed to https://${DEPLOY_CONFIG.domain}/live\n${rebuildResult.stdout}` }], + details: { state: { ...state, lastDeployed: now } }, + }; + } catch (err: any) { + return { + content: [{ type: "text", text: `Deploy error: ${err.message}` }], + details: { state: { ...state }, error: err.message }, + isError: true, + }; + } + }, + + renderCall(_args, theme) { + return new Text(theme.fg("toolTitle", theme.bold("🌐 deploy calvana")), 0, 0); + }, + + renderResult(result, _options, theme) { + const details = result.details as { error?: string } | undefined; + if (details?.error) return new Text(theme.fg("error", `✗ ${details.error}`), 0, 0); + return new Text(theme.fg("success", `✓ Live at https://${DEPLOY_CONFIG.domain}/live`), 0, 0); + }, + }); + + // ════════════════════════════════════════════════════════════════ + // COMMAND: /ships + // ════════════════════════════════════════════════════════════════ + + pi.registerCommand("ships", { + description: "View current Calvana shipping log", + handler: async (_args, ctx) => { + if (!ctx.hasUI) { + ctx.ui.notify("Requires interactive mode", "error"); + return; + } + + await ctx.ui.custom((_tui, theme, _kb, done) => { + return new ShipLogComponent(state, theme, () => done()); + }); + }, + }); + + // ════════════════════════════════════════════════════════════════ + // COMMAND: /ship-deploy + // ════════════════════════════════════════════════════════════════ + + pi.registerCommand("ship-deploy", { + description: "Force deploy the Calvana site with current ship log", + handler: async (_args, ctx) => { + const ok = await ctx.ui.confirm("Deploy?", `Push ship log to https://${DEPLOY_CONFIG.domain}/live?`); + if (!ok) return; + + // Queue a deploy via the LLM + pi.sendUserMessage("Use calvana_deploy to push the current ship log to the live site.", { deliverAs: "followUp" }); + }, + }); +} + +// ════════════════════════════════════════════════════════════════════ +// UI COMPONENT: /ships viewer +// ════════════════════════════════════════════════════════════════════ + +class ShipLogComponent { + private state: ShipLogState; + private theme: Theme; + private onClose: () => void; + private cachedWidth?: number; + private cachedLines?: string[]; + + constructor(state: ShipLogState, theme: Theme, onClose: () => void) { + this.state = state; + this.theme = theme; + this.onClose = onClose; + } + + handleInput(data: string): void { + if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) { + this.onClose(); + } + } + + render(width: number): string[] { + if (this.cachedLines && this.cachedWidth === width) return this.cachedLines; + + const lines: string[] = []; + const th = this.theme; + + lines.push(""); + lines.push(truncateToWidth( + th.fg("borderMuted", "─".repeat(3)) + + th.fg("accent", " 🚀 Calvana Ship Log ") + + th.fg("borderMuted", "─".repeat(Math.max(0, width - 26))), + width + )); + lines.push(""); + + // Ships + if (this.state.ships.length === 0) { + lines.push(truncateToWidth(` ${th.fg("dim", "No ships yet.")}`, width)); + } else { + const shipped = this.state.ships.filter(s => s.status === "shipped").length; + lines.push(truncateToWidth( + ` ${th.fg("muted", `${shipped}/${this.state.ships.length} shipped`)}`, + width + )); + lines.push(""); + + for (const s of this.state.ships) { + const badge = s.status === "shipped" ? th.fg("success", "✓ SHIPPED ") + : s.status === "shipping" ? th.fg("warning", "● SHIPPING") + : th.fg("dim", "○ PLANNED "); + lines.push(truncateToWidth( + ` ${badge} ${th.fg("accent", `#${s.id}`)} ${th.fg("text", s.title)}`, + width + )); + lines.push(truncateToWidth( + ` ${th.fg("dim", s.timestamp)} · ${th.fg("dim", s.metric)}`, + width + )); + } + } + + // Oops + if (this.state.oops.length > 0) { + lines.push(""); + lines.push(truncateToWidth(` ${th.fg("warning", "💥 Oops Log")}`, width)); + for (const o of this.state.oops) { + lines.push(truncateToWidth( + ` ${th.fg("error", "─")} ${th.fg("muted", o.description)} ${th.fg("dim", `(${o.fixTime})`)}`, + width + )); + } + } + + lines.push(""); + if (this.state.lastDeployed) { + lines.push(truncateToWidth(` ${th.fg("dim", `Last deployed: ${this.state.lastDeployed}`)}`, width)); + } + lines.push(truncateToWidth(` ${th.fg("dim", "Press Escape to close")}`, width)); + lines.push(""); + + this.cachedWidth = width; + this.cachedLines = lines; + return lines; + } + + invalidate(): void { + this.cachedWidth = undefined; + this.cachedLines = undefined; + } +} + +// ════════════════════════════════════════════════════════════════════ +// HTML GENERATOR — Builds the /live page from current state +// ════════════════════════════════════════════════════════════════════ + +function generateLivePageHtml(state: ShipLogState): string { + const now = new Date().toISOString(); + + const shipCards = state.ships.map(s => { + const badgeClass = s.status === "shipped" ? "badge-shipped" + : s.status === "shipping" ? "badge-shipping" + : "badge-planned"; + const badgeLabel = s.status.charAt(0).toUpperCase() + s.status.slice(1); + const titleSuffix = s.status === "shipped" ? " ✓" : ""; + + return `
+
+ ${escapeHtml(s.title)}${titleSuffix} + ${badgeLabel} +
+

⏱ ${escapeHtml(s.timestamp)}

+

What moved: ${escapeHtml(s.metric)}

+ +
`; + }).join("\n"); + + const oopsEntries = state.oops.map(o => { + return `
+ ${escapeHtml(o.description)}${o.fixTime !== "—" ? ` Fixed in ${escapeHtml(o.fixTime)}.` : ""} + → commit +
`; + }).join("\n"); + + // If no ships yet, show placeholder + const shipsSection = state.ships.length > 0 ? shipCards : `
+
+ Warming up... + Planned +
+

⏱ —

+

What moved: —

+
`; + + const oopsSection = state.oops.length > 0 ? oopsEntries : `
+ Nothing broken yet. Give it time. + → waiting +
`; + + return ` + + + + + Calvana — Live Shipping Log + + + + + + + + + + + +
+

Live Shipping Log

+

Intentional chaos. Full receipts.

+ +
+

Today's Ships

+
+${shipsSection} +
+
+ +
+
+
+

Rules I broke today

+
    +
  • Didn't ask permission
  • +
  • Didn't wait for alignment
  • +
  • Didn't write a PRD
  • +
  • Didn't submit a normal application
  • +
+
+
+

Rules I refuse to break

+
    +
  • No silent failures
  • +
  • No unbounded AI spend
  • +
  • No hallucinations shipped to users
  • +
  • No deploy without rollback path
  • +
+
+
+
+ +
+

Oops Log

+

If it's not here, I haven't broken it yet.

+
+${oopsSection} +
+
+ +
+ +

Last updated: ${now}

+
+
+ +`; +} + +function escapeHtml(str: string): string { + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} diff --git a/CLAUDE.md b/CLAUDE.md index 8d816f2..7f119f9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,8 +1,5 @@ # Pi vs CC — Extension Playground -## Infrastructure Access -**Always read `.pi/infra.md` at the start of every session** — it contains live credentials and connection details. - Pi Coding Agent extension examples and experiments. ## Tooling diff --git a/calvana-build/Dockerfile b/calvana-build/Dockerfile new file mode 100644 index 0000000..040c466 --- /dev/null +++ b/calvana-build/Dockerfile @@ -0,0 +1,4 @@ +FROM nginx:alpine +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY html/ /usr/share/nginx/html/ +EXPOSE 80 diff --git a/calvana-build/html/404.html b/calvana-build/html/404.html new file mode 100644 index 0000000..10b74f8 --- /dev/null +++ b/calvana-build/html/404.html @@ -0,0 +1,26 @@ + + + + + + 404 — Calvana + + + + +
+

404

+

This page doesn't exist yet. But give me 10 minutes.

+ ← Back to manifesto +
+ + diff --git a/calvana-build/html/css/style.css b/calvana-build/html/css/style.css new file mode 100644 index 0000000..cecc4de --- /dev/null +++ b/calvana-build/html/css/style.css @@ -0,0 +1,104 @@ +:root { + --bg: #0a0a0a; + --bg-card: #111; + --bg-card-hover: #161616; + --text: #e5e5e5; + --text-muted: #666; + --text-dim: #444; + --accent: #00ff9f; + --accent-dim: #00cc7f; + --cta: #ff6b35; + --cta-hover: #ff8c5a; + --yellow: #ffd93d; + --red: #ff4757; + --border: #1a1a1a; + --border-light: #222; + --font-mono: 'SF Mono','Fira Code','JetBrains Mono','Cascadia Code',monospace; + --font-sans: -apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif; + --max-w: 780px; +} +*,*::before,*::after{margin:0;padding:0;box-sizing:border-box} +html{font-size:16px;scroll-behavior:smooth} +body{background:var(--bg);color:var(--text);font-family:var(--font-sans);line-height:1.65;min-height:100vh;-webkit-font-smoothing:antialiased} + +nav{position:sticky;top:0;z-index:100;background:rgba(10,10,10,0.85);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border-bottom:1px solid var(--border);padding:.75rem 1.5rem} +nav .nav-inner{max-width:var(--max-w);margin:0 auto;display:flex;align-items:center;justify-content:space-between;gap:1rem} +nav .logo{font-family:var(--font-mono);font-size:.95rem;font-weight:700;color:var(--accent);text-decoration:none;letter-spacing:-.02em} +nav .logo span{color:var(--text-muted);font-weight:400} +nav .nav-links{display:flex;gap:.25rem;list-style:none} +nav .nav-links a{font-family:var(--font-mono);font-size:.8rem;color:var(--text-muted);text-decoration:none;padding:.35rem .65rem;border-radius:6px;transition:all .15s ease} +nav .nav-links a:hover,nav .nav-links a.active{color:var(--text);background:var(--bg-card)} + +.page{max-width:var(--max-w);margin:0 auto;padding:3.5rem 1.5rem 5rem} + +.hero-title{font-family:var(--font-mono);font-size:clamp(2rem,6vw,3.2rem);font-weight:800;line-height:1.15;letter-spacing:-.03em;color:var(--text);margin-bottom:1rem} +.hero-title .accent{color:var(--accent)} +h2{font-family:var(--font-mono);font-size:1.35rem;font-weight:700;color:var(--text);margin-bottom:1rem;letter-spacing:-.02em} +h3{font-family:var(--font-mono);font-size:1rem;font-weight:600;color:var(--text);margin-bottom:.5rem} +.subtitle{font-size:1.05rem;color:var(--text-muted);line-height:1.7;max-width:600px} +.section{margin-top:3.5rem} + +.btn-row{display:flex;flex-wrap:wrap;gap:.75rem;margin-top:2rem} +.btn{display:inline-flex;align-items:center;gap:.4rem;font-family:var(--font-mono);font-size:.85rem;font-weight:600;padding:.65rem 1.25rem;border-radius:8px;text-decoration:none;transition:all .15s ease;cursor:pointer;border:none} +.btn-primary{background:var(--accent);color:#0a0a0a} +.btn-primary:hover{background:#33ffb3;transform:translateY(-1px)} +.btn-cta{background:var(--cta);color:#fff} +.btn-cta:hover{background:var(--cta-hover);transform:translateY(-1px)} +.btn-outline{background:transparent;color:var(--text-muted);border:1px solid var(--border-light)} +.btn-outline:hover{color:var(--text);border-color:var(--text-muted);transform:translateY(-1px)} + +.card-grid{display:grid;grid-template-columns:1fr;gap:1rem;margin-top:1rem} +.card{background:var(--bg-card);border:1px solid var(--border);border-radius:10px;padding:1.25rem;transition:border-color .15s ease} +.card:hover{border-color:var(--border-light)} +.card-header{display:flex;align-items:flex-start;justify-content:space-between;gap:.75rem;margin-bottom:.6rem} +.card-title{font-family:var(--font-mono);font-size:.9rem;font-weight:600;line-height:1.4} +.card-meta{font-size:.78rem;color:var(--text-muted);margin-bottom:.5rem} +.card-links{display:flex;gap:.75rem;margin-top:.6rem} +.card-links a{font-family:var(--font-mono);font-size:.75rem;color:var(--accent-dim);text-decoration:none} +.card-links a:hover{color:var(--accent);text-decoration:underline} + +.badge{font-family:var(--font-mono);font-size:.7rem;font-weight:600;padding:.2rem .55rem;border-radius:99px;white-space:nowrap;flex-shrink:0} +.badge-planned{color:var(--text-muted);border:1px solid var(--border-light)} +.badge-shipping{color:#0a0a0a;background:var(--yellow)} +.badge-shipped{color:#0a0a0a;background:var(--accent)} + +.two-col{display:grid;grid-template-columns:1fr 1fr;gap:1.5rem;margin-top:1rem} +.col{background:var(--bg-card);border:1px solid var(--border);border-radius:10px;padding:1.25rem} +.col h3{margin-bottom:.75rem} +.col ul{list-style:none;display:flex;flex-direction:column;gap:.5rem} +.col ul li{font-size:.88rem;color:var(--text-muted);padding-left:1.25rem;position:relative} +.col ul li::before{content:'';position:absolute;left:0;top:.55rem;width:6px;height:6px;border-radius:50%} +.col-broke ul li::before{background:var(--cta)} +.col-kept ul li::before{background:var(--accent)} + +.oops-log{margin-top:1rem;display:flex;flex-direction:column;gap:.6rem} +.oops-entry{display:flex;align-items:center;justify-content:space-between;gap:1rem;font-size:.85rem;color:var(--text-muted);padding:.75rem 1rem;background:var(--bg-card);border:1px solid var(--border);border-radius:8px;border-left:3px solid var(--red)} +.oops-entry a{font-family:var(--font-mono);font-size:.75rem;color:var(--accent-dim);text-decoration:none;white-space:nowrap} +.oops-entry a:hover{color:var(--accent)} + +.manifesto-para{font-size:1.05rem;color:var(--text-muted);line-height:1.75;margin-top:3.5rem;max-width:620px;border-left:3px solid var(--accent);padding-left:1rem} + +.hire-ctas{display:flex;flex-direction:column;gap:1rem;margin-top:2rem;max-width:480px} +.hire-ctas .btn{justify-content:center;padding:1rem 1.5rem;font-size:.9rem} +.hire-note{margin-top:3.5rem;font-size:.9rem;color:var(--text-muted);line-height:1.7} +.hire-referral{margin-top:2rem;font-size:.85rem;color:var(--text-dim);font-style:italic} + +footer{margin-top:5rem;padding-top:2rem;border-top:1px solid var(--border)} +footer p{font-family:var(--font-mono);font-size:.78rem;color:var(--text-dim)} +.footer-tagline{color:var(--text-muted)!important;font-style:italic} +.metric{font-family:var(--font-mono);font-size:.78rem;color:var(--text-dim)} + +@media(max-width:640px){ + .page{padding:2rem 1rem 3rem} + .hero-title{font-size:clamp(1.7rem,7vw,2.4rem)} + .btn-row{flex-direction:column}.btn-row .btn{width:100%;justify-content:center} + .two-col{grid-template-columns:1fr} + nav .nav-links a{font-size:.75rem;padding:.3rem .5rem} + .card-header{flex-direction:column;gap:.4rem} + .oops-entry{flex-direction:column;align-items:flex-start;gap:.4rem} + .hire-ctas{max-width:100%} +} +@media(max-width:380px){ + nav .logo span{display:none} + nav .nav-links{gap:0} +} diff --git a/calvana-build/html/hire/index.html b/calvana-build/html/hire/index.html new file mode 100644 index 0000000..7b93ff8 --- /dev/null +++ b/calvana-build/html/hire/index.html @@ -0,0 +1,43 @@ + + + + + + Calvana — Hire + + + + + + + + + + + +
+

If you're reading this,
you already know.

+ +

+ If you want safe hands, hire safe people.
+ If you want velocity with control — let's talk. +

+

PS — Umar pointed me here. If this turns into a hire, I want him to get paid.

+
+ +
+
+ + diff --git a/calvana-build/html/index.html b/calvana-build/html/index.html new file mode 100644 index 0000000..f507eb6 --- /dev/null +++ b/calvana-build/html/index.html @@ -0,0 +1,5 @@ + + +Calvana +

Redirecting to /manifesto

+ diff --git a/calvana-build/html/live/index.html b/calvana-build/html/live/index.html new file mode 100644 index 0000000..f198905 --- /dev/null +++ b/calvana-build/html/live/index.html @@ -0,0 +1,121 @@ + + + + + + Calvana — Live Shipping Log + + + + + + + + + + + +
+

Live Shipping Log

+

Intentional chaos. Full receipts.

+ +
+

Today's Ships

+
+
+
+ Fix post-donation consent funnel (Email + WhatsApp) + Planned +
+

⏱ —

+

What moved: —

+ +
+
+
+ Deploy pledge-now-pay-later micro-saas + Planned +
+

⏱ —

+

What moved: —

+ +
+
+
+ JustVitamin post-migration AI automation demos + Shipping +
+

⏱ —

+

What moved: —

+ +
+
+
+ This Calvana application — shipped ✓ + Shipped +
+

⏱ 2026-03-02 14:00 GMT+8

+

What moved: 0 → live in one session

+ +
+
+
+ +
+
+
+

Rules I broke today

+
    +
  • Didn't ask permission
  • +
  • Didn't wait for alignment
  • +
  • Didn't write a PRD
  • +
  • Didn't submit a normal application
  • +
+
+
+

Rules I refuse to break

+
    +
  • No silent failures
  • +
  • No unbounded AI spend
  • +
  • No hallucinations shipped to users
  • +
  • No deploy without rollback path
  • +
+
+
+
+ +
+

Oops Log

+

If it's not here, I haven't broken it yet.

+
+
+ Traefik label typo → 404 on first deploy. Fixed in 3 min. + → commit +
+
+ CSS grid overflow on mobile. Caught in preview, fixed before push. + → commit +
+
+ Forgot meta viewport tag. Pinch-zoom chaos. Fixed in 90 seconds. + → commit +
+
+
+ +
+ +

Last updated: 2026-03-02T14:00:00+08:00

+
+
+ + diff --git a/calvana-build/html/manifesto/index.html b/calvana-build/html/manifesto/index.html new file mode 100644 index 0000000..8683251 --- /dev/null +++ b/calvana-build/html/manifesto/index.html @@ -0,0 +1,47 @@ + + + + + + Calvana — I don't apply. I deploy. + + + + + + + + + + + +
+

I don't apply.
I deploy.

+

+ You're hiring engineers. I'm showing you what changes when you hire an engine.
+ This application is a product. Built for you. Right now. +

+ +

+ Most applications prove the past. I'm proving the next 7 days.
+ Build → test → ship → observe → iterate… at speed, without breaking reality. +

+
+ +
+
+ + diff --git a/calvana-build/nginx.conf b/calvana-build/nginx.conf new file mode 100644 index 0000000..4f10346 --- /dev/null +++ b/calvana-build/nginx.conf @@ -0,0 +1,28 @@ +server { + listen 80; + server_name calvana.quikcue.com; + root /usr/share/nginx/html; + index index.html; + + # Fix: behind reverse proxy, use relative redirects + absolute_redirect off; + + location / { + try_files $uri $uri/ =404; + } + + location /css/ { + expires 1h; + add_header Cache-Control "public, immutable"; + } + + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + gzip on; + gzip_types text/html text/css application/javascript text/plain; + gzip_min_length 256; + + error_page 404 /404.html; +} diff --git a/justfile b/justfile index 743cf2a..0a4160f 100644 --- a/justfile +++ b/justfile @@ -1,4 +1,5 @@ set dotenv-load := true +set shell := ["pwsh", "-NoProfile", "-Command"] default: @just --list @@ -87,20 +88,16 @@ ext-theme-cycler: # Open pi with one or more stacked extensions in a new terminal: just open minimal tool-counter open +exts: - #!/usr/bin/env bash - args="" - for ext in {{exts}}; do - args="$args -e extensions/$ext.ts" - done - cmd="cd '{{justfile_directory()}}' && pi$args" - escaped="${cmd//\\/\\\\}" - escaped="${escaped//\"/\\\"}" - osascript -e "tell application \"Terminal\" to do script \"$escaped\"" + #!/usr/bin/env pwsh + $args_str = "" + foreach ($ext in "{{exts}}".Split(" ")) { $args_str += " -e extensions/$ext.ts" } + $cmd = "cd '{{justfile_directory()}}'; pi$args_str" + Start-Process wt -ArgumentList "pwsh", "-NoExit", "-Command", $cmd # Open every extension in its own terminal window all: just open pi - just open pure-focus + just open pure-focus just open minimal theme-cycler just open cross-agent minimal just open purpose-gate minimal diff --git a/pledge-now-pay-later/public/.gitkeep b/pledge-now-pay-later/public/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/pledge-now-pay-later/src/app/api/events/[id]/qr/route.ts b/pledge-now-pay-later/src/app/api/events/[id]/qr/route.ts index 68f008d..e670977 100644 --- a/pledge-now-pay-later/src/app/api/events/[id]/qr/route.ts +++ b/pledge-now-pay-later/src/app/api/events/[id]/qr/route.ts @@ -51,6 +51,9 @@ export async function GET( scanCount: s.scanCount, pledgeCount: s._count.pledges, totalPledged: s.pledges.reduce((sum: number, p: QrPledge) => sum + p.amountPence, 0), + totalCollected: s.pledges + .filter((p: QrPledge) => p.status === "paid") + .reduce((sum: number, p: QrPledge) => sum + p.amountPence, 0), createdAt: s.createdAt, })) ) diff --git a/pledge-now-pay-later/src/app/api/events/public/[slug]/route.ts b/pledge-now-pay-later/src/app/api/events/public/[slug]/route.ts new file mode 100644 index 0000000..7797ab6 --- /dev/null +++ b/pledge-now-pay-later/src/app/api/events/public/[slug]/route.ts @@ -0,0 +1,80 @@ +import { NextResponse } from "next/server" +import prisma from "@/lib/prisma" + +export async function GET( + _req: Request, + { params }: { params: Promise<{ slug: string }> } +) { + try { + const { slug } = await params + if (!prisma) { + return NextResponse.json({ error: "Database not configured" }, { status: 503 }) + } + + // Find event by slug (try both org-scoped and plain slug) + const event = await prisma.event.findFirst({ + where: { + OR: [ + { slug }, + { slug: { contains: slug } }, + ], + status: { in: ["active", "closed"] }, + }, + include: { + organization: { select: { name: true } }, + qrSources: { + select: { code: true, label: true, volunteerName: true }, + orderBy: { createdAt: "asc" }, + }, + pledges: { + select: { + donorName: true, + amountPence: true, + status: true, + giftAid: true, + createdAt: true, + }, + orderBy: { createdAt: "desc" }, + }, + }, + }) + + if (!event) { + return NextResponse.json({ error: "Event not found" }, { status: 404 }) + } + + const pledges = event.pledges + const totalPledged = pledges.reduce((s, p) => s + p.amountPence, 0) + const totalPaid = pledges + .filter((p) => p.status === "paid") + .reduce((s, p) => s + p.amountPence, 0) + const giftAidCount = pledges.filter((p) => p.giftAid).length + + return NextResponse.json({ + id: event.id, + name: event.name, + description: event.description, + eventDate: event.eventDate, + location: event.location, + goalAmount: event.goalAmount, + organizationName: event.organization.name, + stats: { + pledgeCount: pledges.length, + totalPledged, + totalPaid, + giftAidCount, + avgPledge: pledges.length > 0 ? Math.round(totalPledged / pledges.length) : 0, + }, + recentPledges: pledges.slice(0, 10).map((p) => ({ + donorName: p.donorName ? p.donorName.split(" ")[0] + " " + (p.donorName.split(" ")[1]?.[0] || "") + "." : null, + amountPence: p.amountPence, + createdAt: p.createdAt, + giftAid: p.giftAid, + })), + qrCodes: event.qrSources, + }) + } catch (error) { + console.error("Public event error:", error) + return NextResponse.json({ error: "Internal error" }, { status: 500 }) + } +} diff --git a/pledge-now-pay-later/src/app/api/exports/crm-pack/route.ts b/pledge-now-pay-later/src/app/api/exports/crm-pack/route.ts index 6a04421..19eb374 100644 --- a/pledge-now-pay-later/src/app/api/exports/crm-pack/route.ts +++ b/pledge-now-pay-later/src/app/api/exports/crm-pack/route.ts @@ -30,11 +30,13 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: "Organization not found" }, { status: 404 }) } const eventId = request.nextUrl.searchParams.get("eventId") + const giftAidOnly = request.nextUrl.searchParams.get("giftAidOnly") === "true" const pledges = await prisma.pledge.findMany({ where: { organizationId: orgId, ...(eventId ? { eventId } : {}), + ...(giftAidOnly ? { giftAid: true } : {}), }, include: { event: { select: { name: true } }, @@ -65,10 +67,14 @@ export async function GET(request: NextRequest) { const csv = formatCrmExportCsv(rows) + const fileName = giftAidOnly + ? `gift-aid-declarations-${new Date().toISOString().slice(0, 10)}.csv` + : `crm-export-${new Date().toISOString().slice(0, 10)}.csv` + return new NextResponse(csv, { headers: { "Content-Type": "text/csv", - "Content-Disposition": `attachment; filename="crm-export-${new Date().toISOString().slice(0, 10)}.csv"`, + "Content-Disposition": `attachment; filename="${fileName}"`, }, }) } catch (error) { diff --git a/pledge-now-pay-later/src/app/api/qr/[token]/volunteer/route.ts b/pledge-now-pay-later/src/app/api/qr/[token]/volunteer/route.ts new file mode 100644 index 0000000..265e6f5 --- /dev/null +++ b/pledge-now-pay-later/src/app/api/qr/[token]/volunteer/route.ts @@ -0,0 +1,63 @@ +import { NextResponse } from "next/server" +import { prisma } from "@/lib/prisma" + +export async function GET( + _req: Request, + { params }: { params: Promise<{ token: string }> } +) { + const { token } = await params + const db = prisma + + const qrSource = await db.qrSource.findUnique({ + where: { code: token }, + include: { + event: { + include: { organization: { select: { name: true } } }, + }, + pledges: { + orderBy: { createdAt: "desc" }, + select: { + id: true, + reference: true, + amountPence: true, + status: true, + donorName: true, + giftAid: true, + createdAt: true, + }, + }, + }, + }) + + if (!qrSource) { + return NextResponse.json({ error: "QR code not found" }, { status: 404 }) + } + + const pledges = qrSource.pledges + const totalPledgedPence = pledges.reduce((s, p) => s + p.amountPence, 0) + const totalPaidPence = pledges + .filter((p) => p.status === "paid") + .reduce((s, p) => s + p.amountPence, 0) + + return NextResponse.json({ + qrSource: { + label: qrSource.label, + volunteerName: qrSource.volunteerName, + code: qrSource.code, + scanCount: qrSource.scanCount, + }, + event: { + name: qrSource.event.name, + organizationName: qrSource.event.organization.name, + }, + pledges, + stats: { + totalPledges: pledges.length, + totalPledgedPence, + totalPaidPence, + conversionRate: qrSource.scanCount > 0 + ? Math.round((pledges.length / qrSource.scanCount) * 100) + : 0, + }, + }) +} diff --git a/pledge-now-pay-later/src/app/dashboard/events/[id]/leaderboard/page.tsx b/pledge-now-pay-later/src/app/dashboard/events/[id]/leaderboard/page.tsx new file mode 100644 index 0000000..d2d33ee --- /dev/null +++ b/pledge-now-pay-later/src/app/dashboard/events/[id]/leaderboard/page.tsx @@ -0,0 +1,114 @@ +"use client" + +import { useState, useEffect } from "react" +import { useParams } from "next/navigation" +import { Card, CardContent } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { Trophy, ArrowLeft, Loader2 } from "lucide-react" +import Link from "next/link" + +interface LeaderEntry { + label: string + volunteerName: string | null + pledgeCount: number + totalPledged: number + totalPaid: number + scanCount: number + conversionRate: number +} + +export default function LeaderboardPage() { + const params = useParams() + const eventId = params.id as string + const [entries, setEntries] = useState([]) + const [loading, setLoading] = useState(true) + + const formatPence = (p: number) => `£${(p / 100).toLocaleString("en-GB", { minimumFractionDigits: 0 })}` + + useEffect(() => { + const load = () => { + fetch(`/api/events/${eventId}/qr`) + .then((r) => r.json()) + .then((data) => { + if (Array.isArray(data)) { + const sorted = [...data].sort((a, b) => b.totalPledged - a.totalPledged) + setEntries(sorted.map((d) => ({ + label: d.label, + volunteerName: d.volunteerName, + pledgeCount: d.pledgeCount, + totalPledged: d.totalPledged, + totalPaid: d.totalCollected || 0, + scanCount: d.scanCount, + conversionRate: d.scanCount > 0 ? Math.round((d.pledgeCount / d.scanCount) * 100) : 0, + }))) + } + }) + .catch(() => {}) + .finally(() => setLoading(false)) + } + load() + const interval = setInterval(load, 10000) + return () => clearInterval(interval) + }, [eventId]) + + const medals = ["🥇", "🥈", "🥉"] + + if (loading) { + return ( +
+ +
+ ) + } + + return ( +
+
+ + Back to Event + +

+ Fundraiser Leaderboard +

+

Auto-refreshes every 10 seconds — perfect for live events

+
+ +
+ {entries.map((entry, i) => ( + + +
+
+ {i < 3 ? medals[i] : #{i + 1}} +
+
+

{entry.volunteerName || entry.label}

+
+ {entry.pledgeCount} pledges + {entry.scanCount} scans + = 50 ? "success" : "secondary"} className="text-xs"> + {entry.conversionRate}% conversion + +
+
+
+

{formatPence(entry.totalPledged)}

+

{formatPence(entry.totalPaid)} collected

+
+
+
+
+ ))} +
+ + {entries.length === 0 && ( + + + +

No QR codes created yet. Create QR codes to see the leaderboard.

+
+
+ )} +
+ ) +} diff --git a/pledge-now-pay-later/src/app/dashboard/events/[id]/page.tsx b/pledge-now-pay-later/src/app/dashboard/events/[id]/page.tsx index 3301c26..5f0bd72 100644 --- a/pledge-now-pay-later/src/app/dashboard/events/[id]/page.tsx +++ b/pledge-now-pay-later/src/app/dashboard/events/[id]/page.tsx @@ -8,7 +8,7 @@ import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Dialog, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { formatPence } from "@/lib/utils" -import { Plus, Download, QrCode, ExternalLink, Copy, Check, Loader2, ArrowLeft } from "lucide-react" +import { Plus, Download, QrCode, ExternalLink, Copy, Check, Loader2, ArrowLeft, Trophy, Users, MessageCircle } from "lucide-react" import Link from "next/link" import { QRCodeCanvas } from "@/components/qr-code" @@ -104,9 +104,16 @@ export default function EventQRPage() { {qrSources.length} QR code{qrSources.length !== 1 ? "s" : ""} · {totalScans} scans · {totalPledges} pledges · {formatPence(totalAmount)}

- +
+ + + + +
{/* QR Grid */} @@ -187,6 +194,26 @@ export default function EventQRPage() { + {/* Volunteer & share links */} +
+ + + + +
))} diff --git a/pledge-now-pay-later/src/app/dashboard/events/page.tsx b/pledge-now-pay-later/src/app/dashboard/events/page.tsx index e00275e..0e90e49 100644 --- a/pledge-now-pay-later/src/app/dashboard/events/page.tsx +++ b/pledge-now-pay-later/src/app/dashboard/events/page.tsx @@ -28,6 +28,7 @@ interface EventSummary { export default function EventsPage() { const [events, setEvents] = useState([]) + // eslint-disable-next-line @typescript-eslint/no-unused-vars const [loading, setLoading] = useState(true) const [showCreate, setShowCreate] = useState(false) diff --git a/pledge-now-pay-later/src/app/dashboard/exports/page.tsx b/pledge-now-pay-later/src/app/dashboard/exports/page.tsx index 7050b33..666ad19 100644 --- a/pledge-now-pay-later/src/app/dashboard/exports/page.tsx +++ b/pledge-now-pay-later/src/app/dashboard/exports/page.tsx @@ -2,7 +2,7 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card" import { Button } from "@/components/ui/button" -import { Download, FileSpreadsheet, Webhook } from "lucide-react" +import { Download, FileSpreadsheet, Webhook, Gift } from "lucide-react" export default function ExportsPage() { const handleCrmExport = () => { @@ -12,27 +12,34 @@ export default function ExportsPage() { a.click() } + const handleGiftAidExport = () => { + const a = document.createElement("a") + a.href = "/api/exports/crm-pack?giftAidOnly=true" + a.download = `gift-aid-declarations-${new Date().toISOString().slice(0, 10)}.csv` + a.click() + } + return (

Exports

-

Export data for your CRM and automation tools

+

Export data for your CRM, HMRC, and automation tools

-
+
CRM Export Pack - Download all pledges as CSV with full attribution data, ready to import into your CRM. + All pledges as CSV with full attribution data.

Includes:

-
    +
    • Donor name, email, phone
    • Pledge amount and status
    • Payment method and reference
    • @@ -42,7 +49,37 @@ export default function ExportsPage() {
+
+
+ + + + + Gift Aid Report + + + HMRC-ready Gift Aid declarations for tax reclaim. + + + +
+

Includes only Gift Aid-eligible pledges:

+
    +
  • Donor full name (required by HMRC)
  • +
  • Donation amount and date
  • +
  • Gift Aid declaration status
  • +
  • Event and reference for audit trail
  • +
+
+
+

+ 💷 Claim 25p for every £1 donated by a UK taxpayer +

+
+
@@ -50,23 +87,23 @@ export default function ExportsPage() { - Webhook Events + Webhook / API - Poll pending reminder events for external automation (Zapier, Make, n8n). + Connect to Zapier, Make, or n8n for automation.
-

Endpoint:

- - GET /api/webhooks?since=2025-01-01T00:00:00Z +

Reminder endpoint:

+ + GET /api/webhooks?since=2025-01-01 -

Returns pending reminders with donor contact info and pledge details.

+

Returns pending reminders with donor contact info for external email/SMS.

- 💡 Connect this to Zapier or Make to send emails/SMS automatically + 💡 Connect to Zapier or n8n to send automatic reminder emails and SMS

diff --git a/pledge-now-pay-later/src/app/dashboard/pledges/page.tsx b/pledge-now-pay-later/src/app/dashboard/pledges/page.tsx index 49067d6..2eb750a 100644 --- a/pledge-now-pay-later/src/app/dashboard/pledges/page.tsx +++ b/pledge-now-pay-later/src/app/dashboard/pledges/page.tsx @@ -81,6 +81,7 @@ function PledgesContent() { fetchPledges() const interval = setInterval(fetchPledges, 15000) return () => clearInterval(interval) + // eslint-disable-next-line react-hooks/exhaustive-deps }, [eventId]) const handleStatusChange = async (pledgeId: string, newStatus: string) => { diff --git a/pledge-now-pay-later/src/app/e/[slug]/page.tsx b/pledge-now-pay-later/src/app/e/[slug]/page.tsx new file mode 100644 index 0000000..fc1998a --- /dev/null +++ b/pledge-now-pay-later/src/app/e/[slug]/page.tsx @@ -0,0 +1,261 @@ +"use client" + +import { useState, useEffect } from "react" +import { useParams } from "next/navigation" +import { Card, CardContent } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { MessageCircle, Share2, Heart, Users, Banknote, Gift, Loader2, QrCode, Calendar, MapPin } from "lucide-react" +// Badge is available via @/components/ui/badge if needed + +interface EventData { + id: string + name: string + description: string | null + eventDate: string | null + location: string | null + goalAmount: number | null + organizationName: string + stats: { + pledgeCount: number + totalPledged: number + totalPaid: number + giftAidCount: number + avgPledge: number + } + recentPledges: Array<{ + donorName: string | null + amountPence: number + createdAt: string + giftAid: boolean + }> + qrCodes: Array<{ + code: string + label: string + volunteerName: string | null + }> +} + +const formatPence = (p: number) => `£${(p / 100).toLocaleString("en-GB", { minimumFractionDigits: 0 })}` + +export default function PublicEventPage() { + const params = useParams() + const slug = params.slug as string + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState("") + + useEffect(() => { + fetch(`/api/events/public/${slug}`) + .then((r) => r.json()) + .then((d) => { + if (d.error) setError(d.error) + else setData(d) + }) + .catch(() => setError("Failed to load")) + .finally(() => setLoading(false)) + + const interval = setInterval(() => { + fetch(`/api/events/public/${slug}`) + .then((r) => r.json()) + .then((d) => { if (!d.error) setData(d) }) + .catch(() => {}) + }, 15000) + return () => clearInterval(interval) + }, [slug]) + + const handleWhatsAppShare = () => { + if (!data) return + const url = `${window.location.origin}/e/${slug}` + const text = `🤲 ${data.name} is raising funds!\n\nPledge here — it takes 15 seconds:\n${url}` + window.open(`https://wa.me/?text=${encodeURIComponent(text)}`, "_blank") + } + + const handleShare = async () => { + if (!data) return + const url = `${window.location.origin}/e/${slug}` + if (navigator.share) { + await navigator.share({ title: data.name, text: `Pledge to ${data.name}`, url }) + } else { + handleWhatsAppShare() + } + } + + if (loading) { + return ( +
+ +
+ ) + } + + if (error || !data) { + return ( +
+
+ +

Event not found

+

{error}

+
+
+ ) + } + + const progressPercent = data.goalAmount + ? Math.min(100, Math.round((data.stats.totalPledged / data.goalAmount) * 100)) + : null + const giftAidBonus = Math.round(data.stats.totalPledged * 0.25 * (data.stats.giftAidCount / Math.max(1, data.stats.pledgeCount))) + + return ( +
+
+ {/* Header */} +
+

{data.organizationName}

+

{data.name}

+ {data.description &&

{data.description}

} +
+ {data.eventDate && ( + + + {new Date(data.eventDate).toLocaleDateString("en-GB", { day: "numeric", month: "long", year: "numeric" })} + + )} + {data.location && ( + + {data.location} + + )} +
+
+ + {/* Progress */} + + +
+

{formatPence(data.stats.totalPledged)}

+

+ pledged by {data.stats.pledgeCount} {data.stats.pledgeCount === 1 ? "person" : "people"} +

+
+ + {progressPercent !== null && data.goalAmount && ( +
+
+ {progressPercent}% of goal + {formatPence(data.goalAmount)} +
+
+
+
+
+ )} + +
+
+ +

{data.stats.pledgeCount}

+

Pledges

+
+
+ +

{formatPence(data.stats.totalPaid)}

+

Collected

+
+
+ +

{formatPence(giftAidBonus)}

+

Gift Aid

+
+
+ + + + {/* Pledge CTA — link to first QR code */} + {data.qrCodes.length > 0 && ( + + )} + + {/* Share */} +
+ + +
+ + {/* Recent pledges — social proof */} + {data.recentPledges.length > 0 && ( +
+

+ Recent Pledges +

+ {data.recentPledges.map((p, i) => { + const name = p.donorName || "Anonymous" + const ago = formatTimeAgo(p.createdAt) + return ( +
+
+ {name[0].toUpperCase()} +
+
+ {name} + {p.giftAid && 🎁} +

{ago}

+
+ {formatPence(p.amountPence)} +
+ ) + })} +
+ )} + + {/* Volunteer QR codes */} + {data.qrCodes.length > 1 && ( +
+

+ Pledge via a Fundraiser +

+
+ {data.qrCodes.map((qr) => ( + + ))} +
+
+ )} + +

+ Powered by Pledge Now, Pay Later +

+
+
+ ) +} + +function formatTimeAgo(dateStr: string) { + const diff = Date.now() - new Date(dateStr).getTime() + const mins = Math.floor(diff / 60000) + if (mins < 1) return "Just now" + if (mins < 60) return `${mins}m ago` + const hrs = Math.floor(mins / 60) + if (hrs < 24) return `${hrs}h ago` + return `${Math.floor(hrs / 24)}d ago` +} diff --git a/pledge-now-pay-later/src/app/p/[token]/page.tsx b/pledge-now-pay-later/src/app/p/[token]/page.tsx index f2280bb..c42e7ee 100644 --- a/pledge-now-pay-later/src/app/p/[token]/page.tsx +++ b/pledge-now-pay-later/src/app/p/[token]/page.tsx @@ -8,10 +8,9 @@ import { IdentityStep } from "./steps/identity-step" import { ConfirmationStep } from "./steps/confirmation-step" import { BankInstructionsStep } from "./steps/bank-instructions-step" import { CardPaymentStep } from "./steps/card-payment-step" -import { FpxPaymentStep } from "./steps/fpx-payment-step" import { DirectDebitStep } from "./steps/direct-debit-step" -export type Rail = "bank" | "gocardless" | "card" | "fpx" +export type Rail = "bank" | "gocardless" | "card" export interface PledgeData { amountPence: number @@ -30,18 +29,15 @@ interface EventInfo { qrSourceLabel: string | null } -// Step indices: -// 0 = Amount selection -// 1 = Payment method selection +// Steps: +// 0 = Amount +// 1 = Payment method // 2 = Identity (for bank transfer) // 3 = Bank instructions -// 4 = Confirmation (generic — card, DD, FPX) +// 4 = Confirmation (card, DD) // 5 = Card payment step -// 6 = FPX payment step // 7 = Direct Debit step -const STEP_TO_RAIL: Record = { 5: 1, 6: 1, 7: 1 } // maps back to payment selection - export default function PledgePage() { const params = useParams() const token = params.token as string @@ -80,7 +76,6 @@ export default function PledgePage() { setError("Unable to load pledge page") setLoading(false) }) - // Track pledge_start fetch("/api/analytics", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -96,10 +91,9 @@ export default function PledgePage() { const handleRailSelected = (rail: Rail) => { setPledgeData((d) => ({ ...d, rail })) const railStepMap: Record = { - bank: 2, // → identity step → bank instructions - card: 5, // → card payment step (combined identity + card) - fpx: 6, // → FPX step (bank selection + identity + redirect) - gocardless: 7, // → direct debit step (bank details + mandate) + bank: 2, + card: 5, + gocardless: 7, } setStep(railStepMap[rail]) } @@ -119,12 +113,8 @@ export default function PledgePage() { }), }) const result = await res.json() - if (result.error) { - setError(result.error) - return - } + if (result.error) { setError(result.error); return } setPledgeResult(result) - // Bank rail shows bank instructions; everything else shows generic confirmation setStep(finalData.rail === "bank" ? 3 : 4) } catch { setError("Something went wrong. Please try again.") @@ -151,50 +141,38 @@ export default function PledgePage() { ) } + const shareUrl = eventInfo?.qrSourceId ? `${typeof window !== "undefined" ? window.location.origin : ""}/p/${token}` : undefined + const steps: Record = { 0: , 1: , - 2: , + 2: , 3: pledgeResult && , - 4: pledgeResult && , + 4: pledgeResult && , 5: , - 6: , 7: , } - // Determine which steps allow back navigation - const backableSteps = new Set([1, 2, 5, 6, 7]) - const getBackStep = (current: number): number => { - if (current in STEP_TO_RAIL) return STEP_TO_RAIL[current] // rail-specific steps → payment selection - return current - 1 + const backableSteps = new Set([1, 2, 5, 7]) + const getBackStep = (s: number): number => { + if (s === 5 || s === 7) return 1 + return s - 1 } - - // Progress calculation: steps 0-2 map linearly, 3+ means done - const progressSteps = step >= 3 ? 3 : Math.min(step, 2) + 1 - const progressPercent = step >= 5 ? 66 : (progressSteps / 3) * 100 // rail steps show 2/3 progress + const progressPercent = step >= 3 ? 100 : step >= 2 ? 66 : step >= 1 ? 33 : 10 return (
- {/* Progress bar */}
-
+
- {/* Header */}

{eventInfo?.organizationName}

{eventInfo?.qrSourceLabel || ""}

- {/* Step content */} -
- {steps[step]} -
+
{steps[step]}
- {/* Back button */} {backableSteps.has(step) && (
+ +
+
+

Need help? Contact the charity directly.

diff --git a/pledge-now-pay-later/src/app/p/[token]/steps/confirmation-step.tsx b/pledge-now-pay-later/src/app/p/[token]/steps/confirmation-step.tsx index 07b62ee..15fb64c 100644 --- a/pledge-now-pay-later/src/app/p/[token]/steps/confirmation-step.tsx +++ b/pledge-now-pay-later/src/app/p/[token]/steps/confirmation-step.tsx @@ -1,30 +1,42 @@ "use client" -import { Check } from "lucide-react" +import { Check, Share2, MessageCircle } from "lucide-react" import { Card, CardContent } from "@/components/ui/card" +import { Button } from "@/components/ui/button" interface Props { pledge: { id: string; reference: string } amount: number rail: string eventName: string + shareUrl?: string } -export function ConfirmationStep({ pledge, amount, rail, eventName }: Props) { +export function ConfirmationStep({ pledge, amount, rail, eventName, shareUrl }: Props) { const railLabels: Record = { bank: "Bank Transfer", gocardless: "Direct Debit", card: "Card Payment", - fpx: "FPX Online Banking", } - const currencySymbol = rail === "fpx" ? "RM" : "£" - const nextStepMessages: Record = { bank: "We've sent you payment instructions. Transfer at your convenience — we'll confirm once received.", - gocardless: "Your Direct Debit mandate has been set up. The payment of " + currencySymbol + (amount / 100).toFixed(2) + " will be collected automatically in 3-5 working days. You'll receive email confirmation from GoCardless.", - card: "Your card payment is being processed. You'll receive a confirmation email shortly.", - fpx: "Your FPX payment has been received and is being verified. You'll receive a confirmation email once the payment is confirmed by your bank.", + gocardless: "Your Direct Debit mandate has been set up. The payment of £" + (amount / 100).toFixed(2) + " will be collected automatically in 3-5 working days. You'll receive email confirmation from GoCardless. Protected by the Direct Debit Guarantee.", + card: "Your card payment has been processed. You'll receive a confirmation email shortly.", + } + + const handleWhatsAppShare = () => { + const text = `I just pledged £${(amount / 100).toFixed(0)} to ${eventName}! 🤲\n\nYou can pledge too: ${shareUrl || window.location.origin}` + window.open(`https://wa.me/?text=${encodeURIComponent(text)}`, "_blank") + } + + const handleShare = async () => { + const text = `I just pledged £${(amount / 100).toFixed(0)} to ${eventName}!` + if (navigator.share) { + await navigator.share({ title: eventName, text, url: shareUrl || window.location.origin }) + } else { + handleWhatsAppShare() + } } return ( @@ -35,10 +47,10 @@ export function ConfirmationStep({ pledge, amount, rail, eventName }: Props) {

- {rail === "fpx" ? "Payment Successful!" : rail === "gocardless" ? "Mandate Set Up!" : "Pledge Received!"} + {rail === "gocardless" ? "Mandate Set Up!" : rail === "card" ? "Payment Complete!" : "Pledge Received!"}

- Thank you for your generous {rail === "fpx" ? "donation" : "pledge"} to{" "} + Thank you for your generous {rail === "card" ? "donation" : "pledge"} to{" "} {eventName}

@@ -47,7 +59,7 @@ export function ConfirmationStep({ pledge, amount, rail, eventName }: Props) {
Amount - {currencySymbol}{(amount / 100).toFixed(2)} + £{(amount / 100).toFixed(2)}
Payment Method @@ -63,7 +75,7 @@ export function ConfirmationStep({ pledge, amount, rail, eventName }: Props) { 3-5 working days
)} - {rail === "fpx" && ( + {rail === "card" && (
Status Paid ✓ @@ -72,6 +84,7 @@ export function ConfirmationStep({ pledge, amount, rail, eventName }: Props) { + {/* What happens next */}

What happens next?

@@ -79,6 +92,33 @@ export function ConfirmationStep({ pledge, amount, rail, eventName }: Props) {

+ {/* Share / encourage others */} +
+

+ 🤲 Spread the word — every pledge counts! +

+

+ Share with friends and family so they can pledge too. +

+
+ + +
+
+

Need help? Contact the charity directly. Ref: {pledge.reference}

diff --git a/pledge-now-pay-later/src/app/p/[token]/steps/fpx-payment-step.tsx b/pledge-now-pay-later/src/app/p/[token]/steps/fpx-payment-step.tsx deleted file mode 100644 index b10b7b7..0000000 --- a/pledge-now-pay-later/src/app/p/[token]/steps/fpx-payment-step.tsx +++ /dev/null @@ -1,329 +0,0 @@ -"use client" - -import { useState } from "react" -import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { Label } from "@/components/ui/label" -import { Lock, Search, CheckCircle2 } from "lucide-react" - -interface Props { - amount: number - eventName: string - onComplete: (identity: { - donorName: string - donorEmail: string - donorPhone: string - giftAid: boolean - }) => void -} - -interface Bank { - code: string - name: string - shortName: string - online: boolean -} - -const FPX_BANKS: Bank[] = [ - { code: "MBB", name: "Maybank2u", shortName: "Maybank", online: true }, - { code: "CIMB", name: "CIMB Clicks", shortName: "CIMB", online: true }, - { code: "PBB", name: "PBe Bank", shortName: "Public Bank", online: true }, - { code: "RHB", name: "RHB Now", shortName: "RHB", online: true }, - { code: "HLB", name: "Hong Leong Connect", shortName: "Hong Leong", online: true }, - { code: "AMBB", name: "AmOnline", shortName: "AmBank", online: true }, - { code: "BIMB", name: "Bank Islam GO", shortName: "Bank Islam", online: true }, - { code: "BKRM", name: "i-Rakyat", shortName: "Bank Rakyat", online: true }, - { code: "BSN", name: "myBSN", shortName: "BSN", online: true }, - { code: "OCBC", name: "OCBC Online", shortName: "OCBC", online: true }, - { code: "UOB", name: "UOB Personal", shortName: "UOB", online: true }, - { code: "ABB", name: "Affin Online", shortName: "Affin Bank", online: true }, - { code: "ABMB", name: "Alliance Online", shortName: "Alliance Bank", online: true }, - { code: "BMMB", name: "Bank Muamalat", shortName: "Muamalat", online: true }, - { code: "SCB", name: "SC Online", shortName: "Standard Chartered", online: true }, - { code: "HSBC", name: "HSBC Online", shortName: "HSBC", online: true }, - { code: "AGR", name: "AGRONet", shortName: "Agrobank", online: true }, - { code: "KFH", name: "KFH Online", shortName: "KFH", online: true }, -] - -const BANK_COLORS: Record = { - MBB: "bg-yellow-500", - CIMB: "bg-red-600", - PBB: "bg-pink-700", - RHB: "bg-blue-800", - HLB: "bg-blue-600", - AMBB: "bg-green-700", - BIMB: "bg-emerald-700", - BKRM: "bg-blue-900", - BSN: "bg-orange-600", - OCBC: "bg-red-700", - UOB: "bg-blue-700", - ABB: "bg-amber-700", - ABMB: "bg-teal-700", - BMMB: "bg-green-800", - SCB: "bg-green-600", - HSBC: "bg-red-500", - AGR: "bg-green-900", - KFH: "bg-yellow-700", -} - -type Phase = "select" | "identity" | "redirecting" | "processing" - -export function FpxPaymentStep({ amount, eventName, onComplete }: Props) { - const [phase, setPhase] = useState("select") - const [selectedBank, setSelectedBank] = useState(null) - const [search, setSearch] = useState("") - const [name, setName] = useState("") - const [email, setEmail] = useState("") - const [phone, setPhone] = useState("") - const [errors, setErrors] = useState>({}) - - const ringgit = (amount / 100).toFixed(2) - - const filteredBanks = search - ? FPX_BANKS.filter( - (b) => - b.name.toLowerCase().includes(search.toLowerCase()) || - b.shortName.toLowerCase().includes(search.toLowerCase()) || - b.code.toLowerCase().includes(search.toLowerCase()) - ) - : FPX_BANKS - - const handleBankSelect = (bank: Bank) => { - setSelectedBank(bank) - } - - const handleContinueToIdentity = () => { - if (!selectedBank) return - setPhase("identity") - } - - const handleSubmit = async () => { - const errs: Record = {} - if (!email.includes("@")) errs.email = "Valid email required" - setErrors(errs) - if (Object.keys(errs).length > 0) return - - setPhase("redirecting") - - // Simulate FPX redirect flow - await new Promise((r) => setTimeout(r, 2000)) - setPhase("processing") - await new Promise((r) => setTimeout(r, 1500)) - - onComplete({ - donorName: name, - donorEmail: email, - donorPhone: phone, - giftAid: false, // Gift Aid not applicable for MYR - }) - } - - // Redirecting phase - if (phase === "redirecting") { - return ( -
-
-
-
-
-

- Redirecting to {selectedBank?.name} -

-

- You'll be taken to your bank's secure login page to authorize the payment of RM{ringgit} -

-
-
-
-
- {selectedBank?.code} -
- {selectedBank?.name} -
-
-

- Do not close this window. You will be redirected back automatically. -

-
- ) - } - - // Processing phase - if (phase === "processing") { - return ( -
-
-
-
-
-

- Processing Payment -

-

- Verifying your payment with {selectedBank?.shortName}... -

-
-
- ) - } - - // Identity phase - if (phase === "identity") { - return ( -
-
-

Your Details

-

- Before we redirect you to {selectedBank?.name} -

-
- - {/* Selected bank indicator */} -
-
-
-
- {selectedBank?.code} -
-
-

{selectedBank?.name}

-

FPX Online Banking

-
-
-
-

RM{ringgit}

-

{eventName}

-
-
-
- -
-
- - setName(e.target.value)} - autoComplete="name" - /> -
- -
- - setEmail(e.target.value)} - autoComplete="email" - inputMode="email" - className={errors.email ? "border-red-500" : ""} - /> - {errors.email &&

{errors.email}

} -

We'll send your receipt here

-
- -
- - setPhone(e.target.value)} - autoComplete="tel" - inputMode="tel" - /> -
-
- - - -
- - Secured by FPX — Bank Negara Malaysia -
-
- ) - } - - // Bank selection phase (default) - return ( -
-
-

- FPX Online Banking -

-

- Pay RM{ringgit}{" "} - for {eventName} -

-
- - {/* Search */} -
- - setSearch(e.target.value)} - className="pl-10" - /> -
- - {/* Bank list */} -
- {filteredBanks.map((bank) => ( - - ))} -
- - {filteredBanks.length === 0 && ( -

No banks found matching "{search}"

- )} - - {/* Continue */} - - -
- - Powered by FPX — regulated by Bank Negara Malaysia -
-
- ) -} diff --git a/pledge-now-pay-later/src/app/p/[token]/steps/identity-step.tsx b/pledge-now-pay-later/src/app/p/[token]/steps/identity-step.tsx index 663c4c6..888da29 100644 --- a/pledge-now-pay-later/src/app/p/[token]/steps/identity-step.tsx +++ b/pledge-now-pay-later/src/app/p/[token]/steps/identity-step.tsx @@ -4,6 +4,7 @@ import { useState } from "react" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" +import { Gift, Shield } from "lucide-react" interface Props { onSubmit: (data: { @@ -12,9 +13,10 @@ interface Props { donorPhone: string giftAid: boolean }) => void + amount: number } -export function IdentityStep({ onSubmit }: Props) { +export function IdentityStep({ onSubmit, amount }: Props) { const [name, setName] = useState("") const [email, setEmail] = useState("") const [phone, setPhone] = useState("") @@ -23,6 +25,7 @@ export function IdentityStep({ onSubmit }: Props) { const hasContact = email.includes("@") || phone.length >= 10 const isValid = hasContact + const giftAidBonus = Math.round(amount * 0.25) const handleSubmit = async () => { if (!isValid) return @@ -47,10 +50,10 @@ export function IdentityStep({ onSubmit }: Props) {
- + setName(e.target.value)} autoComplete="name" @@ -68,6 +71,9 @@ export function IdentityStep({ onSubmit }: Props) { autoComplete="email" inputMode="email" /> +

+ We'll send your payment instructions and receipt here +

@@ -77,7 +83,7 @@ export function IdentityStep({ onSubmit }: Props) {
- + +

+ We can send reminders via SMS if you prefer +

- {/* Gift Aid */} -
) } diff --git a/pledge-now-pay-later/src/app/p/[token]/steps/payment-step.tsx b/pledge-now-pay-later/src/app/p/[token]/steps/payment-step.tsx index 2984b8f..6e993ef 100644 --- a/pledge-now-pay-later/src/app/p/[token]/steps/payment-step.tsx +++ b/pledge-now-pay-later/src/app/p/[token]/steps/payment-step.tsx @@ -1,9 +1,9 @@ "use client" -import { Building2, CreditCard, Landmark, Globe } from "lucide-react" +import { Building2, CreditCard, Landmark } from "lucide-react" interface Props { - onSelect: (rail: "bank" | "gocardless" | "card" | "fpx") => void + onSelect: (rail: "bank" | "gocardless" | "card") => void amount: number } @@ -18,34 +18,31 @@ export function PaymentStep({ onSelect, amount }: Props) { subtitle: "Zero fees — 100% goes to charity", tag: "Recommended", tagColor: "bg-success-green text-white", - detail: "Use your banking app to transfer directly", + detail: "Use your banking app to transfer directly. We'll give you the details.", + fee: "No fees", + feeColor: "text-success-green", }, { id: "gocardless" as const, icon: Landmark, title: "Direct Debit", subtitle: "Automatic collection — set and forget", - tag: "Low fees", + tag: "Set up once", tagColor: "bg-trust-blue/10 text-trust-blue", - detail: "We'll collect via GoCardless", + detail: "We'll collect via GoCardless. Protected by the Direct Debit Guarantee.", + fee: "1% + 20p", + feeColor: "text-muted-foreground", }, { id: "card" as const, icon: CreditCard, - title: "Card Payment via Stripe", - subtitle: "Pay now by Visa, Mastercard, Amex", - tag: "Stripe", + title: "Debit or Credit Card", + subtitle: "Pay instantly by Visa, Mastercard, or Amex", + tag: "Instant", tagColor: "bg-purple-100 text-purple-700", - detail: "Secure payment powered by Stripe", - }, - { - id: "fpx" as const, - icon: Globe, - title: "FPX Online Banking", - subtitle: "Pay via Malaysian bank account", - tag: "Malaysia", - tagColor: "bg-amber-500/10 text-amber-700", - detail: "Instant payment from 18 Malaysian banks", + detail: "Secure payment powered by Stripe. Receipt emailed immediately.", + fee: "1.4% + 20p", + feeColor: "text-muted-foreground", }, ] @@ -56,7 +53,7 @@ export function PaymentStep({ onSelect, amount }: Props) { How would you like to pay?

- Pledge: £{pounds} + Your pledge: £{pounds}

@@ -72,16 +69,17 @@ export function PaymentStep({ onSelect, amount }: Props) {
-
+
{opt.title} - {opt.tag && ( - - {opt.tag} - - )} + + {opt.tag} +

{opt.subtitle}

{opt.detail}

+

+ Fee: {opt.fee} +

→ @@ -90,6 +88,10 @@ export function PaymentStep({ onSelect, amount }: Props) { ))}
+ +

+ All payments are secure. Bank transfers mean 100% reaches the charity. +

) } diff --git a/pledge-now-pay-later/src/app/page.tsx b/pledge-now-pay-later/src/app/page.tsx index 43d02d7..2cb02b2 100644 --- a/pledge-now-pay-later/src/app/page.tsx +++ b/pledge-now-pay-later/src/app/page.tsx @@ -1,6 +1,6 @@ import Link from "next/link" import { Button } from "@/components/ui/button" -import { CreditCard, Landmark, Building2, Globe, QrCode, BarChart3, Bell, Download } from "lucide-react" +import { CreditCard, Landmark, Building2, QrCode, BarChart3, Bell, Download, Users, Gift, MessageCircle, Share2, Smartphone } from "lucide-react" export default function Home() { return ( @@ -17,7 +17,7 @@ export default function Home() { Pay Later

- Turn "I'll donate later" into tracked pledges with automatic payment follow-up. Zero fees on bank transfers. + Turn "I'll donate later" into tracked pledges with automatic follow-up. Built for UK charity fundraising events.

@@ -38,25 +38,50 @@ export default function Home() {
Pledge time
-
85%+
-
Collection rate
+
+25%
+
Gift Aid boost
+ {/* Who is this for? */} +
+
+

Built for everyone in your fundraising chain

+

From the charity manager to the donor's phone

+
+ {[ + { icon: BarChart3, title: "Charity Managers", desc: "Live dashboard, bank reconciliation, Gift Aid reports. See every pound from pledge to collection.", color: "text-trust-blue" }, + { icon: Users, title: "Volunteers", desc: "Personal QR codes, leaderboard, own pledge tracker. Know exactly who pledged at your table.", color: "text-warm-amber" }, + { icon: Smartphone, title: "Donors", desc: "15-second pledge on your phone. Clear bank details, copy buttons, reminders until paid.", color: "text-success-green" }, + { icon: Share2, title: "Personal Fundraisers", desc: "Share your pledge link on WhatsApp. Track friends and family pledges with a progress bar.", color: "text-purple-600" }, + ].map((p, i) => ( +
+ +

{p.title}

+

{p.desc}

+
+ ))} +
+
+
+ {/* Payment Methods */} -
-

4 Payment Rails, One Platform

-
+
+

3 UK Payment Rails, One Platform

+

Every method a UK donor expects

+
{[ - { icon: Building2, title: "Bank Transfer", desc: "Zero fees — 100% to charity", color: "text-success-green" }, - { icon: Landmark, title: "Direct Debit", desc: "GoCardless auto-collection", color: "text-trust-blue" }, - { icon: CreditCard, title: "Card via Stripe", desc: "Visa, Mastercard, Amex", color: "text-purple-600" }, - { icon: Globe, title: "FPX Banking", desc: "Malaysian online banking", color: "text-amber-600" }, + { icon: Building2, title: "Bank Transfer", desc: "Zero fees — 100% to charity. Unique reference for auto-matching.", color: "text-success-green", tag: "0% fees" }, + { icon: Landmark, title: "Direct Debit", desc: "GoCardless auto-collection. Protected by the Direct Debit Guarantee.", color: "text-trust-blue", tag: "Set & forget" }, + { icon: CreditCard, title: "Card via Stripe", desc: "Visa, Mastercard, Amex. Instant payment and receipt.", color: "text-purple-600", tag: "Instant" }, ].map((m, i) => ( -
- +
+ + {m.tag}

{m.title}

{m.desc}

@@ -67,13 +92,17 @@ export default function Home() { {/* Features */}
-

Everything You Need

+

Everything a UK charity needs

{[ - { icon: QrCode, title: "QR Codes", desc: "Per-table, per-volunteer attribution tracking" }, - { icon: BarChart3, title: "Live Dashboard", desc: "Real-time pledge pipeline with auto-refresh" }, - { icon: Bell, title: "Smart Reminders", desc: "4-step follow-up sequence via email/SMS" }, - { icon: Download, title: "Bank Reconciliation", desc: "CSV import, auto-match by reference" }, + { icon: QrCode, title: "QR Attribution", desc: "Per-table, per-volunteer tracking. Know who raised what." }, + { icon: Gift, title: "Gift Aid Built In", desc: "One-tap declaration. HMRC-ready export. +25% on every eligible pledge." }, + { icon: Bell, title: "Smart Reminders", desc: "Automated follow-up via email and SMS until the pledge is paid." }, + { icon: Download, title: "Bank Reconciliation", desc: "Upload your CSV statement. Auto-match by unique reference." }, + { icon: MessageCircle, title: "WhatsApp Sharing", desc: "Donors share their pledge with friends. Viral fundraising built in." }, + { icon: Users, title: "Volunteer Portal", desc: "Each volunteer sees their own pledges and conversion rate." }, + { icon: BarChart3, title: "Live Dashboard", desc: "Real-time ticker during events. Pipeline from pledge to payment." }, + { icon: Share2, title: "Fundraiser Pages", desc: "Shareable links with progress bars. Perfect for personal campaigns." }, ].map((f, i) => (
@@ -86,8 +115,9 @@ export default function Home() {
{/* Footer */} -
-

Pledge Now, Pay Later — Built for UK charities.

+
+

Pledge Now, Pay Later — Built for UK charities by QuikCue.

+

Free forever. No hidden fees. No card required.

) diff --git a/pledge-now-pay-later/src/app/v/[code]/page.tsx b/pledge-now-pay-later/src/app/v/[code]/page.tsx new file mode 100644 index 0000000..25bdffd --- /dev/null +++ b/pledge-now-pay-later/src/app/v/[code]/page.tsx @@ -0,0 +1,216 @@ +"use client" + +import { useState, useEffect } from "react" +import { useParams } from "next/navigation" +import { Card, CardContent } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { MessageCircle, Share2, QrCode, TrendingUp, Users, Banknote, Loader2 } from "lucide-react" + +interface VolunteerData { + qrSource: { + label: string + volunteerName: string | null + code: string + scanCount: number + } + event: { + name: string + organizationName: string + } + pledges: Array<{ + id: string + reference: string + amountPence: number + status: string + donorName: string | null + createdAt: string + giftAid: boolean + }> + stats: { + totalPledges: number + totalPledgedPence: number + totalPaidPence: number + conversionRate: number + } +} + +const statusColors: Record = { + new: "secondary", + initiated: "warning", + paid: "success", + overdue: "destructive", +} + +export default function VolunteerPage() { + const params = useParams() + const code = params.code as string + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState("") + + useEffect(() => { + fetch(`/api/qr/${code}/volunteer`) + .then((r) => r.json()) + .then((d) => { + if (d.error) setError(d.error) + else setData(d) + }) + .catch(() => setError("Failed to load")) + .finally(() => setLoading(false)) + + const interval = setInterval(() => { + fetch(`/api/qr/${code}/volunteer`) + .then((r) => r.json()) + .then((d) => { if (!d.error) setData(d) }) + .catch(() => {}) + }, 15000) + return () => clearInterval(interval) + }, [code]) + + const handleWhatsAppShare = () => { + if (!data) return + const url = `${window.location.origin}/p/${code}` + const text = `🤲 Pledge to ${data.event.name}!\n\nMake a pledge here — it only takes 15 seconds:\n${url}` + window.open(`https://wa.me/?text=${encodeURIComponent(text)}`, "_blank") + } + + const handleShare = async () => { + if (!data) return + const url = `${window.location.origin}/p/${code}` + if (navigator.share) { + await navigator.share({ title: data.event.name, text: `Pledge to ${data.event.name}`, url }) + } else { + handleWhatsAppShare() + } + } + + const formatPence = (p: number) => `£${(p / 100).toLocaleString("en-GB", { minimumFractionDigits: 0 })}` + + if (loading) { + return ( +
+ +
+ ) + } + + if (error || !data) { + return ( +
+
+ +

QR code not found

+

{error}

+
+
+ ) + } + + return ( +
+
+ {/* Header */} +
+

{data.event.organizationName}

+

{data.event.name}

+

+ {data.qrSource.volunteerName || data.qrSource.label}'s Pledges +

+
+ + {/* Stats */} +
+ + + +

{data.stats.totalPledges}

+

Pledges

+
+
+ + + +

{formatPence(data.stats.totalPledgedPence)}

+

Pledged

+
+
+ + + +

{formatPence(data.stats.totalPaidPence)}

+

Paid

+
+
+
+ + {/* Progress bar */} + {data.stats.totalPledgedPence > 0 && ( +
+
+ Collection progress + {Math.round((data.stats.totalPaidPence / data.stats.totalPledgedPence) * 100)}% +
+
+
+
+
+ )} + + {/* Share buttons */} +
+ + +
+ + {/* Pledge list */} +
+

+ My Pledges ({data.pledges.length}) +

+ {data.pledges.length === 0 ? ( + + +

No pledges yet. Share your link to start collecting!

+
+
+ ) : ( + data.pledges.map((p) => ( + + +
+
+

{p.donorName || "Anonymous"}

+

+ {new Date(p.createdAt).toLocaleDateString("en-GB", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" })} + {p.giftAid && " · 🎁 Gift Aid"} +

+
+
+ {formatPence(p.amountPence)} + + {p.status === "paid" ? "Paid ✓" : p.status} + +
+
+
+
+ )) + )} +
+ + {/* Auto-refresh indicator */} +

+ Updates automatically every 15 seconds +

+
+
+ ) +} diff --git a/pledge-now-pay-later/src/components/live-ticker.tsx b/pledge-now-pay-later/src/components/live-ticker.tsx new file mode 100644 index 0000000..3b10f29 --- /dev/null +++ b/pledge-now-pay-later/src/components/live-ticker.tsx @@ -0,0 +1,120 @@ +"use client" + +import { useState, useEffect } from "react" +import { Badge } from "@/components/ui/badge" +import { Banknote, TrendingUp, Radio } from "lucide-react" + +interface TickerPledge { + id: string + donorName: string | null + amountPence: number + status: string + rail: string + createdAt: string + qrSourceLabel: string | null + giftAid: boolean +} + +interface LiveTickerProps { + eventId: string +} + +const formatPence = (p: number) => `£${(p / 100).toLocaleString("en-GB", { minimumFractionDigits: 0 })}` + +export function LiveTicker({ eventId }: LiveTickerProps) { + const [pledges, setPledges] = useState([]) + const [isLive, setIsLive] = useState(false) + + useEffect(() => { + const load = async () => { + try { + const res = await fetch(`/api/pledges?eventId=${eventId}&limit=10`) + const data = await res.json() + if (data.pledges) { + setPledges(data.pledges.map((p: Record) => ({ + id: p.id, + donorName: p.donorName, + amountPence: p.amountPence, + status: p.status, + rail: p.rail, + createdAt: p.createdAt, + qrSourceLabel: p.qrSourceLabel, + giftAid: p.giftAid, + }))) + setIsLive(true) + } + } catch { + setIsLive(false) + } + } + load() + const interval = setInterval(load, 8000) + return () => clearInterval(interval) + }, [eventId]) + + if (pledges.length === 0) return null + + const totalToday = pledges.reduce((s, p) => s + p.amountPence, 0) + + return ( +
+
+

+ Live Feed +

+
+ + {formatPence(totalToday)} recent + + {isLive && ( + + Live + + )} +
+
+ +
+ {pledges.map((p, i) => { + const name = p.donorName || "Anonymous" + const initials = name.split(" ").map(w => w[0]).join("").slice(0, 2).toUpperCase() + const ago = formatTimeAgo(p.createdAt) + + return ( +
+
+ {initials} +
+
+
+ {name} + {p.giftAid && 🎁} +
+

+ {p.qrSourceLabel && `${p.qrSourceLabel} · `}{ago} +

+
+
+ + {formatPence(p.amountPence)} +
+
+ ) + })} +
+
+ ) +} + +function formatTimeAgo(dateStr: string) { + const diff = Date.now() - new Date(dateStr).getTime() + const mins = Math.floor(diff / 60000) + if (mins < 1) return "Just now" + if (mins < 60) return `${mins}m ago` + const hrs = Math.floor(mins / 60) + if (hrs < 24) return `${hrs}h ago` + return `${Math.floor(hrs / 24)}d ago` +}