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
+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 () => {
if (!imageUri && !videoUri) return;
setSaving(true);
try {
const imagePath = imageUri ? await persistImage(imageUri, id) : (getById(id)?.imagePath ?? "");
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);
await persistCard();
setOnBack(false);
setShowing(true);
} 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 existing = getById(id);
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>}
</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 && (
<Pressable onPress={onDelete}>
<Text style={styles.deleteText}>Delete card</Text>
@@ -365,6 +400,15 @@ const styles = StyleSheet.create({
marginTop: 8,
},
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 },
deleteText: { color: "#ff453a", textAlign: "center", paddingVertical: 14, fontWeight: "600" },
viewerRoot: { flex: 1, backgroundColor: "#0a0a0c" },