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,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 },
|
||||
});
|
||||
Reference in New Issue
Block a user