Phase 2 mobile: share UI, advanced layers, analytics dashboard, builder depth

- ShapeLayer type; cardStore reorderLayer + profile/link actions (undoable)
- QRCodeView (renders on-device qr matrix); CardFace renders shape + qr layers
- share.ts / analytics.ts API clients
- ShareSheet (QR overlay, OS share, Add to Google Wallet); per-card analytics
  dashboard (summary + top countries + activity feed)
- Builder: LayerPropertySheet, LayerOrderPanel, ProfileEditor, shape creation

25 logic unit tests; tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-04 12:32:32 -05:00
co-authored by Claude Opus 4.8
parent 8ae1d8f263
commit d0636577dc
18 changed files with 1054 additions and 13 deletions
@@ -99,6 +99,63 @@ describe("cardStore undo/redo", () => {
expect(undos).toBe(MAX_HISTORY); expect(undos).toBe(MAX_HISTORY);
}); });
it("reorderLayer swaps adjacent z-indexes and is reversible", () => {
const a = { ...textLayer("A"), zIndex: 1 };
const b = { ...textLayer("B"), zIndex: 2 };
useCardStore.getState().addLayer("face", a);
useCardStore.getState().addLayer("face", b);
// Move A up: A and B swap z-indexes → A above B.
useCardStore.getState().reorderLayer("face", a.id, "up");
const za = () =>
useCardStore.getState().card!.face.layers.find((l) => l.id === a.id)!.zIndex;
const zb = () =>
useCardStore.getState().card!.face.layers.find((l) => l.id === b.id)!.zIndex;
expect(za()).toBe(2);
expect(zb()).toBe(1);
useCardStore.getState().undo();
expect(za()).toBe(1);
expect(zb()).toBe(2);
});
it("reorderLayer past an edge is a no-op", () => {
const a = { ...textLayer("A"), zIndex: 1 };
useCardStore.getState().addLayer("face", a);
const before = useCardStore.getState().past.length;
useCardStore.getState().reorderLayer("face", a.id, "down"); // already bottom
// The mutation still snapshots, but ordering is unchanged.
expect(useCardStore.getState().card!.face.layers[0].zIndex).toBe(1);
expect(useCardStore.getState().past.length).toBe(before + 1);
});
it("edits profile bio and links with undo support", () => {
const s = useCardStore.getState();
s.setBio("Founder & CEO");
expect(useCardStore.getState().card?.profile?.bio).toBe("Founder & CEO");
useCardStore.getState().addLink({
id: "l1",
type: "linkedin",
label: "LinkedIn",
url: "https://linkedin.com/in/omar",
iconSlug: "linkedin",
});
expect(useCardStore.getState().card?.profile?.links).toHaveLength(1);
useCardStore.getState().updateLink("l1", { label: "My LinkedIn" });
expect(useCardStore.getState().card?.profile?.links[0].label).toBe("My LinkedIn");
useCardStore.getState().removeLink("l1");
expect(useCardStore.getState().card?.profile?.links).toHaveLength(0);
// Undo the removal, then the label edit.
useCardStore.getState().undo();
expect(useCardStore.getState().card?.profile?.links).toHaveLength(1);
useCardStore.getState().undo();
expect(useCardStore.getState().card?.profile?.links[0].label).toBe("LinkedIn");
});
it("undo/redo are no-ops at the ends", () => { it("undo/redo are no-ops at the ends", () => {
expect(() => useCardStore.getState().undo()).not.toThrow(); expect(() => useCardStore.getState().undo()).not.toThrow();
expect(() => useCardStore.getState().redo()).not.toThrow(); expect(() => useCardStore.getState().redo()).not.toThrow();
@@ -0,0 +1,134 @@
import { useQuery } from "@tanstack/react-query";
import { Stack, useLocalSearchParams } from "expo-router";
import { ActivityIndicator, ScrollView, StyleSheet, Text, View } from "react-native";
import { FeedEvent, GeoCount, getFeed, getGeo, getSummary } from "../../../src/api/analytics";
const EVENT_LABELS: Record<string, string> = {
profile_visit: "Profile visit",
qr_scan: "QR scan",
nfc_tap: "NFC tap",
contact_save: "Contact saved",
link_click: "Link click",
wallet_add: "Added to wallet",
};
export default function AnalyticsScreen() {
const { cardId } = useLocalSearchParams<{ cardId: string }>();
const summary = useQuery({
queryKey: ["analytics", cardId, "summary"],
queryFn: () => getSummary(cardId),
enabled: !!cardId,
});
const feed = useQuery({
queryKey: ["analytics", cardId, "feed"],
queryFn: () => getFeed(cardId),
enabled: !!cardId,
});
const geo = useQuery({
queryKey: ["analytics", cardId, "geo"],
queryFn: () => getGeo(cardId),
enabled: !!cardId,
});
if (summary.isLoading) {
return (
<View style={styles.center}>
<ActivityIndicator color="#ff3b30" />
</View>
);
}
const s = summary.data;
return (
<ScrollView style={styles.root} contentContainerStyle={styles.content}>
<Stack.Screen options={{ title: "Analytics" }} />
<View style={styles.metrics}>
<Metric label="Visits (24h)" value={s?.visits24h ?? 0} />
<Metric label="Visits (7d)" value={s?.visits7d ?? 0} />
<Metric label="Total visits" value={s?.totalVisits ?? 0} />
<Metric label="QR scans" value={s?.qrScans ?? 0} />
<Metric label="Contact saves" value={s?.contactSaves ?? 0} />
<Metric label="Link clicks" value={s?.linkClicks ?? 0} />
</View>
{geo.data && geo.data.length > 0 && (
<Section title="Top countries">
{geo.data.slice(0, 5).map((g: GeoCount) => (
<Row key={g.country} left={g.country} right={`${g.visits}`} />
))}
</Section>
)}
<Section title="Recent activity">
{(feed.data ?? []).length === 0 ? (
<Text style={styles.empty}>No activity yet.</Text>
) : (
(feed.data ?? []).slice(0, 30).map((e: FeedEvent) => (
<Row
key={e.id}
left={EVENT_LABELS[e.eventType] ?? e.eventType}
right={[e.city, e.country].filter(Boolean).join(", ") || "—"}
/>
))
)}
</Section>
</ScrollView>
);
}
function Metric({ label, value }: { label: string; value: number }) {
return (
<View style={styles.metric}>
<Text style={styles.metricValue}>{value}</Text>
<Text style={styles.metricLabel}>{label}</Text>
</View>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<View style={styles.section}>
<Text style={styles.sectionTitle}>{title}</Text>
{children}
</View>
);
}
function Row({ left, right }: { left: string; right: string }) {
return (
<View style={styles.rowItem}>
<Text style={styles.rowLeft}>{left}</Text>
<Text style={styles.rowRight}>{right}</Text>
</View>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: "#0a0a0c" },
content: { padding: 16, gap: 20 },
center: { flex: 1, alignItems: "center", justifyContent: "center", backgroundColor: "#0a0a0c" },
metrics: { flexDirection: "row", flexWrap: "wrap", gap: 12 },
metric: {
backgroundColor: "#15151a",
borderRadius: 16,
padding: 16,
width: "47%",
},
metricValue: { color: "#f5f5f7", fontSize: 28, fontWeight: "800" },
metricLabel: { color: "#9a9aa0", fontSize: 13, marginTop: 2 },
section: { gap: 8 },
sectionTitle: { color: "#f5f5f7", fontSize: 17, fontWeight: "700" },
rowItem: {
flexDirection: "row",
justifyContent: "space-between",
paddingVertical: 12,
borderBottomColor: "#1a1a1f",
borderBottomWidth: 1,
},
rowLeft: { color: "#f5f5f7", fontSize: 15 },
rowRight: { color: "#9a9aa0", fontSize: 14 },
empty: { color: "#6b6b70" },
});
+39 -4
View File
@@ -1,11 +1,16 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Stack, useLocalSearchParams } from "expo-router"; import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { ActivityIndicator, View } from "react-native"; import { useState } from "react";
import { ActivityIndicator, Pressable, StyleSheet, Text, View } from "react-native";
import { getCard } from "../../../src/api/cards"; import { getCard } from "../../../src/api/cards";
import { CardViewer } from "../../../src/components/card/CardViewer"; import { CardViewer } from "../../../src/components/card/CardViewer";
import { ShareSheet } from "../../../src/components/share/ShareSheet";
export default function CardViewScreen() { export default function CardViewScreen() {
const { cardId } = useLocalSearchParams<{ cardId: string }>(); const { cardId } = useLocalSearchParams<{ cardId: string }>();
const router = useRouter();
const [sharing, setSharing] = useState(false);
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ["card", cardId], queryKey: ["card", cardId],
queryFn: () => getCard(cardId), queryFn: () => getCard(cardId),
@@ -14,14 +19,44 @@ export default function CardViewScreen() {
return ( return (
<View style={{ flex: 1, backgroundColor: "#0a0a0c" }}> <View style={{ flex: 1, backgroundColor: "#0a0a0c" }}>
<Stack.Screen options={{ title: "", headerTransparent: true }} /> <Stack.Screen
options={{
title: "",
headerTransparent: true,
headerRight: () => (
<View style={styles.actions}>
<Pressable onPress={() => router.push(`/card/${cardId}/analytics`)}>
<Text style={styles.headerBtn}>Stats</Text>
</Pressable>
<Pressable onPress={() => setSharing(true)}>
<Text style={[styles.headerBtn, styles.share]}>Share</Text>
</Pressable>
</View>
),
}}
/>
{isLoading || !data ? ( {isLoading || !data ? (
<View style={{ flex: 1, alignItems: "center", justifyContent: "center" }}> <View style={styles.center}>
<ActivityIndicator color="#ff3b30" /> <ActivityIndicator color="#ff3b30" />
</View> </View>
) : ( ) : (
<>
<CardViewer card={data.definition} /> <CardViewer card={data.definition} />
<ShareSheet
cardId={cardId}
handle={data.handle}
visible={sharing}
onClose={() => setSharing(false)}
/>
</>
)} )}
</View> </View>
); );
} }
const styles = StyleSheet.create({
center: { flex: 1, alignItems: "center", justifyContent: "center" },
actions: { flexDirection: "row", gap: 16 },
headerBtn: { color: "#f5f5f7", fontWeight: "600", fontSize: 16 },
share: { color: "#ff3b30" },
});
+38
View File
@@ -0,0 +1,38 @@
// Analytics API (PRD §13.5).
import { api } from "./client";
export interface AnalyticsSummary {
totalVisits: number;
visits7d: number;
visits24h: number;
qrScans: number;
contactSaves: number;
linkClicks: number;
}
export interface FeedEvent {
id: number;
eventType: string;
shareToken: string | null;
country: string | null;
city: string | null;
occurredAt: string;
}
export interface GeoCount {
country: string;
visits: number;
}
export async function getSummary(cardId: string): Promise<AnalyticsSummary> {
return (await api.get<AnalyticsSummary>(`/v1/cards/${cardId}/analytics`)).data;
}
export async function getFeed(cardId: string): Promise<FeedEvent[]> {
return (await api.get<FeedEvent[]>(`/v1/cards/${cardId}/analytics/feed`)).data;
}
export async function getGeo(cardId: string): Promise<GeoCount[]> {
return (await api.get<GeoCount[]>(`/v1/cards/${cardId}/analytics/geo`)).data;
}
+6
View File
@@ -42,3 +42,9 @@ export async function publishCard(id: string): Promise<CardRecord> {
export function appleWalletUrl(id: string): string { export function appleWalletUrl(id: string): string {
return `${api.defaults.baseURL}/v1/cards/${id}/wallet/apple`; return `${api.defaults.baseURL}/v1/cards/${id}/wallet/apple`;
} }
/** Fetch the "Add to Google Wallet" save URL for a card. */
export async function googleWalletSaveUrl(id: string): Promise<string> {
const res = await api.post<{ saveUrl: string }>(`/v1/cards/${id}/wallet/google`);
return res.data.saveUrl;
}
+2
View File
@@ -4,6 +4,8 @@ import axios, { AxiosError, InternalAxiosRequestConfig } from "axios";
import { useAuthStore } from "../stores/authStore"; import { useAuthStore } from "../stores/authStore";
export const API_BASE = process.env.EXPO_PUBLIC_API_BASE ?? "http://localhost:8080"; export const API_BASE = process.env.EXPO_PUBLIC_API_BASE ?? "http://localhost:8080";
/** Public profile base, e.g. `https://cardclaws.com` — used to build QR/profile URLs. */
export const PROFILE_BASE = process.env.EXPO_PUBLIC_PROFILE_BASE ?? "https://cardclaws.com";
export const api = axios.create({ baseURL: API_BASE }); export const api = axios.create({ baseURL: API_BASE });
+31
View File
@@ -0,0 +1,31 @@
// Share-link API (PRD §13.4).
import { api } from "./client";
export type ShareModality =
| "nfc"
| "qr"
| "airdrop"
| "imessage"
| "email"
| "link"
| "wallet"
| "contact";
export interface ShareLink {
token: string;
url: string;
}
/** Create a tracked share link for a card and return its short URL. */
export async function createShareLink(
cardId: string,
modality: ShareModality,
campaign?: string,
): Promise<ShareLink> {
const res = await api.post<ShareLink>(`/v1/cards/${cardId}/share`, {
modality,
campaign,
});
return res.data;
}
@@ -6,8 +6,11 @@ import { useState } from "react";
import { Pressable, StyleSheet, Text, TextInput, useWindowDimensions, View } from "react-native"; import { Pressable, StyleSheet, Text, TextInput, useWindowDimensions, View } from "react-native";
import { CardFace } from "../card/CardFace"; import { CardFace } from "../card/CardFace";
import { ColorPaletteEditor } from "./ColorPaletteEditor"; import { ColorPaletteEditor } from "./ColorPaletteEditor";
import { LayerOrderPanel } from "./LayerOrderPanel";
import { LayerPropertySheet } from "./LayerPropertySheet";
import { ProfileEditor } from "./ProfileEditor";
import { Side, newLayerId, useCardStore } from "../../stores/cardStore"; import { Side, newLayerId, useCardStore } from "../../stores/cardStore";
import { TextLayer } from "../../types/card"; import { ShapeLayer, TextLayer } from "../../types/card";
export function BuilderCanvas({ side = "face" as Side }: { side?: Side }) { export function BuilderCanvas({ side = "face" as Side }: { side?: Side }) {
const { width, height } = useWindowDimensions(); const { width, height } = useWindowDimensions();
@@ -20,6 +23,9 @@ export function BuilderCanvas({ side = "face" as Side }: { side?: Side }) {
const canRedo = useCardStore((s) => s.canRedo()); const canRedo = useCardStore((s) => s.canRedo());
const [draftText, setDraftText] = useState(""); const [draftText, setDraftText] = useState("");
const [orderOpen, setOrderOpen] = useState(false);
const [profileOpen, setProfileOpen] = useState(false);
const [selectedLayerId, setSelectedLayerId] = useState<string | null>(null);
if (!card) return null; if (!card) return null;
const cardWidth = Math.min(width * 0.9, 360); const cardWidth = Math.min(width * 0.9, 360);
@@ -49,6 +55,24 @@ export function BuilderCanvas({ side = "face" as Side }: { side?: Side }) {
setDraftText(""); setDraftText("");
}; };
const addShape = () => {
const layer: ShapeLayer = {
id: newLayerId(),
type: "shape",
x: 0.1,
y: 0.5,
width: 0.35,
height: 0.2,
opacity: 1,
zIndex: card[side].layers.length + 1,
shape: "rectangle",
fill: "#ff3b30",
strokeWidth: 0,
cornerRadius: 12,
};
addLayer(side, layer);
};
return ( return (
<View style={styles.root}> <View style={styles.root}>
<View style={styles.canvasArea}> <View style={styles.canvasArea}>
@@ -74,6 +98,21 @@ export function BuilderCanvas({ side = "face" as Side }: { side?: Side }) {
<ToolButton label="↶" onPress={undo} disabled={!canUndo} /> <ToolButton label="↶" onPress={undo} disabled={!canUndo} />
<ToolButton label="↷" onPress={redo} disabled={!canRedo} /> <ToolButton label="↷" onPress={redo} disabled={!canRedo} />
</View> </View>
<View style={styles.secondaryBar}>
<ToolButton label="Shape" onPress={addShape} />
<ToolButton label="Layers" onPress={() => setOrderOpen(true)} />
<ToolButton label="Profile" onPress={() => setProfileOpen(true)} />
</View>
<LayerOrderPanel
side={side}
visible={orderOpen}
onClose={() => setOrderOpen(false)}
onSelect={setSelectedLayerId}
/>
<LayerPropertySheet side={side} layerId={selectedLayerId} onClose={() => setSelectedLayerId(null)} />
<ProfileEditor visible={profileOpen} onClose={() => setProfileOpen(false)} />
</View> </View>
); );
} }
@@ -107,6 +146,12 @@ const styles = StyleSheet.create({
padding: 16, padding: 16,
alignItems: "center", alignItems: "center",
}, },
secondaryBar: {
flexDirection: "row",
gap: 8,
paddingHorizontal: 16,
paddingBottom: 16,
},
input: { input: {
flex: 1, flex: 1,
backgroundColor: "#1a1a1f", backgroundColor: "#1a1a1f",
@@ -0,0 +1,93 @@
// Layer list with reorder + select (PRD §6.1.5). Layers shown top-to-bottom by
// descending z-index (front to back). Tapping a row selects it for editing.
import { Modal, Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
import { Side, useCardStore } from "../../stores/cardStore";
import { Layer, TextLayer } from "../../types/card";
interface Props {
side: Side;
visible: boolean;
onClose: () => void;
onSelect: (layerId: string) => void;
}
function describe(layer: Layer): string {
if (layer.type === "text") return `Text — “${(layer as TextLayer).text}”`;
return layer.type.charAt(0).toUpperCase() + layer.type.slice(1);
}
export function LayerOrderPanel({ side, visible, onClose, onSelect }: Props) {
const card = useCardStore((s) => s.card);
const reorderLayer = useCardStore((s) => s.reorderLayer);
const layers = [...(card?.[side].layers ?? [])].sort((a, b) => b.zIndex - a.zIndex);
return (
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
<Pressable style={styles.backdrop} onPress={onClose}>
<Pressable style={styles.sheet} onPress={(e) => e.stopPropagation()}>
<View style={styles.grabber} />
<Text style={styles.title}>Layers</Text>
<ScrollView>
{layers.length === 0 && <Text style={styles.empty}>No layers yet.</Text>}
{layers.map((layer) => (
<View key={layer.id} style={styles.row}>
<Pressable
style={styles.rowLabel}
onPress={() => {
onSelect(layer.id);
onClose();
}}
>
<Text style={styles.rowText} numberOfLines={1}>
{describe(layer)}
</Text>
</Pressable>
<Pressable onPress={() => reorderLayer(side, layer.id, "up")} hitSlop={8}>
<Text style={styles.move}>↑</Text>
</Pressable>
<Pressable onPress={() => reorderLayer(side, layer.id, "down")} hitSlop={8}>
<Text style={styles.move}>↓</Text>
</Pressable>
</View>
))}
</ScrollView>
</Pressable>
</Pressable>
</Modal>
);
}
const styles = StyleSheet.create({
backdrop: { flex: 1, backgroundColor: "rgba(0,0,0,0.5)", justifyContent: "flex-end" },
sheet: {
backgroundColor: "#15151a",
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
padding: 20,
paddingBottom: 36,
maxHeight: "70%",
},
grabber: {
alignSelf: "center",
width: 40,
height: 4,
borderRadius: 2,
backgroundColor: "#3a3a40",
marginBottom: 8,
},
title: { color: "#f5f5f7", fontSize: 20, fontWeight: "700", marginBottom: 8 },
empty: { color: "#6b6b70", paddingVertical: 12 },
row: {
flexDirection: "row",
alignItems: "center",
gap: 16,
paddingVertical: 12,
borderBottomColor: "#1a1a1f",
borderBottomWidth: 1,
},
rowLabel: { flex: 1 },
rowText: { color: "#f5f5f7", fontSize: 15 },
move: { color: "#9a9aa0", fontSize: 22, width: 24, textAlign: "center" },
});
@@ -0,0 +1,146 @@
// Property editor for a selected layer (PRD §6.1.5). Fields vary by layer type;
// edits route through cardStore.updateLayer so they're undoable.
import { Modal, Pressable, StyleSheet, Text, TextInput, View } from "react-native";
import { Side, useCardStore } from "../../stores/cardStore";
import { ShapeLayer, TextLayer } from "../../types/card";
import { ColorPaletteEditor } from "./ColorPaletteEditor";
interface Props {
side: Side;
layerId: string | null;
onClose: () => void;
}
export function LayerPropertySheet({ side, layerId, onClose }: Props) {
const card = useCardStore((s) => s.card);
const updateLayer = useCardStore((s) => s.updateLayer);
const removeLayer = useCardStore((s) => s.removeLayer);
const layer = card?.[side].layers.find((l) => l.id === layerId) ?? null;
const palette = card?.palette.colors ?? [];
const stepOpacity = (delta: number) => {
if (!layer) return;
const opacity = Math.max(0, Math.min(1, Math.round((layer.opacity + delta) * 10) / 10));
updateLayer(side, layer.id, { opacity });
};
return (
<Modal visible={!!layer} transparent animationType="slide" onRequestClose={onClose}>
<Pressable style={styles.backdrop} onPress={onClose}>
<Pressable style={styles.sheet} onPress={(e) => e.stopPropagation()}>
{layer && (
<>
<Text style={styles.title}>{layer.type} layer</Text>
{layer.type === "text" && (
<>
<Field
label="Text"
value={(layer as TextLayer).text}
onChangeText={(text) => updateLayer(side, layer.id, { text } as Partial<TextLayer>)}
/>
<Text style={styles.label}>Color</Text>
<ColorPaletteEditor
colors={palette}
selected={(layer as TextLayer).color}
onSelect={(color) => updateLayer(side, layer.id, { color } as Partial<TextLayer>)}
/>
</>
)}
{layer.type === "shape" && (
<>
<Text style={styles.label}>Fill</Text>
<ColorPaletteEditor
colors={palette}
selected={(layer as ShapeLayer).fill}
onSelect={(fill) => updateLayer(side, layer.id, { fill } as Partial<ShapeLayer>)}
/>
</>
)}
<View style={styles.stepRow}>
<Text style={styles.label}>Opacity {Math.round(layer.opacity * 100)}%</Text>
<View style={styles.steppers}>
<Stepper label="−" onPress={() => stepOpacity(-0.1)} />
<Stepper label="+" onPress={() => stepOpacity(0.1)} />
</View>
</View>
<Pressable
style={styles.delete}
onPress={() => {
removeLayer(side, layer.id);
onClose();
}}
>
<Text style={styles.deleteText}>Delete layer</Text>
</Pressable>
</>
)}
</Pressable>
</Pressable>
</Modal>
);
}
function Field({
label,
value,
onChangeText,
}: {
label: string;
value: string;
onChangeText: (t: string) => void;
}) {
return (
<>
<Text style={styles.label}>{label}</Text>
<TextInput style={styles.input} value={value} onChangeText={onChangeText} />
</>
);
}
function Stepper({ label, onPress }: { label: string; onPress: () => void }) {
return (
<Pressable style={styles.stepper} onPress={onPress}>
<Text style={styles.stepperText}>{label}</Text>
</Pressable>
);
}
const styles = StyleSheet.create({
backdrop: { flex: 1, backgroundColor: "rgba(0,0,0,0.5)", justifyContent: "flex-end" },
sheet: {
backgroundColor: "#15151a",
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
padding: 20,
paddingBottom: 36,
gap: 12,
},
title: { color: "#f5f5f7", fontSize: 18, fontWeight: "700", textTransform: "capitalize" },
label: { color: "#9a9aa0", fontSize: 14 },
input: {
backgroundColor: "#222228",
color: "#f5f5f7",
borderRadius: 12,
paddingHorizontal: 14,
paddingVertical: 12,
},
stepRow: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" },
steppers: { flexDirection: "row", gap: 8 },
stepper: {
backgroundColor: "#222228",
width: 44,
height: 44,
borderRadius: 12,
alignItems: "center",
justifyContent: "center",
},
stepperText: { color: "#f5f5f7", fontSize: 22, fontWeight: "700" },
delete: { paddingVertical: 14, alignItems: "center" },
deleteText: { color: "#ff453a", fontWeight: "600", fontSize: 16 },
});
@@ -0,0 +1,123 @@
// Profile-depth editor (PRD §6.6): bio + links. Edits are undoable via the
// cardStore profile actions. Portfolio/testimonials are Pro-tier and land later.
import { Modal, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from "react-native";
import { newLayerId, useCardStore } from "../../stores/cardStore";
interface Props {
visible: boolean;
onClose: () => void;
}
const MAX_LINKS = 12;
export function ProfileEditor({ visible, onClose }: Props) {
const profile = useCardStore((s) => s.card?.profile);
const setBio = useCardStore((s) => s.setBio);
const addLink = useCardStore((s) => s.addLink);
const updateLink = useCardStore((s) => s.updateLink);
const removeLink = useCardStore((s) => s.removeLink);
const links = profile?.links ?? [];
return (
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
<Pressable style={styles.backdrop} onPress={onClose}>
<Pressable style={styles.sheet} onPress={(e) => e.stopPropagation()}>
<View style={styles.grabber} />
<ScrollView contentContainerStyle={styles.content}>
<Text style={styles.title}>Profile</Text>
<Text style={styles.label}>Bio</Text>
<TextInput
style={[styles.input, styles.bio]}
multiline
maxLength={400}
placeholder="A short bio (up to 400 chars)…"
placeholderTextColor="#6b6b70"
value={profile?.bio ?? ""}
onChangeText={setBio}
/>
<View style={styles.linksHeader}>
<Text style={styles.label}>Links ({links.length}/{MAX_LINKS})</Text>
<Pressable
disabled={links.length >= MAX_LINKS}
onPress={() =>
addLink({ id: newLayerId(), type: "website", label: "", url: "", iconSlug: "website" })
}
>
<Text style={[styles.add, links.length >= MAX_LINKS && styles.disabled]}>+ Add</Text>
</Pressable>
</View>
{links.map((link) => (
<View key={link.id} style={styles.linkRow}>
<TextInput
style={[styles.input, styles.linkField]}
placeholder="Label"
placeholderTextColor="#6b6b70"
value={link.label}
onChangeText={(label) => updateLink(link.id, { label })}
/>
<TextInput
style={[styles.input, styles.linkField]}
placeholder="https://…"
placeholderTextColor="#6b6b70"
autoCapitalize="none"
value={link.url}
onChangeText={(url) => updateLink(link.id, { url })}
/>
<Pressable onPress={() => removeLink(link.id)} hitSlop={8}>
<Text style={styles.remove}>✕</Text>
</Pressable>
</View>
))}
<Pressable style={styles.done} onPress={onClose}>
<Text style={styles.doneText}>Done</Text>
</Pressable>
</ScrollView>
</Pressable>
</Pressable>
</Modal>
);
}
const styles = StyleSheet.create({
backdrop: { flex: 1, backgroundColor: "rgba(0,0,0,0.5)", justifyContent: "flex-end" },
sheet: {
backgroundColor: "#15151a",
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
paddingTop: 12,
maxHeight: "85%",
},
grabber: {
alignSelf: "center",
width: 40,
height: 4,
borderRadius: 2,
backgroundColor: "#3a3a40",
marginBottom: 8,
},
content: { padding: 20, paddingBottom: 36, gap: 10 },
title: { color: "#f5f5f7", fontSize: 20, fontWeight: "700" },
label: { color: "#9a9aa0", fontSize: 14 },
input: {
backgroundColor: "#222228",
color: "#f5f5f7",
borderRadius: 12,
paddingHorizontal: 14,
paddingVertical: 12,
},
bio: { minHeight: 80, textAlignVertical: "top" },
linksHeader: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", marginTop: 8 },
add: { color: "#ff3b30", fontWeight: "600", fontSize: 16 },
disabled: { opacity: 0.4 },
linkRow: { flexDirection: "row", gap: 8, alignItems: "center" },
linkField: { flex: 1 },
remove: { color: "#9a9aa0", fontSize: 18, paddingHorizontal: 4 },
done: { marginTop: 12, backgroundColor: "#ff3b30", borderRadius: 14, paddingVertical: 16, alignItems: "center" },
doneText: { color: "#fff", fontWeight: "700", fontSize: 16 },
});
@@ -9,14 +9,18 @@ import {
ContactLayer, ContactLayer,
Layer, Layer,
LogoLayer, LogoLayer,
ShapeLayer,
TextLayer, TextLayer,
} from "../../types/card"; } from "../../types/card";
import { API_BASE } from "../../api/client"; import { API_BASE } from "../../api/client";
import { QRCodeView } from "./QRCodeView";
interface Props { interface Props {
side: CardSide; side: CardSide;
width: number; width: number;
height: number; height: number;
/** URL encoded by any `qr` layer on this side (the card's profile URL). */
profileUrl?: string;
} }
function backgroundStyle(bg: BackgroundConfig): { backgroundColor: string } { function backgroundStyle(bg: BackgroundConfig): { backgroundColor: string } {
@@ -33,7 +37,17 @@ function assetUri(r2Key: string): string {
return `${API_BASE}/assets/${r2Key}`; return `${API_BASE}/assets/${r2Key}`;
} }
function LayerView({ layer, width, height }: { layer: Layer; width: number; height: number }) { function LayerView({
layer,
width,
height,
profileUrl,
}: {
layer: Layer;
width: number;
height: number;
profileUrl?: string;
}) {
const frame = { const frame = {
position: "absolute" as const, position: "absolute" as const,
left: layer.x * width, left: layer.x * width,
@@ -89,17 +103,52 @@ function LayerView({ layer, width, height }: { layer: Layer; width: number; heig
</View> </View>
); );
} }
case "shape": {
const s = layer as ShapeLayer;
const radius =
s.shape === "circle"
? Math.min(frame.width, frame.height)
: s.shape === "line"
? 0
: s.cornerRadius;
return (
<View
style={[
frame,
{
backgroundColor: s.fill,
borderColor: s.stroke,
borderWidth: s.stroke ? s.strokeWidth : 0,
borderRadius: radius,
height: s.shape === "line" ? Math.max(s.strokeWidth, 1) : frame.height,
},
]}
/>
);
}
case "qr":
return (
<View style={frame}>
<QRCodeView value={profileUrl ?? ""} size={Math.min(frame.width, frame.height)} />
</View>
);
default: default:
return <View style={frame} />; return <View style={frame} />;
} }
} }
export function CardFace({ side, width, height }: Props) { export function CardFace({ side, width, height, profileUrl }: Props) {
const ordered = [...side.layers].sort((a, b) => a.zIndex - b.zIndex); const ordered = [...side.layers].sort((a, b) => a.zIndex - b.zIndex);
return ( return (
<View style={[styles.face, { width, height }, backgroundStyle(side.background)]}> <View style={[styles.face, { width, height }, backgroundStyle(side.background)]}>
{ordered.map((layer) => ( {ordered.map((layer) => (
<LayerView key={layer.id} layer={layer} width={width} height={height} /> <LayerView
key={layer.id}
layer={layer}
width={width}
height={height}
profileUrl={profileUrl}
/>
))} ))}
</View> </View>
); );
@@ -13,6 +13,7 @@ import Animated, {
withSequence, withSequence,
withTiming, withTiming,
} from "react-native-reanimated"; } from "react-native-reanimated";
import { PROFILE_BASE } from "../../api/client";
import { CardDefinition } from "../../types/card"; import { CardDefinition } from "../../types/card";
import { CardFace } from "./CardFace"; import { CardFace } from "./CardFace";
import { CardFlip } from "./CardFlip"; import { CardFlip } from "./CardFlip";
@@ -21,6 +22,7 @@ export function CardViewer({ card }: { card: CardDefinition }) {
const { width, height } = useWindowDimensions(); const { width, height } = useWindowDimensions();
const cardWidth = Math.min(width * 0.92, 380); const cardWidth = Math.min(width * 0.92, 380);
const cardHeight = Math.min(height * 0.72, cardWidth * 1.5); const cardHeight = Math.min(height * 0.72, cardWidth * 1.5);
const profileUrl = `${PROFILE_BASE}/${card.handle}`;
const entry = useSharedValue(0); const entry = useSharedValue(0);
const float = useSharedValue(0); const float = useSharedValue(0);
@@ -58,8 +60,12 @@ export function CardViewer({ card }: { card: CardDefinition }) {
durationMs={card.settings.flipDurationMs} durationMs={card.settings.flipDurationMs}
hapticEnabled={card.settings.hapticEnabled} hapticEnabled={card.settings.hapticEnabled}
gesture={card.settings.flipGesture} gesture={card.settings.flipGesture}
front={<CardFace side={card.face} width={cardWidth} height={cardHeight} />} front={
back={<CardFace side={card.back} width={cardWidth} height={cardHeight} />} <CardFace side={card.face} width={cardWidth} height={cardHeight} profileUrl={profileUrl} />
}
back={
<CardFace side={card.back} width={cardWidth} height={cardHeight} profileUrl={profileUrl} />
}
/> />
</Animated.View> </Animated.View>
</View> </View>
@@ -0,0 +1,54 @@
// Renders a QR code as a grid of cells from the on-device qrMatrix (PRD §16,
// QR layer). Pure RN views — no native QR dependency.
import { useMemo } from "react";
import { StyleSheet, View } from "react-native";
import { qrMatrix } from "../../engine/renderer/qrGenerator";
interface Props {
value: string;
size: number;
/** Quiet-zone border in cells (QR spec recommends 4). */
quietZone?: number;
color?: string;
background?: string;
}
export function QRCodeView({
value,
size,
quietZone = 2,
color = "#000000",
background = "#ffffff",
}: Props) {
const matrix = useMemo(() => qrMatrix(value), [value]);
const modules = matrix.length + quietZone * 2;
const cell = size / modules;
return (
<View style={[styles.root, { width: size, height: size, backgroundColor: background }]}>
{matrix.map((row, r) => (
<View key={r} style={styles.row}>
{row.map((dark, c) => (
<View
key={c}
style={{
position: "absolute",
left: (c + quietZone) * cell,
top: (r + quietZone) * cell,
width: cell,
height: cell,
backgroundColor: dark ? color : "transparent",
}}
/>
))}
</View>
))}
</View>
);
}
const styles = StyleSheet.create({
root: { borderRadius: 8, overflow: "hidden" },
row: { ...StyleSheet.absoluteFillObject },
});
@@ -0,0 +1,35 @@
// Full-screen QR overlay shown for in-person exchange (PRD §6.5.1 QR modality).
import { Pressable, StyleSheet, Text, View } from "react-native";
import { QRCodeView } from "../card/QRCodeView";
interface Props {
url: string;
handle: string;
onClose: () => void;
}
export function QRShareOverlay({ url, handle, onClose }: Props) {
return (
<Pressable style={styles.root} onPress={onClose}>
<View style={styles.card}>
<QRCodeView value={url} size={260} />
</View>
<Text style={styles.handle}>@{handle}</Text>
<Text style={styles.hint}>Point a camera here to open the card</Text>
</Pressable>
);
}
const styles = StyleSheet.create({
root: {
...StyleSheet.absoluteFillObject,
backgroundColor: "rgba(0,0,0,0.92)",
alignItems: "center",
justifyContent: "center",
gap: 16,
},
card: { backgroundColor: "#fff", padding: 20, borderRadius: 24 },
handle: { color: "#f5f5f7", fontSize: 22, fontWeight: "700" },
hint: { color: "#9a9aa0", fontSize: 14 },
});
@@ -0,0 +1,112 @@
// Share modality picker (PRD §6.5.1). Each action first creates a tracked share
// link, then performs the action so analytics attribute the visit correctly.
//
// QR and the OS share sheet (which itself covers AirDrop / iMessage / Email /
// Copy) are implemented here with no native dependency. NFC tap and direct
// Add-to-Wallet require native modules (react-native-nfc-manager, a PassKit
// bridge) and a dev build — they attach in the hardware-integration milestone.
import { useState } from "react";
import { Linking, Modal, Pressable, Share, StyleSheet, Text, View } from "react-native";
import * as Haptics from "expo-haptics";
import { createShareLink } from "../../api/share";
import { googleWalletSaveUrl } from "../../api/cards";
import { QRShareOverlay } from "./QRShareOverlay";
interface Props {
cardId: string;
handle: string;
visible: boolean;
onClose: () => void;
}
export function ShareSheet({ cardId, handle, visible, onClose }: Props) {
const [qrUrl, setQrUrl] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const showQr = async () => {
setBusy(true);
try {
const link = await createShareLink(cardId, "qr");
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
setQrUrl(link.url);
} finally {
setBusy(false);
}
};
const shareLink = async () => {
setBusy(true);
try {
const link = await createShareLink(cardId, "link");
await Share.share({ message: link.url, url: link.url });
} finally {
setBusy(false);
}
};
const addToGoogleWallet = async () => {
setBusy(true);
try {
const url = await googleWalletSaveUrl(cardId);
await Linking.openURL(url);
} finally {
setBusy(false);
}
};
return (
<Modal visible={visible} animationType="slide" transparent onRequestClose={onClose}>
{qrUrl ? (
<QRShareOverlay url={qrUrl} handle={handle} onClose={() => setQrUrl(null)} />
) : (
<Pressable style={styles.backdrop} onPress={onClose}>
<Pressable style={styles.sheet} onPress={(e) => e.stopPropagation()}>
<View style={styles.grabber} />
<Text style={styles.title}>Share your card</Text>
<Action label="Show QR code" onPress={showQr} disabled={busy} />
<Action label="Share link…" onPress={shareLink} disabled={busy} />
<Action label="Add to Google Wallet" onPress={addToGoogleWallet} disabled={busy} />
<Pressable style={styles.cancel} onPress={onClose}>
<Text style={styles.cancelText}>Cancel</Text>
</Pressable>
</Pressable>
</Pressable>
)}
</Modal>
);
}
function Action({ label, onPress, disabled }: { label: string; onPress: () => void; disabled?: boolean }) {
return (
<Pressable style={[styles.action, disabled && styles.disabled]} onPress={onPress} disabled={disabled}>
<Text style={styles.actionText}>{label}</Text>
</Pressable>
);
}
const styles = StyleSheet.create({
backdrop: { flex: 1, backgroundColor: "rgba(0,0,0,0.5)", justifyContent: "flex-end" },
sheet: {
backgroundColor: "#15151a",
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
padding: 20,
paddingBottom: 36,
gap: 10,
},
grabber: {
alignSelf: "center",
width: 40,
height: 4,
borderRadius: 2,
backgroundColor: "#3a3a40",
marginBottom: 8,
},
title: { color: "#f5f5f7", fontSize: 20, fontWeight: "700", marginBottom: 8 },
action: { backgroundColor: "#222228", borderRadius: 14, paddingVertical: 16, alignItems: "center" },
actionText: { color: "#f5f5f7", fontSize: 16, fontWeight: "600" },
disabled: { opacity: 0.4 },
cancel: { paddingVertical: 16, alignItems: "center" },
cancelText: { color: "#9a9aa0", fontSize: 16 },
});
+50 -1
View File
@@ -6,7 +6,7 @@
// Zustand store so it is usable both in React and in headless unit tests. // Zustand store so it is usable both in React and in headless unit tests.
import { create } from "zustand"; import { create } from "zustand";
import { BackgroundConfig, CardDefinition, Layer } from "../types/card"; import { BackgroundConfig, CardDefinition, Layer, ProfileData, ProfileLink } from "../types/card";
export const MAX_HISTORY = 50; export const MAX_HISTORY = 50;
@@ -21,8 +21,14 @@ interface CardState {
addLayer: (side: Side, layer: Layer) => void; addLayer: (side: Side, layer: Layer) => void;
updateLayer: (side: Side, layerId: string, patch: Partial<Layer>) => void; updateLayer: (side: Side, layerId: string, patch: Partial<Layer>) => void;
removeLayer: (side: Side, layerId: string) => void; removeLayer: (side: Side, layerId: string) => void;
reorderLayer: (side: Side, layerId: string, direction: "up" | "down") => void;
setBackground: (side: Side, background: BackgroundConfig) => void; setBackground: (side: Side, background: BackgroundConfig) => void;
setBio: (bio: string) => void;
addLink: (link: ProfileLink) => void;
updateLink: (linkId: string, patch: Partial<ProfileLink>) => void;
removeLink: (linkId: string) => void;
undo: () => void; undo: () => void;
redo: () => void; redo: () => void;
canUndo: () => boolean; canUndo: () => boolean;
@@ -58,6 +64,16 @@ export const useCardStore = create<CardState>((set, get) => {
return card; return card;
}; };
const emptyProfile = (): ProfileData => ({ bio: "", links: [] });
const editProfile = (
card: CardDefinition,
fn: (profile: ProfileData) => ProfileData,
): CardDefinition => {
card.profile = fn(card.profile ?? emptyProfile());
return card;
};
return { return {
card: null, card: null,
past: [], past: [],
@@ -80,12 +96,45 @@ export const useCardStore = create<CardState>((set, get) => {
editSide(card, side, (layers) => layers.filter((l) => l.id !== layerId)), editSide(card, side, (layers) => layers.filter((l) => l.id !== layerId)),
), ),
reorderLayer: (side, layerId, direction) =>
mutate((card) =>
editSide(card, side, (layers) => {
const sorted = [...layers].sort((a, b) => a.zIndex - b.zIndex);
const i = sorted.findIndex((l) => l.id === layerId);
const j = direction === "up" ? i + 1 : i - 1;
if (i === -1 || j < 0 || j >= sorted.length) return layers;
// Swap the z-indexes of the two adjacent layers.
const zi = sorted[i].zIndex;
sorted[i] = { ...sorted[i], zIndex: sorted[j].zIndex };
sorted[j] = { ...sorted[j], zIndex: zi };
return sorted;
}),
),
setBackground: (side, background) => setBackground: (side, background) =>
mutate((card) => { mutate((card) => {
card[side] = { ...card[side], background }; card[side] = { ...card[side], background };
return card; return card;
}), }),
setBio: (bio) => mutate((card) => editProfile(card, (p) => ({ ...p, bio }))),
addLink: (link) =>
mutate((card) => editProfile(card, (p) => ({ ...p, links: [...p.links, link] }))),
updateLink: (linkId, patch) =>
mutate((card) =>
editProfile(card, (p) => ({
...p,
links: p.links.map((l) => (l.id === linkId ? { ...l, ...patch } : l)),
})),
),
removeLink: (linkId) =>
mutate((card) =>
editProfile(card, (p) => ({ ...p, links: p.links.filter((l) => l.id !== linkId) })),
),
undo: () => { undo: () => {
const { card, past, future } = get(); const { card, past, future } = get();
if (!card || past.length === 0) return; if (!card || past.length === 0) return;
+27 -1
View File
@@ -66,7 +66,17 @@ export interface ContactLayer extends BaseLayer {
fields: ContactFields; fields: ContactFields;
} }
export type Layer = TextLayer | LogoLayer | ContactLayer | BaseLayer; export interface ShapeLayer extends BaseLayer {
type: "shape";
shape: "rectangle" | "circle" | "line";
fill?: string;
stroke?: string;
strokeWidth: number;
cornerRadius: number;
}
/** A `qr` layer carries only geometry; its content is the card's profile URL. */
export type Layer = TextLayer | LogoLayer | ContactLayer | ShapeLayer | BaseLayer;
export interface GradientStop { export interface GradientStop {
color: string; color: string;
@@ -105,6 +115,21 @@ export interface CardSettings {
hapticEnabled: boolean; hapticEnabled: boolean;
} }
export interface ProfileLink {
id: string;
type: string;
label: string;
url: string;
iconSlug: string;
}
export interface ProfileData {
bio: string;
avatarR2Key?: string | null;
links: ProfileLink[];
contactFormEnabled?: boolean;
}
export interface CardDefinition { export interface CardDefinition {
id: string; id: string;
ownerId: string; ownerId: string;
@@ -114,6 +139,7 @@ export interface CardDefinition {
back: CardSide; back: CardSide;
palette: ColorPalette; palette: ColorPalette;
settings: CardSettings; settings: CardSettings;
profile?: ProfileData;
} }
export const DEFAULT_SETTINGS: CardSettings = { export const DEFAULT_SETTINGS: CardSettings = {