feat: remove FPX, add UK charity persona features
- Remove FPX payment rail entirely (Malaysian, not UK) - Add volunteer portal (/v/[code]) with live pledge tracking - Add public event page (/e/[slug]) with progress bar + social proof - Add fundraiser leaderboard (/dashboard/events/[id]/leaderboard) - Add WhatsApp share buttons on confirmation, bank instructions, volunteer view - Enhanced Gift Aid UX with +25% bonus display and HMRC declaration text - Gift Aid report export (HMRC-ready CSV filter) - Volunteer view link + WhatsApp share on QR code cards - Updated home page: 4 personas, 3 UK payment rails, 8 features - Public event API endpoint with privacy-safe donor name truncation - Volunteer API with stats, conversion rate, auto-refresh
This commit is contained in:
17
.gitignore
vendored
17
.gitignore
vendored
@@ -1,16 +1,7 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
__pycache__/
|
|
||||||
*.pyc
|
|
||||||
.DS_Store
|
|
||||||
*.swp
|
|
||||||
*.swo
|
|
||||||
|
|
||||||
# API keys — never commit real credentials
|
|
||||||
.env
|
|
||||||
|
|
||||||
.pi/agent-sessions/
|
.pi/agent-sessions/
|
||||||
|
calvana.tar.gz
|
||||||
|
*.tmp
|
||||||
|
nul
|
||||||
|
.env
|
||||||
.playwright-cli/
|
.playwright-cli/
|
||||||
|
|
||||||
tmp/
|
|
||||||
719
.pi/extensions/calvana-shiplog.ts
Normal file
719
.pi/extensions/calvana-shiplog.ts
Normal file
@@ -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<void>((_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 ` <div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="card-title">${escapeHtml(s.title)}${titleSuffix}</span>
|
||||||
|
<span class="badge ${badgeClass}">${badgeLabel}</span>
|
||||||
|
</div>
|
||||||
|
<p class="card-meta">⏱ ${escapeHtml(s.timestamp)}</p>
|
||||||
|
<p class="metric">What moved: ${escapeHtml(s.metric)}</p>
|
||||||
|
<div class="card-links"><a href="${escapeHtml(s.prLink)}">PR</a><a href="${escapeHtml(s.deployLink)}">Deploy</a><a href="${escapeHtml(s.loomLink)}">Loom clip</a></div>
|
||||||
|
</div>`;
|
||||||
|
}).join("\n");
|
||||||
|
|
||||||
|
const oopsEntries = state.oops.map(o => {
|
||||||
|
return ` <div class="oops-entry">
|
||||||
|
<span>${escapeHtml(o.description)}${o.fixTime !== "—" ? ` Fixed in ${escapeHtml(o.fixTime)}.` : ""}</span>
|
||||||
|
<a href="${escapeHtml(o.commitLink)}">→ commit</a>
|
||||||
|
</div>`;
|
||||||
|
}).join("\n");
|
||||||
|
|
||||||
|
// If no ships yet, show placeholder
|
||||||
|
const shipsSection = state.ships.length > 0 ? shipCards : ` <div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="card-title">Warming up...</span>
|
||||||
|
<span class="badge badge-planned">Planned</span>
|
||||||
|
</div>
|
||||||
|
<p class="card-meta">⏱ —</p>
|
||||||
|
<p class="metric">What moved: —</p>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const oopsSection = state.oops.length > 0 ? oopsEntries : ` <div class="oops-entry">
|
||||||
|
<span>Nothing broken yet. Give it time.</span>
|
||||||
|
<a href="#commit">→ waiting</a>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Calvana — Live Shipping Log</title>
|
||||||
|
<meta name="description" content="Intentional chaos. Full receipts. Watch the build happen in real time.">
|
||||||
|
<meta property="og:title" content="Calvana — Live Shipping Log">
|
||||||
|
<meta property="og:description" content="Intentional chaos. Full receipts.">
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta property="og:url" content="https://${DEPLOY_CONFIG.domain}/live">
|
||||||
|
<meta name="twitter:card" content="summary">
|
||||||
|
<link rel="canonical" href="https://${DEPLOY_CONFIG.domain}/live">
|
||||||
|
<link rel="stylesheet" href="/css/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav>
|
||||||
|
<div class="nav-inner">
|
||||||
|
<a href="/" class="logo">calvana<span>.exe</span></a>
|
||||||
|
<div class="nav-links">
|
||||||
|
<a href="/manifesto">/manifesto</a>
|
||||||
|
<a href="/live" class="active">/live</a>
|
||||||
|
<a href="/hire">/hire</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<main class="page">
|
||||||
|
<h1 class="hero-title">Live Shipping Log</h1>
|
||||||
|
<p class="subtitle">Intentional chaos. Full receipts.</p>
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<h2>Today's Ships</h2>
|
||||||
|
<div class="card-grid">
|
||||||
|
${shipsSection}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<div class="two-col">
|
||||||
|
<div class="col col-broke">
|
||||||
|
<h3>Rules I broke today</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Didn't ask permission</li>
|
||||||
|
<li>Didn't wait for alignment</li>
|
||||||
|
<li>Didn't write a PRD</li>
|
||||||
|
<li>Didn't submit a normal application</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="col col-kept">
|
||||||
|
<h3>Rules I refuse to break</h3>
|
||||||
|
<ul>
|
||||||
|
<li>No silent failures</li>
|
||||||
|
<li>No unbounded AI spend</li>
|
||||||
|
<li>No hallucinations shipped to users</li>
|
||||||
|
<li>No deploy without rollback path</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<h2>Oops Log</h2>
|
||||||
|
<p class="subtitle" style="margin-bottom:1rem">If it's not here, I haven't broken it yet.</p>
|
||||||
|
<div class="oops-log">
|
||||||
|
${oopsSection}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<p class="footer-tagline">${SITE_CONFIG.tagline}</p>
|
||||||
|
<p style="margin-top:.4rem">Last updated: ${now}</p>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str: string): string {
|
||||||
|
return str
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
@@ -1,8 +1,5 @@
|
|||||||
# Pi vs CC — Extension Playground
|
# 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.
|
Pi Coding Agent extension examples and experiments.
|
||||||
|
|
||||||
## Tooling
|
## Tooling
|
||||||
|
|||||||
4
calvana-build/Dockerfile
Normal file
4
calvana-build/Dockerfile
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
FROM nginx:alpine
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY html/ /usr/share/nginx/html/
|
||||||
|
EXPOSE 80
|
||||||
26
calvana-build/html/404.html
Normal file
26
calvana-build/html/404.html
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>404 — Calvana</title>
|
||||||
|
<link rel="stylesheet" href="/css/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav>
|
||||||
|
<div class="nav-inner">
|
||||||
|
<a href="/" class="logo">calvana<span>.exe</span></a>
|
||||||
|
<div class="nav-links">
|
||||||
|
<a href="/manifesto">/manifesto</a>
|
||||||
|
<a href="/live">/live</a>
|
||||||
|
<a href="/hire">/hire</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<main class="page" style="text-align:center;padding-top:6rem">
|
||||||
|
<h1 class="hero-title">404</h1>
|
||||||
|
<p class="subtitle" style="margin:1rem auto">This page doesn't exist yet. But give me 10 minutes.</p>
|
||||||
|
<a href="/manifesto" class="btn btn-outline" style="margin-top:1.5rem">← Back to manifesto</a>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
104
calvana-build/html/css/style.css
Normal file
104
calvana-build/html/css/style.css
Normal file
@@ -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}
|
||||||
|
}
|
||||||
43
calvana-build/html/hire/index.html
Normal file
43
calvana-build/html/hire/index.html
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Calvana — Hire</title>
|
||||||
|
<meta name="description" content="If you want velocity with control — let's talk.">
|
||||||
|
<meta property="og:title" content="Calvana — Hire">
|
||||||
|
<meta property="og:description" content="If you want velocity with control — let's talk.">
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta property="og:url" content="https://calvana.quikcue.com/hire">
|
||||||
|
<meta name="twitter:card" content="summary">
|
||||||
|
<link rel="canonical" href="https://calvana.quikcue.com/hire">
|
||||||
|
<link rel="stylesheet" href="/css/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav>
|
||||||
|
<div class="nav-inner">
|
||||||
|
<a href="/" class="logo">calvana<span>.exe</span></a>
|
||||||
|
<div class="nav-links">
|
||||||
|
<a href="/manifesto">/manifesto</a>
|
||||||
|
<a href="/live">/live</a>
|
||||||
|
<a href="/hire" class="active">/hire</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<main class="page">
|
||||||
|
<h1 class="hero-title">If you're reading this,<br>you <span class="accent">already know.</span></h1>
|
||||||
|
<div class="hire-ctas">
|
||||||
|
<a href="mailto:omair@quikcue.com?subject=Calvana%20-%20Repo%20%2B%20Metric" class="btn btn-cta">Give me a repo + one metric →</a>
|
||||||
|
<a href="mailto:omair@quikcue.com?subject=Calvana%20-%207-day%20proof" class="btn btn-primary">Give me 7 days. I'll prove it. →</a>
|
||||||
|
</div>
|
||||||
|
<p class="hire-note">
|
||||||
|
If you want safe hands, hire safe people.<br>
|
||||||
|
If you want velocity with control — let's talk.
|
||||||
|
</p>
|
||||||
|
<p class="hire-referral">PS — Umar pointed me here. If this turns into a hire, I want him to get paid.</p>
|
||||||
|
<footer>
|
||||||
|
<p class="footer-tagline">I break rules. Not production.</p>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
5
calvana-build/html/index.html
Normal file
5
calvana-build/html/index.html
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head><meta charset="utf-8"><meta http-equiv="refresh" content="0;url=/manifesto"><title>Calvana</title></head>
|
||||||
|
<body><p>Redirecting to <a href="/manifesto">/manifesto</a>…</p></body>
|
||||||
|
</html>
|
||||||
121
calvana-build/html/live/index.html
Normal file
121
calvana-build/html/live/index.html
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Calvana — Live Shipping Log</title>
|
||||||
|
<meta name="description" content="Intentional chaos. Full receipts. Watch the build happen in real time.">
|
||||||
|
<meta property="og:title" content="Calvana — Live Shipping Log">
|
||||||
|
<meta property="og:description" content="Intentional chaos. Full receipts.">
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta property="og:url" content="https://calvana.quikcue.com/live">
|
||||||
|
<meta name="twitter:card" content="summary">
|
||||||
|
<link rel="canonical" href="https://calvana.quikcue.com/live">
|
||||||
|
<link rel="stylesheet" href="/css/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav>
|
||||||
|
<div class="nav-inner">
|
||||||
|
<a href="/" class="logo">calvana<span>.exe</span></a>
|
||||||
|
<div class="nav-links">
|
||||||
|
<a href="/manifesto">/manifesto</a>
|
||||||
|
<a href="/live" class="active">/live</a>
|
||||||
|
<a href="/hire">/hire</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<main class="page">
|
||||||
|
<h1 class="hero-title">Live Shipping Log</h1>
|
||||||
|
<p class="subtitle">Intentional chaos. Full receipts.</p>
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<h2>Today's Ships</h2>
|
||||||
|
<div class="card-grid">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="card-title">Fix post-donation consent funnel (Email + WhatsApp)</span>
|
||||||
|
<span class="badge badge-planned">Planned</span>
|
||||||
|
</div>
|
||||||
|
<p class="card-meta">⏱ —</p>
|
||||||
|
<p class="metric">What moved: —</p>
|
||||||
|
<div class="card-links"><a href="#pr">PR</a><a href="#deploy">Deploy</a><a href="#loomclip">Loom clip</a></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="card-title">Deploy pledge-now-pay-later micro-saas</span>
|
||||||
|
<span class="badge badge-planned">Planned</span>
|
||||||
|
</div>
|
||||||
|
<p class="card-meta">⏱ —</p>
|
||||||
|
<p class="metric">What moved: —</p>
|
||||||
|
<div class="card-links"><a href="#pr">PR</a><a href="#deploy">Deploy</a><a href="#loomclip">Loom clip</a></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="card-title">JustVitamin post-migration AI automation demos</span>
|
||||||
|
<span class="badge badge-shipping">Shipping</span>
|
||||||
|
</div>
|
||||||
|
<p class="card-meta">⏱ —</p>
|
||||||
|
<p class="metric">What moved: —</p>
|
||||||
|
<div class="card-links"><a href="#pr">PR</a><a href="#deploy">Deploy</a><a href="#loomclip">Loom clip</a></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="card-title">This Calvana application — shipped ✓</span>
|
||||||
|
<span class="badge badge-shipped">Shipped</span>
|
||||||
|
</div>
|
||||||
|
<p class="card-meta">⏱ 2026-03-02 14:00 GMT+8</p>
|
||||||
|
<p class="metric">What moved: 0 → live in one session</p>
|
||||||
|
<div class="card-links"><a href="#pr">PR</a><a href="#deploy">Deploy</a><a href="#loomclip">Loom clip</a></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<div class="two-col">
|
||||||
|
<div class="col col-broke">
|
||||||
|
<h3>Rules I broke today</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Didn't ask permission</li>
|
||||||
|
<li>Didn't wait for alignment</li>
|
||||||
|
<li>Didn't write a PRD</li>
|
||||||
|
<li>Didn't submit a normal application</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="col col-kept">
|
||||||
|
<h3>Rules I refuse to break</h3>
|
||||||
|
<ul>
|
||||||
|
<li>No silent failures</li>
|
||||||
|
<li>No unbounded AI spend</li>
|
||||||
|
<li>No hallucinations shipped to users</li>
|
||||||
|
<li>No deploy without rollback path</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<h2>Oops Log</h2>
|
||||||
|
<p class="subtitle" style="margin-bottom:1rem">If it's not here, I haven't broken it yet.</p>
|
||||||
|
<div class="oops-log">
|
||||||
|
<div class="oops-entry">
|
||||||
|
<span>Traefik label typo → 404 on first deploy. Fixed in 3 min.</span>
|
||||||
|
<a href="#commit">→ commit</a>
|
||||||
|
</div>
|
||||||
|
<div class="oops-entry">
|
||||||
|
<span>CSS grid overflow on mobile. Caught in preview, fixed before push.</span>
|
||||||
|
<a href="#commit">→ commit</a>
|
||||||
|
</div>
|
||||||
|
<div class="oops-entry">
|
||||||
|
<span>Forgot meta viewport tag. Pinch-zoom chaos. Fixed in 90 seconds.</span>
|
||||||
|
<a href="#commit">→ commit</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<p class="footer-tagline">I break rules. Not production.</p>
|
||||||
|
<p style="margin-top:.4rem">Last updated: 2026-03-02T14:00:00+08:00</p>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
47
calvana-build/html/manifesto/index.html
Normal file
47
calvana-build/html/manifesto/index.html
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Calvana — I don't apply. I deploy.</title>
|
||||||
|
<meta name="description" content="Most applications prove the past. This one proves the next 7 days. Built live. Shipped now.">
|
||||||
|
<meta property="og:title" content="Calvana — I don't apply. I deploy.">
|
||||||
|
<meta property="og:description" content="You're hiring engineers. I'm showing you what changes when you hire an engine.">
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta property="og:url" content="https://calvana.quikcue.com/manifesto">
|
||||||
|
<meta name="twitter:card" content="summary">
|
||||||
|
<link rel="canonical" href="https://calvana.quikcue.com/manifesto">
|
||||||
|
<link rel="stylesheet" href="/css/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav>
|
||||||
|
<div class="nav-inner">
|
||||||
|
<a href="/" class="logo">calvana<span>.exe</span></a>
|
||||||
|
<div class="nav-links">
|
||||||
|
<a href="/manifesto" class="active">/manifesto</a>
|
||||||
|
<a href="/live">/live</a>
|
||||||
|
<a href="/hire">/hire</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<main class="page">
|
||||||
|
<h1 class="hero-title">I don't apply.<br><span class="accent">I deploy.</span></h1>
|
||||||
|
<p class="subtitle">
|
||||||
|
You're hiring engineers. I'm showing you what changes when you hire an engine.<br>
|
||||||
|
This application is a product. Built for you. Right now.
|
||||||
|
</p>
|
||||||
|
<div class="btn-row">
|
||||||
|
<a href="#loom" class="btn btn-primary" id="loom">▶ Watch the build (Loom)</a>
|
||||||
|
<a href="/live" class="btn btn-outline">◉ Open the live shipping log</a>
|
||||||
|
<a href="#repo" class="btn btn-outline" id="repo">↗ Open the repo</a>
|
||||||
|
</div>
|
||||||
|
<p class="manifesto-para">
|
||||||
|
Most applications prove the past. I'm proving the next 7 days.<br>
|
||||||
|
Build → test → ship → observe → iterate… at speed, without breaking reality.
|
||||||
|
</p>
|
||||||
|
<footer>
|
||||||
|
<p class="footer-tagline">I break rules. Not production.</p>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
28
calvana-build/nginx.conf
Normal file
28
calvana-build/nginx.conf
Normal file
@@ -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;
|
||||||
|
}
|
||||||
15
justfile
15
justfile
@@ -1,4 +1,5 @@
|
|||||||
set dotenv-load := true
|
set dotenv-load := true
|
||||||
|
set shell := ["pwsh", "-NoProfile", "-Command"]
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@just --list
|
@just --list
|
||||||
@@ -87,15 +88,11 @@ ext-theme-cycler:
|
|||||||
|
|
||||||
# Open pi with one or more stacked extensions in a new terminal: just open minimal tool-counter
|
# Open pi with one or more stacked extensions in a new terminal: just open minimal tool-counter
|
||||||
open +exts:
|
open +exts:
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env pwsh
|
||||||
args=""
|
$args_str = ""
|
||||||
for ext in {{exts}}; do
|
foreach ($ext in "{{exts}}".Split(" ")) { $args_str += " -e extensions/$ext.ts" }
|
||||||
args="$args -e extensions/$ext.ts"
|
$cmd = "cd '{{justfile_directory()}}'; pi$args_str"
|
||||||
done
|
Start-Process wt -ArgumentList "pwsh", "-NoExit", "-Command", $cmd
|
||||||
cmd="cd '{{justfile_directory()}}' && pi$args"
|
|
||||||
escaped="${cmd//\\/\\\\}"
|
|
||||||
escaped="${escaped//\"/\\\"}"
|
|
||||||
osascript -e "tell application \"Terminal\" to do script \"$escaped\""
|
|
||||||
|
|
||||||
# Open every extension in its own terminal window
|
# Open every extension in its own terminal window
|
||||||
all:
|
all:
|
||||||
|
|||||||
0
pledge-now-pay-later/public/.gitkeep
Normal file
0
pledge-now-pay-later/public/.gitkeep
Normal file
@@ -51,6 +51,9 @@ export async function GET(
|
|||||||
scanCount: s.scanCount,
|
scanCount: s.scanCount,
|
||||||
pledgeCount: s._count.pledges,
|
pledgeCount: s._count.pledges,
|
||||||
totalPledged: s.pledges.reduce((sum: number, p: QrPledge) => sum + p.amountPence, 0),
|
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,
|
createdAt: s.createdAt,
|
||||||
}))
|
}))
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,11 +30,13 @@ export async function GET(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: "Organization not found" }, { status: 404 })
|
return NextResponse.json({ error: "Organization not found" }, { status: 404 })
|
||||||
}
|
}
|
||||||
const eventId = request.nextUrl.searchParams.get("eventId")
|
const eventId = request.nextUrl.searchParams.get("eventId")
|
||||||
|
const giftAidOnly = request.nextUrl.searchParams.get("giftAidOnly") === "true"
|
||||||
|
|
||||||
const pledges = await prisma.pledge.findMany({
|
const pledges = await prisma.pledge.findMany({
|
||||||
where: {
|
where: {
|
||||||
organizationId: orgId,
|
organizationId: orgId,
|
||||||
...(eventId ? { eventId } : {}),
|
...(eventId ? { eventId } : {}),
|
||||||
|
...(giftAidOnly ? { giftAid: true } : {}),
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
event: { select: { name: true } },
|
event: { select: { name: true } },
|
||||||
@@ -65,10 +67,14 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
const csv = formatCrmExportCsv(rows)
|
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, {
|
return new NextResponse(csv, {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "text/csv",
|
"Content-Type": "text/csv",
|
||||||
"Content-Disposition": `attachment; filename="crm-export-${new Date().toISOString().slice(0, 10)}.csv"`,
|
"Content-Disposition": `attachment; filename="${fileName}"`,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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<LeaderEntry[]>([])
|
||||||
|
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 (
|
||||||
|
<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>
|
||||||
|
<Link href={`/dashboard/events/${eventId}`} className="text-sm text-muted-foreground hover:text-foreground inline-flex items-center gap-1 mb-2">
|
||||||
|
<ArrowLeft className="h-3 w-3" /> Back to Event
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-3xl font-extrabold text-gray-900 flex items-center gap-3">
|
||||||
|
<Trophy className="h-8 w-8 text-warm-amber" /> Fundraiser Leaderboard
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground mt-1">Auto-refreshes every 10 seconds — perfect for live events</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{entries.map((entry, i) => (
|
||||||
|
<Card key={i} className={`${i < 3 ? "border-warm-amber/30 bg-warm-amber/5" : ""}`}>
|
||||||
|
<CardContent className="py-4 px-5">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="text-3xl w-12 text-center">
|
||||||
|
{i < 3 ? medals[i] : <span className="text-lg font-bold text-muted-foreground">#{i + 1}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-bold text-lg">{entry.volunteerName || entry.label}</p>
|
||||||
|
<div className="flex items-center gap-3 text-xs text-muted-foreground mt-1">
|
||||||
|
<span>{entry.pledgeCount} pledges</span>
|
||||||
|
<span>{entry.scanCount} scans</span>
|
||||||
|
<Badge variant={entry.conversionRate >= 50 ? "success" : "secondary"} className="text-xs">
|
||||||
|
{entry.conversionRate}% conversion
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-2xl font-extrabold text-trust-blue">{formatPence(entry.totalPledged)}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{formatPence(entry.totalPaid)} collected</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{entries.length === 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-12 text-center">
|
||||||
|
<Trophy className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||||
|
<p className="text-muted-foreground">No QR codes created yet. Create QR codes to see the leaderboard.</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import { Input } from "@/components/ui/input"
|
|||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Dialog, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
import { Dialog, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||||
import { formatPence } from "@/lib/utils"
|
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 Link from "next/link"
|
||||||
import { QRCodeCanvas } from "@/components/qr-code"
|
import { QRCodeCanvas } from "@/components/qr-code"
|
||||||
|
|
||||||
@@ -104,10 +104,17 @@ export default function EventQRPage() {
|
|||||||
{qrSources.length} QR code{qrSources.length !== 1 ? "s" : ""} · {totalScans} scans · {totalPledges} pledges · {formatPence(totalAmount)}
|
{qrSources.length} QR code{qrSources.length !== 1 ? "s" : ""} · {totalScans} scans · {totalPledges} pledges · {formatPence(totalAmount)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Link href={`/dashboard/events/${eventId}/leaderboard`}>
|
||||||
|
<Button variant="outline">
|
||||||
|
<Trophy className="h-4 w-4 mr-2" /> Leaderboard
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
<Button onClick={() => setShowCreate(true)}>
|
<Button onClick={() => setShowCreate(true)}>
|
||||||
<Plus className="h-4 w-4 mr-2" /> New QR Code
|
<Plus className="h-4 w-4 mr-2" /> New QR Code
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* QR Grid */}
|
{/* QR Grid */}
|
||||||
{qrSources.length === 0 ? (
|
{qrSources.length === 0 ? (
|
||||||
@@ -187,6 +194,26 @@ export default function EventQRPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Volunteer & share links */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<a href={`/v/${qr.code}`} target="_blank" className="flex-1">
|
||||||
|
<Button variant="outline" size="sm" className="w-full text-xs">
|
||||||
|
<Users className="h-3 w-3 mr-1" /> Volunteer View
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1 text-xs bg-[#25D366]/5 border-[#25D366]/30 text-[#25D366] hover:bg-[#25D366]/10"
|
||||||
|
onClick={() => {
|
||||||
|
const url = `${baseUrl}/p/${qr.code}`
|
||||||
|
const text = `Hi! Scan this to pledge: ${url}`
|
||||||
|
window.open(`https://wa.me/?text=${encodeURIComponent(text)}`, "_blank")
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MessageCircle className="h-3 w-3 mr-1" /> Share
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ interface EventSummary {
|
|||||||
|
|
||||||
export default function EventsPage() {
|
export default function EventsPage() {
|
||||||
const [events, setEvents] = useState<EventSummary[]>([])
|
const [events, setEvents] = useState<EventSummary[]>([])
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [showCreate, setShowCreate] = useState(false)
|
const [showCreate, setShowCreate] = useState(false)
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||||
import { Button } from "@/components/ui/button"
|
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() {
|
export default function ExportsPage() {
|
||||||
const handleCrmExport = () => {
|
const handleCrmExport = () => {
|
||||||
@@ -12,27 +12,34 @@ export default function ExportsPage() {
|
|||||||
a.click()
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-extrabold text-gray-900">Exports</h1>
|
<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>
|
<p className="text-muted-foreground mt-1">Export data for your CRM, HMRC, and automation tools</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid md:grid-cols-2 gap-4">
|
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-lg flex items-center gap-2">
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
<FileSpreadsheet className="h-5 w-5" /> CRM Export Pack
|
<FileSpreadsheet className="h-5 w-5" /> CRM Export Pack
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Download all pledges as CSV with full attribution data, ready to import into your CRM.
|
All pledges as CSV with full attribution data.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="text-sm text-muted-foreground space-y-1">
|
<div className="text-sm text-muted-foreground space-y-1">
|
||||||
<p>Includes:</p>
|
<p>Includes:</p>
|
||||||
<ul className="list-disc list-inside space-y-0.5">
|
<ul className="list-disc list-inside space-y-0.5 text-xs">
|
||||||
<li>Donor name, email, phone</li>
|
<li>Donor name, email, phone</li>
|
||||||
<li>Pledge amount and status</li>
|
<li>Pledge amount and status</li>
|
||||||
<li>Payment method and reference</li>
|
<li>Payment method and reference</li>
|
||||||
@@ -42,7 +49,37 @@ export default function ExportsPage() {
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={handleCrmExport} className="w-full">
|
<Button onClick={handleCrmExport} className="w-full">
|
||||||
<Download className="h-4 w-4 mr-2" /> Download CRM Pack (CSV)
|
<Download className="h-4 w-4 mr-2" /> Download CRM Pack
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-success-green/30">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<Gift className="h-5 w-5 text-success-green" /> Gift Aid Report
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
HMRC-ready Gift Aid declarations for tax reclaim.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="text-sm text-muted-foreground space-y-1">
|
||||||
|
<p>Includes only Gift Aid-eligible pledges:</p>
|
||||||
|
<ul className="list-disc list-inside space-y-0.5 text-xs">
|
||||||
|
<li>Donor full name (required by HMRC)</li>
|
||||||
|
<li>Donation amount and date</li>
|
||||||
|
<li>Gift Aid declaration status</li>
|
||||||
|
<li>Event and reference for audit trail</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl bg-success-green/5 border border-success-green/20 p-3">
|
||||||
|
<p className="text-xs text-success-green font-medium">
|
||||||
|
💷 Claim 25p for every £1 donated by a UK taxpayer
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleGiftAidExport} className="w-full bg-success-green hover:bg-success-green/90">
|
||||||
|
<Download className="h-4 w-4 mr-2" /> Download Gift Aid Report
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -50,23 +87,23 @@ export default function ExportsPage() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-lg flex items-center gap-2">
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
<Webhook className="h-5 w-5" /> Webhook Events
|
<Webhook className="h-5 w-5" /> Webhook / API
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Poll pending reminder events for external automation (Zapier, Make, n8n).
|
Connect to Zapier, Make, or n8n for automation.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="text-sm text-muted-foreground space-y-2">
|
<div className="text-sm text-muted-foreground space-y-2">
|
||||||
<p>Endpoint:</p>
|
<p>Reminder endpoint:</p>
|
||||||
<code className="block bg-gray-100 rounded-lg p-3 text-xs font-mono">
|
<code className="block bg-gray-100 rounded-lg p-3 text-xs font-mono break-all">
|
||||||
GET /api/webhooks?since=2025-01-01T00:00:00Z
|
GET /api/webhooks?since=2025-01-01
|
||||||
</code>
|
</code>
|
||||||
<p>Returns pending reminders with donor contact info and pledge details.</p>
|
<p className="text-xs">Returns pending reminders with donor contact info for external email/SMS.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl bg-trust-blue/5 border border-trust-blue/20 p-3">
|
<div className="rounded-xl bg-trust-blue/5 border border-trust-blue/20 p-3">
|
||||||
<p className="text-xs text-trust-blue font-medium">
|
<p className="text-xs text-trust-blue font-medium">
|
||||||
💡 Connect this to Zapier or Make to send emails/SMS automatically
|
💡 Connect to Zapier or n8n to send automatic reminder emails and SMS
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ function PledgesContent() {
|
|||||||
fetchPledges()
|
fetchPledges()
|
||||||
const interval = setInterval(fetchPledges, 15000)
|
const interval = setInterval(fetchPledges, 15000)
|
||||||
return () => clearInterval(interval)
|
return () => clearInterval(interval)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [eventId])
|
}, [eventId])
|
||||||
|
|
||||||
const handleStatusChange = async (pledgeId: string, newStatus: string) => {
|
const handleStatusChange = async (pledgeId: string, newStatus: string) => {
|
||||||
|
|||||||
261
pledge-now-pay-later/src/app/e/[slug]/page.tsx
Normal file
261
pledge-now-pay-later/src/app/e/[slug]/page.tsx
Normal file
@@ -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<EventData | null>(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 (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-trust-blue/5 via-white to-warm-amber/5">
|
||||||
|
<Loader2 className="h-8 w-8 text-trust-blue animate-spin" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !data) {
|
||||||
|
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">
|
||||||
|
<Heart className="h-12 w-12 text-muted-foreground mx-auto" />
|
||||||
|
<h1 className="text-xl font-bold">Event not found</h1>
|
||||||
|
<p className="text-muted-foreground">{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-trust-blue/5 via-white to-warm-amber/5">
|
||||||
|
<div className="max-w-lg mx-auto px-4 py-8 space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="text-center space-y-3">
|
||||||
|
<p className="text-sm text-muted-foreground">{data.organizationName}</p>
|
||||||
|
<h1 className="text-3xl font-extrabold text-gray-900">{data.name}</h1>
|
||||||
|
{data.description && <p className="text-muted-foreground">{data.description}</p>}
|
||||||
|
<div className="flex items-center justify-center gap-4 text-xs text-muted-foreground">
|
||||||
|
{data.eventDate && (
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<Calendar className="h-3 w-3" />
|
||||||
|
{new Date(data.eventDate).toLocaleDateString("en-GB", { day: "numeric", month: "long", year: "numeric" })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{data.location && (
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<MapPin className="h-3 w-3" /> {data.location}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress */}
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<CardContent className="pt-6 space-y-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-4xl font-extrabold text-trust-blue">{formatPence(data.stats.totalPledged)}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
pledged by {data.stats.pledgeCount} {data.stats.pledgeCount === 1 ? "person" : "people"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{progressPercent !== null && data.goalAmount && (
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between text-xs text-muted-foreground mb-1">
|
||||||
|
<span>{progressPercent}% of goal</span>
|
||||||
|
<span>{formatPence(data.goalAmount)}</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: `${progressPercent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-3 pt-2">
|
||||||
|
<div className="text-center">
|
||||||
|
<Users className="h-4 w-4 text-trust-blue mx-auto" />
|
||||||
|
<p className="font-bold text-sm">{data.stats.pledgeCount}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Pledges</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<Banknote className="h-4 w-4 text-success-green mx-auto" />
|
||||||
|
<p className="font-bold text-sm">{formatPence(data.stats.totalPaid)}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Collected</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<Gift className="h-4 w-4 text-warm-amber mx-auto" />
|
||||||
|
<p className="font-bold text-sm">{formatPence(giftAidBonus)}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Gift Aid</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Pledge CTA — link to first QR code */}
|
||||||
|
{data.qrCodes.length > 0 && (
|
||||||
|
<Button
|
||||||
|
size="xl"
|
||||||
|
className="w-full text-lg"
|
||||||
|
onClick={() => window.location.href = `/p/${data.qrCodes[0].code}`}
|
||||||
|
>
|
||||||
|
<Heart className="h-5 w-5 mr-2" /> Pledge Now
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Share */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button onClick={handleWhatsAppShare} className="flex-1 bg-[#25D366] hover:bg-[#20BD5A] text-white">
|
||||||
|
<MessageCircle className="h-4 w-4 mr-2" /> WhatsApp
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleShare} variant="outline" className="flex-1">
|
||||||
|
<Share2 className="h-4 w-4 mr-2" /> Share
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent pledges — social proof */}
|
||||||
|
{data.recentPledges.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h2 className="font-bold text-sm text-muted-foreground uppercase tracking-wider">
|
||||||
|
Recent Pledges
|
||||||
|
</h2>
|
||||||
|
{data.recentPledges.map((p, i) => {
|
||||||
|
const name = p.donorName || "Anonymous"
|
||||||
|
const ago = formatTimeAgo(p.createdAt)
|
||||||
|
return (
|
||||||
|
<div key={i} className="flex items-center gap-3 py-2 border-b last:border-0">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-trust-blue/10 flex items-center justify-center text-xs font-bold text-trust-blue">
|
||||||
|
{name[0].toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<span className="text-sm font-medium">{name}</span>
|
||||||
|
{p.giftAid && <span className="text-xs ml-1">🎁</span>}
|
||||||
|
<p className="text-xs text-muted-foreground">{ago}</p>
|
||||||
|
</div>
|
||||||
|
<span className="font-bold text-sm">{formatPence(p.amountPence)}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Volunteer QR codes */}
|
||||||
|
{data.qrCodes.length > 1 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h2 className="font-bold text-sm text-muted-foreground uppercase tracking-wider">
|
||||||
|
Pledge via a Fundraiser
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{data.qrCodes.map((qr) => (
|
||||||
|
<Button
|
||||||
|
key={qr.code}
|
||||||
|
variant="outline"
|
||||||
|
className="h-auto py-3 justify-start"
|
||||||
|
onClick={() => window.location.href = `/p/${qr.code}`}
|
||||||
|
>
|
||||||
|
<QrCode className="h-4 w-4 mr-2 shrink-0" />
|
||||||
|
<span className="truncate text-xs">{qr.volunteerName || qr.label}</span>
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-center text-xs text-muted-foreground">
|
||||||
|
Powered by <a href="/" className="text-trust-blue hover:underline">Pledge Now, Pay Later</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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`
|
||||||
|
}
|
||||||
@@ -8,10 +8,9 @@ import { IdentityStep } from "./steps/identity-step"
|
|||||||
import { ConfirmationStep } from "./steps/confirmation-step"
|
import { ConfirmationStep } from "./steps/confirmation-step"
|
||||||
import { BankInstructionsStep } from "./steps/bank-instructions-step"
|
import { BankInstructionsStep } from "./steps/bank-instructions-step"
|
||||||
import { CardPaymentStep } from "./steps/card-payment-step"
|
import { CardPaymentStep } from "./steps/card-payment-step"
|
||||||
import { FpxPaymentStep } from "./steps/fpx-payment-step"
|
|
||||||
import { DirectDebitStep } from "./steps/direct-debit-step"
|
import { DirectDebitStep } from "./steps/direct-debit-step"
|
||||||
|
|
||||||
export type Rail = "bank" | "gocardless" | "card" | "fpx"
|
export type Rail = "bank" | "gocardless" | "card"
|
||||||
|
|
||||||
export interface PledgeData {
|
export interface PledgeData {
|
||||||
amountPence: number
|
amountPence: number
|
||||||
@@ -30,18 +29,15 @@ interface EventInfo {
|
|||||||
qrSourceLabel: string | null
|
qrSourceLabel: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step indices:
|
// Steps:
|
||||||
// 0 = Amount selection
|
// 0 = Amount
|
||||||
// 1 = Payment method selection
|
// 1 = Payment method
|
||||||
// 2 = Identity (for bank transfer)
|
// 2 = Identity (for bank transfer)
|
||||||
// 3 = Bank instructions
|
// 3 = Bank instructions
|
||||||
// 4 = Confirmation (generic — card, DD, FPX)
|
// 4 = Confirmation (card, DD)
|
||||||
// 5 = Card payment step
|
// 5 = Card payment step
|
||||||
// 6 = FPX payment step
|
|
||||||
// 7 = Direct Debit 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() {
|
export default function PledgePage() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const token = params.token as string
|
const token = params.token as string
|
||||||
@@ -80,7 +76,6 @@ export default function PledgePage() {
|
|||||||
setError("Unable to load pledge page")
|
setError("Unable to load pledge page")
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
})
|
})
|
||||||
// Track pledge_start
|
|
||||||
fetch("/api/analytics", {
|
fetch("/api/analytics", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@@ -96,10 +91,9 @@ export default function PledgePage() {
|
|||||||
const handleRailSelected = (rail: Rail) => {
|
const handleRailSelected = (rail: Rail) => {
|
||||||
setPledgeData((d) => ({ ...d, rail }))
|
setPledgeData((d) => ({ ...d, rail }))
|
||||||
const railStepMap: Record<Rail, number> = {
|
const railStepMap: Record<Rail, number> = {
|
||||||
bank: 2, // → identity step → bank instructions
|
bank: 2,
|
||||||
card: 5, // → card payment step (combined identity + card)
|
card: 5,
|
||||||
fpx: 6, // → FPX step (bank selection + identity + redirect)
|
gocardless: 7,
|
||||||
gocardless: 7, // → direct debit step (bank details + mandate)
|
|
||||||
}
|
}
|
||||||
setStep(railStepMap[rail])
|
setStep(railStepMap[rail])
|
||||||
}
|
}
|
||||||
@@ -119,12 +113,8 @@ export default function PledgePage() {
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
const result = await res.json()
|
const result = await res.json()
|
||||||
if (result.error) {
|
if (result.error) { setError(result.error); return }
|
||||||
setError(result.error)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setPledgeResult(result)
|
setPledgeResult(result)
|
||||||
// Bank rail shows bank instructions; everything else shows generic confirmation
|
|
||||||
setStep(finalData.rail === "bank" ? 3 : 4)
|
setStep(finalData.rail === "bank" ? 3 : 4)
|
||||||
} catch {
|
} catch {
|
||||||
setError("Something went wrong. Please try again.")
|
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<number, React.ReactNode> = {
|
const steps: Record<number, React.ReactNode> = {
|
||||||
0: <AmountStep onSelect={handleAmountSelected} eventName={eventInfo?.name || ""} />,
|
0: <AmountStep onSelect={handleAmountSelected} eventName={eventInfo?.name || ""} />,
|
||||||
1: <PaymentStep onSelect={handleRailSelected} amount={pledgeData.amountPence} />,
|
1: <PaymentStep onSelect={handleRailSelected} amount={pledgeData.amountPence} />,
|
||||||
2: <IdentityStep onSubmit={submitPledge} />,
|
2: <IdentityStep onSubmit={submitPledge} amount={pledgeData.amountPence} />,
|
||||||
3: pledgeResult && <BankInstructionsStep pledge={pledgeResult} amount={pledgeData.amountPence} eventName={eventInfo?.name || ""} />,
|
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 || ""} />,
|
4: pledgeResult && <ConfirmationStep pledge={pledgeResult} amount={pledgeData.amountPence} rail={pledgeData.rail} eventName={eventInfo?.name || ""} shareUrl={shareUrl} />,
|
||||||
5: <CardPaymentStep amount={pledgeData.amountPence} eventName={eventInfo?.name || ""} eventId={eventInfo?.id || ""} qrSourceId={eventInfo?.qrSourceId || null} onComplete={submitPledge} />,
|
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} />,
|
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, 7])
|
||||||
const backableSteps = new Set([1, 2, 5, 6, 7])
|
const getBackStep = (s: number): number => {
|
||||||
const getBackStep = (current: number): number => {
|
if (s === 5 || s === 7) return 1
|
||||||
if (current in STEP_TO_RAIL) return STEP_TO_RAIL[current] // rail-specific steps → payment selection
|
return s - 1
|
||||||
return current - 1
|
|
||||||
}
|
}
|
||||||
|
const progressPercent = step >= 3 ? 100 : step >= 2 ? 66 : step >= 1 ? 33 : 10
|
||||||
// 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 (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-trust-blue/5 via-white to-warm-amber/5">
|
<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="fixed top-0 left-0 right-0 h-1 bg-gray-100 z-50">
|
||||||
<div
|
<div className="h-full bg-trust-blue transition-all duration-500 ease-out" style={{ width: `${progressPercent}%` }} />
|
||||||
className="h-full bg-trust-blue transition-all duration-500 ease-out"
|
|
||||||
style={{ width: `${progressPercent}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
<div className="pt-6 pb-2 px-4 text-center">
|
<div className="pt-6 pb-2 px-4 text-center">
|
||||||
<p className="text-sm text-muted-foreground">{eventInfo?.organizationName}</p>
|
<p className="text-sm text-muted-foreground">{eventInfo?.organizationName}</p>
|
||||||
<p className="text-xs text-muted-foreground/60">{eventInfo?.qrSourceLabel || ""}</p>
|
<p className="text-xs text-muted-foreground/60">{eventInfo?.qrSourceLabel || ""}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Step content */}
|
<div className="px-4 pb-8">{steps[step]}</div>
|
||||||
<div className="px-4 pb-8">
|
|
||||||
{steps[step]}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Back button */}
|
|
||||||
{backableSteps.has(step) && (
|
{backableSteps.has(step) && (
|
||||||
<div className="fixed bottom-6 left-4">
|
<div className="fixed bottom-6 left-4">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
import { Check, Copy, ExternalLink } from "lucide-react"
|
import { Check, Copy, ExternalLink, MessageCircle, Share2 } from "lucide-react"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
pledge: {
|
pledge: {
|
||||||
@@ -70,6 +70,35 @@ export function BankInstructionsStep({ pledge, amount, eventName }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
{/* Share CTA */}
|
||||||
|
<div className="rounded-2xl bg-warm-amber/5 border border-warm-amber/20 p-4 space-y-3 text-center">
|
||||||
|
<p className="text-sm font-semibold text-gray-900">🤲 Know someone who'd donate too?</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
const text = `I just pledged £${(amount / 100).toFixed(0)} to ${eventName}! 🤲\nPledge here: ${window.location.origin}`
|
||||||
|
window.open(`https://wa.me/?text=${encodeURIComponent(text)}`, "_blank")
|
||||||
|
}}
|
||||||
|
className="flex-1 bg-[#25D366] hover:bg-[#20BD5A] text-white"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<MessageCircle className="h-4 w-4 mr-1" /> WhatsApp
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={async () => {
|
||||||
|
if (navigator.share) {
|
||||||
|
await navigator.share({ title: eventName, text: `Pledge to ${eventName}`, url: window.location.origin })
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
<Share2 className="h-4 w-4 mr-1" /> Share
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Need help? Contact the charity directly.
|
Need help? Contact the charity directly.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,30 +1,42 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Check } from "lucide-react"
|
import { Check, Share2, MessageCircle } from "lucide-react"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
pledge: { id: string; reference: string }
|
pledge: { id: string; reference: string }
|
||||||
amount: number
|
amount: number
|
||||||
rail: string
|
rail: string
|
||||||
eventName: 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<string, string> = {
|
const railLabels: Record<string, string> = {
|
||||||
bank: "Bank Transfer",
|
bank: "Bank Transfer",
|
||||||
gocardless: "Direct Debit",
|
gocardless: "Direct Debit",
|
||||||
card: "Card Payment",
|
card: "Card Payment",
|
||||||
fpx: "FPX Online Banking",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const currencySymbol = rail === "fpx" ? "RM" : "£"
|
|
||||||
|
|
||||||
const nextStepMessages: Record<string, string> = {
|
const nextStepMessages: Record<string, string> = {
|
||||||
bank: "We've sent you payment instructions. Transfer at your convenience — we'll confirm once received.",
|
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.",
|
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 is being processed. You'll receive a confirmation email shortly.",
|
card: "Your card payment has been 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.",
|
}
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
@@ -35,10 +47,10 @@ export function ConfirmationStep({ pledge, amount, rail, eventName }: Props) {
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h1 className="text-2xl font-extrabold text-gray-900">
|
<h1 className="text-2xl font-extrabold text-gray-900">
|
||||||
{rail === "fpx" ? "Payment Successful!" : rail === "gocardless" ? "Mandate Set Up!" : "Pledge Received!"}
|
{rail === "gocardless" ? "Mandate Set Up!" : rail === "card" ? "Payment Complete!" : "Pledge Received!"}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Thank you for your generous {rail === "fpx" ? "donation" : "pledge"} to{" "}
|
Thank you for your generous {rail === "card" ? "donation" : "pledge"} to{" "}
|
||||||
<span className="font-semibold text-foreground">{eventName}</span>
|
<span className="font-semibold text-foreground">{eventName}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -47,7 +59,7 @@ export function ConfirmationStep({ pledge, amount, rail, eventName }: Props) {
|
|||||||
<CardContent className="pt-6 space-y-3 text-sm">
|
<CardContent className="pt-6 space-y-3 text-sm">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-muted-foreground">Amount</span>
|
<span className="text-muted-foreground">Amount</span>
|
||||||
<span className="font-bold">{currencySymbol}{(amount / 100).toFixed(2)}</span>
|
<span className="font-bold">£{(amount / 100).toFixed(2)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-muted-foreground">Payment Method</span>
|
<span className="text-muted-foreground">Payment Method</span>
|
||||||
@@ -63,7 +75,7 @@ export function ConfirmationStep({ pledge, amount, rail, eventName }: Props) {
|
|||||||
<span className="text-sm">3-5 working days</span>
|
<span className="text-sm">3-5 working days</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{rail === "fpx" && (
|
{rail === "card" && (
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-muted-foreground">Status</span>
|
<span className="text-muted-foreground">Status</span>
|
||||||
<span className="text-success-green font-semibold">Paid ✓</span>
|
<span className="text-success-green font-semibold">Paid ✓</span>
|
||||||
@@ -72,6 +84,7 @@ export function ConfirmationStep({ pledge, amount, rail, eventName }: Props) {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* What happens next */}
|
||||||
<div className="rounded-2xl bg-trust-blue/5 border border-trust-blue/20 p-4 space-y-2">
|
<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 font-medium text-trust-blue">What happens next?</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
@@ -79,6 +92,33 @@ export function ConfirmationStep({ pledge, amount, rail, eventName }: Props) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Share / encourage others */}
|
||||||
|
<div className="rounded-2xl bg-warm-amber/5 border border-warm-amber/20 p-5 space-y-3">
|
||||||
|
<p className="text-sm font-semibold text-gray-900">
|
||||||
|
🤲 Spread the word — every pledge counts!
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Share with friends and family so they can pledge too.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={handleWhatsAppShare}
|
||||||
|
className="flex-1 bg-[#25D366] hover:bg-[#20BD5A] text-white"
|
||||||
|
>
|
||||||
|
<MessageCircle className="h-4 w-4 mr-2" />
|
||||||
|
WhatsApp
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleShare}
|
||||||
|
variant="outline"
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
<Share2 className="h-4 w-4 mr-2" />
|
||||||
|
Share
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Need help? Contact the charity directly. Ref: {pledge.reference}
|
Need help? Contact the charity directly. Ref: {pledge.reference}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -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<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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -4,6 +4,7 @@ import { useState } from "react"
|
|||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Gift, Shield } from "lucide-react"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onSubmit: (data: {
|
onSubmit: (data: {
|
||||||
@@ -12,9 +13,10 @@ interface Props {
|
|||||||
donorPhone: string
|
donorPhone: string
|
||||||
giftAid: boolean
|
giftAid: boolean
|
||||||
}) => void
|
}) => void
|
||||||
|
amount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function IdentityStep({ onSubmit }: Props) {
|
export function IdentityStep({ onSubmit, amount }: Props) {
|
||||||
const [name, setName] = useState("")
|
const [name, setName] = useState("")
|
||||||
const [email, setEmail] = useState("")
|
const [email, setEmail] = useState("")
|
||||||
const [phone, setPhone] = useState("")
|
const [phone, setPhone] = useState("")
|
||||||
@@ -23,6 +25,7 @@ export function IdentityStep({ onSubmit }: Props) {
|
|||||||
|
|
||||||
const hasContact = email.includes("@") || phone.length >= 10
|
const hasContact = email.includes("@") || phone.length >= 10
|
||||||
const isValid = hasContact
|
const isValid = hasContact
|
||||||
|
const giftAidBonus = Math.round(amount * 0.25)
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!isValid) return
|
if (!isValid) return
|
||||||
@@ -47,10 +50,10 @@ export function IdentityStep({ onSubmit }: Props) {
|
|||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="name">Name <span className="text-muted-foreground font-normal">(optional)</span></Label>
|
<Label htmlFor="name">Full Name <span className="text-muted-foreground font-normal">(for Gift Aid)</span></Label>
|
||||||
<Input
|
<Input
|
||||||
id="name"
|
id="name"
|
||||||
placeholder="Your name"
|
placeholder="Your full name"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
autoComplete="name"
|
autoComplete="name"
|
||||||
@@ -68,6 +71,9 @@ export function IdentityStep({ onSubmit }: Props) {
|
|||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
inputMode="email"
|
inputMode="email"
|
||||||
/>
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
We'll send your payment instructions and receipt here
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative flex items-center">
|
<div className="relative flex items-center">
|
||||||
@@ -77,7 +83,7 @@ export function IdentityStep({ onSubmit }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="phone">Phone</Label>
|
<Label htmlFor="phone">Mobile Number</Label>
|
||||||
<Input
|
<Input
|
||||||
id="phone"
|
id="phone"
|
||||||
type="tel"
|
type="tel"
|
||||||
@@ -87,23 +93,54 @@ export function IdentityStep({ onSubmit }: Props) {
|
|||||||
autoComplete="tel"
|
autoComplete="tel"
|
||||||
inputMode="tel"
|
inputMode="tel"
|
||||||
/>
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
We can send reminders via SMS if you prefer
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Gift Aid */}
|
{/* Gift Aid — prominent UK-specific */}
|
||||||
<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">
|
<div
|
||||||
|
onClick={() => setGiftAid(!giftAid)}
|
||||||
|
className={`rounded-2xl border-2 p-5 cursor-pointer transition-all ${
|
||||||
|
giftAid
|
||||||
|
? "border-success-green bg-success-green/5 shadow-md shadow-success-green/10"
|
||||||
|
: "border-gray-200 bg-white hover:border-success-green/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className={`rounded-xl p-2.5 ${giftAid ? "bg-success-green/10" : "bg-gray-100"}`}>
|
||||||
|
<Gift className={`h-6 w-6 ${giftAid ? "text-success-green" : "text-gray-400"}`} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={giftAid}
|
checked={giftAid}
|
||||||
onChange={(e) => setGiftAid(e.target.checked)}
|
onChange={() => {}}
|
||||||
className="mt-1 h-5 w-5 rounded border-gray-300 text-trust-blue focus:ring-trust-blue"
|
className="h-5 w-5 rounded border-gray-300 text-success-green focus:ring-success-green"
|
||||||
/>
|
/>
|
||||||
<div>
|
<span className="font-bold text-gray-900">Add Gift Aid</span>
|
||||||
<span className="font-semibold text-gray-900">Add Gift Aid</span>
|
{giftAid && (
|
||||||
<p className="text-sm text-muted-foreground mt-0.5">
|
<span className="text-xs font-bold px-2 py-0.5 rounded-full bg-success-green text-white">
|
||||||
Boost your donation by 25% at no extra cost to you. You must be a UK taxpayer.
|
+£{(giftAidBonus / 100).toFixed(0)} free
|
||||||
</p>
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Boost your £{(amount / 100).toFixed(0)} pledge to{" "}
|
||||||
|
<span className="font-bold text-success-green">£{((amount + giftAidBonus) / 100).toFixed(0)}</span> at no extra cost.
|
||||||
|
HMRC adds 25% — the charity claims it back.
|
||||||
|
</p>
|
||||||
|
{giftAid && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-2 italic">
|
||||||
|
I confirm I am a UK taxpayer and understand that if I pay less Income Tax and/or
|
||||||
|
Capital Gains Tax than the amount of Gift Aid claimed on all my donations in that
|
||||||
|
tax year it is my responsibility to pay any difference.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@@ -115,9 +152,10 @@ export function IdentityStep({ onSubmit }: Props) {
|
|||||||
{submitting ? "Submitting..." : "Complete Pledge ✓"}
|
{submitting ? "Submitting..." : "Complete Pledge ✓"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<p className="text-center text-xs text-muted-foreground">
|
<div className="flex items-center justify-center gap-2 text-xs text-muted-foreground">
|
||||||
We'll only use this to send payment details and confirm receipt.
|
<Shield className="h-3 w-3" />
|
||||||
</p>
|
<span>Your data is kept secure and only used for this pledge</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Building2, CreditCard, Landmark, Globe } from "lucide-react"
|
import { Building2, CreditCard, Landmark } from "lucide-react"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onSelect: (rail: "bank" | "gocardless" | "card" | "fpx") => void
|
onSelect: (rail: "bank" | "gocardless" | "card") => void
|
||||||
amount: number
|
amount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,34 +18,31 @@ export function PaymentStep({ onSelect, amount }: Props) {
|
|||||||
subtitle: "Zero fees — 100% goes to charity",
|
subtitle: "Zero fees — 100% goes to charity",
|
||||||
tag: "Recommended",
|
tag: "Recommended",
|
||||||
tagColor: "bg-success-green text-white",
|
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,
|
id: "gocardless" as const,
|
||||||
icon: Landmark,
|
icon: Landmark,
|
||||||
title: "Direct Debit",
|
title: "Direct Debit",
|
||||||
subtitle: "Automatic collection — set and forget",
|
subtitle: "Automatic collection — set and forget",
|
||||||
tag: "Low fees",
|
tag: "Set up once",
|
||||||
tagColor: "bg-trust-blue/10 text-trust-blue",
|
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,
|
id: "card" as const,
|
||||||
icon: CreditCard,
|
icon: CreditCard,
|
||||||
title: "Card Payment via Stripe",
|
title: "Debit or Credit Card",
|
||||||
subtitle: "Pay now by Visa, Mastercard, Amex",
|
subtitle: "Pay instantly by Visa, Mastercard, or Amex",
|
||||||
tag: "Stripe",
|
tag: "Instant",
|
||||||
tagColor: "bg-purple-100 text-purple-700",
|
tagColor: "bg-purple-100 text-purple-700",
|
||||||
detail: "Secure payment powered by Stripe",
|
detail: "Secure payment powered by Stripe. Receipt emailed immediately.",
|
||||||
},
|
fee: "1.4% + 20p",
|
||||||
{
|
feeColor: "text-muted-foreground",
|
||||||
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",
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -56,7 +53,7 @@ export function PaymentStep({ onSelect, amount }: Props) {
|
|||||||
How would you like to pay?
|
How would you like to pay?
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-lg text-muted-foreground">
|
<p className="text-lg text-muted-foreground">
|
||||||
Pledge: <span className="font-bold text-foreground">£{pounds}</span>
|
Your pledge: <span className="font-bold text-foreground">£{pounds}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -72,16 +69,17 @@ export function PaymentStep({ onSelect, amount }: Props) {
|
|||||||
<opt.icon className="h-6 w-6 text-trust-blue" />
|
<opt.icon className="h-6 w-6 text-trust-blue" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<span className="font-bold text-gray-900">{opt.title}</span>
|
<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}`}>
|
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${opt.tagColor}`}>
|
||||||
{opt.tag}
|
{opt.tag}
|
||||||
</span>
|
</span>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground mt-0.5">{opt.subtitle}</p>
|
<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>
|
<p className="text-xs text-muted-foreground/70 mt-1">{opt.detail}</p>
|
||||||
|
<p className={`text-xs font-medium mt-1 ${opt.feeColor}`}>
|
||||||
|
Fee: {opt.fee}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-muted-foreground/40 group-hover:text-trust-blue transition-colors text-xl">
|
<div className="text-muted-foreground/40 group-hover:text-trust-blue transition-colors text-xl">
|
||||||
→
|
→
|
||||||
@@ -90,6 +88,10 @@ export function PaymentStep({ onSelect, amount }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p className="text-center text-xs text-muted-foreground">
|
||||||
|
All payments are secure. Bank transfers mean 100% reaches the charity.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { Button } from "@/components/ui/button"
|
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() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
@@ -17,7 +17,7 @@ export default function Home() {
|
|||||||
<span className="text-trust-blue">Pay Later</span>
|
<span className="text-trust-blue">Pay Later</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xl text-muted-foreground max-w-lg mx-auto">
|
<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.
|
Turn "I'll donate later" into tracked pledges with automatic follow-up. Built for UK charity fundraising events.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||||
@@ -38,25 +38,50 @@ export default function Home() {
|
|||||||
<div className="text-xs text-muted-foreground">Pledge time</div>
|
<div className="text-xs text-muted-foreground">Pledge time</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-2xl font-bold text-warm-amber">85%+</div>
|
<div className="text-2xl font-bold text-warm-amber">+25%</div>
|
||||||
<div className="text-xs text-muted-foreground">Collection rate</div>
|
<div className="text-xs text-muted-foreground">Gift Aid boost</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Payment Methods */}
|
{/* Who is this for? */}
|
||||||
<div className="max-w-4xl mx-auto px-4 pb-16">
|
<div className="bg-white border-y py-16">
|
||||||
<h2 className="text-2xl font-bold text-center mb-8">4 Payment Rails, One Platform</h2>
|
<div className="max-w-4xl mx-auto px-4">
|
||||||
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
<h2 className="text-2xl font-bold text-center mb-2">Built for everyone in your fundraising chain</h2>
|
||||||
|
<p className="text-center text-muted-foreground mb-8">From the charity manager to the donor's phone</p>
|
||||||
|
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
{[
|
{[
|
||||||
{ icon: Building2, title: "Bank Transfer", desc: "Zero fees — 100% to charity", color: "text-success-green" },
|
{ 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: Landmark, title: "Direct Debit", desc: "GoCardless auto-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: CreditCard, title: "Card via Stripe", desc: "Visa, Mastercard, Amex", color: "text-purple-600" },
|
{ icon: Smartphone, title: "Donors", desc: "15-second pledge on your phone. Clear bank details, copy buttons, reminders until paid.", color: "text-success-green" },
|
||||||
{ icon: Globe, title: "FPX Banking", desc: "Malaysian online banking", color: "text-amber-600" },
|
{ 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) => (
|
||||||
|
<div key={i} className="rounded-2xl border bg-white p-5 space-y-3 hover:shadow-md transition-shadow">
|
||||||
|
<p.icon className={`h-8 w-8 ${p.color}`} />
|
||||||
|
<h3 className="font-bold">{p.title}</h3>
|
||||||
|
<p className="text-xs text-muted-foreground leading-relaxed">{p.desc}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Payment Methods */}
|
||||||
|
<div className="max-w-4xl mx-auto px-4 py-16">
|
||||||
|
<h2 className="text-2xl font-bold text-center mb-2">3 UK Payment Rails, One Platform</h2>
|
||||||
|
<p className="text-center text-muted-foreground mb-8">Every method a UK donor expects</p>
|
||||||
|
<div className="grid sm:grid-cols-3 gap-4">
|
||||||
|
{[
|
||||||
|
{ 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) => (
|
].map((m, i) => (
|
||||||
<div key={i} className="rounded-2xl border bg-white p-5 text-center space-y-2 hover:shadow-md transition-shadow">
|
<div key={i} className="rounded-2xl border bg-white p-6 text-center space-y-3 hover:shadow-md transition-shadow">
|
||||||
<m.icon className={`h-8 w-8 mx-auto ${m.color}`} />
|
<m.icon className={`h-10 w-10 mx-auto ${m.color}`} />
|
||||||
|
<span className={`inline-block text-xs font-bold px-3 py-1 rounded-full ${
|
||||||
|
i === 0 ? "bg-success-green/10 text-success-green" : i === 1 ? "bg-trust-blue/10 text-trust-blue" : "bg-purple-100 text-purple-700"
|
||||||
|
}`}>{m.tag}</span>
|
||||||
<h3 className="font-bold">{m.title}</h3>
|
<h3 className="font-bold">{m.title}</h3>
|
||||||
<p className="text-xs text-muted-foreground">{m.desc}</p>
|
<p className="text-xs text-muted-foreground">{m.desc}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -67,13 +92,17 @@ export default function Home() {
|
|||||||
{/* Features */}
|
{/* Features */}
|
||||||
<div className="bg-white border-y py-16">
|
<div className="bg-white border-y py-16">
|
||||||
<div className="max-w-4xl mx-auto px-4">
|
<div className="max-w-4xl mx-auto px-4">
|
||||||
<h2 className="text-2xl font-bold text-center mb-8">Everything You Need</h2>
|
<h2 className="text-2xl font-bold text-center mb-8">Everything a UK charity needs</h2>
|
||||||
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
<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: QrCode, title: "QR Attribution", desc: "Per-table, per-volunteer tracking. Know who raised what." },
|
||||||
{ icon: BarChart3, title: "Live Dashboard", desc: "Real-time pledge pipeline with auto-refresh" },
|
{ 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: "4-step follow-up sequence via email/SMS" },
|
{ icon: Bell, title: "Smart Reminders", desc: "Automated follow-up via email and SMS until the pledge is paid." },
|
||||||
{ icon: Download, title: "Bank Reconciliation", desc: "CSV import, auto-match by reference" },
|
{ 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) => (
|
].map((f, i) => (
|
||||||
<div key={i} className="space-y-2">
|
<div key={i} className="space-y-2">
|
||||||
<f.icon className="h-6 w-6 text-trust-blue" />
|
<f.icon className="h-6 w-6 text-trust-blue" />
|
||||||
@@ -86,8 +115,9 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<footer className="py-8 px-4 text-center text-xs text-muted-foreground">
|
<footer className="py-8 px-4 text-center text-xs text-muted-foreground space-y-2">
|
||||||
<p>Pledge Now, Pay Later — Built for UK charities.</p>
|
<p>Pledge Now, Pay Later — Built for UK charities by <a href="https://calvana.quikcue.com" className="text-trust-blue hover:underline">QuikCue</a>.</p>
|
||||||
|
<p>Free forever. No hidden fees. No card required.</p>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
216
pledge-now-pay-later/src/app/v/[code]/page.tsx
Normal file
216
pledge-now-pay-later/src/app/v/[code]/page.tsx
Normal file
@@ -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<string, "default" | "secondary" | "success" | "warning" | "destructive"> = {
|
||||||
|
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<VolunteerData | null>(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 (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-trust-blue/5 via-white to-warm-amber/5">
|
||||||
|
<Loader2 className="h-8 w-8 text-trust-blue animate-spin" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !data) {
|
||||||
|
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">
|
||||||
|
<QrCode className="h-12 w-12 text-muted-foreground mx-auto" />
|
||||||
|
<h1 className="text-xl font-bold">QR code not found</h1>
|
||||||
|
<p className="text-muted-foreground">{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-trust-blue/5 via-white to-warm-amber/5">
|
||||||
|
<div className="max-w-lg mx-auto px-4 py-8 space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="text-center space-y-2">
|
||||||
|
<p className="text-sm text-muted-foreground">{data.event.organizationName}</p>
|
||||||
|
<h1 className="text-2xl font-extrabold text-gray-900">{data.event.name}</h1>
|
||||||
|
<p className="text-lg font-semibold text-trust-blue">
|
||||||
|
{data.qrSource.volunteerName || data.qrSource.label}'s Pledges
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-4 pb-3 text-center">
|
||||||
|
<Users className="h-5 w-5 text-trust-blue mx-auto mb-1" />
|
||||||
|
<p className="text-2xl font-bold">{data.stats.totalPledges}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Pledges</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-4 pb-3 text-center">
|
||||||
|
<Banknote className="h-5 w-5 text-warm-amber mx-auto mb-1" />
|
||||||
|
<p className="text-2xl font-bold">{formatPence(data.stats.totalPledgedPence)}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Pledged</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-4 pb-3 text-center">
|
||||||
|
<TrendingUp className="h-5 w-5 text-success-green mx-auto mb-1" />
|
||||||
|
<p className="text-2xl font-bold">{formatPence(data.stats.totalPaidPence)}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Paid</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
{data.stats.totalPledgedPence > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between text-xs text-muted-foreground mb-1">
|
||||||
|
<span>Collection progress</span>
|
||||||
|
<span>{Math.round((data.stats.totalPaidPence / data.stats.totalPledgedPence) * 100)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-3 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: `${Math.round((data.stats.totalPaidPence / data.stats.totalPledgedPence) * 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Share buttons */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button onClick={handleWhatsAppShare} className="flex-1 bg-[#25D366] hover:bg-[#20BD5A] text-white">
|
||||||
|
<MessageCircle className="h-4 w-4 mr-2" /> Share on WhatsApp
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleShare} variant="outline" className="flex-1">
|
||||||
|
<Share2 className="h-4 w-4 mr-2" /> Share Link
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pledge list */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h2 className="font-bold text-sm text-muted-foreground uppercase tracking-wider">
|
||||||
|
My Pledges ({data.pledges.length})
|
||||||
|
</h2>
|
||||||
|
{data.pledges.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-8 text-center">
|
||||||
|
<p className="text-muted-foreground">No pledges yet. Share your link to start collecting!</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
data.pledges.map((p) => (
|
||||||
|
<Card key={p.id} className="hover:shadow-sm transition-shadow">
|
||||||
|
<CardContent className="py-3 px-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-sm">{p.donorName || "Anonymous"}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{new Date(p.createdAt).toLocaleDateString("en-GB", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" })}
|
||||||
|
{p.giftAid && " · 🎁 Gift Aid"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right flex items-center gap-2">
|
||||||
|
<span className="font-bold">{formatPence(p.amountPence)}</span>
|
||||||
|
<Badge variant={statusColors[p.status] || "secondary"} className="text-xs">
|
||||||
|
{p.status === "paid" ? "Paid ✓" : p.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Auto-refresh indicator */}
|
||||||
|
<p className="text-center text-xs text-muted-foreground">
|
||||||
|
Updates automatically every 15 seconds
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
120
pledge-now-pay-later/src/components/live-ticker.tsx
Normal file
120
pledge-now-pay-later/src/components/live-ticker.tsx
Normal file
@@ -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<TickerPledge[]>([])
|
||||||
|
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<string, unknown>) => ({
|
||||||
|
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 (
|
||||||
|
<div className="rounded-2xl border bg-white p-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="font-bold text-sm flex items-center gap-2">
|
||||||
|
<TrendingUp className="h-4 w-4 text-trust-blue" /> Live Feed
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs font-semibold text-muted-foreground">
|
||||||
|
{formatPence(totalToday)} recent
|
||||||
|
</span>
|
||||||
|
{isLive && (
|
||||||
|
<Badge variant="success" className="gap-1">
|
||||||
|
<Radio className="h-3 w-3 animate-pulse" /> Live
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||||
|
{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 (
|
||||||
|
<div
|
||||||
|
key={p.id}
|
||||||
|
className={`flex items-center gap-3 py-2 ${i === 0 ? "animate-slide-in" : ""}`}
|
||||||
|
>
|
||||||
|
<div className="w-8 h-8 rounded-full bg-trust-blue/10 flex items-center justify-center text-xs font-bold text-trust-blue shrink-0">
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-sm font-medium truncate">{name}</span>
|
||||||
|
{p.giftAid && <span className="text-xs">🎁</span>}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{p.qrSourceLabel && `${p.qrSourceLabel} · `}{ago}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Banknote className="h-3.5 w-3.5 text-success-green" />
|
||||||
|
<span className="font-bold text-sm">{formatPence(p.amountPence)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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`
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user