import { useState, useEffect, useCallback, useMemo } from 'react'; import { useParams } from 'react-router-dom'; import { adminData } from '@/lib/adminApi'; import { auditedAdminData } from '@/hooks/useAuditedAdminData'; import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription'; import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator'; import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; import { Badge } from '@/components/ui/badge'; import { Skeleton } from '@/components/ui/skeleton'; import { PaginationControls } from '@/components/ui/pagination'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from '@/components/ui/dialog'; import { Card } from '@/components/ui/card'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { Plus, Pencil, Trash2, Sparkles, Loader2, Users, Link2, Eye } from 'lucide-react'; import { Checkbox } from '@/components/ui/checkbox'; import { toast } from 'sonner'; 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; name: string; description: string | null; icon: string | null; } type QuestionType = 'swipe' | 'text_answer' | 'audio' | 'photo' | 'who_likely'; interface QuestionConfig { max_duration_seconds?: number; } interface Question { id: string; pack_id: string; text: string; partner_text: string | null; intensity: number; allowed_couple_genders: string[] | null; target_user_genders: string[] | null; required_props?: string[] | null; inverse_of?: string | null; 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; const QUESTION_TYPE_OPTIONS: Array<{ value: QuestionType; label: string }> = [ { value: 'swipe', label: 'Swipe' }, { value: 'text_answer', label: 'Text Answer' }, { value: 'audio', label: 'Audio' }, { value: 'photo', label: 'Photo' }, { value: 'who_likely', label: 'Who Is More Likely' }, ]; const getQuestionTypeLabel = (type?: QuestionType | null) => { const resolved = type ?? 'swipe'; return QUESTION_TYPE_OPTIONS.find(option => option.value === resolved)?.label ?? 'Swipe'; }; const getConfigSummary = (question: Question) => { if (question.question_type === 'audio') { const duration = question.config?.max_duration_seconds ?? DEFAULT_AUDIO_DURATION; return `${duration}s max`; } return null; }; export function QuestionsPage() { const { packId } = useParams<{ packId: string }>(); const [pack, setPack] = useState(null); const [questions, setQuestions] = useState([]); const [loading, setLoading] = useState(true); const [dialogOpen, setDialogOpen] = useState(false); const [aiDialogOpen, setAiDialogOpen] = useState(false); const [reviewOpen, setReviewOpen] = useState(false); const [reviewQuestions, setReviewQuestions] = useState([]); const [reviewLoading, setReviewLoading] = useState(false); const [editingQuestion, setEditingQuestion] = useState(null); const [saving, setSaving] = useState(false); const [selectedIds, setSelectedIds] = useState>(new Set()); const [bulkDeleting, setBulkDeleting] = useState(false); const [peekOpen, setPeekOpen] = useState(false); const [peekPrimary, setPeekPrimary] = useState(null); const [peekInverse, setPeekInverse] = useState(null); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(25); const [totalCount, setTotalCount] = useState(0); const [questionTypeFilter, setQuestionTypeFilter] = useState('all'); // Form state const [formData, setFormData] = useState<{ text: string; partner_text: string; allowed_couple_genders: string[]; target_user_genders: string[]; required_props: string; inverse_of: string; question_type: QuestionType; max_duration_seconds: string; }>({ text: '', partner_text: '', allowed_couple_genders: [], target_user_genders: [], required_props: '', inverse_of: '', question_type: 'swipe', max_duration_seconds: String(DEFAULT_AUDIO_DURATION), }); // All questions in pack for inverse_of dropdown (loaded once) const [allPackQuestions, setAllPackQuestions] = useState([]); const fetchData = useCallback(async () => { if (!packId) return; setLoading(true); try { const from = (page - 1) * pageSize; const to = from + pageSize - 1; const packPromise = adminData .from('question_packs') .select('id, name, description, icon') .eq('id', packId) .single(); let questionsQuery = adminData .from('questions') .select('*', { count: 'exact' }) .eq('pack_id', packId) .order('created_at', { ascending: true }) .range(from, to); if (questionTypeFilter !== 'all') { questionsQuery = questionsQuery.eq('question_type', questionTypeFilter); } const questionsPromise = questionsQuery; const allPackPromise = adminData .from('questions') .select('id, text, inverse_of') .eq('pack_id', packId) .is('deleted_at', null) .order('created_at', { ascending: true }); const [ { data: packData, error: packError }, { data: questionData, error, count }, { data: allPackData, error: allPackError }, ] = await Promise.all([packPromise, questionsPromise, allPackPromise]); if (packError) throw packError; if (error) throw error; setTotalCount(count || 0); setQuestions(questionData || []); setPack(packData); if (allPackError) { toast.error('Failed to load inverse peek data'); console.error(allPackError); } else { setAllPackQuestions((allPackData as Question[]) || []); } } catch (error) { toast.error('Failed to load questions'); console.error(error); } finally { setLoading(false); } }, [packId, page, pageSize, questionTypeFilter]); // Real-time subscription for questions in this pack const { status: realtimeStatus } = useRealtimeSubscription({ table: 'questions', filter: packId ? `pack_id=eq.${packId}` : undefined, enabled: !!packId, onInsert: useCallback(() => { fetchData(); }, [fetchData]), onUpdate: useCallback(() => { fetchData(); }, [fetchData]), onDelete: useCallback((deleted: Question) => { setSelectedIds(prev => { const next = new Set(prev); next.delete(deleted.id); return next; }); fetchData(); }, [fetchData]), }); useEffect(() => { fetchData(); }, [fetchData]); useEffect(() => { const totalPages = Math.max(1, Math.ceil(totalCount / pageSize)); if (page > totalPages) { setPage(totalPages); } }, [page, pageSize, totalCount]); useEffect(() => { setPage(1); setSelectedIds(new Set()); setReviewQuestions([]); setReviewOpen(false); }, [packId]); useEffect(() => { setPage(1); setSelectedIds(new Set()); }, [questionTypeFilter]); useEffect(() => { setSelectedIds(new Set()); }, [page, pageSize]); // Fetch all questions in pack for inverse_of dropdown const fetchAllPackQuestions = useCallback(async () => { if (!packId) return; const { data } = await adminData .from('questions') .select('id, text, inverse_of') .eq('pack_id', packId) .is('deleted_at', null) .order('created_at', { ascending: true }); setAllPackQuestions((data as Question[]) || []); }, [packId]); const primaryQuestionById = useMemo(() => { return new Map(allPackQuestions.map(question => [question.id, question])); }, [allPackQuestions]); const openCreateDialog = async () => { setEditingQuestion(null); setFormData({ text: '', partner_text: '', allowed_couple_genders: [], target_user_genders: [], required_props: '', inverse_of: '', question_type: 'swipe', max_duration_seconds: String(DEFAULT_AUDIO_DURATION), }); await fetchAllPackQuestions(); setDialogOpen(true); }; const openEditDialog = async (question: Question) => { setEditingQuestion(question); setFormData({ text: question.text, partner_text: question.partner_text || '', allowed_couple_genders: question.allowed_couple_genders || [], target_user_genders: question.target_user_genders || [], required_props: question.required_props?.join(', ') ?? '', inverse_of: question.inverse_of || '', question_type: question.question_type ?? 'swipe', max_duration_seconds: String(question.config?.max_duration_seconds ?? DEFAULT_AUDIO_DURATION), }); await fetchAllPackQuestions(); setDialogOpen(true); }; const handleSave = async () => { if (!formData.text.trim()) { toast.error('Question text is required'); return; } const requiredProps = formData.required_props .split(/[,;\n]/) .map(value => value.trim()) .filter(Boolean); const requiredPropsValue = requiredProps.length > 0 ? requiredProps : null; const parsedMaxDuration = Number(formData.max_duration_seconds); const maxDuration = Number.isFinite(parsedMaxDuration) && parsedMaxDuration > 0 ? parsedMaxDuration : DEFAULT_AUDIO_DURATION; const configValue = formData.question_type === 'audio' ? { max_duration_seconds: maxDuration } : null; setSaving(true); try { if (editingQuestion) { const { error } = await auditedAdminData.update('questions', editingQuestion.id, { text: formData.text, partner_text: formData.partner_text || null, allowed_couple_genders: formData.allowed_couple_genders.length > 0 ? formData.allowed_couple_genders : null, target_user_genders: formData.target_user_genders.length > 0 ? formData.target_user_genders : null, required_props: requiredPropsValue, inverse_of: formData.inverse_of || null, question_type: formData.question_type, config: configValue, }); if (error) throw error; toast.success('Question updated'); } else { const { error } = await auditedAdminData.insert('questions', { pack_id: packId, text: formData.text, partner_text: formData.partner_text || null, allowed_couple_genders: formData.allowed_couple_genders.length > 0 ? formData.allowed_couple_genders : null, target_user_genders: formData.target_user_genders.length > 0 ? formData.target_user_genders : null, required_props: requiredPropsValue, inverse_of: formData.inverse_of || null, question_type: formData.question_type, config: configValue, }); if (error) throw error; toast.success('Question created'); } setDialogOpen(false); fetchData(); } catch (error) { toast.error('Failed to save question'); console.error(error); } finally { setSaving(false); } }; const handleDelete = async (question: Question) => { if (!confirm('Delete this question? This cannot be undone.')) { return; } try { const { error } = await auditedAdminData.delete('questions', question.id); if (error) throw error; toast.success('Question deleted'); fetchData(); } catch (error) { toast.error('Failed to delete question'); console.error(error); } }; const handleAiGenerated = (generatedQuestions: Array<{ text: string; partner_text?: string; intensity: number; inverse_pair_id?: string | null }>) => { const generatedQuestionType: QuestionType = 'swipe'; // Bulk insert AI-generated questions with inverse_of linking const insertQuestions = async () => { try { // Group questions by inverse_pair_id const pairMap = new Map(); const standaloneQuestions: typeof generatedQuestions = []; for (const q of generatedQuestions) { if (q.inverse_pair_id) { const existing = pairMap.get(q.inverse_pair_id) || []; existing.push(q); pairMap.set(q.inverse_pair_id, existing); } else { standaloneQuestions.push(q); } } // Insert standalone questions first (no inverse_of) if (standaloneQuestions.length > 0) { const { error: standaloneError } = await auditedAdminData.insert( 'questions', standaloneQuestions.map(q => ({ pack_id: packId, text: q.text, partner_text: q.partner_text || null, inverse_of: null, question_type: generatedQuestionType, config: null, })) ); if (standaloneError) throw standaloneError; } // Insert paired questions: primary first, then inverse with inverse_of link for (const [, pair] of pairMap) { if (pair.length >= 2) { // First question is primary (inverse_of = NULL) const primary = pair[0]; const { data: primaryData, error: primaryError } = await auditedAdminData.insert( 'questions', [{ pack_id: packId, text: primary.text, partner_text: primary.partner_text || null, inverse_of: null, question_type: generatedQuestionType, config: null, }] ); if (primaryError) throw primaryError; // Second question is inverse (inverse_of = primary's id) const primaryId = primaryData?.[0]?.id; if (primaryId) { const inverse = pair[1]; const { error: inverseError } = await auditedAdminData.insert( 'questions', [{ pack_id: packId, text: inverse.text, partner_text: inverse.partner_text || null, inverse_of: primaryId, question_type: generatedQuestionType, config: null, }] ); if (inverseError) throw inverseError; } // Handle any additional questions in the pair (shouldn't happen, but just in case) for (let i = 2; i < pair.length; i++) { const extra = pair[i]; const { error: extraError } = await auditedAdminData.insert( 'questions', [{ pack_id: packId, text: extra.text, partner_text: extra.partner_text || null, inverse_of: null, question_type: generatedQuestionType, config: null, }] ); if (extraError) throw extraError; } } else if (pair.length === 1) { // Single question with pair_id but no pair - treat as standalone const { error } = await auditedAdminData.insert( 'questions', [{ pack_id: packId, text: pair[0].text, partner_text: pair[0].partner_text || null, inverse_of: null, question_type: generatedQuestionType, config: null, }] ); if (error) throw error; } } toast.success(`${generatedQuestions.length} questions added`); fetchData(); } catch (error) { toast.error('Failed to add questions'); console.error(error); } }; insertQuestions(); setAiDialogOpen(false); }; const handleReviewOpen = async () => { if (!packId) return; setReviewLoading(true); try { const { data, error } = await adminData .from('questions') .select('id, pack_id, text, partner_text, intensity, allowed_couple_genders, target_user_genders, required_props, question_type, created_at') .eq('pack_id', packId) .order('created_at', { ascending: true }); if (error) throw error; setReviewQuestions(data || []); setReviewOpen(true); } catch (error) { toast.error('Failed to load questions for review'); console.error(error); } finally { setReviewLoading(false); } }; // Selection handlers const toggleSelection = (id: string) => { const next = new Set(selectedIds); if (next.has(id)) { next.delete(id); } else { next.add(id); } setSelectedIds(next); }; const toggleSelectAll = () => { if (selectedIds.size === questions.length) { setSelectedIds(new Set()); } else { setSelectedIds(new Set(questions.map(q => q.id))); } }; const handleBulkDelete = async () => { if (selectedIds.size === 0) return; const count = selectedIds.size; if (!confirm(`Delete ${count} question${count !== 1 ? 's' : ''}? This cannot be undone.`)) { return; } setBulkDeleting(true); try { await Promise.all( Array.from(selectedIds).map(id => auditedAdminData.delete('questions', id)) ); toast.success(`${count} question${count !== 1 ? 's' : ''} deleted`); setSelectedIds(new Set()); fetchData(); } catch (error) { toast.error('Failed to delete questions'); console.error(error); } finally { setBulkDeleting(false); } }; const handlePeekPrimary = (question: Question) => { if (!question.inverse_of) return; setPeekPrimary(primaryQuestionById.get(question.inverse_of) ?? null); setPeekInverse(question); setPeekOpen(true); }; if (loading) { return (
); } return (
{/* Header */}

{pack && ( <> {pack.name} )}

{totalCount} question{totalCount !== 1 ? 's' : ''} in this pack

{editingQuestion ? 'Edit Question' : 'Add Question'} {editingQuestion ? 'Update the question details below.' : 'Add a new question to this pack.'}
setFormData(d => ({ ...d, text: val }))} />