diff --git a/apps/admin/src/pages/activity/UserActivityPage.tsx b/apps/admin/src/pages/activity/UserActivityPage.tsx new file mode 100644 index 0000000..128c566 --- /dev/null +++ b/apps/admin/src/pages/activity/UserActivityPage.tsx @@ -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: , color: 'text-green-500', bg: 'bg-green-500/10' }, + no: { icon: , color: 'text-red-500', bg: 'bg-red-500/10' }, + maybe: { icon: , 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('responses'); + const [responses, setResponses] = useState([]); + const [matches, setMatches] = useState([]); + const [messages, setMessages] = useState([]); + const [signups, setSignups] = useState([]); + 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 = {}; + + 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 Unknown User; + return ( + + {profile.name || profile.email || 'Unknown'} + + ); + }; + + // Render answer badge + const renderAnswerBadge = (answer: 'yes' | 'no' | 'maybe') => { + const config = answerConfig[answer]; + return ( + + {config.icon} + {answer} + + ); + }; + + return ( + + {/* Header */} + + + + + + User Activity + + + + + Monitor recent user activity across the platform + + + + + {/* Tabs */} + setActiveTab(v as ActivityTab)}> + + + + Responses + + + + Matches + + + + Messages + + + + Signups + + + + {/* Responses Tab */} + + + + + + User + Question + Pack + Answer + Time + + + + {loading.responses ? ( + + + + + + ) : responses.length === 0 ? ( + + + No responses yet. + + + ) : ( + responses.map((item) => ( + + {renderUserLink(item.profile)} + + + {item.question?.text || 'Unknown question'} + + + + + {item.question?.pack?.name || 'Unknown'} + + + {renderAnswerBadge(item.answer)} + + {formatDistanceToNow(new Date(item.created_at), { addSuffix: true })} + + + )) + )} + + + + + + {/* Matches Tab */} + + + + + + Couple + Question + Match Type + Status + Time + + + + {loading.matches ? ( + + + + + + ) : matches.length === 0 ? ( + + + No matches yet. + + + ) : ( + matches.map((item) => ( + + + + {item.coupleProfiles && item.coupleProfiles.length > 0 ? ( + <> + {renderUserLink(item.coupleProfiles[0])} + {item.coupleProfiles.length > 1 && ( + <> + & + {renderUserLink(item.coupleProfiles[1])} + > + )} + > + ) : ( + Unknown couple + )} + + + + + {item.question?.text || 'Unknown question'} + + + + + {matchTypeLabels[item.match_type]?.label || item.match_type} + + + + {item.is_new ? ( + New + ) : ( + Viewed + )} + + + {formatDistanceToNow(new Date(item.created_at), { addSuffix: true })} + + + )) + )} + + + + + + {/* Messages Tab */} + + + + + + User + Message + Context + Time + + + + {loading.messages ? ( + + + + + + ) : messages.length === 0 ? ( + + + No messages yet. + + + ) : ( + messages.map((item) => ( + + {renderUserLink(item.profile)} + + {item.media_path ? ( + + + [Media] + + ) : ( + + {item.content || Empty message} + + )} + + + + {item.match?.question?.text || 'Unknown match'} + + + + {formatDistanceToNow(new Date(item.created_at), { addSuffix: true })} + + + )) + )} + + + + + + {/* Signups Tab */} + + + + + + User + Email + Status + Paired + Joined + + + + {loading.signups ? ( + + + + + + ) : signups.length === 0 ? ( + + + No signups yet. + + + ) : ( + signups.map((item) => ( + + + + + + + {(item.name || item.email || '?').charAt(0).toUpperCase()} + + + + {item.name || 'No name'} + + + + + {item.email || 'No email'} + + + {item.onboarding_completed ? ( + Onboarded + ) : ( + Pending + )} + + + {item.couple_id ? ( + Paired + ) : ( + Single + )} + + + {format(new Date(item.created_at), 'MMM d, yyyy')} + + + )) + )} + + + + + + + ); +} diff --git a/apps/mobile/app.json b/apps/mobile/app.json index b114e28..e0bf46d 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -32,7 +32,9 @@ "package": "com.sauci.app", "permissions": [ "RECEIVE_BOOT_COMPLETED", - "VIBRATE" + "VIBRATE", + "android.permission.USE_BIOMETRIC", + "android.permission.USE_FINGERPRINT" ] }, "web": { diff --git a/etc/icons/google/logo-120x120-white.png b/etc/icons/google/logo-120x120-white.png new file mode 100644 index 0000000..18b55b4 Binary files /dev/null and b/etc/icons/google/logo-120x120-white.png differ diff --git a/scripts/build-android-local.sh b/scripts/build-android-local.sh new file mode 100644 index 0000000..896e2d4 --- /dev/null +++ b/scripts/build-android-local.sh @@ -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 "==========================================" diff --git a/scripts/setup-wsl-android.sh b/scripts/setup-wsl-android.sh new file mode 100644 index 0000000..8f24f38 --- /dev/null +++ b/scripts/setup-wsl-android.sh @@ -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 ""
+ Monitor recent user activity across the platform +