AI Welcome scan experience — backend + web profile
CI / backend (push) Has been cancelled
CI / profile (push) Has been cancelled
CI / mobile (push) Has been cancelled
CI / policy (push) Has been cancelled

When a card is scanned, the public profile can open with a full-bleed,
AI-generated welcome hero before the contact info — experience #10 from the
scan-experiences brief, on the share-token experience selector foundation.

Backend (cardclaws-backend):
- migration 0005_welcome.sql: add cards.welcome JSONB; flows through
  PublicProfile via serde flatten.
- POST /v1/cards/:id/welcome (owner-gated): refine + generate via the existing
  AiClient (nano-banana), store {kind, message, imageDataUrl}.
- card_service::set_welcome + queries::update_welcome; CardRow gains `welcome`.
- 2 tests (generate→public-profile, ownership); workspace gate green (116 tests).
- Verified live with real Gemini end-to-end.

Web (cardclaws-profile):
- WelcomeHero.astro: full-bleed AI hero + greeting + "scroll to connect" cue.
- [handle].astro renders it when the card has a welcome; experience selector
  (?x=profile suppresses) as the lever for the other experiences.
- PublicProfile gains an optional `welcome`; astro check clean.

MVP inlines the image as a data URL; production swaps to an R2 URL (isolated in
the welcome column).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-05 18:21:27 -05:00
co-authored by Claude Opus 4.8
parent a66978910d
commit 5aba3ef1a1
10 changed files with 272 additions and 6 deletions
@@ -19,6 +19,38 @@ pub struct CreateCardRequest {
pub definition: serde_json::Value, pub definition: serde_json::Value,
} }
#[derive(Deserialize)]
pub struct WelcomeRequest {
pub prompt: String,
#[serde(default)]
pub message: Option<String>,
#[serde(default)]
pub style: Option<String>,
#[serde(default)]
pub mood: Option<String>,
}
/// Generate + attach an AI welcome (owner only). Shown when the QR is scanned.
pub async fn set_welcome(
State(state): State<AppState>,
user: AuthUser,
Path(id): Path<Uuid>,
Json(req): Json<WelcomeRequest>,
) -> ApiResult<Json<CardRow>> {
Ok(Json(
card_service::set_welcome(
&state,
id,
user.user_id,
&req.prompt,
req.message.as_deref(),
req.style.as_deref(),
req.mood.as_deref(),
)
.await?,
))
}
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct DefinitionBody { pub struct DefinitionBody {
pub definition: serde_json::Value, pub definition: serde_json::Value,
@@ -65,6 +65,7 @@ pub fn build_router(state: AppState) -> Router {
axum::routing::delete(teams::delete_template), axum::routing::delete(teams::delete_template),
) )
.route("/analytics/event", post(analytics::ingest_event)) .route("/analytics/event", post(analytics::ingest_event))
.route("/cards/:id/welcome", post(cards::set_welcome))
.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))
@@ -21,6 +21,42 @@ pub async fn get_owned(state: &AppState, id: Uuid, user_id: Uuid) -> Result<Card
Ok(card) Ok(card)
} }
/// Generate an AI welcome image (nano-banana) and attach it to the card the
/// caller owns — shown on the public profile when the QR is scanned.
pub async fn set_welcome(
state: &AppState,
id: Uuid,
user_id: Uuid,
prompt: &str,
message: Option<&str>,
style: Option<&str>,
mood: Option<&str>,
) -> Result<CardRow, AppError> {
get_owned(state, id, user_id).await?;
if prompt.trim().is_empty() {
return Err(AppError::Validation("prompt is required".into()));
}
let brief = crate::ai::SceneBrief {
scene: prompt.to_string(),
style: style.map(str::to_string),
mood: mood.map(str::to_string),
};
let refined = state.ai.refine_prompt(&brief).await.map_err(ai_to_app)?;
let img = state.ai.generate_image(&refined).await.map_err(ai_to_app)?;
let welcome = serde_json::json!({
"kind": "image",
"message": message.unwrap_or("Great to meet you"),
"imageDataUrl": format!("data:{};base64,{}", img.mime_type, img.base64),
});
cards::update_welcome(&state.db, id, &welcome)
.await
.map_db()
}
fn ai_to_app(e: crate::ai::AiError) -> AppError {
AppError::Internal(e.to_string())
}
pub async fn list(state: &AppState, user_id: Uuid) -> Result<Vec<CardRow>, AppError> { pub async fn list(state: &AppState, user_id: Uuid) -> Result<Vec<CardRow>, AppError> {
cards::list_by_owner(&state.db, user_id).await.map_db() cards::list_by_owner(&state.db, user_id).await.map_db()
} }
@@ -223,3 +223,61 @@ async fn duplicate_creates_independent_draft() {
assert_ne!(body["handle"].as_str().unwrap(), handle); assert_ne!(body["handle"].as_str().unwrap(), handle);
assert_eq!(body["status"], "draft"); assert_eq!(body["status"], "draft");
} }
#[tokio::test]
async fn welcome_generates_and_appears_on_public_profile() {
let app = require_app!();
let token = app.register_and_token().await;
let (id, handle) = create_card(&app, &token).await;
// Generate the AI welcome (FakeAiClient returns a 1x1 png).
let (status, body) = app
.request(
"POST",
&format!("/v1/cards/{id}/welcome"),
Some(&token),
Some(json!({ "prompt": "a calm forest at dawn", "message": "Great to meet you, I'm Omar" })),
)
.await;
assert_eq!(status, StatusCode::OK, "set welcome failed: {body}");
assert_eq!(body["welcome"]["kind"], "image");
assert!(body["welcome"]["imageDataUrl"]
.as_str()
.unwrap()
.starts_with("data:image/png;base64,"));
// Publish -> the public profile exposes the welcome.
app.request(
"POST",
&format!("/v1/cards/{id}/publish"),
Some(&token),
None,
)
.await;
let (status, profile) = app
.request("GET", &format!("/v1/cards/handle/{handle}"), None, None)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(profile["welcome"]["message"], "Great to meet you, I'm Omar");
assert!(profile["welcome"]["imageDataUrl"]
.as_str()
.unwrap()
.starts_with("data:image/png"));
}
#[tokio::test]
async fn welcome_requires_ownership() {
let app = require_app!();
let owner = app.register_and_token().await;
let (id, _handle) = create_card(&app, &owner).await;
let other = app.register_and_token().await;
let (status, _) = app
.request(
"POST",
&format!("/v1/cards/{id}/welcome"),
Some(&other),
Some(json!({ "prompt": "x" })),
)
.await;
assert_eq!(status, StatusCode::NOT_FOUND);
}
@@ -0,0 +1,5 @@
-- AI Welcome (PRD §21 Phase 5, "scan experiences"): an optional generated
-- greeting shown when the public profile is scanned. Stored as JSONB:
-- { "kind": "image", "message": "...", "imageDataUrl": "data:image/png;base64,..." }
-- (image inlined as a data URL for the MVP; swaps to an R2 URL later).
ALTER TABLE cards ADD COLUMN welcome JSONB;
@@ -17,4 +17,7 @@ pub struct CardRow {
pub version: i32, pub version: i32,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>, pub updated_at: DateTime<Utc>,
/// Optional AI welcome shown on scan: { kind, message, imageDataUrl }.
#[serde(skip_serializing_if = "Option::is_none")]
pub welcome: Option<serde_json::Value>,
} }
@@ -17,7 +17,7 @@ pub async fn insert(db: &Db, new: NewCard<'_>) -> Result<CardRow, sqlx::Error> {
INSERT INTO cards (owner_id, handle, definition) INSERT INTO cards (owner_id, handle, definition)
VALUES ($1, $2, $3) VALUES ($1, $2, $3)
RETURNING id, owner_id, handle, status, definition, version, RETURNING id, owner_id, handle, status, definition, version,
created_at, updated_at created_at, updated_at, welcome
"#, "#,
) )
.bind(new.owner_id) .bind(new.owner_id)
@@ -29,7 +29,7 @@ pub async fn insert(db: &Db, new: NewCard<'_>) -> Result<CardRow, sqlx::Error> {
pub async fn find_by_id(db: &Db, id: Uuid) -> Result<Option<CardRow>, sqlx::Error> { pub async fn find_by_id(db: &Db, id: Uuid) -> Result<Option<CardRow>, sqlx::Error> {
sqlx::query_as( sqlx::query_as(
r#"SELECT id, owner_id, handle, status, definition, version, created_at, updated_at r#"SELECT id, owner_id, handle, status, definition, version, created_at, updated_at, welcome
FROM cards WHERE id = $1"#, FROM cards WHERE id = $1"#,
) )
.bind(id) .bind(id)
@@ -39,7 +39,7 @@ pub async fn find_by_id(db: &Db, id: Uuid) -> Result<Option<CardRow>, sqlx::Erro
pub async fn list_by_owner(db: &Db, owner_id: Uuid) -> Result<Vec<CardRow>, sqlx::Error> { pub async fn list_by_owner(db: &Db, owner_id: Uuid) -> Result<Vec<CardRow>, sqlx::Error> {
sqlx::query_as( sqlx::query_as(
r#"SELECT id, owner_id, handle, status, definition, version, created_at, updated_at r#"SELECT id, owner_id, handle, status, definition, version, created_at, updated_at, welcome
FROM cards WHERE owner_id = $1 ORDER BY created_at DESC"#, FROM cards WHERE owner_id = $1 ORDER BY created_at DESC"#,
) )
.bind(owner_id) .bind(owner_id)
@@ -51,7 +51,7 @@ pub async fn list_by_owner(db: &Db, owner_id: Uuid) -> Result<Vec<CardRow>, sqlx
/// review A2 — the profile resolves the active card's handle). /// review A2 — the profile resolves the active card's handle).
pub async fn find_active_by_handle(db: &Db, handle: &str) -> Result<Option<CardRow>, sqlx::Error> { pub async fn find_active_by_handle(db: &Db, handle: &str) -> Result<Option<CardRow>, sqlx::Error> {
sqlx::query_as( sqlx::query_as(
r#"SELECT id, owner_id, handle, status, definition, version, created_at, updated_at r#"SELECT id, owner_id, handle, status, definition, version, created_at, updated_at, welcome
FROM cards WHERE handle = $1 AND status = 'active'"#, FROM cards WHERE handle = $1 AND status = 'active'"#,
) )
.bind(handle) .bind(handle)
@@ -80,7 +80,7 @@ pub async fn update_definition(
UPDATE cards UPDATE cards
SET definition = $2, version = version + 1, updated_at = now() SET definition = $2, version = version + 1, updated_at = now()
WHERE id = $1 WHERE id = $1
RETURNING id, owner_id, handle, status, definition, version, created_at, updated_at RETURNING id, owner_id, handle, status, definition, version, created_at, updated_at, welcome
"#, "#,
) )
.bind(id) .bind(id)
@@ -89,12 +89,31 @@ pub async fn update_definition(
.await .await
} }
/// Set (or clear) the AI welcome shown on scan.
pub async fn update_welcome(
db: &Db,
id: Uuid,
welcome: &serde_json::Value,
) -> Result<CardRow, sqlx::Error> {
sqlx::query_as(
r#"
UPDATE cards SET welcome = $2, updated_at = now()
WHERE id = $1
RETURNING id, owner_id, handle, status, definition, version, created_at, updated_at, welcome
"#,
)
.bind(id)
.bind(welcome)
.fetch_one(db)
.await
}
pub async fn set_status(db: &Db, id: Uuid, status: &str) -> Result<CardRow, sqlx::Error> { pub async fn set_status(db: &Db, id: Uuid, status: &str) -> Result<CardRow, sqlx::Error> {
sqlx::query_as( sqlx::query_as(
r#" r#"
UPDATE cards SET status = $2, updated_at = now() UPDATE cards SET status = $2, updated_at = now()
WHERE id = $1 WHERE id = $1
RETURNING id, owner_id, handle, status, definition, version, created_at, updated_at RETURNING id, owner_id, handle, status, definition, version, created_at, updated_at, welcome
"#, "#,
) )
.bind(id) .bind(id)
@@ -0,0 +1,94 @@
---
// AI Welcome scan experience (PRD §21 Phase 5): a full-bleed, AI-generated hero
// with a personal greeting that plays first when the QR is scanned, then the
// visitor scrolls down to the profile + Save Contact.
interface Props {
message: string;
name: string;
imageUrl: string;
}
const { message, name, imageUrl } = Astro.props;
---
<section class="welcome">
<img src={imageUrl} alt="" class="bg" />
<div class="scrim"></div>
<div class="overlay">
<p class="greeting">{message}</p>
<h1 class="name">{name}</h1>
<div class="cue">
<span>Scroll to connect</span>
<span class="chev">↓</span>
</div>
</div>
</section>
<style>
.welcome {
position: relative;
width: 100%;
height: 100vh;
min-height: 100svh;
overflow: hidden;
display: flex;
align-items: flex-end;
}
.bg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.scrim {
position: absolute;
inset: 0;
background: linear-gradient(
to top,
rgba(10, 10, 12, 0.95) 0%,
rgba(10, 10, 12, 0.25) 45%,
rgba(10, 10, 12, 0) 70%
);
}
.overlay {
position: relative;
width: 100%;
max-width: 480px;
margin: 0 auto;
padding: 0 24px 56px;
color: #fff;
}
.greeting {
margin: 0 0 6px;
font-size: 22px;
font-weight: 600;
color: #f5f5f7;
line-height: 1.3;
}
.name {
margin: 0;
font-size: 34px;
font-weight: 800;
letter-spacing: -0.02em;
}
.cue {
margin-top: 22px;
display: flex;
align-items: center;
gap: 8px;
color: #cfcfd4;
font-size: 14px;
}
.chev {
animation: bob 1.4s ease-in-out infinite;
}
@keyframes bob {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(5px);
}
}
</style>
+9
View File
@@ -10,6 +10,15 @@ export interface PublicProfile {
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
ownerDisplayName: string; ownerDisplayName: string;
/** Optional AI welcome shown on scan (set by the card owner). */
welcome?: Welcome;
}
export interface Welcome {
kind: "image";
message: string;
/** `data:image/...;base64,...` (MVP) or an https URL later. */
imageDataUrl: string;
} }
// Loose mirror of the Rust CardDefinition — only the parts the profile reads. // Loose mirror of the Rust CardDefinition — only the parts the profile reads.
@@ -3,6 +3,7 @@
// active card, renders the hero + sticky contact-actions bar, and wires the // active card, renders the hero + sticky contact-actions bar, and wires the
// Save Contact (.vcf) download with a contact_save analytics ping. // Save Contact (.vcf) download with a contact_save analytics ping.
import CardHero from "../components/CardHero.astro"; import CardHero from "../components/CardHero.astro";
import WelcomeHero from "../components/WelcomeHero.astro";
import { fetchProfile, publicApiBase } from "../lib/api"; import { fetchProfile, publicApiBase } from "../lib/api";
import { buildVcard, extractContact, faceBackground } from "../lib/contact"; import { buildVcard, extractContact, faceBackground } from "../lib/contact";
@@ -13,6 +14,11 @@ if (!profile) {
return new Response("Profile not found", { status: 404 }); return new Response("Profile not found", { status: 404 });
} }
// Scan-experience selector. The AI Welcome plays when the card has one, unless
// the scan explicitly asks for the plain profile (?x=profile).
const experience = Astro.url.searchParams.get("x");
const welcome = experience === "profile" ? null : profile.welcome;
const contact = extractContact(profile.definition); const contact = extractContact(profile.definition);
const bgHex = faceBackground(profile.definition); const bgHex = faceBackground(profile.definition);
const name = profile.ownerDisplayName; const name = profile.ownerDisplayName;
@@ -36,6 +42,9 @@ 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 && (
<WelcomeHero message={welcome.message} name={name} imageUrl={welcome.imageDataUrl} />
)}
<main> <main>
<CardHero <CardHero
name={name} name={name}