diff --git a/apps/admin/src/pages/users/UserDetailPage.tsx b/apps/admin/src/pages/users/UserDetailPage.tsx index 7c29053..792f4fc 100644 --- a/apps/admin/src/pages/users/UserDetailPage.tsx +++ b/apps/admin/src/pages/users/UserDetailPage.tsx @@ -37,7 +37,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { Crown, Users, MessageCircle, ChevronRight, ThumbsUp, ThumbsDown, Minus, Gift, Image, Video as VideoIcon, Target, User, Eye, EyeOff, CheckCircle, Package, Sparkles, Flame, Trophy, Calendar } from 'lucide-react'; +import { Crown, Users, MessageCircle, ChevronRight, ThumbsUp, ThumbsDown, Minus, Gift, Image, Video as VideoIcon, Target, User, Eye, EyeOff, CheckCircle, Package, Sparkles, Flame, Trophy, Calendar, AlertCircle, Clock } from 'lucide-react'; import { format } from 'date-fns'; import { IconPreview } from '@/components/ui/icon-picker'; @@ -130,6 +130,20 @@ interface FeatureInterest { created_at: string; } +interface OutstandingQuestion { + id: string; + text: string; + intensity: number | null; + pack: { + id: string; + name: string; + }; + partner_response: { + answer: 'yes' | 'no' | 'maybe'; + created_at: string | null; + }; +} + interface CoupleStreak { id: string; couple_id: string; @@ -277,6 +291,13 @@ export function UserDetailPage() { const [enabledPacks, setEnabledPacks] = useState([]); const [featureInterests, setFeatureInterests] = useState([]); const [streak, setStreak] = useState(null); + const [outstandingQuestions, setOutstandingQuestions] = useState([]); + const [outstandingPage, setOutstandingPage] = useState(1); + const [outstandingPageSize, setOutstandingPageSize] = useState(10); + const [outstandingTotal, setOutstandingTotal] = useState(0); + const [gapDisabled, setGapDisabled] = useState(false); + const [gapThreshold, setGapThreshold] = useState(0); + const [netGap, setNetGap] = useState(0); const [responsesPage, setResponsesPage] = useState(1); const [responsesPageSize, setResponsesPageSize] = useState(10); const [responsesTotal, setResponsesTotal] = useState(0); @@ -467,19 +488,124 @@ export function UserDetailPage() { } else { setEnabledPacks((enabledPacksData || []) as unknown as EnabledPack[]); } + + // Fetch outstanding questions (partner answered, user hasn't) + // Use partner from state (set earlier in the first couple_id block) + const partnerProfile = partner || (await supabase.from('profiles').select('id, name').eq('couple_id', profileData.couple_id).neq('id', userId).maybeSingle()).data; + if (partnerProfile) { + const enabledPackIds = (enabledPacksData || []).map((ep: any) => ep.pack_id); + + if (enabledPackIds.length > 0) { + // Get question IDs user has already answered + const { data: userResponseIds } = await supabase + .from('responses') + .select('question_id') + .eq('user_id', userId); + const answeredIds = new Set((userResponseIds || []).map((r: any) => r.question_id)); + + // Get all partner responses in this couple + const { data: partnerResponses } = await supabase + .from('responses') + .select(` + question_id, + answer, + created_at + `) + .eq('user_id', partnerProfile.id) + .eq('couple_id', profileData.couple_id) + .order('created_at', { ascending: false }); + + // Filter to only questions user hasn't answered + const unansweredPartnerResponses = (partnerResponses || []).filter((r: any) => !answeredIds.has(r.question_id)); + const unansweredQuestionIds = unansweredPartnerResponses.map((r: any) => r.question_id); + + if (unansweredQuestionIds.length > 0) { + // Fetch question details, filtered to enabled packs + const { data: questionDetails } = await supabase + .from('questions') + .select('id, text, intensity, pack:question_packs(id, name)') + .in('id', unansweredQuestionIds) + .in('pack_id', enabledPackIds); + + const questionMap = new Map((questionDetails || []).map((q: any) => [q.id, q])); + const partnerResponseMap = new Map(unansweredPartnerResponses.map((r: any) => [r.question_id, r])); + + const outstanding: OutstandingQuestion[] = []; + for (const qId of unansweredQuestionIds) { + const q = questionMap.get(qId); + const pr = partnerResponseMap.get(qId); + if (q && pr) { + outstanding.push({ + id: qId, + text: q.text || '', + intensity: q.intensity || null, + pack: { id: q.pack?.id || '', name: q.pack?.name || '' }, + partner_response: { answer: pr.answer, created_at: pr.created_at }, + }); + } + } + + setOutstandingTotal(outstanding.length); + const outFrom = (outstandingPage - 1) * outstandingPageSize; + setOutstandingQuestions(outstanding.slice(outFrom, outFrom + outstandingPageSize)); + } else { + setOutstandingQuestions([]); + setOutstandingTotal(0); + } + + // Calculate gap status + const { data: appConfig } = await supabase + .from('app_config') + .select('answer_gap_threshold') + .limit(1) + .maybeSingle(); + + const threshold = appConfig?.answer_gap_threshold ?? 10; + setGapThreshold(threshold); + + if (threshold > 0) { + // Count questions user answered that partner hasn't + const partnerAllAnswered = new Set((partnerResponses || []).map((r: any) => r.question_id)); + const { data: userResponses } = await supabase + .from('responses') + .select('question_id') + .eq('user_id', userId) + .eq('couple_id', profileData.couple_id); + const userAhead = (userResponses || []).filter((r: any) => !partnerAllAnswered.has(r.question_id)).length; + const partnerAhead = (partnerResponses || []).filter((r: any) => !answeredIds.has(r.question_id)).length; + const gap = Math.max(0, userAhead - partnerAhead); + setNetGap(gap); + setGapDisabled(gap >= threshold); + } else { + setNetGap(0); + setGapDisabled(false); + } + } else { + setOutstandingQuestions([]); + setOutstandingTotal(0); + setGapDisabled(false); + } + } else { + setOutstandingQuestions([]); + setOutstandingTotal(0); + setGapDisabled(false); + } } else { setMatches([]); setMatchesTotal(0); setMediaMessages([]); setMediaTotal(0); setEnabledPacks([]); + setOutstandingQuestions([]); + setOutstandingTotal(0); + setGapDisabled(false); } } catch (error) { console.error('Failed to load user data:', error); } finally { setLoading(false); } - }, [userId, responsesPage, responsesPageSize, matchesPage, matchesPageSize, mediaPage, mediaPageSize]); + }, [userId, responsesPage, responsesPageSize, matchesPage, matchesPageSize, mediaPage, mediaPageSize, outstandingPage, outstandingPageSize]); const { status: profileStatus } = useRealtimeSubscription({ table: 'profiles', filter: userId ? `id=eq.${userId}` : undefined, enabled: !!userId, onUpdate: useCallback(({ new: updated }: { old: Profile; new: Profile }) => { setProfile(updated); }, []) }); const { status: responsesStatus } = useRealtimeSubscription({ table: 'responses', filter: userId ? `user_id=eq.${userId}` : undefined, enabled: !!userId, onInsert: fetchData, onDelete: fetchData }); @@ -490,6 +616,7 @@ export function UserDetailPage() { setResponsesPage(1); setMatchesPage(1); setMediaPage(1); + setOutstandingPage(1); }, [userId]); useEffect(() => { @@ -526,6 +653,8 @@ export function UserDetailPage() {

{profile.name || 'Unnamed User'}

{profile.is_premium && Premium} + {outstandingTotal > 0 && {outstandingTotal} outstanding} + {gapDisabled && Gap blocked ({netGap}/{gapThreshold})} {!profile.is_premium && hasPermission(PERMISSION_KEYS.GIFT_PREMIUM) && Gift Premium AccessManually upgrade this user to premium status.
{expiryType === 'custom' && (
setCustomDate(e.target.value)} />
)}
}
@@ -633,6 +762,7 @@ export function UserDetailPage() { {canViewResponses && Responses ({responsesTotal})} {canViewMatches && Matches ({matchesTotal})} {canViewMedia && Media ({mediaTotal})} + {canViewResponses && Outstanding ({outstandingTotal}){gapDisabled && }} Enabled Packs ({enabledPacks.length}) {canViewResponses && {responses.length === 0 ?

No responses yet

:
QuestionPackAnswerDate{responses.map((r) => ({r.question.pack?.id ? ({r.question.text}) : ({r.question.text})}{r.question.pack?.id ? ({r.question.pack.name}) : ({r.question.pack?.name || '—'})}
{answerIcons[r.answer]}{r.answer}
{r.created_at ? format(new Date(r.created_at), 'MMM d, yyyy') : '—'}
))}
{ setResponsesPage(1); setResponsesPageSize(size); }} />
}
} @@ -644,6 +774,86 @@ export function UserDetailPage() { )}
{message.media_type === 'video' ? 'Video' : 'Image'}
{canViewChats && message.match_id && View Chat}
{message.created_at ? format(new Date(message.created_at), 'MMM d, yyyy') : '—'}
))} { setMediaPage(1); setMediaPageSize(size); }} />}} + {canViewResponses && + {gapDisabled && ( + + +
+ + Gap disabled + - This user is {netGap} questions ahead of their partner (threshold: {gapThreshold}). They won't see new questions until their partner catches up. +
+
+
+ )} + {outstandingQuestions.length === 0 ? ( + + +

No outstanding questions - all caught up!

+
+ ) : ( +
+
+ + + + Question + Pack + Intensity + Partner's Answer + Partner Answered + + + + {outstandingQuestions.map((q) => ( + + + {q.pack?.id ? ( + {q.text} + ) : ( + {q.text} + )} + + + {q.pack?.id ? ( + {q.pack.name} + ) : ( + — + )} + + + {q.intensity ? ( +
+ {Array.from({ length: q.intensity }, (_, i) => ( + + ))} +
+ ) : '—'} +
+ +
+ {answerIcons[q.partner_response.answer]} + {q.partner_response.answer} +
+
+ + {q.partner_response.created_at ? format(new Date(q.partner_response.created_at), 'MMM d, yyyy') : '—'} + +
+ ))} +
+
+
+ { setOutstandingPage(1); setOutstandingPageSize(size); }} + /> +
+ )} +
} {enabledPacks.length === 0 ? (