mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 10:19:43 +00:00
fix(admin): support typed question responses
Render database-backed text, audio, photo, and choice responses across admin activity and user detail views. Add support for the both_answered match state while retaining legacy swipe behavior.
This commit is contained in:
1 parent
01bb8225ae
commit
6df9442cd0
6 files changed
+175
-42
No files matched your search
@@ -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 (
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium">{formatted.label}</span>
|
||||
{resolvedType !== 'swipe' && (
|
||||
<Badge variant="outline" className="text-[10px] font-normal">
|
||||
{QUESTION_TYPE_LABELS[resolvedType]}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{formatted.detail && (
|
||||
<p className="max-w-md whitespace-pre-wrap break-words text-xs text-muted-foreground">
|
||||
{formatted.detail}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>
|
||||
| null;
|
||||
|
||||
export const QUESTION_TYPE_LABELS: Record<QuestionType, string> = {
|
||||
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 };
|
||||
}
|
||||
@@ -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: <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 },
|
||||
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 (
|
||||
<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 */}
|
||||
@@ -533,7 +521,13 @@ export function UserActivityPage() {
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{renderAnswerBadge(item.answer)}</TableCell>
|
||||
<TableCell>
|
||||
<ResponseAnswer
|
||||
answer={item.answer}
|
||||
questionType={item.question?.question_type}
|
||||
responseData={item.response_data}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm whitespace-nowrap">
|
||||
{formatDistanceToNow(new Date(item.created_at), { addSuffix: true })}
|
||||
</TableCell>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: <ThumbsUp className="h-4 w-4 text-green-500" />,
|
||||
no: <ThumbsDown className="h-4 w-4 text-red-500" />,
|
||||
maybe: <Minus className="h-4 w-4 text-yellow-500" />,
|
||||
};
|
||||
|
||||
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() {
|
||||
<TabsTrigger value="packs" disabled={!profile.couple_id}>Enabled Packs ({enabledPacks.length})</TabsTrigger>
|
||||
<TabsTrigger value="livedraw" disabled={!profile.couple_id}>Live Draw</TabsTrigger>
|
||||
</TabsList>
|
||||
{canViewResponses && <TabsContent value="responses" className="mt-4">{responses.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><p className="text-muted-foreground">No responses yet</p></Card> : <div className="space-y-4"><div className="rounded-md border"><Table><TableHeader><TableRow><TableHead>Question</TableHead><TableHead>Pack</TableHead><TableHead className="w-24">Answer</TableHead><TableHead className="w-32">Date</TableHead></TableRow></TableHeader><TableBody>{responses.map((r) => (<TableRow key={r.id}><TableCell className="max-w-md">{r.question.pack?.id ? (<Link to={`/packs/${r.question.pack.id}/questions`} className="line-clamp-2 text-primary hover:underline cursor-pointer">{r.question.text}</Link>) : (<span className="line-clamp-2">{r.question.text}</span>)}</TableCell><TableCell>{r.question.pack?.id ? (<Link to={`/packs/${r.question.pack.id}/questions`} className="text-primary hover:underline">{r.question.pack.name}</Link>) : (<span className="text-muted-foreground">{r.question.pack?.name || '—'}</span>)}</TableCell><TableCell><div className="flex items-center gap-2">{answerIcons[r.answer]}<span className="capitalize">{r.answer}</span></div></TableCell><TableCell className="text-muted-foreground text-sm">{r.created_at ? format(new Date(r.created_at), 'MMM d, yyyy') : '—'}</TableCell></TableRow>))}</TableBody></Table></div><PaginationControls page={responsesPage} pageSize={responsesPageSize} totalCount={responsesTotal} onPageChange={setResponsesPage} onPageSizeChange={(size) => { setResponsesPage(1); setResponsesPageSize(size); }} /></div>}</TabsContent>}
|
||||
{canViewResponses && <TabsContent value="responses" className="mt-4">{responses.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><p className="text-muted-foreground">No responses yet</p></Card> : <div className="space-y-4"><div className="rounded-md border"><Table><TableHeader><TableRow><TableHead>Question</TableHead><TableHead>Pack</TableHead><TableHead className="w-64">Response</TableHead><TableHead className="w-32">Date</TableHead></TableRow></TableHeader><TableBody>{responses.map((r) => (<TableRow key={r.id}><TableCell className="max-w-md">{r.question.pack?.id ? (<Link to={`/packs/${r.question.pack.id}/questions`} className="line-clamp-2 text-primary hover:underline cursor-pointer">{r.question.text}</Link>) : (<span className="line-clamp-2">{r.question.text}</span>)}</TableCell><TableCell>{r.question.pack?.id ? (<Link to={`/packs/${r.question.pack.id}/questions`} className="text-primary hover:underline">{r.question.pack.name}</Link>) : (<span className="text-muted-foreground">{r.question.pack?.name || '—'}</span>)}</TableCell><TableCell><ResponseAnswer answer={r.answer} questionType={r.question.question_type} responseData={r.response_data} /></TableCell><TableCell className="text-muted-foreground text-sm">{r.created_at ? format(new Date(r.created_at), 'MMM d, yyyy') : '—'}</TableCell></TableRow>))}</TableBody></Table></div><PaginationControls page={responsesPage} pageSize={responsesPageSize} totalCount={responsesTotal} onPageChange={setResponsesPage} onPageSizeChange={(size) => { setResponsesPage(1); setResponsesPageSize(size); }} /></div>}</TabsContent>}
|
||||
{canViewMatches && <TabsContent value="matches" className="mt-4">{matches.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><p className="text-muted-foreground">No matches yet</p></Card> : <div className="space-y-4"><div className="grid gap-4 md:grid-cols-2">{matches.map((match) => (<Card key={match.id} className="hover:shadow-md transition-shadow"><CardHeader className="pb-2"><div className="flex items-start justify-between"><Badge variant="secondary">{matchTypeLabels[match.match_type]}</Badge>{match.is_new && <Badge variant="default" className="text-xs">New</Badge>}</div><CardDescription className="line-clamp-2 mt-2">{match.question?.text || 'Unknown question'}</CardDescription></CardHeader><CardContent><div className="flex items-center justify-between"><div className="flex items-center gap-1 text-sm text-muted-foreground"><MessageCircle className="h-4 w-4" />{match.message_count} message{match.message_count !== 1 ? 's' : ''}</div>{canViewChats && <Link to={`/users/${userId}/matches/${match.id}`}><Button variant="ghost" size="sm">View Chat<ChevronRight className="ml-1 h-4 w-4" /></Button></Link>}</div></CardContent></Card>))}</div><PaginationControls page={matchesPage} pageSize={matchesPageSize} totalCount={matchesTotal} onPageChange={setMatchesPage} onPageSizeChange={(size) => { setMatchesPage(1); setMatchesPageSize(size); }} /></div>}</TabsContent>}
|
||||
{canViewMedia && <TabsContent value="media" className="mt-4">{mediaMessages.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><Image className="h-12 w-12 text-muted-foreground mb-4" /><p className="text-muted-foreground">No media uploaded</p></Card> : <div className="space-y-4"><div className="grid gap-4 grid-cols-2 md:grid-cols-3 lg:grid-cols-4">{mediaMessages.map((message) => (<Card key={message.id} className="overflow-hidden"><div className="aspect-square bg-muted relative">
|
||||
{message.media_type === 'video' ? (
|
||||
@@ -964,8 +972,11 @@ export function UserDetailPage() {
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
{answerIcons[q.partner_response.answer]}
|
||||
<span className="capitalize">{q.partner_response.answer}</span>
|
||||
<ResponseAnswer
|
||||
answer={q.partner_response.answer}
|
||||
questionType={q.question_type}
|
||||
responseData={q.partner_response.response_data}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
|
||||
Reference in new issue
Block a user