diff --git a/frontend/app/(auth)/(tabs)/_layout.tsx b/frontend/app/(auth)/(tabs)/_layout.tsx index ae63bbc9..870f0f8c 100644 --- a/frontend/app/(auth)/(tabs)/_layout.tsx +++ b/frontend/app/(auth)/(tabs)/_layout.tsx @@ -1,11 +1,11 @@ import FilledBellIcon from '@/app/components/icons/FilledBellIcon'; import FilledChatIcon from '@/app/components/icons/FilledChatIcon'; -import FilledHeartIcon from '@/app/components/icons/FilledHeartIcon'; +import FilledHomeIcon from '@/app/components/icons/FilledHomeIcon'; import FilledProfileIcon from '@/app/components/icons/FilledProfileIcon'; import { useNotifications } from '@/app/contexts/NotificationsContext'; import { useHapticFeedback } from '@/app/hooks/useHapticFeedback'; import { Tabs } from 'expo-router'; -import { Bell, Heart, MessageCircle, User } from 'lucide-react-native'; +import { Bell, Home, MessageCircle, User } from 'lucide-react-native'; import { useRef } from 'react'; import { Animated, Pressable } from 'react-native'; import { AppColors } from '../../components/AppColors'; @@ -90,12 +90,12 @@ export default function TabLayout() { focused ? ( - + ) : ( - + ), tabBarButton: (props) => , }} diff --git a/frontend/app/(auth)/(tabs)/index.tsx b/frontend/app/(auth)/(tabs)/index.tsx index cbca0c3f..990d4a44 100644 --- a/frontend/app/(auth)/(tabs)/index.tsx +++ b/frontend/app/(auth)/(tabs)/index.tsx @@ -1,5 +1,6 @@ -// Main Matches/Home Screen -import { sendNudge } from '@/app/api/nudgesApi'; +// Main Home Screen — Card-based daily engagement +import { getCurrentUser } from '@/app/api/authService'; +import { getConversations } from '@/app/api/chatApi'; import { getActivePrompt, getBatchMatchData, @@ -11,21 +12,20 @@ import { AppColors } from '@/app/components/AppColors'; import AppInput from '@/app/components/ui/AppInput'; import AppText from '@/app/components/ui/AppText'; import Button from '@/app/components/ui/Button'; -import CountdownTimer from '@/app/components/ui/CountdownTimer'; -import EmptyState from '@/app/components/ui/EmptyState'; +import CardStack from '@/app/components/ui/CardStack'; +import { DailyCard, TutorialCard } from '@/app/components/ui/cardTypes'; +import IconButton from '@/app/components/ui/IconButton'; +import ListItem from '@/app/components/ui/ListItem'; import ListItemWrapper from '@/app/components/ui/ListItemWrapper'; import Sheet from '@/app/components/ui/Sheet'; -import WeeklyMatchCard from '@/app/components/ui/WeeklyMatchCard'; import { useThemeAware } from '@/app/contexts/ThemeContext'; import { - getMatchDropDescription, - shouldShowCountdown, -} from '@/app/utils/dateUtils'; -import { - cacheMatchData, - clearMatchCache, - getCachedMatchData, -} from '@/app/utils/matchCache'; + MOCK_MATCH_CARDS, + PREFERENCE_CARDS, + PROFILE_ACTION_CARDS, + WEEKLY_PROMPT_CARDS, +} from '@/app/data/mockCards'; +import { cacheMatchData, getCachedMatchData } from '@/app/utils/matchCache'; import { getProfileAge, NudgeStatusResponse, @@ -34,83 +34,168 @@ import { WeeklyPromptAnswerResponse, WeeklyPromptResponse, } from '@/types'; +import AsyncStorage from '@react-native-async-storage/async-storage'; import { useFocusEffect } from '@react-navigation/native'; -import { useRouter } from 'expo-router'; -import { Check, Heart, Pencil } from 'lucide-react-native'; -import React, { useCallback, useEffect, useRef, useState } from 'react'; import { - Animated, - Dimensions, - ScrollView, - StatusBar, - StyleSheet, - View, -} from 'react-native'; - -const { width } = Dimensions.get('window'); + Check, + Heart, + HelpCircle, + LayoutGrid, + Pencil, + SlidersHorizontal, + Sparkles, + User, +} from 'lucide-react-native'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { StatusBar, StyleSheet, TouchableOpacity, View } from 'react-native'; + +const TOUR_STORAGE_KEY = 'home_tour_seen_v1'; + +const TUTORIAL_CARDS: TutorialCard[] = [ + { + id: 'tutorial-skip', + type: 'tutorial', + step: 'skip', + title: 'Swipe or tap Skip to come back later', + subtitle: + 'Drag the card in any direction or hit the Skip button — the card moves to the back of your stack.', + }, + { + id: 'tutorial-act', + type: 'tutorial', + step: 'act', + title: 'Tap the card or the main button to take action', + subtitle: + 'Tapping the card or the accent button at the bottom opens a sheet where you can complete the action.', + }, + { + id: 'tutorial-filter', + type: 'tutorial', + step: 'filter', + title: 'Use the filter button to focus your stack', + subtitle: + 'Hit the sliders icon in the top-right corner to show only a specific type of card — profile actions, preferences, prompts, or matches.', + }, +]; + +const FILTER_OPTIONS = [ + { + value: 'all' as const, + label: 'All cards', + color: null, + icon: LayoutGrid, + iconColor: AppColors.foregroundDimmer, + }, + { + value: 'profile_action' as const, + label: 'Profile building', + color: '#DBEAFE', + icon: User, + iconColor: '#2563EB', + }, + { + value: 'preference' as const, + label: 'Preferences', + color: '#EDE9FE', + icon: HelpCircle, + iconColor: '#7C3AED', + }, + { + value: 'weekly_prompt' as const, + label: 'Prompts', + color: '#D1FAE5', + icon: Sparkles, + iconColor: '#059669', + }, + { + value: 'match' as const, + label: 'Matches', + color: '#FCE7F3', + icon: Heart, + iconColor: '#EC4899', + }, +]; interface MatchWithProfile { netid: string; profile: ProfileResponse | null; revealed: boolean; nudgeStatus?: NudgeStatusResponse; - promptId: string; // Store promptId with each match for nudging + promptId: string; } -export default function MatchesScreen() { +export default function HomeScreen() { useThemeAware(); - const router = useRouter(); - const [showCountdown, setShowCountdown] = useState(false); const [activePrompt, setActivePrompt] = useState( null ); const [userAnswer, setUserAnswer] = useState(''); const [currentMatches, setCurrentMatches] = useState([]); + // ── TEMPORARY PLACEHOLDER ──────────────────────────────────────────────────── + // Seeds match cards from existing chat conversations so the card stack isn't + // empty during development. Remove once the real match pipeline is wired up. + const [chatMatchCards, setChatMatchCards] = useState([]); + // ───────────────────────────────────────────────────────────────────────────── const [showPromptSheet, setShowPromptSheet] = useState(false); + const [showFilterSheet, setShowFilterSheet] = useState(false); + const [activeFilter, setActiveFilter] = useState< + 'all' | 'profile_action' | 'preference' | 'weekly_prompt' | 'match' + >('all'); const [tempAnswer, setTempAnswer] = useState(''); + const [tourSeen, setTourSeen] = useState(true); // default true to avoid flash + const dismissedTutorialIds = useRef>(new Set()); const lastLoadTime = useRef(0); - // Track local nudges for immediate UI feedback - const [localNudges, setLocalNudges] = useState>(new Set()); - // Track scroll position for real-time dot animation - const scrollX = useRef(new Animated.Value(0)).current; + useEffect(() => { + AsyncStorage.getItem(TOUR_STORAGE_KEY).then((val: string | null) => { + setTourSeen(val === 'true'); + }); + }, []); + + const resetTour = useCallback(async () => { + await AsyncStorage.removeItem(TOUR_STORAGE_KEY); + dismissedTutorialIds.current = new Set(); + setTourSeen(false); + }, []); + + const markTourSeen = useCallback(async () => { + await AsyncStorage.setItem(TOUR_STORAGE_KEY, 'true'); + setTourSeen(true); + }, []); + + const handleCardDismissed = useCallback( + (card: DailyCard) => { + if (card.type !== 'tutorial') return; + dismissedTutorialIds.current.add(card.id); + if (TUTORIAL_CARDS.every((t) => dismissedTutorialIds.current.has(t.id))) { + markTourSeen(); + } + }, + [markTourSeen] + ); const loadData = useCallback(async () => { - // Prevent rapid successive calls (rate limiting on client side) const now = Date.now(); - const timeSinceLastLoad = now - lastLoadTime.current; - - if (timeSinceLastLoad < 1000) { - // Minimum 1 second between loads - console.log('⚠️ Skipping load - too soon since last load'); - return; - } - + if (now - lastLoadTime.current < 1000) return; lastLoadTime.current = now; try { let prompt: WeeklyPromptResponse | null = null; - // Get active prompt (optional - matches can exist without an active prompt) try { prompt = await getActivePrompt(); setActivePrompt(prompt); - - // Set countdown visibility based on active prompt - if (prompt) { - setShowCountdown(shouldShowCountdown(prompt.matchDate)); - } else { - setShowCountdown(false); - } - } catch (promptError) { - console.error('Error fetching active prompt:', promptError); + } catch { setActivePrompt(null); - setShowCountdown(false); // Hide countdown if no active prompt - // Don't return early - continue loading matches even without an active prompt } - // Get user's answer to the prompt (if there's an active prompt) if (prompt) { try { const answer: WeeklyPromptAnswerResponse = await getPromptAnswer( @@ -118,52 +203,42 @@ export default function MatchesScreen() { ); setUserAnswer(answer.answer); } catch { - // No answer yet setUserAnswer(''); } } - // Get all match history - backend filters by expiration date automatically try { const history: WeeklyMatchResponse[] = await getMatchHistory(10); if (history.length > 0) { - // Collect all matches from all active match records const allMatchesData: MatchWithProfile[] = []; for (const matchRecord of history) { if (matchRecord.matches.length === 0) continue; - // Try to get cached data first for the most recent match let batchData; if (matchRecord === history[0]) { const cachedData = await getCachedMatchData(matchRecord.promptId); if (cachedData) { batchData = cachedData; - console.log('✅ Using cached match data'); } else { batchData = await getBatchMatchData( matchRecord.promptId, matchRecord.matches ); await cacheMatchData(matchRecord.promptId, batchData); - console.log('✅ Fetched and cached fresh match data'); } } else { - // For older matches, just fetch the data batchData = await getBatchMatchData( matchRecord.promptId, matchRecord.matches ); } - // Map the batch data to matches with profiles const matchesWithProfiles: MatchWithProfile[] = matchRecord.matches.map((netid: string, index: number) => { const profile = batchData.profiles.find((p) => p.netid === netid) || null; - - // Only get nudge status for the most recent matches const nudgeStatus = matchRecord === history[0] ? batchData.nudgeStatuses[index] || { @@ -172,7 +247,6 @@ export default function MatchesScreen() { mutual: false, } : undefined; - return { netid, profile, @@ -187,42 +261,52 @@ export default function MatchesScreen() { setCurrentMatches(allMatchesData); } else { - // No match history at all setCurrentMatches([]); } - } catch (error) { - console.error('Error loading matches:', error); + } catch { setCurrentMatches([]); } + + // ── TEMPORARY PLACEHOLDER ─────────────────────────────────────────────── + // Builds match cards from existing chat conversations for dev/testing. + // Remove this block once the real weekly-match pipeline populates the feed. + try { + const convos = await getConversations(); + const currentUid = getCurrentUser()?.uid; + const cards: DailyCard[] = convos + .filter((c) => c.lastMessage) + .flatMap((c) => { + const otherUid = c.participantIds.find((id) => id !== currentUid); + if (!otherUid) return []; + const other = c.participants[otherUid]; + if (!other || other.deleted) return []; + return [ + { + id: `chat-match-${c.id}`, + type: 'match' as const, + matchName: other.name, + matchImage: other.image ?? undefined, + }, + ]; + }); + setChatMatchCards(cards); + } catch { + // Non-critical — silently ignore + } + // ──────────────────────────────────────────────────────────────────────── } catch (error) { console.error('Error loading data:', error); - if (error instanceof Error) { - console.error('Error details:', error.message); - } - } finally { - // Clear local nudges after data reload since server state is now current - setLocalNudges(new Set()); } - }, []); // Empty dependency array since we use refs for state that shouldn't trigger re-renders + }, []); - useEffect(() => { - loadData(); - - // Update countdown state every minute - const interval = setInterval(() => { - if (activePrompt) { - setShowCountdown(shouldShowCountdown(activePrompt.matchDate)); - } else { - setShowCountdown(false); - } - }, 60000); - - return () => clearInterval(interval); - }, [activePrompt, loadData]); // Update when activePrompt changes or loadData changes + useFocusEffect( + useCallback(() => { + loadData(); + }, [loadData]) + ); const handleSubmitAnswer = async () => { if (!activePrompt || !tempAnswer.trim()) return; - try { await submitPromptAnswer(activePrompt.promptId, tempAnswer); setUserAnswer(tempAnswer); @@ -233,262 +317,147 @@ export default function MatchesScreen() { } }; - const [animationTrigger, setAnimationTrigger] = useState(0); + // Build the daily card list: interleave mock cards with real match cards + const dailyCards = useMemo((): DailyCard[] => { + const apiMatchCards: DailyCard[] = currentMatches + .filter((m) => m.profile) + .map((m) => { + const p = m.profile!; + return { + id: `match-${m.netid}-${m.promptId}`, + type: 'match' as const, + matchName: p.firstName, + matchAge: getProfileAge(p), + matchYear: p.year, + matchMajor: p.major.join(', '), + matchImage: p.pictures[0] || undefined, + matchProfile: p, + }; + }); + // Fallback chain: real API matches → chat contacts (temp) → static mocks + const matchCards: DailyCard[] = + apiMatchCards.length > 0 + ? apiMatchCards + : chatMatchCards.length > 0 + ? chatMatchCards + : MOCK_MATCH_CARDS; + + const profileCards = PROFILE_ACTION_CARDS.filter((c) => !c.completed); + const result: DailyCard[] = []; + const maxLen = Math.max( + profileCards.length, + PREFERENCE_CARDS.length, + matchCards.length, + WEEKLY_PROMPT_CARDS.length + ); + for (let i = 0; i < maxLen; i++) { + if (i < profileCards.length) result.push(profileCards[i]); + if (i < PREFERENCE_CARDS.length) result.push(PREFERENCE_CARDS[i]); + if (i < matchCards.length) result.push(matchCards[i]); + if (i < WEEKLY_PROMPT_CARDS.length) result.push(WEEKLY_PROMPT_CARDS[i]); + } - // Trigger animation every time screen is focused - useFocusEffect( - useCallback(() => { - setAnimationTrigger((prev) => prev + 1); - }, []) - ); + const all = + result.length === 0 + ? [...PROFILE_ACTION_CARDS, ...PREFERENCE_CARDS] + : result; + const filtered = + activeFilter === 'all' ? all : all.filter((c) => c.type === activeFilter); + return tourSeen ? filtered : [...TUTORIAL_CARDS, ...filtered]; + }, [currentMatches, chatMatchCards, activeFilter, tourSeen]); - const renderCountdownPeriod = () => ( - <> - - {activePrompt && ( - - - Weekly Prompt: - - {activePrompt.question} - -