Publish: real card photo on the web hero + event banner (#5)
CI / policy (push) Has been cancelled
CI / backend (push) Has been cancelled
CI / profile (push) Has been cancelled
CI / mobile (push) Has been cancelled

- Photo on web: the publish flow uploads the card's front photo (base64 →
  object storage) and sets it as the face image; the web profile renders it as
  a full-bleed hero (when there's no AI welcome). /demo/publish body limit
  raised to 12MB for the image. Verified live (image served from MinIO,
  photo-hero rendered).
- Event mode (#5): optional `event` → a "📍 …" banner at the top of the profile.
- Mobile publish screen: Event field + sends the front photo.
- Gate green (120 tests), astro check clean, mobile tsc+jest green.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-05 21:35:53 -05:00
co-authored by Claude Opus 4.8
parent b487195def
commit a19f5abc6f
6 changed files with 140 additions and 4 deletions
@@ -69,7 +69,11 @@ pub fn build_router(state: AppState) -> Router {
.route("/cards/:id/welcome", post(cards::set_welcome)) .route("/cards/:id/welcome", post(cards::set_welcome))
.route("/cards/:id/connections", get(cards::list_connections)) .route("/cards/:id/connections", get(cards::list_connections))
.route("/profile/:handle/connect", post(profile::submit_connection)) .route("/profile/:handle/connect", post(profile::submit_connection))
.route("/demo/publish", post(demo::publish)) .route(
"/demo/publish",
// The front photo is sent as base64, so allow a larger body here.
post(demo::publish).layer(axum::extract::DefaultBodyLimit::max(12 * 1024 * 1024)),
)
.route("/ai/refine", post(ai::refine)) .route("/ai/refine", post(ai::refine))
.route("/ai/image", post(ai::image)) .route("/ai/image", post(ai::image))
.route("/ai/video", post(ai::start_video)) .route("/ai/video", post(ai::start_video))
@@ -3,6 +3,7 @@
//! Each publish creates a fresh password-less owner so the profile shows the //! Each publish creates a fresh password-less owner so the profile shows the
//! right name and stays within the free-tier 1-card limit. //! right name and stays within the free-tier 1-card limit.
use base64::Engine;
use cardclaws_db::queries::users; use cardclaws_db::queries::users;
use cardclaws_types::{AppError, Tier}; use cardclaws_types::{AppError, Tier};
use serde::Deserialize; use serde::Deserialize;
@@ -44,6 +45,14 @@ pub struct DemoPublishRequest {
/// "Now" status — what the person is currently up to. /// "Now" status — what the person is currently up to.
#[serde(default)] #[serde(default)]
pub now: Option<String>, pub now: Option<String>,
/// Event context shown as a banner ("📍 met at …").
#[serde(default)]
pub event: Option<String>,
/// The card's front photo (base64), shown as the web hero.
#[serde(default)]
pub front_image_base64: Option<String>,
#[serde(default)]
pub front_image_mime: Option<String>,
} }
pub struct Published { pub struct Published {
@@ -64,6 +73,26 @@ pub async fn publish(state: &AppState, req: &DemoPublishRequest) -> Result<Publi
.collect(); .collect();
let card_handle = format!("{}-{}", slugify(&req.name), &suffix[..6]); let card_handle = format!("{}-{}", slugify(&req.name), &suffix[..6]);
// Upload the card's front photo (if any) so the web hero shows the real card.
let mut face_background = serde_json::json!({ "type": "solid", "value": "#101014" });
if let Some(b64) = req.front_image_base64.as_deref() {
if !b64.trim().is_empty() {
let bytes = base64::engine::general_purpose::STANDARD
.decode(b64.trim().as_bytes())
.map_err(|e| AppError::Internal(format!("decode front image: {e}")))?;
let mime = req.front_image_mime.as_deref().unwrap_or("image/jpeg");
let ext = if mime.contains("png") { "png" } else { "jpg" };
let key = format!("front/{suffix}.{ext}");
state
.assets
.put_object(&key, bytes, mime)
.await
.map_err(|e| AppError::Internal(e.to_string()))?;
face_background =
serde_json::json!({ "type": "image", "value": state.assets.public_url(&key) });
}
}
// Fresh password-less owner so the profile shows this person's name. // Fresh password-less owner so the profile shows this person's name.
let user = users::insert( let user = users::insert(
&state.db, &state.db,
@@ -98,8 +127,13 @@ pub async fn publish(state: &AppState, req: &DemoPublishRequest) -> Result<Publi
profile["now"] = serde_json::json!(now.trim()); profile["now"] = serde_json::json!(now.trim());
} }
} }
if let Some(event) = req.event.as_deref() {
if !event.trim().is_empty() {
profile["event"] = serde_json::json!(event.trim());
}
}
let definition = serde_json::json!({ let definition = serde_json::json!({
"face": { "layers": [], "background": { "type": "solid", "value": "#101014" } }, "face": { "layers": [], "background": face_background },
"back": { "back": {
"layers": [{ "layers": [{
"id": Uuid::new_v4(), "id": Uuid::new_v4(),
+28
View File
@@ -2,6 +2,7 @@
// welcome shown when the QR is scanned. Returns a real cardclaws.com/<handle> // welcome shown when the QR is scanned. Returns a real cardclaws.com/<handle>
// URL and points the card's QR at it. // URL and points the card's QR at it.
import * as FileSystem from "expo-file-system";
import { Stack, useLocalSearchParams, useRouter } from "expo-router"; import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useState } from "react"; import { useState } from "react";
import { import {
@@ -32,6 +33,7 @@ export default function PublishScreen() {
const [payloadLabel, setPayloadLabel] = useState(""); const [payloadLabel, setPayloadLabel] = useState("");
const [payloadValue, setPayloadValue] = useState(""); const [payloadValue, setPayloadValue] = useState("");
const [now, setNow] = useState(""); const [now, setNow] = useState("");
const [event, setEvent] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [result, setResult] = useState<string | null>(card?.publishedUrl ?? null); const [result, setResult] = useState<string | null>(card?.publishedUrl ?? null);
@@ -47,9 +49,24 @@ export default function PublishScreen() {
const publish = async () => { const publish = async () => {
setBusy(true); setBusy(true);
try { try {
// Upload the card's front photo so the web hero shows the real card.
let frontImageBase64: string | undefined;
let frontImageMime: string | undefined;
if (card.imagePath) {
try {
frontImageBase64 = await FileSystem.readAsStringAsync(card.imagePath, {
encoding: FileSystem.EncodingType.Base64,
});
frontImageMime = card.imagePath.endsWith(".png") ? "image/png" : "image/jpeg";
} catch {
/* no readable photo — publish without it */
}
}
const { profileUrl } = await publishToWeb({ const { profileUrl } = await publishToWeb({
name: card.name, name: card.name,
title: card.title || undefined, title: card.title || undefined,
frontImageBase64,
frontImageMime,
links: card.links.filter((l) => l.url.trim()).map((l) => ({ label: l.label, url: l.url })), links: card.links.filter((l) => l.url.trim()).map((l) => ({ label: l.label, url: l.url })),
welcomePrompt: welcomePrompt.trim() || undefined, welcomePrompt: welcomePrompt.trim() || undefined,
welcomeMessage: welcomePrompt.trim() ? welcomeMessage : undefined, welcomeMessage: welcomePrompt.trim() ? welcomeMessage : undefined,
@@ -58,6 +75,7 @@ export default function PublishScreen() {
? { kind: payloadKind, label: payloadLabel.trim(), value: payloadValue.trim() } ? { kind: payloadKind, label: payloadLabel.trim(), value: payloadValue.trim() }
: undefined, : undefined,
now: now.trim() || undefined, now: now.trim() || undefined,
event: event.trim() || undefined,
}); });
// Point the card's QR at the real profile + remember it. // Point the card's QR at the real profile + remember it.
upsert({ ...card, url: profileUrl, publishedUrl: profileUrl, updatedAt: Date.now() }); upsert({ ...card, url: profileUrl, publishedUrl: profileUrl, updatedAt: Date.now() });
@@ -103,6 +121,16 @@ export default function PublishScreen() {
</> </>
)} )}
<Text style={styles.label}>Event (optional)</Text>
<TextInput
style={styles.input}
editable={!busy}
placeholder="e.g. SXSW 2026"
placeholderTextColor="#6b6b70"
value={event}
onChangeText={setEvent}
/>
<Text style={styles.label}>Now — what you’re up to (optional)</Text> <Text style={styles.label}>Now — what you’re up to (optional)</Text>
<TextInput <TextInput
style={styles.input} style={styles.input}
+3
View File
@@ -58,6 +58,9 @@ export interface PublishPayload {
welcomeMessage?: string; welcomeMessage?: string;
payload?: { kind: "link" | "code"; label: string; value: string }; payload?: { kind: "link" | "code"; label: string; value: string };
now?: string; now?: string;
event?: string;
frontImageBase64?: string;
frontImageMime?: string;
} }
/** Publish a demo card to the web (no auth) → a real, scannable profile URL. */ /** Publish a demo card to the web (no auth) → a real, scannable profile URL. */
+2
View File
@@ -45,6 +45,8 @@ export interface ProfileData {
payload?: { kind: "link" | "code"; label: string; value: string }; payload?: { kind: "link" | "code"; label: string; value: string };
/** "Now" status — what the person is currently up to. */ /** "Now" status — what the person is currently up to. */
now?: string; now?: string;
/** Event context shown as a banner. */
event?: string;
} }
export interface CardSide { export interface CardSide {
+67 -2
View File
@@ -27,6 +27,9 @@ const bio = profileData.bio?.trim();
const links = (profileData.links ?? []).filter((l) => l.url.trim().length > 0); const links = (profileData.links ?? []).filter((l) => l.url.trim().length > 0);
const payload = profileData.payload && profileData.payload.value?.trim() ? profileData.payload : null; const payload = profileData.payload && profileData.payload.value?.trim() ? profileData.payload : null;
const now = profileData.now?.trim(); const now = profileData.now?.trim();
const event = profileData.event?.trim();
const faceBg = profile.definition.face?.background;
const faceImage = faceBg?.type === "image" && faceBg.value ? faceBg.value : null;
const vcard = buildVcard(name, contact); const vcard = buildVcard(name, contact);
const og = `${name}${contact.title ? ` — ${contact.title}` : ""}${ const og = `${name}${contact.title ? ` — ${contact.title}` : ""}${
contact.company ? ` at ${contact.company}` : "" contact.company ? ` at ${contact.company}` : ""
@@ -44,9 +47,18 @@ const og = `${name}${contact.title ? ` — ${contact.title}` : ""}${
<meta name="twitter:card" content="summary_large_image" /> <meta name="twitter:card" content="summary_large_image" />
</head> </head>
<body> <body>
{welcome && ( {welcome ? (
<WelcomeHero message={welcome.message} name={name} imageUrl={welcome.imageUrl} /> <WelcomeHero message={welcome.message} name={name} imageUrl={welcome.imageUrl} />
)} ) : faceImage ? (
<section class="photo-hero">
<img src={faceImage} alt="" />
<div class="photo-scrim"></div>
<div class="photo-text">
<h1>{name}</h1>
{contact.title && <p>{contact.title}</p>}
</div>
</section>
) : null}
<main> <main>
<CardHero <CardHero
name={name} name={name}
@@ -56,6 +68,12 @@ const og = `${name}${contact.title ? ` — ${contact.title}` : ""}${
contact={contact} contact={contact}
/> />
{event && (
<section class="block">
<p class="event-banner">📍 {event}</p>
</section>
)}
{now && ( {now && (
<section class="block"> <section class="block">
<p class="now"><span class="now-dot"></span>Now: {now}</p> <p class="now"><span class="now-dot"></span>Now: {now}</p>
@@ -145,6 +163,43 @@ const og = `${name}${contact.title ? ` — ${contact.title}` : ""}${
padding: 48px 20px 120px; padding: 48px 20px 120px;
gap: 32px; gap: 32px;
} }
.photo-hero {
position: relative;
width: 100%;
height: 70vh;
overflow: hidden;
display: flex;
align-items: flex-end;
}
.photo-hero img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.photo-scrim {
position: absolute;
inset: 0;
background: linear-gradient(to top, #0a0a0c 2%, rgba(10, 10, 12, 0) 55%);
}
.photo-text {
position: relative;
width: 100%;
max-width: 480px;
margin: 0 auto;
padding: 0 24px 28px;
color: #fff;
}
.photo-text h1 {
margin: 0;
font-size: 32px;
font-weight: 800;
}
.photo-text p {
margin: 4px 0 0;
color: #cfcfd4;
}
.block { .block {
width: 100%; width: 100%;
max-width: 420px; max-width: 420px;
@@ -162,6 +217,16 @@ const og = `${name}${contact.title ? ` — ${contact.title}` : ""}${
line-height: 1.5; line-height: 1.5;
color: #d8d8dc; color: #d8d8dc;
} }
.event-banner {
margin: 0;
padding: 12px 16px;
border-radius: 14px;
background: #1a1430;
border: 1px solid #3a2d6b;
color: #cbb8ff;
font-weight: 600;
text-align: center;
}
.now { .now {
margin: 0; margin: 0;
display: flex; display: flex;