diff --git a/.pi/extensions/calvana-shiplog.ts b/.pi/extensions/calvana-shiplog.ts index f33cca5..8541ccf 100644 --- a/.pi/extensions/calvana-shiplog.ts +++ b/.pi/extensions/calvana-shiplog.ts @@ -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) { diff --git a/gen_personas_v2.py b/gen_personas_v2.py new file mode 100644 index 0000000..0c34cdb --- /dev/null +++ b/gen_personas_v2.py @@ -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") diff --git a/pledge-now-pay-later/public/images/landing/persona-01-dinner.jpg b/pledge-now-pay-later/public/images/landing/persona-01-dinner.jpg new file mode 100644 index 0000000..dfeb340 Binary files /dev/null and b/pledge-now-pay-later/public/images/landing/persona-01-dinner.jpg differ diff --git a/pledge-now-pay-later/public/images/landing/persona-02-phone.jpg b/pledge-now-pay-later/public/images/landing/persona-02-phone.jpg new file mode 100644 index 0000000..64522c3 Binary files /dev/null and b/pledge-now-pay-later/public/images/landing/persona-02-phone.jpg differ diff --git a/pledge-now-pay-later/public/images/landing/persona-03-volunteer.jpg b/pledge-now-pay-later/public/images/landing/persona-03-volunteer.jpg new file mode 100644 index 0000000..457111c Binary files /dev/null and b/pledge-now-pay-later/public/images/landing/persona-03-volunteer.jpg differ diff --git a/pledge-now-pay-later/public/images/landing/persona-04-desk.jpg b/pledge-now-pay-later/public/images/landing/persona-04-desk.jpg new file mode 100644 index 0000000..fa2257a Binary files /dev/null and b/pledge-now-pay-later/public/images/landing/persona-04-desk.jpg differ diff --git a/pledge-now-pay-later/src/app/page.tsx b/pledge-now-pay-later/src/app/page.tsx index 4afb991..c503d8d 100644 --- a/pledge-now-pay-later/src/app/page.tsx +++ b/pledge-now-pay-later/src/app/page.tsx @@ -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() { - {/* ━━ WHO IT'S FOR — gap-px grid (signature pattern 2) ━━━━ */} + {/* ━━ THE PLEDGE GAP — scenario-based personas ━━━━━━━━━━━━ */}
{/* Eyebrow — border-l-2 accent (signature pattern 1) */}

- Who it's for + The pledge gap

- Built for how you actually work + People don't break promises.

-

- Four roles. One platform. Every pledge tracked. +

+ Systems do. +

+

+ We built the missing system between “I'll donate” and the money arriving.

{/* 2×2 gap-px grid */}
- {PERSONAS.map((p, i) => ( + {PERSONAS.map((p) => ( - {/* Image */} -
+ {/* Cinematic image strip */} +
{p.title}
- {/* Text — numbered + border-l accent */} + {/* Text — scenario + pain stats + story */}
-
- - {String(i + 1).padStart(2, "0")} - -
-

- {p.title} -

-

{p.oneLiner}

-
- {p.tags.map((t) => ( - - {t} - - ))} -
-

- Learn more → -

-
+

+ {p.scenario} +

+ + {/* Pain stats — the gap */} +
+

{p.stat1}

+

{p.stat2}

+ +

{p.copy}

+ +

+ {p.cta} → +

))} diff --git a/screenshots/cr-pillars-502.png b/screenshots/cr-pillars-502.png new file mode 100644 index 0000000..e1cf222 Binary files /dev/null and b/screenshots/cr-pillars-502.png differ