This commit is contained in:
jjbrunton committed 2026-01-01 16:09:01 +00:00
1 parent 3fcbea8d60
commit b4c8a2f303
5 files changed
+908 -1

No files matched your search

@@ -0,0 +1,670 @@
import { useState, useEffect, useCallback } from 'react';
import { Link } from 'react-router-dom';
import { supabase } from '@/config';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@/components/ui/tabs';
import { formatDistanceToNow, format } from 'date-fns';
import { toast } from 'sonner';
import {
Activity,
ThumbsUp,
ThumbsDown,
Minus,
Heart,
MessageCircle,
UserPlus,
Image as ImageIcon,
} from 'lucide-react';
// Types
interface ResponseActivity {
id: string;
user_id: string;
answer: 'yes' | 'no' | 'maybe';
created_at: string;
profile: {
id: string;
name: string | null;
email: string | null;
} | null;
question: {
id: string;
text: string;
pack: {
name: string;
} | null;
} | null;
}
interface MatchActivity {
id: string;
couple_id: string;
match_type: 'yes_yes' | 'yes_maybe' | 'maybe_maybe';
is_new: boolean;
created_at: string;
question: {
id: string;
text: string;
} | null;
coupleProfiles?: Array<{
id: string;
name: string | null;
email: string | null;
}>;
}
interface MessageActivity {
id: string;
user_id: string;
content: string | null;
media_path: string | null;
created_at: string;
profile: {
id: string;
name: string | null;
email: string | null;
} | null;
match: {
id: string;
question: {
text: string;
} | null;
} | null;
}
interface SignupActivity {
id: string;
name: string | null;
email: string | null;
avatar_url: string | null;
created_at: string;
onboarding_completed: boolean | null;
couple_id: string | null;
}
type ActivityTab = 'responses' | 'matches' | 'messages' | 'signups';
// Answer icons
const answerConfig = {
yes: { icon: <ThumbsUp className="h-4 w-4" />, color: 'text-green-500', bg: 'bg-green-500/10' },
no: { icon: <ThumbsDown className="h-4 w-4" />, color: 'text-red-500', bg: 'bg-red-500/10' },
maybe: { icon: <Minus className="h-4 w-4" />, color: 'text-yellow-500', bg: 'bg-yellow-500/10' },
};
const matchTypeLabels = {
yes_yes: { label: 'Both Yes!', variant: 'default' as const },
yes_maybe: { label: 'Yes + Maybe', variant: 'secondary' as const },
maybe_maybe: { label: 'Both Maybe', variant: 'outline' as const },
};
export function UserActivityPage() {
const [activeTab, setActiveTab] = useState<ActivityTab>('responses');
const [responses, setResponses] = useState<ResponseActivity[]>([]);
const [matches, setMatches] = useState<MatchActivity[]>([]);
const [messages, setMessages] = useState<MessageActivity[]>([]);
const [signups, setSignups] = useState<SignupActivity[]>([]);
const [loading, setLoading] = useState({
responses: true,
matches: true,
messages: true,
signups: true,
});
// Fetch responses
const fetchResponses = useCallback(async () => {
setLoading(prev => ({ ...prev, responses: true }));
try {
const { data, error } = await supabase
.from('responses')
.select(`
id, user_id, answer, created_at,
profile:profiles!responses_user_id_fkey(id, name, email),
question:questions(
id, text,
pack:question_packs(name)
)
`)
.order('created_at', { ascending: false })
.limit(50);
if (error) throw error;
// Transform data to match our types (Supabase returns nested objects, not arrays for 1:1 relations)
const transformed: ResponseActivity[] = (data || []).map((item: any) => ({
id: item.id,
user_id: item.user_id,
answer: item.answer,
created_at: item.created_at,
profile: item.profile,
question: item.question ? {
id: item.question.id,
text: item.question.text,
pack: item.question.pack,
} : null,
}));
setResponses(transformed);
} catch (error) {
console.error('Failed to load responses:', error);
toast.error("Failed to load responses");
} finally {
setLoading(prev => ({ ...prev, responses: false }));
}
}, []);
// Fetch matches with couple profiles
const fetchMatches = useCallback(async () => {
setLoading(prev => ({ ...prev, matches: true }));
try {
const { data: matchesData, error: matchesError } = await supabase
.from('matches')
.select(`
id, couple_id, match_type, is_new, created_at,
question:questions(id, text)
`)
.order('created_at', { ascending: false })
.limit(50);
if (matchesError) throw matchesError;
// Get unique couple IDs and fetch profiles
const coupleIds = [...new Set((matchesData || []).map((m: any) => m.couple_id))];
if (coupleIds.length > 0) {
const { data: profiles, error: profilesError } = await supabase
.from('profiles')
.select('id, name, email, couple_id')
.in('couple_id', coupleIds);
if (profilesError) throw profilesError;
// Attach profiles to matches
const matchesWithProfiles: MatchActivity[] = (matchesData || []).map((match: any) => ({
id: match.id,
couple_id: match.couple_id,
match_type: match.match_type,
is_new: match.is_new,
created_at: match.created_at,
question: match.question,
coupleProfiles: (profiles || []).filter((p: any) => p.couple_id === match.couple_id),
}));
setMatches(matchesWithProfiles);
} else {
const transformed: MatchActivity[] = (matchesData || []).map((match: any) => ({
id: match.id,
couple_id: match.couple_id,
match_type: match.match_type,
is_new: match.is_new,
created_at: match.created_at,
question: match.question,
coupleProfiles: [],
}));
setMatches(transformed);
}
} catch (error) {
console.error('Failed to load matches:', error);
toast.error("Failed to load matches");
} finally {
setLoading(prev => ({ ...prev, matches: false }));
}
}, []);
// Fetch messages
const fetchMessages = useCallback(async () => {
setLoading(prev => ({ ...prev, messages: true }));
try {
// Fetch messages with match data
const { data: messagesData, error: messagesError } = await supabase
.from('messages')
.select(`
id, user_id, content, media_path, created_at,
match:matches(
id,
question:questions(text)
)
`)
.order('created_at', { ascending: false })
.limit(50);
if (messagesError) throw messagesError;
// Get unique user IDs and fetch profiles
const userIds = [...new Set((messagesData || []).map((m: any) => m.user_id))];
let profilesMap: Record<string, { id: string; name: string | null; email: string | null }> = {};
if (userIds.length > 0) {
const { data: profiles, error: profilesError } = await supabase
.from('profiles')
.select('id, name, email')
.in('id', userIds);
if (profilesError) throw profilesError;
profilesMap = (profiles || []).reduce((acc: any, p: any) => {
acc[p.id] = p;
return acc;
}, {});
}
// Transform data to match our types
const transformed: MessageActivity[] = (messagesData || []).map((item: any) => ({
id: item.id,
user_id: item.user_id,
content: item.content,
media_path: item.media_path,
created_at: item.created_at,
profile: profilesMap[item.user_id] || null,
match: item.match ? {
id: item.match.id,
question: item.match.question,
} : null,
}));
setMessages(transformed);
} catch (error) {
console.error('Failed to load messages:', error);
toast.error("Failed to load messages");
} finally {
setLoading(prev => ({ ...prev, messages: false }));
}
}, []);
// Fetch signups
const fetchSignups = useCallback(async () => {
setLoading(prev => ({ ...prev, signups: true }));
try {
const { data, error } = await supabase
.from('profiles')
.select('id, name, email, avatar_url, created_at, onboarding_completed, couple_id')
.order('created_at', { ascending: false })
.limit(50);
if (error) throw error;
setSignups(data || []);
} catch (error) {
console.error('Failed to load signups:', error);
toast.error("Failed to load signups");
} finally {
setLoading(prev => ({ ...prev, signups: false }));
}
}, []);
// Real-time subscriptions
const { status: responsesStatus } = useRealtimeSubscription({
table: 'responses',
onInsert: useCallback(() => fetchResponses(), [fetchResponses]),
insertToast: { enabled: true, message: () => 'New response', type: 'info' },
});
const { status: matchesStatus } = useRealtimeSubscription({
table: 'matches',
onInsert: useCallback(() => fetchMatches(), [fetchMatches]),
insertToast: { enabled: true, message: () => 'New match!', type: 'success' },
});
const { status: messagesStatus } = useRealtimeSubscription({
table: 'messages',
onInsert: useCallback(() => fetchMessages(), [fetchMessages]),
insertToast: { enabled: true, message: () => 'New message', type: 'info' },
});
const { status: signupsStatus } = useRealtimeSubscription({
table: 'profiles',
onInsert: useCallback(() => fetchSignups(), [fetchSignups]),
insertToast: { enabled: true, message: () => 'New user signed up!', type: 'success' },
});
// Fetch all data on mount
useEffect(() => {
fetchResponses();
fetchMatches();
fetchMessages();
fetchSignups();
}, [fetchResponses, fetchMatches, fetchMessages, fetchSignups]);
// Get current realtime status based on active tab
const getCurrentRealtimeStatus = () => {
switch (activeTab) {
case 'responses': return responsesStatus;
case 'matches': return matchesStatus;
case 'messages': return messagesStatus;
case 'signups': return signupsStatus;
}
};
// Render user link
const renderUserLink = (profile: { id: string; name: string | null; email: string | null } | null) => {
if (!profile) return <span className="text-muted-foreground">Unknown User</span>;
return (
<Link
to={`/users/${profile.id}`}
className="text-primary hover:underline"
>
{profile.name || profile.email || 'Unknown'}
</Link>
);
};
// Render answer badge
const renderAnswerBadge = (answer: 'yes' | 'no' | 'maybe') => {
const config = answerConfig[answer];
return (
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-md ${config.bg} ${config.color}`}>
{config.icon}
<span className="text-sm font-medium capitalize">{answer}</span>
</span>
);
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
<Activity className="h-8 w-8 text-primary" />
User Activity
</h1>
<RealtimeStatusIndicator status={getCurrentRealtimeStatus()} showLabel />
</div>
<p className="text-muted-foreground">
Monitor recent user activity across the platform
</p>
</div>
</div>
{/* Tabs */}
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as ActivityTab)}>
<TabsList className="grid w-full grid-cols-4 max-w-[600px]">
<TabsTrigger value="responses" className="gap-2">
<ThumbsUp className="h-4 w-4" />
Responses
</TabsTrigger>
<TabsTrigger value="matches" className="gap-2">
<Heart className="h-4 w-4" />
Matches
</TabsTrigger>
<TabsTrigger value="messages" className="gap-2">
<MessageCircle className="h-4 w-4" />
Messages
</TabsTrigger>
<TabsTrigger value="signups" className="gap-2">
<UserPlus className="h-4 w-4" />
Signups
</TabsTrigger>
</TabsList>
{/* Responses Tab */}
<TabsContent value="responses" className="mt-6">
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead>Question</TableHead>
<TableHead>Pack</TableHead>
<TableHead>Answer</TableHead>
<TableHead>Time</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading.responses ? (
<TableRow>
<TableCell colSpan={5} className="text-center py-8">
<div className="flex justify-center"><Skeleton className="h-6 w-32" /></div>
</TableCell>
</TableRow>
) : responses.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
No responses yet.
</TableCell>
</TableRow>
) : (
responses.map((item) => (
<TableRow key={item.id}>
<TableCell>{renderUserLink(item.profile)}</TableCell>
<TableCell className="max-w-[300px]">
<span className="truncate block">
{item.question?.text || 'Unknown question'}
</span>
</TableCell>
<TableCell>
<Badge variant="outline">
{item.question?.pack?.name || 'Unknown'}
</Badge>
</TableCell>
<TableCell>{renderAnswerBadge(item.answer)}</TableCell>
<TableCell className="text-muted-foreground text-sm whitespace-nowrap">
{formatDistanceToNow(new Date(item.created_at), { addSuffix: true })}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</TabsContent>
{/* Matches Tab */}
<TabsContent value="matches" className="mt-6">
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Couple</TableHead>
<TableHead>Question</TableHead>
<TableHead>Match Type</TableHead>
<TableHead>Status</TableHead>
<TableHead>Time</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading.matches ? (
<TableRow>
<TableCell colSpan={5} className="text-center py-8">
<div className="flex justify-center"><Skeleton className="h-6 w-32" /></div>
</TableCell>
</TableRow>
) : matches.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
No matches yet.
</TableCell>
</TableRow>
) : (
matches.map((item) => (
<TableRow key={item.id}>
<TableCell>
<div className="flex items-center gap-1">
{item.coupleProfiles && item.coupleProfiles.length > 0 ? (
<>
{renderUserLink(item.coupleProfiles[0])}
{item.coupleProfiles.length > 1 && (
<>
<span className="text-muted-foreground mx-1">&</span>
{renderUserLink(item.coupleProfiles[1])}
</>
)}
</>
) : (
<span className="text-muted-foreground">Unknown couple</span>
)}
</div>
</TableCell>
<TableCell className="max-w-[300px]">
<span className="truncate block">
{item.question?.text || 'Unknown question'}
</span>
</TableCell>
<TableCell>
<Badge variant={matchTypeLabels[item.match_type]?.variant || 'secondary'}>
{matchTypeLabels[item.match_type]?.label || item.match_type}
</Badge>
</TableCell>
<TableCell>
{item.is_new ? (
<Badge variant="destructive">New</Badge>
) : (
<Badge variant="outline">Viewed</Badge>
)}
</TableCell>
<TableCell className="text-muted-foreground text-sm whitespace-nowrap">
{formatDistanceToNow(new Date(item.created_at), { addSuffix: true })}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</TabsContent>
{/* Messages Tab */}
<TabsContent value="messages" className="mt-6">
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead>Message</TableHead>
<TableHead>Context</TableHead>
<TableHead>Time</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading.messages ? (
<TableRow>
<TableCell colSpan={4} className="text-center py-8">
<div className="flex justify-center"><Skeleton className="h-6 w-32" /></div>
</TableCell>
</TableRow>
) : messages.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground">
No messages yet.
</TableCell>
</TableRow>
) : (
messages.map((item) => (
<TableRow key={item.id}>
<TableCell>{renderUserLink(item.profile)}</TableCell>
<TableCell className="max-w-[300px]">
{item.media_path ? (
<span className="inline-flex items-center gap-1 text-muted-foreground">
<ImageIcon className="h-4 w-4" />
[Media]
</span>
) : (
<span className="truncate block">
{item.content || <span className="text-muted-foreground italic">Empty message</span>}
</span>
)}
</TableCell>
<TableCell className="max-w-[200px]">
<span className="truncate block text-muted-foreground text-sm">
{item.match?.question?.text || 'Unknown match'}
</span>
</TableCell>
<TableCell className="text-muted-foreground text-sm whitespace-nowrap">
{formatDistanceToNow(new Date(item.created_at), { addSuffix: true })}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</TabsContent>
{/* Signups Tab */}
<TabsContent value="signups" className="mt-6">
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead>Email</TableHead>
<TableHead>Status</TableHead>
<TableHead>Paired</TableHead>
<TableHead>Joined</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading.signups ? (
<TableRow>
<TableCell colSpan={5} className="text-center py-8">
<div className="flex justify-center"><Skeleton className="h-6 w-32" /></div>
</TableCell>
</TableRow>
) : signups.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
No signups yet.
</TableCell>
</TableRow>
) : (
signups.map((item) => (
<TableRow key={item.id}>
<TableCell>
<Link
to={`/users/${item.id}`}
className="flex items-center gap-2 hover:opacity-80"
>
<Avatar className="h-8 w-8">
<AvatarImage src={item.avatar_url || undefined} />
<AvatarFallback className="bg-primary/10 text-primary">
{(item.name || item.email || '?').charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<span className="text-primary hover:underline">
{item.name || 'No name'}
</span>
</Link>
</TableCell>
<TableCell className="text-muted-foreground">
{item.email || 'No email'}
</TableCell>
<TableCell>
{item.onboarding_completed ? (
<Badge variant="default">Onboarded</Badge>
) : (
<Badge variant="secondary">Pending</Badge>
)}
</TableCell>
<TableCell>
{item.couple_id ? (
<Badge variant="default">Paired</Badge>
) : (
<Badge variant="outline">Single</Badge>
)}
</TableCell>
<TableCell className="text-muted-foreground text-sm whitespace-nowrap">
{format(new Date(item.created_at), 'MMM d, yyyy')}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</TabsContent>
</Tabs>
</div>
);
}
+3 -1
View File
@@ -32,7 +32,9 @@
"package": "com.sauci.app",
"permissions": [
"RECEIVE_BOOT_COMPLETED",
"VIBRATE"
"VIBRATE",
"android.permission.USE_BIOMETRIC",
"android.permission.USE_FINGERPRINT"
]
},
"web": {