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:
co-authored by
Claude Opus 4.8
parent
8ae1d8f263
commit
d0636577dc
@@ -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" },
|
||||
});
|
||||
Reference in New Issue
Block a user