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) {

84
gen_personas_v2.py Normal file
View File

@@ -0,0 +1,84 @@
"""Generate 4 cinematic persona images — moment shots, not portraits.
Wide 2:1 aspect ratio for editorial strip layout.
"""
import os, time
from concurrent.futures import ThreadPoolExecutor, as_completed
from google import genai
from google.genai import types
client = genai.Client(api_key="AIzaSyCHnesXLjPw-UgeZaQotut66bgjFdvy12E")
OUT = "pledge-now-pay-later/public/images/landing"
PROMPTS = {
"persona-01-dinner.jpg": (
"End of a charity fundraising dinner. A round banquet table, white tablecloth slightly rumpled. "
"A single small pledge card sits slightly askew on the tablecloth near an empty water glass. "
"Chairs pushed back. Warm tungsten chandelier light creates golden glow. A few guests leaving "
"in the far background, blurred. Crumpled napkin. The generosity happened here, but the table "
"is emptying. Cinematic wide shot, melancholy but beautiful. No one looking at camera. "
"Shot on Leica Q2, 28mm, f/1.7, available light. Portra-like color rendering. "
"2:1 landscape aspect ratio."
),
"persona-02-phone.jpg": (
"Close-up of a British South Asian woman's hands holding a smartphone above a wooden kitchen table. "
"The phone screen shows a messaging app with a green chat bubble. Warm afternoon window light "
"from the left illuminates the scene. Shallow depth of field - the rest of the table (a mug, "
"a notebook) is softly blurred. Her sleeves are pushed up casually. Intimate, everyday, relatable. "
"Not looking at camera - we only see hands and phone. "
"Shot on Sony A7III, 55mm, f/1.4, natural light. Warm, honest. "
"2:1 landscape aspect ratio."
),
"persona-03-volunteer.jpg": (
"A young British South Asian man with a lanyard and dark polo shirt, seen from behind and slightly "
"to the side, walking between round dinner tables at a charity gala. He carries a small stack of "
"cards in one hand. Seated guests in smart dress at the white-clothed tables. Warm golden gala "
"lighting, crystal chandelier bokeh in the upper background. The volunteer is in motion, purposeful. "
"Not looking at camera. Other volunteers visible but blurred. Energy, purpose, youth. "
"Shot on Canon EOS R5, 35mm, f/1.8, available light. Documentary candid event photography. "
"2:1 landscape aspect ratio."
),
"persona-04-desk.jpg": (
"A clean wooden desk photographed from a 45-degree overhead angle. An open laptop shows a "
"spreadsheet with organized rows and a green progress indicator. Beside the laptop: a neat stack "
"of printed A4 papers with a black binder clip, a fine-point pen, and a ceramic mug of tea. "
"Soft morning window light from the left creates gentle shadows. No person visible - just the "
"workspace. Everything is orderly, calm, under control. Documentary still life. "
"Shot on Fujifilm GFX 50S, 63mm, f/4.0, natural light. Calm, precise. "
"2:1 landscape aspect ratio."
),
}
def generate(filename, prompt):
t0 = time.time()
print(f" [GEN] {filename}...")
try:
resp = client.models.generate_content(
model="gemini-3-pro-image-preview",
contents=prompt,
config=types.GenerateContentConfig(
response_modalities=["TEXT", "IMAGE"],
),
)
for part in resp.candidates[0].content.parts:
if part.inline_data:
path = os.path.join(OUT, filename)
with open(path, "wb") as f:
f.write(part.inline_data.data)
sz = os.path.getsize(path) / 1024
print(f" [OK] {filename} -- {sz:.0f}KB ({time.time()-t0:.1f}s)")
return filename, True
print(f" [FAIL] {filename} -- no image in response")
return filename, False
except Exception as e:
print(f" [FAIL] {filename} -- {e}")
return filename, False
if __name__ == "__main__":
print(f"Generating {len(PROMPTS)} cinematic persona images...")
ok = 0
with ThreadPoolExecutor(max_workers=2) as pool:
futures = {pool.submit(generate, fn, p): fn for fn, p in PROMPTS.items()}
for f in as_completed(futures):
_, success = f.result()
if success: ok += 1
print(f"\nDone: {ok}/{len(PROMPTS)} ok")

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

View File

@@ -10,35 +10,43 @@ const HERO_STATS = [
{ stat: "2 min", label: "signup to first link" },
]
/* ── Persona cards ── */
/* ── Personas — defined by what they DID, not their job title ── */
const PERSONAS = [
{
slug: "charities",
title: "Charity Manager",
oneLiner: "You raise pledges at events. We make sure the money actually arrives.",
tags: ["Dashboard", "WhatsApp reminders", "Gift Aid", "Zakat", "HMRC export"],
image: "/images/landing/persona-charity-manager.jpg",
},
{
slug: "organisations",
title: "Programme Manager",
oneLiner: "You coordinate campaigns across teams. We give you the full pipeline view.",
tags: ["Multi-campaign", "Team oversight", "Pipeline view", "Instalments", "Reporting"],
image: "/images/landing/persona-programme-manager.jpg",
scenario: "You organized the dinner",
stat1: "\u00A350,000 pledged.",
stat2: "\u00A322,000 collected.",
copy: "The speeches landed. The pledges poured in. Then Monday came \u2014 and the spreadsheet started. We follow up automatically. They pay. You stay focused on the mission.",
cta: "For charity organizers",
image: "/images/landing/persona-01-dinner.jpg",
},
{
slug: "fundraisers",
title: "Personal Fundraiser",
oneLiner: "You share a LaunchGood or JustGiving link. We track who actually donates.",
tags: ["LaunchGood", "Enthuse", "JustGiving", "Social sharing", "Conversion tracking"],
image: "/images/landing/persona-fundraiser.jpg",
scenario: "You shared the link",
stat1: "23 said \u201CI\u2019ll donate.\u201D",
stat2: "8 actually did.",
copy: "You shared it on WhatsApp, posted on Instagram, mentioned it over dinner. People promised. Then life got busy. We track every promise and send the reminders \u2014 so you don\u2019t have to chase friends.",
cta: "For personal fundraisers",
image: "/images/landing/persona-02-phone.jpg",
},
{
slug: "volunteers",
title: "Volunteer",
oneLiner: "You help collect pledges at events. We show you exactly how much you raised.",
tags: ["Personal link", "Live stats", "Leaderboard", "WhatsApp share"],
image: "/images/landing/persona-volunteer.jpg",
scenario: "You were on the ground",
stat1: "40 pledges collected.",
stat2: "0 updates received.",
copy: "You spent the evening going table to table. Smiling, explaining, handing out cards. You went home and never found out what happened next. With us, you see every payment \u2014 live.",
cta: "For event volunteers",
image: "/images/landing/persona-03-volunteer.jpg",
},
{
slug: "organisations",
scenario: "You claim the Gift Aid",
stat1: "200 rows. 47 typos.",
stat2: "6 hours every quarter.",
copy: "Names, amounts, home addresses, declaration dates. Half the records are incomplete. Every quarter, you build the spreadsheet from scratch. We give you a one\u2011click HMRC export \u2014 every field, every time.",
cta: "For treasurers",
image: "/images/landing/persona-04-desk.jpg",
},
]
@@ -130,64 +138,61 @@ export default function HomePage() {
</div>
</section>
{/* ━━ WHO IT'S FOR — gap-px grid (signature pattern 2) ━━━━ */}
{/* ━━ THE PLEDGE GAP — scenario-based personas ━━━━━━━━━━━━ */}
<section className="py-20 md:py-28 px-6">
<div className="max-w-7xl mx-auto">
{/* Eyebrow — border-l-2 accent (signature pattern 1) */}
<div className="border-l-2 border-promise-blue pl-3 mb-6">
<p className="text-[11px] font-semibold tracking-[0.15em] uppercase text-gray-500">
Who it&apos;s for
The pledge gap
</p>
</div>
<h2 className="text-4xl md:text-5xl font-black text-gray-900 tracking-tight">
Built for how you actually work
People don&apos;t break promises.
</h2>
<p className="text-lg text-gray-500 mt-3 max-w-xl">
Four roles. One platform. Every pledge tracked.
<p className="text-3xl md:text-4xl font-black text-gray-300 tracking-tight mt-1">
Systems do.
</p>
<p className="text-base text-gray-500 mt-4 max-w-lg">
We built the missing system between &ldquo;I&apos;ll donate&rdquo; and the money arriving.
</p>
{/* 2×2 gap-px grid */}
<div className="grid md:grid-cols-2 gap-px bg-gray-200 mt-14">
{PERSONAS.map((p, i) => (
{PERSONAS.map((p) => (
<Link
key={p.slug}
href={`/for/${p.slug}`}
className="group bg-white overflow-hidden"
>
{/* Image */}
<div className="aspect-[3/2] relative overflow-hidden">
{/* Cinematic image strip */}
<div className="aspect-[2/1] relative overflow-hidden">
<Image
src={p.image}
alt={p.title}
alt={p.scenario}
fill
className="object-cover transition-opacity duration-300 group-hover:opacity-90"
sizes="(max-width: 768px) 100vw, 50vw"
/>
</div>
{/* Text — numbered + border-l accent */}
{/* Text — scenario + pain stats + story */}
<div className="p-6 md:p-8">
<div className="flex items-start gap-5">
<span className="text-4xl font-black text-gray-100 leading-none select-none shrink-0">
{String(i + 1).padStart(2, "0")}
</span>
<div className="flex-1 min-w-0 border-l-2 border-gray-900 pl-4">
<h3 className="text-lg font-black text-gray-900 group-hover:text-promise-blue transition-colors">
{p.title}
</h3>
<p className="text-sm text-gray-500 mt-1.5 leading-relaxed">{p.oneLiner}</p>
<div className="flex flex-wrap gap-1.5 mt-3">
{p.tags.map((t) => (
<span key={t} className="text-[10px] font-medium text-gray-400 bg-gray-50 px-2 py-0.5">
{t}
</span>
))}
</div>
<p className="text-xs font-semibold text-promise-blue mt-4 opacity-0 group-hover:opacity-100 transition-opacity">
Learn more &rarr;
</p>
</div>
<h3 className="text-base font-black text-gray-900 group-hover:text-promise-blue transition-colors">
{p.scenario}
</h3>
{/* Pain stats — the gap */}
<div className="mt-4 space-y-0.5">
<p className="text-2xl md:text-3xl font-black text-gray-200 tracking-tight leading-tight">{p.stat1}</p>
<p className="text-2xl md:text-3xl font-black text-gray-900 tracking-tight leading-tight">{p.stat2}</p>
</div>
<p className="text-sm text-gray-500 leading-relaxed mt-4">{p.copy}</p>
<p className="text-xs font-semibold text-promise-blue mt-5 group-hover:underline transition-all">
{p.cta} &rarr;
</p>
</div>
</Link>
))}

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB