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
@@ -21,6 +21,42 @@ pub async fn get_owned(state: &AppState, id: Uuid, user_id: Uuid) -> Result<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> {
cards::list_by_owner(&state.db, user_id).await.map_db()
}