Three card families: Collectibles (mint) + Agents (skill-bar back); New-card under cards
CI / policy (push) Has been cancelled
CI / backend (push) Has been cancelled
CI / profile (push) Has been cancelled
CI / mobile (push) Has been cancelled

- localCardsStore: cards gain `kind` (business/collectible/agent) + optional
  provenance (collectibles) and agent meta (agents); cardsOfKind() filter.
- Cards tab: "+ New card" moved to a footer below the cards (no floating FAB);
  filtered to business cards.
- Collectibles + Agents tabs: real grids (CardCollection) with empty states and
  a "+ New" footer that opens the creator pre-set to that kind.
- Creator (demo.tsx) is kind-aware: collectibles mint a provenance record on
  save (utils/provenance stub, mapped to the creator); agents capture flip-side
  details (AgentDetailsEditor: tagline, skill bars, tools, capabilities).
- Card back per kind: AgentBackTemplate (skill bars/tools/capabilities) for
  agents; CardBackTemplate gains a "Minted · #token · chain" footer for
  collectibles. Both reuse the AI image/video front workflow.
- tsc + 28 jest green.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-06 00:16:06 -05:00
co-authored by Claude Opus 4.8
parent e77e812609
commit d30deaf857
10 changed files with 516 additions and 60 deletions
+10 -22
View File
@@ -1,27 +1,15 @@
// Work-agent cards — AI agents attached to your cards. None yet; this is their // Agents: cards for your AI assistants. Front is an AI-generated portrait; the
// home. // flip side shows skill bars, tools, and special capabilities.
import { MaterialCommunityIcons } from "@expo/vector-icons"; import { CardCollection } from "../../src/components/CardCollection";
import { StyleSheet, Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
export default function Agents() { export default function Agents() {
const insets = useSafeAreaInsets();
return ( return (
<View style={[styles.root, { paddingTop: insets.top }]}> <CardCollection
<View style={styles.center}> kind="agent"
<MaterialCommunityIcons name="robot" size={48} color="#3a3a44" /> heading="Agents"
<Text style={styles.title}>No agent cards yet</Text> addLabel="+ New agent"
<Text style={styles.hint}> emptyTitle="No agent cards yet"
Cards backed by an AI work agent will appear here. emptyHint="Generate a portrait of your assistant; flip it for skills & tools."
</Text> />
</View>
</View>
); );
} }
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: "#0a0a0c" },
center: { flex: 1, alignItems: "center", justifyContent: "center", padding: 32, gap: 10 },
title: { color: "#f5f5f7", fontSize: 20, fontWeight: "700" },
hint: { color: "#6b6b70", fontSize: 14, textAlign: "center", lineHeight: 20 },
});
+10 -22
View File
@@ -1,27 +1,15 @@
// Collectible cards — cards you've collected from people you meet (a deck you // Collectibles: AI-generated cards minted to the blockchain (provenance mapped
// build up). No collectibles yet; this is the home for them. // to the creator). Create flow reuses the card creator with kind=collectible.
import { MaterialCommunityIcons } from "@expo/vector-icons"; import { CardCollection } from "../../src/components/CardCollection";
import { StyleSheet, Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
export default function Collectibles() { export default function Collectibles() {
const insets = useSafeAreaInsets();
return ( return (
<View style={[styles.root, { paddingTop: insets.top }]}> <CardCollection
<View style={styles.center}> kind="collectible"
<MaterialCommunityIcons name="sack" size={48} color="#3a3a44" /> heading="Collectibles"
<Text style={styles.title}>No collectibles yet</Text> addLabel="+ New collectible"
<Text style={styles.hint}> emptyTitle="No collectibles yet"
Cards you collect from people you meet will live here build your deck. emptyHint="Create one with AI — it's minted to the chain and mapped to you."
</Text> />
</View>
</View>
); );
} }
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: "#0a0a0c" },
center: { flex: 1, alignItems: "center", justifyContent: "center", padding: 32, gap: 10 },
title: { color: "#f5f5f7", fontSize: 20, fontWeight: "700" },
hint: { color: "#6b6b70", fontSize: 14, textAlign: "center", lineHeight: 20 },
});
+5 -7
View File
@@ -6,7 +6,7 @@ import { useState } from "react";
import { FlatList, Pressable, StyleSheet, Text, View } from "react-native"; import { FlatList, Pressable, StyleSheet, Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { CardThumb } from "../../src/components/CardThumb"; import { CardThumb } from "../../src/components/CardThumb";
import { LocalCard, useLocalCardsStore } from "../../src/stores/localCardsStore"; import { cardsOfKind, LocalCard, useLocalCardsStore } from "../../src/stores/localCardsStore";
type ViewMode = "grid" | "showcase"; type ViewMode = "grid" | "showcase";
@@ -20,7 +20,10 @@ function columnsFor(count: number): number {
export default function Gallery() { export default function Gallery() {
const router = useRouter(); const router = useRouter();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const cards = useLocalCardsStore((s) => s.cards); const cards = cardsOfKind(
useLocalCardsStore((s) => s.cards),
"business",
);
const [view, setView] = useState<ViewMode>("grid"); const [view, setView] = useState<ViewMode>("grid");
const columns = view === "showcase" ? 1 : columnsFor(cards.length); const columns = view === "showcase" ? 1 : columnsFor(cards.length);
@@ -44,11 +47,9 @@ export default function Gallery() {
</View> </View>
} }
ListFooterComponent={ ListFooterComponent={
view === "showcase" && cards.length > 0 ? (
<Pressable style={styles.addTile} onPress={() => router.push("/demo")}> <Pressable style={styles.addTile} onPress={() => router.push("/demo")}>
<Text style={styles.addTileText}>+ New card</Text> <Text style={styles.addTileText}>+ New card</Text>
</Pressable> </Pressable>
) : null
} }
renderItem={({ item }: { item: LocalCard }) => renderItem={({ item }: { item: LocalCard }) =>
view === "showcase" ? ( view === "showcase" ? (
@@ -83,9 +84,6 @@ export default function Gallery() {
} }
/> />
<Pressable style={[styles.fab, { bottom: insets.bottom + 80 }]} onPress={() => router.push("/demo")}>
<Text style={styles.fabText}>+ New card</Text>
</Pressable>
</View> </View>
); );
} }
+62 -5
View File
@@ -18,13 +18,22 @@ import {
TextInput, TextInput,
View, View,
} from "react-native"; } from "react-native";
import { AgentDetailsEditor } from "../src/components/AgentDetailsEditor";
import { AgentBackTemplate } from "../src/components/card/AgentBackTemplate";
import { CardBackTemplate } from "../src/components/card/CardBackTemplate"; import { CardBackTemplate } from "../src/components/card/CardBackTemplate";
import { CardViewer } from "../src/components/card/CardViewer"; import { CardViewer } from "../src/components/card/CardViewer";
import { defaultLinks, LinksEditor } from "../src/components/LinksEditor"; import { defaultLinks } from "../src/components/LinksEditor";
import { newLayerId } from "../src/stores/cardStore"; import { newLayerId } from "../src/stores/cardStore";
import { useDraftStore } from "../src/stores/draftStore"; import { useDraftStore } from "../src/stores/draftStore";
import { CardLink, useLocalCardsStore } from "../src/stores/localCardsStore"; import {
AgentMeta,
CardKind,
CardLink,
Provenance,
useLocalCardsStore,
} from "../src/stores/localCardsStore";
import { useProfileStore } from "../src/stores/profileStore"; import { useProfileStore } from "../src/stores/profileStore";
import { mintProvenance } from "../src/utils/provenance";
import { CardDefinition, DEFAULT_SETTINGS, TextLayer } from "../src/types/card"; import { CardDefinition, DEFAULT_SETTINGS, TextLayer } from "../src/types/card";
import { deleteImage, persistImage, persistVideo } from "../src/utils/imageStore"; import { deleteImage, persistImage, persistVideo } from "../src/utils/imageStore";
@@ -100,7 +109,7 @@ function buildDemoCard(
export default function CardEditorScreen() { export default function CardEditorScreen() {
const router = useRouter(); const router = useRouter();
const { cardId } = useLocalSearchParams<{ cardId?: string }>(); const { cardId, kind: kindParam } = useLocalSearchParams<{ cardId?: string; kind?: string }>();
const upsert = useLocalCardsStore((s) => s.upsert); const upsert = useLocalCardsStore((s) => s.upsert);
const remove = useLocalCardsStore((s) => s.remove); const remove = useLocalCardsStore((s) => s.remove);
const getById = useLocalCardsStore((s) => s.getById); const getById = useLocalCardsStore((s) => s.getById);
@@ -118,6 +127,14 @@ export default function CardEditorScreen() {
const [links, setLinks] = useState<CardLink[]>(() => const [links, setLinks] = useState<CardLink[]>(() =>
cardId ? [] : profile.socialLinks.length > 0 ? profile.socialLinks : defaultLinks(), cardId ? [] : profile.socialLinks.length > 0 ? profile.socialLinks : defaultLinks(),
); );
const [cardKind, setCardKind] = useState<CardKind>(() => (kindParam as CardKind) || "business");
const [agentMeta, setAgentMeta] = useState<AgentMeta>(() => ({
tagline: "",
skills: [],
tools: [],
capabilities: [],
}));
const [provenance, setProvenance] = useState<Provenance | undefined>(undefined);
const [showing, setShowing] = useState(false); const [showing, setShowing] = useState(false);
const [onBack, setOnBack] = useState(false); const [onBack, setOnBack] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -133,6 +150,9 @@ export default function CardEditorScreen() {
setTitle(existing.title); setTitle(existing.title);
setUrl(existing.url); setUrl(existing.url);
setLinks(existing.links ?? []); setLinks(existing.links ?? []);
setCardKind(existing.kind ?? "business");
setProvenance(existing.provenance);
if (existing.agent) setAgentMeta(existing.agent);
setOnBack(false); setOnBack(false);
setShowing(true); setShowing(true);
} }
@@ -189,17 +209,28 @@ export default function CardEditorScreen() {
const existing = getById(id); const existing = getById(id);
const imagePath = imageUri ? await persistImage(imageUri, id) : (existing?.imagePath ?? ""); const imagePath = imageUri ? await persistImage(imageUri, id) : (existing?.imagePath ?? "");
const videoPath = videoUri ? await persistVideo(videoUri, id) : undefined; const videoPath = videoUri ? await persistVideo(videoUri, id) : undefined;
// Collectibles are "minted" (provenance recorded, mapped to the creator) on
// acceptance — once; agents carry their flip-side metadata.
const prov =
cardKind === "collectible"
? (existing?.provenance ?? mintProvenance(profile.displayName || name))
: undefined;
const agent = cardKind === "agent" ? agentMeta : undefined;
upsert({ upsert({
id, id,
name, name,
title, title,
url, url,
kind: cardKind,
imagePath, imagePath,
videoPath, videoPath,
links, links,
publishedUrl: existing?.publishedUrl, publishedUrl: existing?.publishedUrl,
provenance: prov,
agent,
updatedAt: Date.now(), updatedAt: Date.now(),
}); });
setProvenance(prov);
if (imageUri) setImageUri(imagePath); if (imageUri) setImageUri(imagePath);
if (videoPath) setVideoUri(videoPath); if (videoPath) setVideoUri(videoPath);
}; };
@@ -247,7 +278,17 @@ export default function CardEditorScreen() {
fullScreen fullScreen
onSideChange={setOnBack} onSideChange={setOnBack}
backContent={ backContent={
<CardBackTemplate name={name} title={title} profileUrl={url} links={links} /> cardKind === "agent" ? (
<AgentBackTemplate name={name} title={title} agent={agentMeta} />
) : (
<CardBackTemplate
name={name}
title={title}
profileUrl={url}
links={links}
provenance={cardKind === "collectible" ? provenance : undefined}
/>
)
} }
/> />
{/* Front: a hint to flip. Back (QR side): the Gallery / Edit controls. */} {/* Front: a hint to flip. Back (QR side): the Gallery / Edit controls. */}
@@ -270,7 +311,16 @@ export default function CardEditorScreen() {
return ( return (
<ScrollView style={styles.root} contentContainerStyle={styles.content}> <ScrollView style={styles.root} contentContainerStyle={styles.content}>
<Stack.Screen <Stack.Screen
options={{ title: cardId ? "Edit card" : "Make your card", headerTitleAlign: "center" }} options={{
title: cardId
? "Edit card"
: cardKind === "collectible"
? "New collectible"
: cardKind === "agent"
? "New agent"
: "Make your card",
headerTitleAlign: "center",
}}
/> />
{videoUri ? ( {videoUri ? (
@@ -310,6 +360,13 @@ export default function CardEditorScreen() {
</Pressable> </Pressable>
</View> </View>
{cardKind === "agent" && (
<>
<Text style={styles.sectionLabel}>Agent details · shown on the flip side</Text>
<AgentDetailsEditor meta={agentMeta} onChange={setAgentMeta} />
</>
)}
<Pressable <Pressable
style={[styles.cta, (!media || saving) && styles.ctaDisabled]} style={[styles.cta, (!media || saving) && styles.ctaDisabled]}
disabled={!media || saving} disabled={!media || saving}
@@ -0,0 +1,125 @@
// Editor for an agent card's flip-side data: tagline, skill bars, tools, and
// special capabilities.
import { useState } from "react";
import { Pressable, StyleSheet, Text, TextInput, View } from "react-native";
import { AgentMeta } from "../stores/localCardsStore";
const splitCsv = (s: string) =>
s
.split(",")
.map((x) => x.trim())
.filter(Boolean);
export function AgentDetailsEditor({
meta,
onChange,
}: {
meta: AgentMeta;
onChange: (m: AgentMeta) => void;
}) {
// Keep the comma-separated fields as raw text so separators are typeable.
const [toolsText, setToolsText] = useState(meta.tools.join(", "));
const [capsText, setCapsText] = useState(meta.capabilities.join(", "));
const set = (patch: Partial<AgentMeta>) => onChange({ ...meta, ...patch });
return (
<View style={{ gap: 10 }}>
<Text style={styles.label}>Tagline</Text>
<TextInput
style={styles.input}
placeholder="e.g. Your always-on research copilot"
placeholderTextColor="#6b6b70"
value={meta.tagline}
onChangeText={(t) => set({ tagline: t })}
/>
<Text style={styles.label}>Skills</Text>
{meta.skills.map((s, i) => (
<View key={i} style={styles.skillRow}>
<TextInput
style={[styles.input, { flex: 1 }]}
placeholder="Skill"
placeholderTextColor="#6b6b70"
value={s.name}
onChangeText={(t) =>
set({ skills: meta.skills.map((x, idx) => (idx === i ? { ...x, name: t } : x)) })
}
/>
<View style={styles.dots}>
{[1, 2, 3, 4, 5].map((n) => (
<Pressable
key={n}
onPress={() =>
set({ skills: meta.skills.map((x, idx) => (idx === i ? { ...x, level: n } : x)) })
}
style={[styles.dot, n <= s.level && styles.dotOn]}
/>
))}
</View>
<Pressable onPress={() => set({ skills: meta.skills.filter((_, idx) => idx !== i) })} hitSlop={8}>
<Text style={styles.remove}></Text>
</Pressable>
</View>
))}
<Pressable
style={styles.add}
onPress={() => set({ skills: [...meta.skills, { name: "", level: 3 }] })}
>
<Text style={styles.addText}>+ Add skill</Text>
</Pressable>
<Text style={styles.label}>Tools (comma-separated)</Text>
<TextInput
style={styles.input}
placeholder="e.g. Search, Code, Email"
placeholderTextColor="#6b6b70"
autoCapitalize="none"
value={toolsText}
onChangeText={(t) => {
setToolsText(t);
set({ tools: splitCsv(t) });
}}
/>
<Text style={styles.label}>Special capabilities (comma-separated)</Text>
<TextInput
style={[styles.input, { minHeight: 64, textAlignVertical: "top" }]}
multiline
placeholder="e.g. Multi-step planning, Long-term memory"
placeholderTextColor="#6b6b70"
value={capsText}
onChangeText={(t) => {
setCapsText(t);
set({ capabilities: splitCsv(t) });
}}
/>
</View>
);
}
const styles = StyleSheet.create({
label: { color: "#9a9aa0", fontSize: 14, marginTop: 4 },
input: {
backgroundColor: "#15151a",
color: "#f5f5f7",
borderRadius: 12,
paddingHorizontal: 14,
paddingVertical: 12,
fontSize: 16,
},
skillRow: { flexDirection: "row", alignItems: "center", gap: 10 },
dots: { flexDirection: "row", gap: 4 },
dot: { width: 14, height: 14, borderRadius: 7, backgroundColor: "#26262e" },
dotOn: { backgroundColor: "#ff3b30" },
remove: { color: "#ff453a", fontSize: 16, fontWeight: "700", paddingHorizontal: 4 },
add: {
borderWidth: 1,
borderColor: "#2a2a30",
borderStyle: "dashed",
borderRadius: 12,
paddingVertical: 12,
alignItems: "center",
},
addText: { color: "#f5f5f7", fontWeight: "600", fontSize: 15 },
});
@@ -0,0 +1,109 @@
// Reusable grid for a card family (Collectibles / Agents): responsive columns,
// an empty state, and a "+ New" tile below the cards that starts the creator
// pre-set to that kind.
import { useRouter } from "expo-router";
import { FlatList, Pressable, StyleSheet, Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { CardKind, cardsOfKind, LocalCard, useLocalCardsStore } from "../stores/localCardsStore";
import { CardThumb } from "./CardThumb";
function columnsFor(count: number): number {
if (count <= 4) return 2;
if (count <= 9) return 3;
return 4;
}
export function CardCollection({
kind,
heading,
addLabel,
emptyTitle,
emptyHint,
}: {
kind: CardKind;
heading: string;
addLabel: string;
emptyTitle: string;
emptyHint: string;
}) {
const router = useRouter();
const insets = useSafeAreaInsets();
const cards = cardsOfKind(
useLocalCardsStore((s) => s.cards),
kind,
);
const columns = columnsFor(cards.length);
const openNew = () => router.push({ pathname: "/demo", params: { kind } });
return (
<View style={styles.root}>
<View style={[styles.topBar, { paddingTop: insets.top + 14 }]}>
<Text style={styles.heading}>{heading}</Text>
</View>
<FlatList
data={cards}
keyExtractor={(c) => c.id}
key={`cols-${columns}`}
numColumns={columns}
columnWrapperStyle={columns > 1 ? styles.column : undefined}
contentContainerStyle={styles.list}
ListEmptyComponent={
<View style={styles.empty}>
<Text style={styles.emptyTitle}>{emptyTitle}</Text>
<Text style={styles.emptyHint}>{emptyHint}</Text>
</View>
}
ListFooterComponent={
<Pressable style={styles.addTile} onPress={openNew}>
<Text style={styles.addText}>{addLabel}</Text>
</Pressable>
}
renderItem={({ item }: { item: LocalCard }) => (
<Pressable
style={styles.tile}
onPress={() => router.push({ pathname: "/demo", params: { cardId: item.id } })}
>
<CardThumb item={item} style={styles.tileImage} />
<View style={styles.tileLabel}>
<Text style={styles.tileName} numberOfLines={1}>
{item.name}
</Text>
{!!item.title && (
<Text style={styles.tileTitle} numberOfLines={1}>
{item.title}
</Text>
)}
</View>
</Pressable>
)}
/>
</View>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: "#0a0a0c" },
topBar: { paddingHorizontal: 16, paddingBottom: 10 },
heading: { color: "#f5f5f7", fontSize: 26, fontWeight: "800" },
list: { paddingHorizontal: 16, paddingBottom: 24, flexGrow: 1 },
column: { gap: 12 },
tile: { flex: 1, marginBottom: 12, borderRadius: 18, overflow: "hidden", backgroundColor: "#15151a" },
tileImage: { width: "100%", aspectRatio: 2 / 3, overflow: "hidden" },
tileLabel: { padding: 10 },
tileName: { color: "#f5f5f7", fontSize: 14, fontWeight: "700" },
tileTitle: { color: "#9a9aa0", fontSize: 12, marginTop: 2 },
addTile: {
borderWidth: 1,
borderColor: "#2a2a30",
borderStyle: "dashed",
borderRadius: 18,
paddingVertical: 22,
alignItems: "center",
marginTop: 4,
},
addText: { color: "#f5f5f7", fontWeight: "700", fontSize: 16 },
empty: { alignItems: "center", justifyContent: "center", paddingVertical: 80, gap: 6 },
emptyTitle: { color: "#f5f5f7", fontSize: 20, fontWeight: "700" },
emptyHint: { color: "#6b6b70", fontSize: 14, textAlign: "center", paddingHorizontal: 24 },
});
@@ -0,0 +1,113 @@
// Agent card back: shown when an agent card is flipped. Renders the assistant's
// skill bars, tools, and special capabilities (trading-card style).
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { ScrollView, StyleSheet, Text, View } from "react-native";
import { AgentMeta } from "../../stores/localCardsStore";
export function AgentBackTemplate({
name,
title,
agent,
}: {
name: string;
title: string;
agent: AgentMeta;
}) {
return (
<View style={styles.root}>
<View style={styles.header}>
<Text style={styles.name} numberOfLines={1}>
{name}
</Text>
{!!title && (
<Text style={styles.title} numberOfLines={1}>
{title}
</Text>
)}
{!!agent.tagline && <Text style={styles.tagline}>{agent.tagline}</Text>}
</View>
<ScrollView contentContainerStyle={styles.body} showsVerticalScrollIndicator={false}>
{agent.skills.length > 0 && (
<>
<Text style={styles.section}>Skills</Text>
{agent.skills.map((s, i) => (
<View key={`${s.name}-${i}`} style={styles.skillRow}>
<Text style={styles.skillName} numberOfLines={1}>
{s.name}
</Text>
<View style={styles.bars}>
{[1, 2, 3, 4, 5].map((n) => (
<View key={n} style={[styles.bar, n <= s.level && styles.barOn]} />
))}
</View>
</View>
))}
</>
)}
{agent.tools.length > 0 && (
<>
<Text style={styles.section}>Tools</Text>
<View style={styles.chips}>
{agent.tools.map((t, i) => (
<View key={`${t}-${i}`} style={styles.chip}>
<MaterialCommunityIcons name="wrench" size={13} color="#cbb8ff" />
<Text style={styles.chipText}>{t}</Text>
</View>
))}
</View>
</>
)}
{agent.capabilities.length > 0 && (
<>
<Text style={styles.section}>Special capabilities</Text>
{agent.capabilities.map((c, i) => (
<View key={`${c}-${i}`} style={styles.capRow}>
<MaterialCommunityIcons name="star-four-points" size={14} color="#ffd60a" />
<Text style={styles.capText}>{c}</Text>
</View>
))}
</>
)}
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: "#0e0e12", paddingHorizontal: 22, paddingVertical: 28 },
header: { marginBottom: 12 },
name: { color: "#ffffff", fontSize: 24, fontWeight: "800" },
title: { color: "#9a9aa0", fontSize: 15, marginTop: 2 },
tagline: { color: "#cbb8ff", fontSize: 13, marginTop: 6, fontStyle: "italic" },
body: { gap: 8, paddingBottom: 16 },
section: {
color: "#9a9aa0",
fontSize: 12,
fontWeight: "700",
textTransform: "uppercase",
letterSpacing: 0.06 * 12,
marginTop: 12,
},
skillRow: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", gap: 12 },
skillName: { color: "#f5f5f7", fontSize: 15, flex: 1 },
bars: { flexDirection: "row", gap: 4 },
bar: { width: 16, height: 8, borderRadius: 2, backgroundColor: "#26262e" },
barOn: { backgroundColor: "#ff3b30" },
chips: { flexDirection: "row", flexWrap: "wrap", gap: 8 },
chip: {
flexDirection: "row",
alignItems: "center",
gap: 5,
backgroundColor: "#16161c",
borderRadius: 10,
paddingHorizontal: 10,
paddingVertical: 7,
},
chipText: { color: "#f5f5f7", fontSize: 13, fontWeight: "600" },
capRow: { flexDirection: "row", alignItems: "center", gap: 8 },
capText: { color: "#e0e0e4", fontSize: 14, flex: 1 },
});
@@ -4,7 +4,7 @@
import { MaterialCommunityIcons } from "@expo/vector-icons"; import { MaterialCommunityIcons } from "@expo/vector-icons";
import { ScrollView, StyleSheet, Text, View } from "react-native"; import { ScrollView, StyleSheet, Text, View } from "react-native";
import { CardLink, LinkKind } from "../../stores/localCardsStore"; import { CardLink, LinkKind, Provenance } from "../../stores/localCardsStore";
import { QRCodeView } from "./QRCodeView"; import { QRCodeView } from "./QRCodeView";
const KIND_ICON: Record<LinkKind, keyof typeof MaterialCommunityIcons.glyphMap> = { const KIND_ICON: Record<LinkKind, keyof typeof MaterialCommunityIcons.glyphMap> = {
@@ -24,11 +24,13 @@ export function CardBackTemplate({
title, title,
profileUrl, profileUrl,
links, links,
provenance,
}: { }: {
name: string; name: string;
title: string; title: string;
profileUrl: string; profileUrl: string;
links: CardLink[]; links: CardLink[];
provenance?: Provenance;
}) { }) {
// Only show links that actually have a URL (empty ones would be blank QRs). // Only show links that actually have a URL (empty ones would be blank QRs).
const shown = links.filter((l) => l.url.trim()); const shown = links.filter((l) => l.url.trim());
@@ -81,6 +83,15 @@ export function CardBackTemplate({
<Text style={styles.empty}>Add links in the editor to show scannable codes here.</Text> <Text style={styles.empty}>Add links in the editor to show scannable codes here.</Text>
)} )}
</ScrollView> </ScrollView>
{provenance && (
<View style={styles.provenance}>
<MaterialCommunityIcons name="link-variant" size={13} color="#30d158" />
<Text style={styles.provText} numberOfLines={1}>
Minted · #{provenance.tokenId} · {provenance.chain}
</Text>
</View>
)}
</View> </View>
); );
} }
@@ -117,4 +128,14 @@ const styles = StyleSheet.create({
rowLabel: { color: "#f5f5f7", fontSize: 15, fontWeight: "700" }, rowLabel: { color: "#f5f5f7", fontSize: 15, fontWeight: "700" },
rowUrl: { color: "#8a8a90", fontSize: 12, marginTop: 1 }, rowUrl: { color: "#8a8a90", fontSize: 12, marginTop: 1 },
empty: { color: "#6b6b70", fontSize: 13, textAlign: "center", paddingVertical: 24 }, empty: { color: "#6b6b70", fontSize: 13, textAlign: "center", paddingVertical: 24 },
provenance: {
flexDirection: "row",
alignItems: "center",
gap: 6,
marginTop: 10,
paddingTop: 10,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: "#2a2a30",
},
provText: { color: "#9a9aa0", fontSize: 12 },
}); });
@@ -18,11 +18,39 @@ export interface CardLink {
url: string; url: string;
} }
/** The three card families, one per tab. Missing = "business" (legacy cards). */
export type CardKind = "business" | "collectible" | "agent";
/** Blockchain provenance recorded when a collectible is minted (stubbed). */
export interface Provenance {
tokenId: string;
txHash: string;
chain: string;
owner: string;
mintedAt: number;
}
export interface AgentSkill {
name: string;
/** 15 bars. */
level: number;
}
/** Extra metadata shown on an agent card's flipped (back) side. */
export interface AgentMeta {
tagline: string;
skills: AgentSkill[];
tools: string[];
capabilities: string[];
}
export interface LocalCard { export interface LocalCard {
id: string; id: string;
name: string; name: string;
title: string; title: string;
url: string; url: string;
/** Which tab/family this card belongs to (default "business"). */
kind?: CardKind;
/** Persistent file:// path to the card photo. */ /** Persistent file:// path to the card photo. */
imagePath: string; imagePath: string;
/** Persistent file:// path to an AI video for the card front (optional). */ /** Persistent file:// path to an AI video for the card front (optional). */
@@ -31,9 +59,18 @@ export interface LocalCard {
links: CardLink[]; links: CardLink[];
/** Public profile URL once published to the web (cardclaws.com/<handle>). */ /** Public profile URL once published to the web (cardclaws.com/<handle>). */
publishedUrl?: string; publishedUrl?: string;
/** Collectibles: on-chain provenance, mapped to the creating user. */
provenance?: Provenance;
/** Agents: skills/tools/capabilities shown on the flip side. */
agent?: AgentMeta;
updatedAt: number; updatedAt: number;
} }
/** Cards belonging to a family (treats missing kind as "business"). */
export function cardsOfKind(cards: LocalCard[], kind: CardKind): LocalCard[] {
return cards.filter((c) => (c.kind ?? "business") === kind);
}
interface LocalCardsState { interface LocalCardsState {
cards: LocalCard[]; cards: LocalCard[];
upsert: (card: LocalCard) => void; upsert: (card: LocalCard) => void;
+20
View File
@@ -0,0 +1,20 @@
// Collectible "minting" — records on-chain provenance for a generated asset and
// maps it to the creating user. Stubbed for now (deterministic-looking fake tx);
// the real chain submission swaps in behind this same shape (PRD §21, plan
// Phase 2 ProvenanceClient).
import { Provenance } from "../stores/localCardsStore";
const hex = (n: number) =>
Array.from({ length: n }, () => Math.floor(Math.random() * 16).toString(16)).join("");
/** Mint a collectible to the (stub) chain, owned by `owner`. */
export function mintProvenance(owner: string): Provenance {
return {
tokenId: String(Math.floor(Math.random() * 1_000_000)),
txHash: `0x${hex(64)}`,
chain: "Base (stub)",
owner: owner.trim() || "anonymous",
mintedAt: Date.now(),
};
}