persona section: scenario-based personas + cinematic photography + pain stats

PERSONA OVERHAUL:
- Personas now defined by WHAT THEY DID, not job titles
- 'Charity Manager' -> 'You organized the dinner'
- 'Personal Fundraiser' -> 'You shared the link'
- 'Volunteer' -> 'You were on the ground'
- 'Organisation/Programme Manager' -> 'You claim the Gift Aid'

SECTION HEADING:
- Brand core insight: 'People don't break promises. Systems do.'
- Eyebrow: 'THE PLEDGE GAP'
- Sub: 'We built the missing system between I'll donate and the money arriving.'

PAIN STATS (visual anchors):
- £50,000 pledged / £22,000 collected (the gap)
- 23 said I'll donate / 8 actually did
- 40 pledges collected / 0 updates received
- 200 rows, 47 typos / 6 hours every quarter

COPY: Emotionally precise, tells each persona's specific story

PHOTOGRAPHY (4 cinematic moment shots):
- Dinner aftermath: empty table with lone pledge card, chandeliers
- Phone: hands on WhatsApp at kitchen table, warm light
- Volunteer: seen from behind, walking between gala tables with cards
- Desk still life: laptop spreadsheet, papers, tea, window light
- All 2:1 wide aspect, 2.7MB -> 260KB optimized
This commit is contained in:
2026-03-03 22:21:49 +08:00
parent c18dc50657
commit 3a6ec55a68
8 changed files with 190 additions and 72 deletions

View File

@@ -502,32 +502,51 @@ Current state from DB: ${state.ships.length} ships (${state.ships.filter(s => s.
async execute(_toolCallId, params, signal, onUpdate, _ctx) {
onUpdate?.({ content: [{ type: "text", text: "Querying database for full ship log..." }] });
// ── Build a helper script on the remote server to avoid quoting hell ──
// This is the ONLY way to reliably run psql inside docker inside incus inside ssh.
// Previous approach with nested escaping silently returned empty results.
const sshBase = `ssh -o ConnectTimeout=10 -p ${DEPLOY_CONFIG.sshPort} ${DEPLOY_CONFIG.sshHost}`;
const pgContainer = "$(docker ps --format '{{.Names}}' | grep dokploy-postgres)";
const psqlCmd = (sql: string) => `${sshBase} "incus exec ${DEPLOY_CONFIG.container} -- bash -c 'docker exec ${pgContainer} psql -U dokploy -d calvana -t -A -F \\\"|||\\\" -c \\\"${sql}\\\"'"`;
const HELPER_SCRIPT = `
PG_CONTAINER=$(incus exec ${DEPLOY_CONFIG.container} -- bash -c "docker ps --format '{{.Names}}' | grep dokploy-postgres")
if [ -z "$PG_CONTAINER" ]; then echo "ERR:NO_PG_CONTAINER"; exit 1; fi
SHIPS=$(incus exec ${DEPLOY_CONFIG.container} -- bash -c "docker exec $PG_CONTAINER psql -U dokploy -d calvana -t -A -F '|||' -c \\"SELECT id, title, status, COALESCE(metric,'-'), COALESCE(details,' '), created_at::text, COALESCE(updated_at::text, created_at::text) FROM ships ORDER BY id\\"")
OOPS=$(incus exec ${DEPLOY_CONFIG.container} -- bash -c "docker exec $PG_CONTAINER psql -U dokploy -d calvana -t -A -F '|||' -c \\"SELECT id, description, COALESCE(fix_time,'-'), COALESCE(commit_link,'#commit'), created_at::text FROM oops ORDER BY id\\"")
echo "===SHIPS==="
echo "$SHIPS"
echo "===OOPS==="
echo "$OOPS"
echo "===END==="
`.trim();
try {
// 1. Query all ships from database
const shipsResult = await pi.exec("bash", ["-c",
psqlCmd("SELECT id, title, status, COALESCE(metric,'—'), COALESCE(details,''), created_at::text, COALESCE(updated_at::text, created_at::text) FROM ships ORDER BY id")
], { signal, timeout: 20000 });
// 1. Run the helper script via SSH to get ALL ships + oops from DB
const dbResult = await pi.exec("bash", ["-c",
`${sshBase} 'bash -s' << 'DBEOF'\n${HELPER_SCRIPT}\nDBEOF`
], { signal, timeout: 30000 });
if (shipsResult.code !== 0) {
if (dbResult.code !== 0) {
return {
content: [{ type: "text", text: `DB query failed: ${shipsResult.stderr}` }],
details: { state: { ...state }, error: shipsResult.stderr },
content: [{ type: "text", text: `DB query failed (code ${dbResult.code}): ${dbResult.stderr}\nstdout: ${dbResult.stdout?.slice(0, 200)}` }],
details: { state: { ...state }, error: dbResult.stderr },
isError: true,
};
}
// 2. Query all oops from database
const oopsResult = await pi.exec("bash", ["-c",
psqlCmd("SELECT id, description, COALESCE(fix_time,'—'), COALESCE(commit_link,'#commit'), created_at::text FROM oops ORDER BY id")
], { signal, timeout: 20000 });
const output = dbResult.stdout || "";
if (output.includes("ERR:NO_PG_CONTAINER")) {
return {
content: [{ type: "text", text: `ABORT: PostgreSQL container not found. Refusing to deploy.` }],
details: { state: { ...state }, error: "PG container not found" },
isError: true,
};
}
// 2. Parse the structured output
const shipsSection = output.split("===SHIPS===")[1]?.split("===OOPS===")[0]?.trim() || "";
const oopsSection = output.split("===OOPS===")[1]?.split("===END===")[0]?.trim() || "";
// 3. Parse DB results into ship/oops arrays
const dbShips: Array<{ id: number; title: string; status: string; metric: string; details: string; created: string; updated: string }> = [];
for (const line of shipsResult.stdout.trim().split("\n")) {
for (const line of shipsSection.split("\n")) {
if (!line.trim()) continue;
const parts = line.split("|||");
if (parts.length >= 6) {
@@ -544,7 +563,7 @@ Current state from DB: ${state.ships.length} ships (${state.ships.filter(s => s.
}
const dbOops: Array<{ id: number; description: string; fixTime: string; commitLink: string; created: string }> = [];
for (const line of (oopsResult.stdout || "").trim().split("\n")) {
for (const line of oopsSection.split("\n")) {
if (!line.trim()) continue;
const parts = line.split("|||");
if (parts.length >= 4) {
@@ -558,9 +577,19 @@ Current state from DB: ${state.ships.length} ships (${state.ships.filter(s => s.
}
}
onUpdate?.({ content: [{ type: "text", text: `Found ${dbShips.length} ships + ${dbOops.length} oops. Generating HTML...` }] });
// ── SAFETY GATE: refuse to deploy if DB returned 0 ships ──
// The DB has 48+ entries. If we get 0, the query broke silently.
if (dbShips.length === 0) {
return {
content: [{ type: "text", text: `ABORT: DB query returned 0 ships. This would wipe the live site.\nRaw output (first 500 chars): ${output.slice(0, 500)}\n\nRefusing to deploy. Fix the DB query first.` }],
details: { state: { ...state }, error: "0 ships from DB — refusing to deploy" },
isError: true,
};
}
// 4. Generate HTML from DB data
onUpdate?.({ content: [{ type: "text", text: `Found ${dbShips.length} ships + ${dbOops.length} oops from DB. Generating HTML...` }] });
// 3. Generate HTML from DB data
const liveHtml = generateLivePageFromDb(dbShips, dbOops);
if (params.dryRun) {
@@ -570,9 +599,9 @@ Current state from DB: ${state.ships.length} ships (${state.ships.filter(s => s.
};
}
onUpdate?.({ content: [{ type: "text", text: "Deploying to server..." }] });
onUpdate?.({ content: [{ type: "text", text: `Deploying ${dbShips.length} ships to server...` }] });
// 5. Deploy via base64
// 4. Deploy via base64
const b64Html = Buffer.from(liveHtml).toString("base64");
const deployResult = await pi.exec("bash", ["-c",
`${sshBase} "incus exec ${DEPLOY_CONFIG.container} -- bash -c 'echo ${b64Html} | base64 -d > ${DEPLOY_CONFIG.sitePath}/live/index.html'"`
@@ -586,7 +615,7 @@ Current state from DB: ${state.ships.length} ships (${state.ships.filter(s => s.
};
}
// 6. Rebuild and update docker service
// 5. Rebuild and update docker service
const rebuildResult = await pi.exec("bash", ["-c",
`${sshBase} "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 });
@@ -595,7 +624,7 @@ Current state from DB: ${state.ships.length} ships (${state.ships.filter(s => s.
state.lastDeployed = now;
return {
content: [{ type: "text", text: `✓ Deployed to https://${DEPLOY_CONFIG.domain}/live${dbShips.length} ships + ${dbOops.length} oops from database\n${rebuildResult.stdout}` }],
content: [{ type: "text", text: `✓ Deployed to https://${DEPLOY_CONFIG.domain}/live\n${dbShips.length} ships + ${dbOops.length} oops from database\n${rebuildResult.stdout}` }],
details: { state: { ...state, lastDeployed: now } },
};
} catch (err: any) {