Files
Omar SobhandClaude Opus 4.8 a19f5abc6f
CI / policy (push) Has been cancelled
CI / backend (push) Has been cancelled
CI / profile (push) Has been cancelled
CI / mobile (push) Has been cancelled
Publish: real card photo on the web hero + event banner (#5)
- Photo on web: the publish flow uploads the card's front photo (base64 →
  object storage) and sets it as the face image; the web profile renders it as
  a full-bleed hero (when there's no AI welcome). /demo/publish body limit
  raised to 12MB for the image. Verified live (image served from MinIO,
  photo-hero rendered).
- Event mode (#5): optional `event` → a "📍 …" banner at the top of the profile.
- Mobile publish screen: Event field + sends the front photo.
- Gate green (120 tests), astro check clean, mobile tsc+jest green.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-05 21:35:53 -05:00

261 lines
8.8 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 * as FileSystem from "expo-file-system";
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useState } from "react";
import {
ActivityIndicator,
Alert,
Linking,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from "react-native";
import { publishToWeb } from "../src/api/ai";
import { QRCodeView } from "../src/components/card/QRCodeView";
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 [payloadKind, setPayloadKind] = useState<"none" | "link" | "code">("none");
const [payloadLabel, setPayloadLabel] = useState("");
const [payloadValue, setPayloadValue] = useState("");
const [now, setNow] = useState("");
const [event, setEvent] = useState("");
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 {
// Upload the card's front photo so the web hero shows the real card.
let frontImageBase64: string | undefined;
let frontImageMime: string | undefined;
if (card.imagePath) {
try {
frontImageBase64 = await FileSystem.readAsStringAsync(card.imagePath, {
encoding: FileSystem.EncodingType.Base64,
});
frontImageMime = card.imagePath.endsWith(".png") ? "image/png" : "image/jpeg";
} catch {
/* no readable photo — publish without it */
}
}
const { profileUrl } = await publishToWeb({
name: card.name,
title: card.title || undefined,
frontImageBase64,
frontImageMime,
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,
payload:
payloadKind !== "none" && payloadValue.trim()
? { kind: payloadKind, label: payloadLabel.trim(), value: payloadValue.trim() }
: undefined,
now: now.trim() || undefined,
event: event.trim() || 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 cards 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}
/>
</>
)}
<Text style={styles.label}>Event (optional)</Text>
<TextInput
style={styles.input}
editable={!busy}
placeholder="e.g. SXSW 2026"
placeholderTextColor="#6b6b70"
value={event}
onChangeText={setEvent}
/>
<Text style={styles.label}>Now what youre up to (optional)</Text>
<TextInput
style={styles.input}
editable={!busy}
placeholder="e.g. Building CardClaws · open to design partners"
placeholderTextColor="#6b6b70"
value={now}
onChangeText={setNow}
/>
<Text style={styles.label}>Payload drop (optional)</Text>
<View style={styles.kindRow}>
{(["none", "link", "code"] as const).map((k) => (
<Pressable
key={k}
onPress={() => setPayloadKind(k)}
style={[styles.kindChip, payloadKind === k && styles.kindOn]}
>
<Text style={[styles.kindText, payloadKind === k && styles.kindTextOn]}>
{k === "none" ? "None" : k === "link" ? "Link" : "Code"}
</Text>
</Pressable>
))}
</View>
{payloadKind !== "none" && (
<>
<TextInput
style={styles.input}
editable={!busy}
placeholder={payloadKind === "code" ? "Label (e.g. 20% off)" : "Button label (e.g. Get the deck)"}
placeholderTextColor="#6b6b70"
value={payloadLabel}
onChangeText={setPayloadLabel}
/>
<TextInput
style={styles.input}
editable={!busy}
autoCapitalize={payloadKind === "code" ? "characters" : "none"}
placeholder={payloadKind === "code" ? "CODE123" : "https://…"}
placeholderTextColor="#6b6b70"
value={payloadValue}
onChangeText={setPayloadValue}
/>
</>
)}
<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 show this to scan</Text>
<View style={styles.qrWrap}>
<QRCodeView value={result} size={220} />
</View>
<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 },
kindRow: { flexDirection: "row", gap: 8 },
kindChip: {
flex: 1,
paddingVertical: 10,
borderRadius: 10,
backgroundColor: "#222228",
alignItems: "center",
},
kindOn: { backgroundColor: "#ff3b30" },
kindText: { color: "#9a9aa0", fontWeight: "600" },
kindTextOn: { color: "#fff" },
qrWrap: {
backgroundColor: "#ffffff",
borderRadius: 16,
padding: 14,
alignSelf: "center",
},
url: { color: "#4da3ff", fontSize: 16 },
secondary: {
backgroundColor: "#222228",
borderRadius: 14,
paddingVertical: 14,
alignItems: "center",
},
secondaryText: { color: "#f5f5f7", fontWeight: "600", fontSize: 16 },
});