mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-01 18:19:43 +00:00
Merge remote-tracking branch 'origin/master' into task/self-hosted-backend
# Conflicts: # apps/mobile/app/(app)/onboarding.tsx # apps/mobile/src/__tests__/stores/packsStore.test.ts # apps/mobile/src/features/profile/hooks/useProfileSettings.ts # apps/mobile/src/store/packsStore.ts
This commit is contained in:
commit
be20391788
77 files changed
+48548
-671
No files matched your search
@@ -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 adminData
|
||||
.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,
|
||||
}));
|
||||
@@ -423,17 +422,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 */}
|
||||
@@ -530,7 +518,16 @@ export function UserActivityPage() {
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{renderAnswerBadge(item.answer)}</TableCell>
|
||||
<TableCell>
|
||||
<ResponseAnswer
|
||||
answer={item.answer}
|
||||
responseId={item.id}
|
||||
questionType={item.question?.question_type}
|
||||
responseData={item.response_data}
|
||||
responderId={item.user_id}
|
||||
responderName={item.profile?.name || item.profile?.email}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm whitespace-nowrap">
|
||||
{formatDistanceToNow(new Date(item.created_at), { addSuffix: true })}
|
||||
</TableCell>
|
||||
|
||||
@@ -43,6 +43,8 @@ import {
|
||||
rectSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { ContentReviewControl } from '@/components/content/ContentReviewControl';
|
||||
import type { ContentReviewStatus } from '@sauci/shared';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
@@ -56,6 +58,8 @@ interface Category {
|
||||
sort_order: number | null;
|
||||
created_at: string | null;
|
||||
is_public: boolean;
|
||||
content_status?: ContentReviewStatus | null;
|
||||
content_review_reason?: string | null;
|
||||
question_pack_count?: number;
|
||||
dare_pack_count?: number;
|
||||
}
|
||||
@@ -76,9 +80,10 @@ interface SortableCategoryCardProps {
|
||||
onEdit: (category: Category) => void;
|
||||
onDelete: (category: Category) => void;
|
||||
onToggleVisibility: (category: Category) => void;
|
||||
onReviewChanged: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
function SortableCategoryCard({ category, onEdit, onDelete, onToggleVisibility }: SortableCategoryCardProps) {
|
||||
function SortableCategoryCard({ category, onEdit, onDelete, onToggleVisibility, onReviewChanged }: SortableCategoryCardProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
@@ -164,6 +169,16 @@ function SortableCategoryCard({ category, onEdit, onDelete, onToggleVisibility }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ContentReviewControl
|
||||
table="categories"
|
||||
entityId={category.id}
|
||||
entityLabel={`category “${category.name}”`}
|
||||
status={category.content_status}
|
||||
reason={category.content_review_reason}
|
||||
onChanged={onReviewChanged}
|
||||
compact
|
||||
/>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Link to={`/categories/${category.id}/packs`}>
|
||||
<Button variant="outline" className="w-full">
|
||||
@@ -579,6 +594,7 @@ export function CategoriesPage() {
|
||||
onEdit={form.openEdit}
|
||||
onDelete={handleDelete}
|
||||
onToggleVisibility={handleToggleVisibility}
|
||||
onReviewChanged={fetchCategories}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -31,6 +31,8 @@ import {
|
||||
} from '@/components/ui/select';
|
||||
import { Plus, Zap, Pencil, Trash2, Loader2, Crown, Eye, EyeOff, ChevronUp, ChevronDown } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { ContentReviewControl } from '@/components/content/ContentReviewControl';
|
||||
import type { ContentReviewStatus } from '@sauci/shared';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
@@ -54,6 +56,8 @@ interface DarePack {
|
||||
category_id: string | null;
|
||||
created_at: string | null;
|
||||
dare_count?: number;
|
||||
content_status?: ContentReviewStatus | null;
|
||||
content_review_reason?: string | null;
|
||||
}
|
||||
|
||||
interface DarePackFormData {
|
||||
@@ -511,6 +515,15 @@ export function DarePacksPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<ContentReviewControl
|
||||
table="dare_packs"
|
||||
entityId={pack.id}
|
||||
entityLabel={`dare pack “${pack.name}”`}
|
||||
status={pack.content_status}
|
||||
reason={pack.content_review_reason}
|
||||
onChanged={fetchData}
|
||||
compact
|
||||
/>
|
||||
{pack.is_explicit && (
|
||||
<Badge variant="destructive">
|
||||
NSFW
|
||||
|
||||
@@ -37,6 +37,8 @@ import { Plus, Pencil, Trash2, Zap, Loader2, Clock } from 'lucide-react';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { toast } from 'sonner';
|
||||
import { IconPreview } from '@/components/ui/icon-picker';
|
||||
import { ContentReviewControl } from '@/components/content/ContentReviewControl';
|
||||
import type { ContentReviewStatus } from '@sauci/shared';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
@@ -56,6 +58,8 @@ interface Dare {
|
||||
intensity: number;
|
||||
suggested_duration_hours: number | null;
|
||||
created_at: string | null;
|
||||
content_status?: ContentReviewStatus | null;
|
||||
content_review_reason?: string | null;
|
||||
}
|
||||
|
||||
// Duration options for dares
|
||||
@@ -439,6 +443,7 @@ export function DaresPage() {
|
||||
</TableHead>
|
||||
<TableHead className="w-12">#</TableHead>
|
||||
<TableHead>Dare</TableHead>
|
||||
<TableHead className="w-40">Catalogue</TableHead>
|
||||
<TableHead className="w-24">Duration</TableHead>
|
||||
<TableHead className="w-24">Actions</TableHead>
|
||||
</TableRow>
|
||||
@@ -462,6 +467,17 @@ export function DaresPage() {
|
||||
<TableCell>
|
||||
<span className="line-clamp-2">{dare.text}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<ContentReviewControl
|
||||
table="dares"
|
||||
entityId={dare.id}
|
||||
entityLabel={`dare ${((page - 1) * pageSize) + index + 1}`}
|
||||
status={dare.content_status}
|
||||
reason={dare.content_review_reason}
|
||||
onChanged={fetchData}
|
||||
compact
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{dare.suggested_duration_hours ? (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
|
||||
@@ -14,6 +14,8 @@ import { AiGeneratorDialog } from '@/components/ai/AiGeneratorDialog';
|
||||
import { ExtractTopicsDialog } from '@/components/content/ExtractTopicsDialog';
|
||||
import { PackFormDialog, type PackFormData } from '@/components/content/PackFormDialog';
|
||||
import { IconPreview } from '@/components/ui/icon-picker';
|
||||
import { ContentReviewControl } from '@/components/content/ContentReviewControl';
|
||||
import type { ContentReviewStatus } from '@sauci/shared';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
@@ -43,6 +45,8 @@ interface QuestionPack {
|
||||
created_at: string | null;
|
||||
scheduled_release_at?: string | null; // Optional for backwards compatibility
|
||||
release_notified?: boolean; // True if notifications have been sent or should be skipped
|
||||
content_status?: ContentReviewStatus | null;
|
||||
content_review_reason?: string | null;
|
||||
question_count?: number;
|
||||
topics?: Topic[];
|
||||
}
|
||||
@@ -513,6 +517,15 @@ export function PacksPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<ContentReviewControl
|
||||
table="question_packs"
|
||||
entityId={pack.id}
|
||||
entityLabel={`pack “${pack.name}”`}
|
||||
status={pack.content_status}
|
||||
reason={pack.content_review_reason}
|
||||
onChanged={fetchData}
|
||||
compact
|
||||
/>
|
||||
{pack.is_explicit && (
|
||||
<Badge variant="destructive">
|
||||
NSFW
|
||||
|
||||
@@ -43,6 +43,8 @@ import { AiGeneratorDialog } from '@/components/ai/AiGeneratorDialog';
|
||||
import { AIPolishButton } from '@/components/ai/AIPolishButton';
|
||||
import { ReviewQuestionsDialog } from '@/components/content/ReviewQuestionsDialog';
|
||||
import { IconPreview } from '@/components/ui/icon-picker';
|
||||
import { ContentReviewControl } from '@/components/content/ContentReviewControl';
|
||||
import type { ContentReviewStatus } from '@sauci/shared';
|
||||
|
||||
interface QuestionPack {
|
||||
id: string;
|
||||
@@ -70,6 +72,8 @@ interface Question {
|
||||
created_at: string | null;
|
||||
question_type?: QuestionType | null;
|
||||
config?: QuestionConfig | null;
|
||||
content_status?: ContentReviewStatus | null;
|
||||
content_review_reason?: string | null;
|
||||
}
|
||||
|
||||
const DEFAULT_AUDIO_DURATION = 60;
|
||||
@@ -982,6 +986,7 @@ export function QuestionsPage() {
|
||||
</TableHead>
|
||||
<TableHead className="w-12">#</TableHead>
|
||||
<TableHead>Question</TableHead>
|
||||
<TableHead className="w-40">Catalogue</TableHead>
|
||||
<TableHead className="w-28">Type</TableHead>
|
||||
<TableHead className="w-20">Inverse</TableHead>
|
||||
<TableHead className="w-32">Partner Text</TableHead>
|
||||
@@ -1009,6 +1014,17 @@ export function QuestionsPage() {
|
||||
<TableCell>
|
||||
<span className="line-clamp-2">{question.text}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<ContentReviewControl
|
||||
table="questions"
|
||||
entityId={question.id}
|
||||
entityLabel={`question ${((page - 1) * pageSize) + index + 1}`}
|
||||
status={question.content_status}
|
||||
reason={question.content_review_reason}
|
||||
onChanged={fetchData}
|
||||
compact
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<Badge variant="secondary" className="text-[10px] uppercase tracking-wide">
|
||||
|
||||
@@ -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,15 @@ interface OutstandingQuestion {
|
||||
id: string;
|
||||
text: string;
|
||||
intensity: number | null;
|
||||
question_type: QuestionType;
|
||||
pack: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
partner_response: {
|
||||
answer: 'yes' | 'no' | 'maybe';
|
||||
id: string;
|
||||
answer: AnswerType;
|
||||
response_data: AdminResponseData;
|
||||
created_at: string | null;
|
||||
};
|
||||
}
|
||||
@@ -155,16 +163,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,
|
||||
};
|
||||
|
||||
|
||||
@@ -510,7 +513,7 @@ export function UserDetailPage() {
|
||||
|
||||
const { data: responseData, count: responsesCount, error: responsesError } = await adminData
|
||||
.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);
|
||||
@@ -618,8 +621,10 @@ export function UserDetailPage() {
|
||||
const { data: partnerResponses } = await adminData
|
||||
.from('responses')
|
||||
.select(`
|
||||
id,
|
||||
question_id,
|
||||
answer,
|
||||
response_data,
|
||||
created_at
|
||||
`)
|
||||
.eq('user_id', partnerProfile.id)
|
||||
@@ -634,7 +639,7 @@ export function UserDetailPage() {
|
||||
// Fetch question details (no pack filter — admin sees all)
|
||||
const { data: questionDetails } = await adminData
|
||||
.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]));
|
||||
@@ -649,8 +654,14 @@ 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: {
|
||||
id: pr.id,
|
||||
answer: pr.answer,
|
||||
response_data: pr.response_data,
|
||||
created_at: pr.created_at,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -871,7 +882,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} responseId={r.id} questionType={r.question.question_type} responseData={r.response_data} responderId={userId} responderName={profile.name || profile.email} /></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' ? (
|
||||
@@ -938,8 +949,14 @@ 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}
|
||||
responseId={q.partner_response.id}
|
||||
questionType={q.question_type}
|
||||
responseData={q.partner_response.response_data}
|
||||
responderId={partner?.id}
|
||||
responderName={partner?.name}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
|
||||
Reference in new issue
Block a user