Files
cardclaws/cardclaws-mobile/app/card/[cardId]/analytics.tsx
T
Omar SobhandClaude Opus 4.8 d0636577dc 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]>
2026-06-04 12:32:32 -05:00

135 lines
4.1 KiB
TypeScript

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" },
});