AI Welcome scan experience — backend + web profile
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:
co-authored by
Claude Opus 4.8
parent
a66978910d
commit
5aba3ef1a1
@@ -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>
|
||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user