Mobile "Publish to web" + anonymous demo publish (AI welcome, no auth)
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:
co-authored by
Claude Opus 4.8
parent
63fdc4913b
commit
fd7a55e809
@@ -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!();
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user