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:
@@ -502,32 +502,51 @@ Current state from DB: ${state.ships.length} ships (${state.ships.filter(s => s.
|
|||||||
async execute(_toolCallId, params, signal, onUpdate, _ctx) {
|
async execute(_toolCallId, params, signal, onUpdate, _ctx) {
|
||||||
onUpdate?.({ content: [{ type: "text", text: "Querying database for full ship log..." }] });
|
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 sshBase = `ssh -o ConnectTimeout=10 -p ${DEPLOY_CONFIG.sshPort} ${DEPLOY_CONFIG.sshHost}`;
|
||||||
const pgContainer = "$(docker ps --format '{{.Names}}' | grep dokploy-postgres)";
|
const HELPER_SCRIPT = `
|
||||||
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}\\\"'"`;
|
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 {
|
try {
|
||||||
// 1. Query all ships from database
|
// 1. Run the helper script via SSH to get ALL ships + oops from DB
|
||||||
const shipsResult = await pi.exec("bash", ["-c",
|
const dbResult = 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")
|
`${sshBase} 'bash -s' << 'DBEOF'\n${HELPER_SCRIPT}\nDBEOF`
|
||||||
], { signal, timeout: 20000 });
|
], { signal, timeout: 30000 });
|
||||||
|
|
||||||
if (shipsResult.code !== 0) {
|
if (dbResult.code !== 0) {
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text: `DB query failed: ${shipsResult.stderr}` }],
|
content: [{ type: "text", text: `DB query failed (code ${dbResult.code}): ${dbResult.stderr}\nstdout: ${dbResult.stdout?.slice(0, 200)}` }],
|
||||||
details: { state: { ...state }, error: shipsResult.stderr },
|
details: { state: { ...state }, error: dbResult.stderr },
|
||||||
isError: true,
|
isError: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Query all oops from database
|
const output = dbResult.stdout || "";
|
||||||
const oopsResult = await pi.exec("bash", ["-c",
|
if (output.includes("ERR:NO_PG_CONTAINER")) {
|
||||||
psqlCmd("SELECT id, description, COALESCE(fix_time,'—'), COALESCE(commit_link,'#commit'), created_at::text FROM oops ORDER BY id")
|
return {
|
||||||
], { signal, timeout: 20000 });
|
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 }> = [];
|
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;
|
if (!line.trim()) continue;
|
||||||
const parts = line.split("|||");
|
const parts = line.split("|||");
|
||||||
if (parts.length >= 6) {
|
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 }> = [];
|
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;
|
if (!line.trim()) continue;
|
||||||
const parts = line.split("|||");
|
const parts = line.split("|||");
|
||||||
if (parts.length >= 4) {
|
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);
|
const liveHtml = generateLivePageFromDb(dbShips, dbOops);
|
||||||
|
|
||||||
if (params.dryRun) {
|
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 b64Html = Buffer.from(liveHtml).toString("base64");
|
||||||
const deployResult = await pi.exec("bash", ["-c",
|
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'"`
|
`${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",
|
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'"`
|
`${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 });
|
], { signal, timeout: 60000 });
|
||||||
@@ -595,7 +624,7 @@ Current state from DB: ${state.ships.length} ships (${state.ships.filter(s => s.
|
|||||||
state.lastDeployed = now;
|
state.lastDeployed = now;
|
||||||
|
|
||||||
return {
|
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 } },
|
details: { state: { ...state, lastDeployed: now } },
|
||||||
};
|
};
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
|||||||
84
gen_personas_v2.py
Normal file
84
gen_personas_v2.py
Normal 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")
|
||||||
BIN
pledge-now-pay-later/public/images/landing/persona-01-dinner.jpg
Normal file
BIN
pledge-now-pay-later/public/images/landing/persona-01-dinner.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
BIN
pledge-now-pay-later/public/images/landing/persona-02-phone.jpg
Normal file
BIN
pledge-now-pay-later/public/images/landing/persona-02-phone.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
BIN
pledge-now-pay-later/public/images/landing/persona-04-desk.jpg
Normal file
BIN
pledge-now-pay-later/public/images/landing/persona-04-desk.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
@@ -10,35 +10,43 @@ const HERO_STATS = [
|
|||||||
{ stat: "2 min", label: "signup to first link" },
|
{ stat: "2 min", label: "signup to first link" },
|
||||||
]
|
]
|
||||||
|
|
||||||
/* ── Persona cards ── */
|
/* ── Personas — defined by what they DID, not their job title ── */
|
||||||
const PERSONAS = [
|
const PERSONAS = [
|
||||||
{
|
{
|
||||||
slug: "charities",
|
slug: "charities",
|
||||||
title: "Charity Manager",
|
scenario: "You organized the dinner",
|
||||||
oneLiner: "You raise pledges at events. We make sure the money actually arrives.",
|
stat1: "\u00A350,000 pledged.",
|
||||||
tags: ["Dashboard", "WhatsApp reminders", "Gift Aid", "Zakat", "HMRC export"],
|
stat2: "\u00A322,000 collected.",
|
||||||
image: "/images/landing/persona-charity-manager.jpg",
|
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: "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",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
slug: "fundraisers",
|
slug: "fundraisers",
|
||||||
title: "Personal Fundraiser",
|
scenario: "You shared the link",
|
||||||
oneLiner: "You share a LaunchGood or JustGiving link. We track who actually donates.",
|
stat1: "23 said \u201CI\u2019ll donate.\u201D",
|
||||||
tags: ["LaunchGood", "Enthuse", "JustGiving", "Social sharing", "Conversion tracking"],
|
stat2: "8 actually did.",
|
||||||
image: "/images/landing/persona-fundraiser.jpg",
|
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",
|
slug: "volunteers",
|
||||||
title: "Volunteer",
|
scenario: "You were on the ground",
|
||||||
oneLiner: "You help collect pledges at events. We show you exactly how much you raised.",
|
stat1: "40 pledges collected.",
|
||||||
tags: ["Personal link", "Live stats", "Leaderboard", "WhatsApp share"],
|
stat2: "0 updates received.",
|
||||||
image: "/images/landing/persona-volunteer.jpg",
|
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,65 +138,62 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<section className="py-20 md:py-28 px-6">
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
{/* Eyebrow — border-l-2 accent (signature pattern 1) */}
|
{/* Eyebrow — border-l-2 accent (signature pattern 1) */}
|
||||||
<div className="border-l-2 border-promise-blue pl-3 mb-6">
|
<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">
|
<p className="text-[11px] font-semibold tracking-[0.15em] uppercase text-gray-500">
|
||||||
Who it's for
|
The pledge gap
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-4xl md:text-5xl font-black text-gray-900 tracking-tight">
|
<h2 className="text-4xl md:text-5xl font-black text-gray-900 tracking-tight">
|
||||||
Built for how you actually work
|
People don't break promises.
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-lg text-gray-500 mt-3 max-w-xl">
|
<p className="text-3xl md:text-4xl font-black text-gray-300 tracking-tight mt-1">
|
||||||
Four roles. One platform. Every pledge tracked.
|
Systems do.
|
||||||
|
</p>
|
||||||
|
<p className="text-base text-gray-500 mt-4 max-w-lg">
|
||||||
|
We built the missing system between “I'll donate” and the money arriving.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* 2×2 gap-px grid */}
|
{/* 2×2 gap-px grid */}
|
||||||
<div className="grid md:grid-cols-2 gap-px bg-gray-200 mt-14">
|
<div className="grid md:grid-cols-2 gap-px bg-gray-200 mt-14">
|
||||||
{PERSONAS.map((p, i) => (
|
{PERSONAS.map((p) => (
|
||||||
<Link
|
<Link
|
||||||
key={p.slug}
|
key={p.slug}
|
||||||
href={`/for/${p.slug}`}
|
href={`/for/${p.slug}`}
|
||||||
className="group bg-white overflow-hidden"
|
className="group bg-white overflow-hidden"
|
||||||
>
|
>
|
||||||
{/* Image */}
|
{/* Cinematic image strip */}
|
||||||
<div className="aspect-[3/2] relative overflow-hidden">
|
<div className="aspect-[2/1] relative overflow-hidden">
|
||||||
<Image
|
<Image
|
||||||
src={p.image}
|
src={p.image}
|
||||||
alt={p.title}
|
alt={p.scenario}
|
||||||
fill
|
fill
|
||||||
className="object-cover transition-opacity duration-300 group-hover:opacity-90"
|
className="object-cover transition-opacity duration-300 group-hover:opacity-90"
|
||||||
sizes="(max-width: 768px) 100vw, 50vw"
|
sizes="(max-width: 768px) 100vw, 50vw"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Text — numbered + border-l accent */}
|
{/* Text — scenario + pain stats + story */}
|
||||||
<div className="p-6 md:p-8">
|
<div className="p-6 md:p-8">
|
||||||
<div className="flex items-start gap-5">
|
<h3 className="text-base font-black text-gray-900 group-hover:text-promise-blue transition-colors">
|
||||||
<span className="text-4xl font-black text-gray-100 leading-none select-none shrink-0">
|
{p.scenario}
|
||||||
{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>
|
</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">
|
{/* Pain stats — the gap */}
|
||||||
{p.tags.map((t) => (
|
<div className="mt-4 space-y-0.5">
|
||||||
<span key={t} className="text-[10px] font-medium text-gray-400 bg-gray-50 px-2 py-0.5">
|
<p className="text-2xl md:text-3xl font-black text-gray-200 tracking-tight leading-tight">{p.stat1}</p>
|
||||||
{t}
|
<p className="text-2xl md:text-3xl font-black text-gray-900 tracking-tight leading-tight">{p.stat2}</p>
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs font-semibold text-promise-blue mt-4 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
||||||
Learn more →
|
<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} →
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
BIN
screenshots/cr-pillars-502.png
Normal file
BIN
screenshots/cr-pillars-502.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
Reference in New Issue
Block a user