mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 10:19:43 +00:00
Update
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
@@ -32,7 +32,9 @@
|
||||
"package": "com.sauci.app",
|
||||
"permissions": [
|
||||
"RECEIVE_BOOT_COMPLETED",
|
||||
"VIBRATE"
|
||||
"VIBRATE",
|
||||
"android.permission.USE_BIOMETRIC",
|
||||
"android.permission.USE_FINGERPRINT"
|
||||
]
|
||||
},
|
||||
"web": {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,141 @@
|
||||
#!/bin/bash
|
||||
# Build Android app locally using EAS in WSL2
|
||||
# Usage: ./scripts/build-android-local.sh [profile]
|
||||
# Profiles: development, preview, production (default: production)
|
||||
|
||||
set -e
|
||||
|
||||
PROFILE="${1:-production}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
MOBILE_DIR="$PROJECT_ROOT/apps/mobile"
|
||||
|
||||
echo "=========================================="
|
||||
echo " Sauci Android Local Build (WSL2)"
|
||||
echo " Profile: $PROFILE"
|
||||
echo "=========================================="
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
check_dependency() {
|
||||
if ! command -v "$1" &> /dev/null; then
|
||||
echo -e "${RED}✗ $1 is not installed${NC}"
|
||||
return 1
|
||||
else
|
||||
echo -e "${GREEN}✓ $1 found${NC}"
|
||||
return 0
|
||||
fi
|
||||
}
|
||||
|
||||
# Check dependencies
|
||||
echo ""
|
||||
echo "Checking dependencies..."
|
||||
DEPS_OK=true
|
||||
|
||||
check_dependency "node" || DEPS_OK=false
|
||||
check_dependency "npm" || DEPS_OK=false
|
||||
check_dependency "java" || DEPS_OK=false
|
||||
|
||||
# Check Java version (need JDK 17)
|
||||
if command -v java &> /dev/null; then
|
||||
JAVA_VERSION=$(java -version 2>&1 | head -1 | cut -d'"' -f2 | cut -d'.' -f1)
|
||||
if [ "$JAVA_VERSION" -lt 17 ] 2>/dev/null; then
|
||||
echo -e "${YELLOW}⚠ Java $JAVA_VERSION found, but JDK 17+ recommended${NC}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check/set ANDROID_HOME
|
||||
if [ -z "$ANDROID_HOME" ]; then
|
||||
# Try common WSL2 paths to Windows Android SDK
|
||||
POSSIBLE_PATHS=(
|
||||
"/mnt/c/Users/$(cmd.exe /c "echo %USERNAME%" 2>/dev/null | tr -d '\r')/AppData/Local/Android/Sdk"
|
||||
"$HOME/Android/Sdk"
|
||||
"/usr/lib/android-sdk"
|
||||
)
|
||||
|
||||
for SDK_PATH in "${POSSIBLE_PATHS[@]}"; do
|
||||
if [ -d "$SDK_PATH" ]; then
|
||||
export ANDROID_HOME="$SDK_PATH"
|
||||
echo -e "${GREEN}✓ Found Android SDK at $ANDROID_HOME${NC}"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$ANDROID_HOME" ]; then
|
||||
echo -e "${RED}✗ ANDROID_HOME not set and SDK not found${NC}"
|
||||
echo " Install Android SDK or set ANDROID_HOME manually"
|
||||
DEPS_OK=false
|
||||
fi
|
||||
else
|
||||
echo -e "${GREEN}✓ ANDROID_HOME=$ANDROID_HOME${NC}"
|
||||
fi
|
||||
|
||||
# Set ANDROID_SDK_ROOT (some tools use this)
|
||||
export ANDROID_SDK_ROOT="$ANDROID_HOME"
|
||||
|
||||
# Add platform-tools to PATH
|
||||
if [ -n "$ANDROID_HOME" ]; then
|
||||
export PATH="$ANDROID_HOME/platform-tools:$ANDROID_HOME/tools:$PATH"
|
||||
fi
|
||||
|
||||
if [ "$DEPS_OK" = false ]; then
|
||||
echo ""
|
||||
echo -e "${RED}Missing dependencies. Please install them first:${NC}"
|
||||
echo ""
|
||||
echo " # Install Node.js (via nvm)"
|
||||
echo " curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash"
|
||||
echo " nvm install 20"
|
||||
echo ""
|
||||
echo " # Install JDK 17"
|
||||
echo " sudo apt update && sudo apt install openjdk-17-jdk"
|
||||
echo ""
|
||||
echo " # Install Android SDK (or use Windows SDK via /mnt/c/...)"
|
||||
echo " sudo apt install android-sdk"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Navigate to mobile app
|
||||
cd "$MOBILE_DIR"
|
||||
echo ""
|
||||
echo "Working directory: $(pwd)"
|
||||
|
||||
# Install dependencies if needed
|
||||
if [ ! -d "node_modules" ]; then
|
||||
echo ""
|
||||
echo "Installing dependencies..."
|
||||
npm ci
|
||||
fi
|
||||
|
||||
# Check if eas-cli is available
|
||||
if ! command -v eas &> /dev/null; then
|
||||
echo ""
|
||||
echo "Installing EAS CLI..."
|
||||
npm install -g eas-cli
|
||||
fi
|
||||
|
||||
# Create output directory
|
||||
OUTPUT_DIR="$MOBILE_DIR/build-output"
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
|
||||
# Run the build
|
||||
echo ""
|
||||
echo "Starting EAS local build..."
|
||||
echo "This may take 10-20 minutes..."
|
||||
echo ""
|
||||
|
||||
eas build \
|
||||
--local \
|
||||
--platform android \
|
||||
--profile "$PROFILE" \
|
||||
--output "$OUTPUT_DIR/sauci-$PROFILE.aab"
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo -e "${GREEN}Build complete!${NC}"
|
||||
echo "Output: $OUTPUT_DIR/sauci-$PROFILE.aab"
|
||||
echo "=========================================="
|
||||
@@ -0,0 +1,94 @@
|
||||
#!/bin/bash
|
||||
# Setup WSL2 environment for Android builds
|
||||
# Run this once to install all dependencies
|
||||
|
||||
set -e
|
||||
|
||||
echo "=========================================="
|
||||
echo " WSL2 Android Build Environment Setup"
|
||||
echo "=========================================="
|
||||
|
||||
# Update system
|
||||
echo ""
|
||||
echo "Updating system packages..."
|
||||
sudo apt-get update
|
||||
|
||||
# Install JDK 17 (try multiple approaches)
|
||||
echo ""
|
||||
echo "Installing OpenJDK 17..."
|
||||
if ! sudo apt-get install -y openjdk-17-jdk 2>/dev/null; then
|
||||
echo "Adding Ubuntu toolchain PPA for JDK 17..."
|
||||
sudo apt-get install -y software-properties-common
|
||||
sudo add-apt-repository -y ppa:openjdk-r/ppa
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y openjdk-17-jdk
|
||||
fi
|
||||
|
||||
# Set JAVA_HOME
|
||||
echo ""
|
||||
echo "Configuring JAVA_HOME..."
|
||||
JAVA_PATH=$(dirname $(dirname $(readlink -f $(which java))))
|
||||
if ! grep -q "JAVA_HOME" ~/.bashrc; then
|
||||
echo "export JAVA_HOME=$JAVA_PATH" >> ~/.bashrc
|
||||
fi
|
||||
export JAVA_HOME=$JAVA_PATH
|
||||
echo "JAVA_HOME=$JAVA_HOME"
|
||||
|
||||
# Install build essentials
|
||||
echo ""
|
||||
echo "Installing build tools..."
|
||||
sudo apt-get install -y build-essential curl git unzip
|
||||
|
||||
# Install Node.js via nvm
|
||||
echo ""
|
||||
if command -v node &> /dev/null; then
|
||||
echo "Node.js already installed: $(node -v)"
|
||||
else
|
||||
echo "Installing Node.js via nvm..."
|
||||
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
|
||||
|
||||
# Load nvm for this session
|
||||
export NVM_DIR="$HOME/.nvm"
|
||||
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
|
||||
|
||||
nvm install 20
|
||||
nvm use 20
|
||||
fi
|
||||
|
||||
# Install EAS CLI
|
||||
echo ""
|
||||
echo "Installing EAS CLI..."
|
||||
export NVM_DIR="$HOME/.nvm"
|
||||
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
|
||||
npm install -g eas-cli
|
||||
|
||||
# Configure Android SDK path (use Windows SDK)
|
||||
echo ""
|
||||
echo "Configuring Android SDK..."
|
||||
WIN_USER=$(cmd.exe /c "echo %USERNAME%" 2>/dev/null | tr -d '\r\n')
|
||||
WIN_SDK_PATH="/mnt/c/Users/$WIN_USER/AppData/Local/Android/Sdk"
|
||||
|
||||
if [ -d "$WIN_SDK_PATH" ]; then
|
||||
echo "Found Windows Android SDK at: $WIN_SDK_PATH"
|
||||
if ! grep -q "ANDROID_HOME" ~/.bashrc; then
|
||||
echo "export ANDROID_HOME=\"$WIN_SDK_PATH\"" >> ~/.bashrc
|
||||
echo "export ANDROID_SDK_ROOT=\"$WIN_SDK_PATH\"" >> ~/.bashrc
|
||||
echo 'export PATH="$ANDROID_HOME/platform-tools:$ANDROID_HOME/cmdline-tools/latest/bin:$PATH"' >> ~/.bashrc
|
||||
fi
|
||||
else
|
||||
echo "Windows Android SDK not found at: $WIN_SDK_PATH"
|
||||
echo "You may need to set ANDROID_HOME manually in ~/.bashrc"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo " Setup complete!"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "Run this to reload your shell:"
|
||||
echo " source ~/.bashrc"
|
||||
echo ""
|
||||
echo "Then build with:"
|
||||
echo " cd /mnt/i/Projects/Sauci"
|
||||
echo " ./scripts/build-android-local.sh"
|
||||
echo ""
|
||||
Reference in new issue
Block a user