diff --git a/apps/admin/src/components/content/ResponseAnswer.tsx b/apps/admin/src/components/content/ResponseAnswer.tsx new file mode 100644 index 0000000..7ceb005 --- /dev/null +++ b/apps/admin/src/components/content/ResponseAnswer.tsx @@ -0,0 +1,32 @@ +import type { AnswerType, QuestionType } from '@sauci/shared'; +import { Badge } from '@/components/ui/badge'; +import { formatAdminResponse, QUESTION_TYPE_LABELS, type AdminResponseData } from '@/lib/questionResponses'; + +interface ResponseAnswerProps { + answer: AnswerType; + questionType?: QuestionType | null; + responseData?: AdminResponseData; +} + +export function ResponseAnswer({ answer, questionType, responseData = null }: ResponseAnswerProps) { + const formatted = formatAdminResponse(questionType, answer, responseData); + const resolvedType = questionType ?? 'swipe'; + + return ( +
+
+ {formatted.label} + {resolvedType !== 'swipe' && ( + + {QUESTION_TYPE_LABELS[resolvedType]} + + )} +
+ {formatted.detail && ( +

+ {formatted.detail} +

+ )} +
+ ); +} diff --git a/apps/admin/src/lib/questionResponses.test.ts b/apps/admin/src/lib/questionResponses.test.ts new file mode 100644 index 0000000..65007b9 --- /dev/null +++ b/apps/admin/src/lib/questionResponses.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { formatAdminResponse, MATCH_TYPE_LABELS, resolveQuestionType } from './questionResponses'; + +describe('admin question response formatting', () => { + it('keeps legacy questions compatible with swipe answers', () => { + expect(resolveQuestionType(null)).toBe('swipe'); + expect(formatAdminResponse(null, 'maybe', null)).toEqual({ label: 'Maybe', detail: null }); + }); + + it('shows typed response details without exposing media storage paths', () => { + expect(formatAdminResponse('text_answer', 'yes', { type: 'text_answer', text: ' My answer ' })) + .toEqual({ label: 'Text submitted', detail: 'My answer' }); + expect(formatAdminResponse('audio', 'yes', { + type: 'audio', + media_path: 'private/user/audio.m4a', + duration_seconds: 12.6, + })).toEqual({ label: 'Audio submitted', detail: '13s recording' }); + expect(formatAdminResponse('photo', 'yes', { + type: 'photo', + media_path: 'private/user/photo.jpg', + })).toEqual({ label: 'Photo submitted', detail: null }); + }); + + it('labels non-swipe matches', () => { + expect(MATCH_TYPE_LABELS.both_answered).toBe('Both Answered'); + }); +}); diff --git a/apps/admin/src/lib/questionResponses.ts b/apps/admin/src/lib/questionResponses.ts new file mode 100644 index 0000000..915fdb8 --- /dev/null +++ b/apps/admin/src/lib/questionResponses.ts @@ -0,0 +1,67 @@ +import type { AnswerType, QuestionType } from '@sauci/shared'; + +export type AdminResponseData = + | { type: 'text_answer'; text: string } + | { type: 'audio'; media_path: string; duration_seconds: number } + | { type: 'photo'; media_path: string } + | { type: 'who_likely'; chosen_user_id: string } + | Record + | null; + +export const QUESTION_TYPE_LABELS: Record = { + swipe: 'Swipe', + text_answer: 'Text answer', + audio: 'Audio', + photo: 'Photo', + who_likely: 'Who is more likely', +}; + +export const MATCH_TYPE_LABELS = { + yes_yes: 'Both Yes!', + yes_maybe: 'Yes + Maybe', + maybe_maybe: 'Both Maybe', + both_answered: 'Both Answered', +} as const; + +export function resolveQuestionType(questionType?: QuestionType | null): QuestionType { + return questionType ?? 'swipe'; +} + +export function formatAdminResponse( + questionType: QuestionType | null | undefined, + answer: AnswerType, + responseData: AdminResponseData, +): { label: string; detail: string | null } { + const resolvedType = resolveQuestionType(questionType); + + if (resolvedType === 'swipe') { + return { + label: answer.charAt(0).toUpperCase() + answer.slice(1), + detail: null, + }; + } + + if (resolvedType === 'text_answer') { + const text = responseData && 'text' in responseData && typeof responseData.text === 'string' + ? responseData.text.trim() + : ''; + return { label: 'Text submitted', detail: text || null }; + } + + if (resolvedType === 'audio') { + const duration = responseData && 'duration_seconds' in responseData + && typeof responseData.duration_seconds === 'number' + ? Math.max(0, Math.round(responseData.duration_seconds)) + : null; + return { + label: 'Audio submitted', + detail: duration === null ? null : `${duration}s recording`, + }; + } + + if (resolvedType === 'photo') { + return { label: 'Photo submitted', detail: null }; + } + + return { label: 'Choice submitted', detail: null }; +} diff --git a/apps/admin/src/pages/activity/UserActivityPage.tsx b/apps/admin/src/pages/activity/UserActivityPage.tsx index eb6027d..dce6ce2 100644 --- a/apps/admin/src/pages/activity/UserActivityPage.tsx +++ b/apps/admin/src/pages/activity/UserActivityPage.tsx @@ -23,11 +23,12 @@ import { } from '@/components/ui/tabs'; import { formatDistanceToNow, format } from 'date-fns'; import { toast } from 'sonner'; +import type { AnswerType, QuestionType } from '@sauci/shared'; +import { ResponseAnswer } from '@/components/content/ResponseAnswer'; +import { MATCH_TYPE_LABELS, type AdminResponseData } from '@/lib/questionResponses'; import { Activity, ThumbsUp, - ThumbsDown, - Minus, Heart, MessageCircle, UserPlus, @@ -38,7 +39,8 @@ import { interface ResponseActivity { id: string; user_id: string; - answer: 'yes' | 'no' | 'maybe'; + answer: AnswerType; + response_data: AdminResponseData; created_at: string; profile: { id: string; @@ -48,6 +50,7 @@ interface ResponseActivity { question: { id: string; text: string; + question_type: QuestionType; pack: { id: string; name: string; @@ -58,7 +61,7 @@ interface ResponseActivity { interface MatchActivity { id: string; couple_id: string; - match_type: 'yes_yes' | 'yes_maybe' | 'maybe_maybe'; + match_type: keyof typeof MATCH_TYPE_LABELS; is_new: boolean; created_at: string; question: { @@ -104,17 +107,11 @@ interface SignupActivity { 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 }, + both_answered: { label: MATCH_TYPE_LABELS.both_answered, variant: 'secondary' as const }, }; export function UserActivityPage() { @@ -152,10 +149,10 @@ export function UserActivityPage() { const { data, error, count } = await supabase .from('responses') .select(` - id, user_id, answer, created_at, + id, user_id, answer, response_data, created_at, profile:profiles!responses_user_id_fkey(id, name, email), question:questions( - id, text, + id, text, question_type, pack:question_packs(id, name) ) `, { count: 'exact' }) @@ -171,11 +168,13 @@ export function UserActivityPage() { id: item.id, user_id: item.user_id, answer: item.answer, + response_data: item.response_data, created_at: item.created_at, profile: item.profile, question: item.question ? { id: item.question.id, text: item.question.text, + question_type: item.question.question_type ?? 'swipe', pack: item.question.pack, } : null, })); @@ -426,17 +425,6 @@ export function UserActivityPage() { ); }; - // Render answer badge - const renderAnswerBadge = (answer: 'yes' | 'no' | 'maybe') => { - const config = answerConfig[answer]; - return ( - - {config.icon} - {answer} - - ); - }; - return (
{/* Header */} @@ -533,7 +521,13 @@ export function UserActivityPage() { )} - {renderAnswerBadge(item.answer)} + + + {formatDistanceToNow(new Date(item.created_at), { addSuffix: true })} diff --git a/apps/admin/src/pages/users/MatchChatPage.tsx b/apps/admin/src/pages/users/MatchChatPage.tsx index 15d419d..1b53b4d 100644 --- a/apps/admin/src/pages/users/MatchChatPage.tsx +++ b/apps/admin/src/pages/users/MatchChatPage.tsx @@ -13,6 +13,7 @@ import { cn } from '@/lib/utils'; import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription'; import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator'; import { PaginationControls } from '@/components/ui/pagination'; +import { MATCH_TYPE_LABELS } from '@/lib/questionResponses'; interface Profile { id: string; @@ -22,7 +23,7 @@ interface Profile { interface Match { id: string; - match_type: 'yes_yes' | 'yes_maybe' | 'maybe_maybe'; + match_type: keyof typeof MATCH_TYPE_LABELS; question: { id: string; text: string; @@ -48,6 +49,7 @@ const matchTypeLabels = { yes_yes: 'Both Yes!', yes_maybe: 'Yes + Maybe', maybe_maybe: 'Both Maybe', + both_answered: MATCH_TYPE_LABELS.both_answered, }; function formatMessageTime(date: Date): string { diff --git a/apps/admin/src/pages/users/UserDetailPage.tsx b/apps/admin/src/pages/users/UserDetailPage.tsx index bf606b0..1437c71 100644 --- a/apps/admin/src/pages/users/UserDetailPage.tsx +++ b/apps/admin/src/pages/users/UserDetailPage.tsx @@ -37,9 +37,12 @@ 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, AlertCircle, Clock, Pencil } from 'lucide-react'; +import { Crown, Users, MessageCircle, ChevronRight, Gift, Image, Video as VideoIcon, Target, User, Eye, EyeOff, CheckCircle, Package, Sparkles, Flame, Trophy, Calendar, AlertCircle, Clock, Pencil } from 'lucide-react'; import { format } from 'date-fns'; import { IconPreview } from '@/components/ui/icon-picker'; +import type { AnswerType, QuestionType } from '@sauci/shared'; +import { ResponseAnswer } from '@/components/content/ResponseAnswer'; +import { MATCH_TYPE_LABELS, type AdminResponseData } from '@/lib/questionResponses'; type UsageReason = 'improve_communication' | 'spice_up_intimacy' | 'deeper_connection' | 'have_fun' | 'strengthen_relationship'; type Gender = 'male' | 'female' | 'non-binary' | 'prefer-not-to-say'; @@ -82,11 +85,13 @@ interface Partner { interface Response { id: string; - answer: 'yes' | 'no' | 'maybe'; + answer: AnswerType; + response_data: AdminResponseData; created_at: string | null; question: { id: string; text: string; + question_type: QuestionType; pack: { id: string; name: string; @@ -96,7 +101,7 @@ interface Response { interface Match { id: string; - match_type: 'yes_yes' | 'yes_maybe' | 'maybe_maybe'; + match_type: keyof typeof MATCH_TYPE_LABELS; is_new: boolean; created_at: string | null; question: { @@ -134,12 +139,14 @@ interface OutstandingQuestion { id: string; text: string; intensity: number | null; + question_type: QuestionType; pack: { id: string; name: string; }; partner_response: { - answer: 'yes' | 'no' | 'maybe'; + answer: AnswerType; + response_data: AdminResponseData; created_at: string | null; }; } @@ -155,16 +162,11 @@ interface CoupleStreak { updated_at: string; } -const answerIcons = { - yes: , - no: , - maybe: , -}; - const matchTypeLabels = { yes_yes: 'Both Yes!', yes_maybe: 'Yes + Maybe', maybe_maybe: 'Both Maybe', + both_answered: MATCH_TYPE_LABELS.both_answered, }; @@ -536,7 +538,7 @@ export function UserDetailPage() { const { data: responseData, count: responsesCount, error: responsesError } = await supabase .from('responses') - .select('id, answer, created_at, question:questions(id, text, pack:question_packs(id, name))', { count: 'exact' }) + .select('id, answer, response_data, created_at, question:questions(id, text, question_type, pack:question_packs(id, name))', { count: 'exact' }) .eq('user_id', userId) .order('created_at', { ascending: false }) .range(responsesFrom, responsesTo); @@ -646,6 +648,7 @@ export function UserDetailPage() { .select(` question_id, answer, + response_data, created_at `) .eq('user_id', partnerProfile.id) @@ -660,7 +663,7 @@ export function UserDetailPage() { // Fetch question details (no pack filter — admin sees all) const { data: questionDetails } = await supabase .from('questions') - .select('id, text, intensity, pack:question_packs(id, name)') + .select('id, text, intensity, question_type, pack:question_packs(id, name)') .in('id', unansweredQuestionIds); const questionMap = new Map((questionDetails || []).map((q: any) => [q.id, q])); @@ -675,8 +678,13 @@ export function UserDetailPage() { id: qId, text: q.text || '', intensity: q.intensity || null, + question_type: q.question_type ?? 'swipe', pack: { id: q.pack?.id || '', name: q.pack?.name || '' }, - partner_response: { answer: pr.answer, created_at: pr.created_at }, + partner_response: { + answer: pr.answer, + response_data: pr.response_data, + created_at: pr.created_at, + }, }); } } @@ -897,7 +905,7 @@ export function UserDetailPage() { Enabled Packs ({enabledPacks.length}) Live Draw - {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); }} />
}
} + {canViewResponses && {responses.length === 0 ?

No responses yet

:
QuestionPackResponseDate{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 || '—'})}{r.created_at ? format(new Date(r.created_at), 'MMM d, yyyy') : '—'}))}
{ setResponsesPage(1); setResponsesPageSize(size); }} />
}
} {canViewMatches && {matches.length === 0 ?

No matches yet

:
{matches.map((match) => (
{matchTypeLabels[match.match_type]}{match.is_new && New}
{match.question?.text || 'Unknown question'}
{match.message_count} message{match.message_count !== 1 ? 's' : ''}
{canViewChats && }
))}
{ setMatchesPage(1); setMatchesPageSize(size); }} />
}
} {canViewMedia && {mediaMessages.length === 0 ?

No media uploaded

:
{mediaMessages.map((message) => (
{message.media_type === 'video' ? ( @@ -964,8 +972,11 @@ export function UserDetailPage() {
- {answerIcons[q.partner_response.answer]} - {q.partner_response.answer} +