Mobile "Publish to web" + anonymous demo publish (AI welcome, no auth)
CI / policy (push) Has been cancelled
CI / backend (push) Has been cancelled
CI / profile (push) Has been cancelled
CI / mobile (push) Has been cancelled

Turns a no-login demo card into a real, scannable web profile with an optional
AI welcome — completing the owner side of the welcome feature without auth.

Backend:
- POST /v1/demo/publish (public, rate-limited 10/h/IP): creates a fresh
  password-less owner (so the profile shows the right name + fits the free-tier
  1-card limit), builds a card (contact title + profile links), optionally
  generates an AI welcome, and publishes. Returns { handle, profileUrl }.
- demo_service + handlers/demo; 2 tests (publish→public profile w/ welcome,
  name required). Gate green (120 tests). Verified live: publish → Astro renders
  the welcome hero + image (from MinIO) + We-Met form, 18KB page.

Mobile:
- publish.tsx: enter an optional AI welcome scene + message, Publish → shows the
  live cardclaws URL (Open/Done) and points the card's QR at it.
- demo.tsx: "Publish to web · AI welcome" button (saves, then opens publish).
- api.publishToWeb; LocalCard.publishedUrl.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-05 20:29:33 -05:00
co-authored by Claude Opus 4.8
parent 63fdc4913b
commit fd7a55e809
10 changed files with 447 additions and 6 deletions
@@ -0,0 +1,40 @@
//! Anonymous demo "publish to web" handler (no auth). Rate-limited because it
//! creates a card + may run AI generation.
use axum::extract::State;
use axum::http::HeaderMap;
use axum::Json;
use serde::Serialize;
use crate::error::ApiResult;
use crate::middleware::rate_limit;
use crate::services::demo_service::{self, DemoPublishRequest};
use crate::state::AppState;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DemoPublishResponse {
pub handle: String,
pub profile_url: String,
}
pub async fn publish(
State(state): State<AppState>,
headers: HeaderMap,
Json(req): Json<DemoPublishRequest>,
) -> ApiResult<Json<DemoPublishResponse>> {
let ip = crate::handlers::analytics::client_ip(&headers).unwrap_or_else(|| "unknown".into());
rate_limit::check(
state.cache.as_ref(),
&format!("demo_publish:{ip}"),
10,
3600,
)
.await?;
let published = demo_service::publish(&state, &req).await?;
Ok(Json(DemoPublishResponse {
handle: published.handle,
profile_url: published.profile_url,
}))
}
@@ -4,6 +4,7 @@ pub mod analytics;
pub mod assets; pub mod assets;
pub mod auth; pub mod auth;
pub mod cards; pub mod cards;
pub mod demo;
pub mod health; pub mod health;
pub mod profile; pub mod profile;
pub mod share; pub mod share;
@@ -5,7 +5,8 @@ use axum::routing::{get, post};
use axum::Router; use axum::Router;
use crate::handlers::{ use crate::handlers::{
account, ai, analytics, assets, auth, cards, health, profile, share, teams, wallet, webhooks, account, ai, analytics, assets, auth, cards, demo, health, profile, share, teams, wallet,
webhooks,
}; };
use crate::middleware::cors; use crate::middleware::cors;
use crate::state::AppState; use crate::state::AppState;
@@ -68,6 +69,7 @@ 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("/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))
@@ -0,0 +1,132 @@
//! Anonymous "publish to web" for the no-login demo: turns a demo card into a
//! real, scannable public profile (with an optional AI welcome) without auth.
//! Each publish creates a fresh password-less owner so the profile shows the
//! right name and stays within the free-tier 1-card limit.
use cardclaws_db::queries::users;
use cardclaws_types::{AppError, Tier};
use serde::Deserialize;
use uuid::Uuid;
use crate::error::SqlxResultExt;
use crate::services::card_service;
use crate::state::AppState;
#[derive(Deserialize)]
pub struct DemoLink {
pub label: String,
pub url: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DemoPublishRequest {
pub name: String,
#[serde(default)]
pub title: Option<String>,
#[serde(default)]
pub links: Vec<DemoLink>,
#[serde(default)]
pub welcome_prompt: Option<String>,
#[serde(default)]
pub welcome_message: Option<String>,
}
pub struct Published {
pub handle: String,
pub profile_url: String,
}
pub async fn publish(state: &AppState, req: &DemoPublishRequest) -> Result<Published, AppError> {
if req.name.trim().is_empty() {
return Err(AppError::Validation("name is required".into()));
}
let suffix: String = Uuid::new_v4()
.simple()
.to_string()
.chars()
.take(10)
.collect();
let card_handle = format!("{}-{}", slugify(&req.name), &suffix[..6]);
// Fresh password-less owner so the profile shows this person's name.
let user = users::insert(
&state.db,
users::NewUser {
email: &format!("demo-{suffix}@cardclaws.local"),
handle: &format!("u{suffix}"),
display_name: req.name.trim(),
password_hash: None,
},
)
.await
.map_db()?;
let links: Vec<serde_json::Value> = req
.links
.iter()
.filter(|l| !l.url.trim().is_empty())
.map(|l| serde_json::json!({ "label": l.label, "url": l.url }))
.collect();
let definition = serde_json::json!({
"face": { "layers": [], "background": { "type": "solid", "value": "#101014" } },
"back": {
"layers": [{
"id": Uuid::new_v4(),
"type": "contact",
"x": 0.1, "y": 0.1, "width": 0.8, "height": 0.2, "opacity": 1, "zIndex": 1,
"fields": { "title": req.title }
}],
"background": { "type": "solid", "value": "#101014" }
},
"profile": { "links": links }
});
let card = card_service::create(state, user.id, &card_handle, &definition).await?;
if let Some(prompt) = req.welcome_prompt.as_deref() {
if !prompt.trim().is_empty() {
card_service::set_welcome(
state,
card.id,
user.id,
prompt,
req.welcome_message.as_deref(),
None,
None,
)
.await?;
}
}
card_service::publish(state, card.id, user.id, Tier::Free).await?;
Ok(Published {
profile_url: format!("{}/{}", state.profile_base_url, card.handle),
handle: card.handle,
})
}
/// Lowercase, hyphenate, trim to a valid handle stem (validate_handle adds the
/// final rules; the random suffix guarantees uniqueness).
fn slugify(name: &str) -> String {
let mut out = String::new();
let mut prev_dash = false;
for c in name.trim().to_lowercase().chars() {
if c.is_ascii_alphanumeric() {
out.push(c);
prev_dash = false;
} else if !prev_dash {
out.push('-');
prev_dash = true;
}
}
let stem: String = out.trim_matches('-').chars().take(20).collect();
let stem = stem.trim_matches('-').to_string();
if stem.chars().count() < 2 {
"card".to_string()
} else {
stem
}
}
@@ -3,6 +3,7 @@ pub mod analytics_service;
pub mod auth_service; pub mod auth_service;
pub mod billing_service; pub mod billing_service;
pub mod card_service; pub mod card_service;
pub mod demo_service;
pub mod profile_service; pub mod profile_service;
pub mod share_service; pub mod share_service;
pub mod team_service; pub mod team_service;
@@ -331,6 +331,50 @@ async fn we_met_capture_and_owner_list() {
assert_eq!(s, StatusCode::BAD_REQUEST); assert_eq!(s, StatusCode::BAD_REQUEST);
} }
#[tokio::test]
async fn demo_publish_creates_scannable_profile_with_welcome() {
let app = require_app!();
let (status, body) = app
.request(
"POST",
"/v1/demo/publish",
None,
Some(json!({
"name": "Omar Sobh",
"title": "Founder",
"links": [{ "label": "Site", "url": "https://cardclaws.com" }],
"welcomePrompt": "a calm forest at dawn",
"welcomeMessage": "Great to meet you"
})),
)
.await;
assert_eq!(status, StatusCode::OK, "publish failed: {body}");
let handle = body["handle"].as_str().unwrap();
assert!(body["profileUrl"].as_str().unwrap().ends_with(handle));
// The published card resolves publicly with the owner name + welcome.
let (status, profile) = app
.request("GET", &format!("/v1/cards/handle/{handle}"), None, None)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(profile["ownerDisplayName"], "Omar Sobh");
assert_eq!(profile["welcome"]["message"], "Great to meet you");
}
#[tokio::test]
async fn demo_publish_requires_a_name() {
let app = require_app!();
let (status, _) = app
.request(
"POST",
"/v1/demo/publish",
None,
Some(json!({ "name": " " })),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
}
#[tokio::test] #[tokio::test]
async fn connections_list_is_owner_only() { async fn connections_list_is_owner_only() {
let app = require_app!(); let app = require_app!();
+49 -5
View File
@@ -178,15 +178,31 @@ export default function CardEditorScreen() {
} }
}; };
// Persist the current edits to the local gallery (shared by Save + Publish).
const persistCard = async () => {
const existing = getById(id);
const imagePath = imageUri ? await persistImage(imageUri, id) : (existing?.imagePath ?? "");
const videoPath = videoUri ? await persistVideo(videoUri, id) : undefined;
upsert({
id,
name,
title,
url,
imagePath,
videoPath,
links,
publishedUrl: existing?.publishedUrl,
updatedAt: Date.now(),
});
if (imageUri) setImageUri(imagePath);
if (videoPath) setVideoUri(videoPath);
};
const save = async () => { const save = async () => {
if (!imageUri && !videoUri) return; if (!imageUri && !videoUri) return;
setSaving(true); setSaving(true);
try { try {
const imagePath = imageUri ? await persistImage(imageUri, id) : (getById(id)?.imagePath ?? ""); await persistCard();
const videoPath = videoUri ? await persistVideo(videoUri, id) : undefined;
upsert({ id, name, title, url, imagePath, videoPath, links, updatedAt: Date.now() });
if (imageUri) setImageUri(imagePath);
if (videoPath) setVideoUri(videoPath);
setOnBack(false); setOnBack(false);
setShowing(true); setShowing(true);
} finally { } finally {
@@ -194,6 +210,16 @@ export default function CardEditorScreen() {
} }
}; };
const goPublish = async () => {
setSaving(true);
try {
await persistCard();
} finally {
setSaving(false);
}
router.push(`/publish?cardId=${id}`);
};
const onDelete = async () => { const onDelete = async () => {
const existing = getById(id); const existing = getById(id);
if (existing?.imagePath) await deleteImage(existing.imagePath); if (existing?.imagePath) await deleteImage(existing.imagePath);
@@ -292,6 +318,15 @@ export default function CardEditorScreen() {
{saving ? <ActivityIndicator color="#fff" /> : <Text style={styles.ctaText}>Save card</Text>} {saving ? <ActivityIndicator color="#fff" /> : <Text style={styles.ctaText}>Save card</Text>}
</Pressable> </Pressable>
<Pressable
style={[styles.publishBtn, saving && styles.ctaDisabled]}
disabled={saving}
onPress={goPublish}
>
<MaterialCommunityIcons name="web" size={20} color="#f5f5f7" />
<Text style={styles.photoText}>Publish to web · AI welcome</Text>
</Pressable>
{cardId && ( {cardId && (
<Pressable onPress={onDelete}> <Pressable onPress={onDelete}>
<Text style={styles.deleteText}>Delete card</Text> <Text style={styles.deleteText}>Delete card</Text>
@@ -365,6 +400,15 @@ const styles = StyleSheet.create({
marginTop: 8, marginTop: 8,
}, },
ctaDisabled: { opacity: 0.4 }, ctaDisabled: { opacity: 0.4 },
publishBtn: {
flexDirection: "row",
gap: 8,
backgroundColor: "#222228",
borderRadius: 16,
paddingVertical: 15,
alignItems: "center",
justifyContent: "center",
},
ctaText: { color: "#fff", fontWeight: "700", fontSize: 17 }, ctaText: { color: "#fff", fontWeight: "700", fontSize: 17 },
deleteText: { color: "#ff453a", textAlign: "center", paddingVertical: 14, fontWeight: "600" }, deleteText: { color: "#ff453a", textAlign: "center", paddingVertical: 14, fontWeight: "600" },
viewerRoot: { flex: 1, backgroundColor: "#0a0a0c" }, viewerRoot: { flex: 1, backgroundColor: "#0a0a0c" },
+155
View File
@@ -0,0 +1,155 @@
// Publish a saved demo card to the web (no login) — optionally generating an AI
// welcome shown when the QR is scanned. Returns a real cardclaws.com/<handle>
// URL and points the card's QR at it.
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useState } from "react";
import {
ActivityIndicator,
Alert,
Linking,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
} from "react-native";
import { publishToWeb } from "../src/api/ai";
import { wizardInputStyle } from "../src/components/genwizard/Wizard";
import { useLocalCardsStore } from "../src/stores/localCardsStore";
export default function PublishScreen() {
const router = useRouter();
const { cardId } = useLocalSearchParams<{ cardId?: string }>();
const card = useLocalCardsStore((s) => (cardId ? s.getById(cardId) : undefined));
const upsert = useLocalCardsStore((s) => s.upsert);
const [welcomePrompt, setWelcomePrompt] = useState("");
const [welcomeMessage, setWelcomeMessage] = useState("Great to meet you");
const [busy, setBusy] = useState(false);
const [result, setResult] = useState<string | null>(card?.publishedUrl ?? null);
if (!card) {
return (
<ScrollView style={styles.root} contentContainerStyle={styles.content}>
<Stack.Screen options={{ title: "Publish" }} />
<Text style={styles.note}>Save the card first, then publish.</Text>
</ScrollView>
);
}
const publish = async () => {
setBusy(true);
try {
const { profileUrl } = await publishToWeb({
name: card.name,
title: card.title || undefined,
links: card.links.filter((l) => l.url.trim()).map((l) => ({ label: l.label, url: l.url })),
welcomePrompt: welcomePrompt.trim() || undefined,
welcomeMessage: welcomePrompt.trim() ? welcomeMessage : undefined,
});
// Point the card's QR at the real profile + remember it.
upsert({ ...card, url: profileUrl, publishedUrl: profileUrl, updatedAt: Date.now() });
setResult(profileUrl);
} catch (e) {
const msg = e instanceof Error ? e.message : "Please try again in a moment.";
Alert.alert("Couldn't publish", msg);
} finally {
setBusy(false);
}
};
return (
<ScrollView style={styles.root} contentContainerStyle={styles.content}>
<Stack.Screen options={{ title: "Publish to web" }} />
<Text style={styles.h1}>Publish “{card.name}”</Text>
<Text style={styles.note}>
Creates a real, scannable page at cardclaws.com — your card’s QR will point to it.
</Text>
<Text style={styles.label}>AI welcome scene (optional)</Text>
<TextInput
style={wizardInputStyle}
multiline
editable={!busy}
placeholder="e.g. a calm misty redwood forest at dawn — plays when scanned"
placeholderTextColor="#6b6b70"
value={welcomePrompt}
onChangeText={setWelcomePrompt}
/>
{!!welcomePrompt.trim() && (
<>
<Text style={styles.label}>Welcome message</Text>
<TextInput
style={styles.input}
editable={!busy}
placeholder="Great to meet you"
placeholderTextColor="#6b6b70"
value={welcomeMessage}
onChangeText={setWelcomeMessage}
/>
</>
)}
<Pressable style={[styles.cta, busy && styles.ctaBusy]} disabled={busy} onPress={publish}>
{busy ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.ctaText}>{result ? "Re-publish" : "Publish to web"}</Text>
)}
</Pressable>
{busy && !!welcomePrompt.trim() && (
<Text style={styles.note}>Generating your AI welcome, then publishing…</Text>
)}
{result && (
<>
<Text style={styles.label}>Your live page</Text>
<Text style={styles.url} selectable>
{result}
</Text>
<Pressable style={styles.secondary} onPress={() => Linking.openURL(result)}>
<Text style={styles.secondaryText}>Open page</Text>
</Pressable>
<Pressable style={styles.secondary} onPress={() => router.replace("/")}>
<Text style={styles.secondaryText}>Done</Text>
</Pressable>
</>
)}
</ScrollView>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: "#0a0a0c" },
content: { padding: 20, gap: 12 },
h1: { color: "#f5f5f7", fontSize: 26, fontWeight: "800" },
note: { color: "#9a9aa0", fontSize: 14 },
label: { color: "#9a9aa0", fontSize: 15, marginTop: 8 },
input: {
backgroundColor: "#15151a",
color: "#f5f5f7",
borderRadius: 12,
paddingHorizontal: 14,
paddingVertical: 12,
fontSize: 16,
},
cta: {
backgroundColor: "#ff3b30",
borderRadius: 16,
paddingVertical: 16,
alignItems: "center",
marginTop: 12,
},
ctaBusy: { opacity: 0.8 },
ctaText: { color: "#fff", fontWeight: "700", fontSize: 17 },
url: { color: "#4da3ff", fontSize: 16 },
secondary: {
backgroundColor: "#222228",
borderRadius: 14,
paddingVertical: 14,
alignItems: "center",
},
secondaryText: { color: "#f5f5f7", fontWeight: "600", fontSize: 16 },
});
+20
View File
@@ -49,3 +49,23 @@ export async function pollVideo(operationId: string): Promise<VideoStatus> {
); );
return res.data; return res.data;
} }
export interface PublishPayload {
name: string;
title?: string;
links: { label: string; url: string }[];
welcomePrompt?: string;
welcomeMessage?: string;
}
/** Publish a demo card to the web (no auth) → a real, scannable profile URL. */
export async function publishToWeb(
payload: PublishPayload,
): Promise<{ handle: string; profileUrl: string }> {
const res = await api.post<{ handle: string; profileUrl: string }>(
"/v1/demo/publish",
payload,
{ timeout: 120000 },
);
return res.data;
}
@@ -29,6 +29,8 @@ export interface LocalCard {
videoPath?: string; videoPath?: string;
/** Back-of-card links (domain, repos, publications, services…). */ /** Back-of-card links (domain, repos, publications, services…). */
links: CardLink[]; links: CardLink[];
/** Public profile URL once published to the web (cardclaws.com/<handle>). */
publishedUrl?: string;
updatedAt: number; updatedAt: number;
} }