import { useState, useEffect, useCallback, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; import { z } from "zod"; import { MessageSquare, Upload, RefreshCw, AlertCircle, List, Users, FileText, Play, UploadCloud, Loader2, Search, Filter, Folder, FolderPlus, MoreHorizontal, Plus, Check, X, Download } from 'lucide-react'; import { toast } from 'sonner'; import { personasApi, focusGroupsApi } from '@/lib/api'; import GenerationProgressBar from '@/components/ui/GenerationProgressBar'; import DiscussionGuideViewer from './focus-group-session/DiscussionGuideViewer'; import { Button } from "@/components/ui/button"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger, DialogFooter, } from "@/components/ui/dialog"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, } from "@/components/ui/accordion"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; import { Label } from "@/components/ui/label"; import { Checkbox } from "@/components/ui/checkbox"; import UserCard from "@/components/UserCard"; import PersonaDetailsModal from "@/components/PersonaDetailsModal"; import { Persona } from "@/types/persona"; // Define folder interface interface Folder { id: string; name: string; personaIds: string[]; } // Default folder ID for "All Personas" const DEFAULT_FOLDER_ID = 'all'; // Define filter state interface interface FilterState { age: string[]; gender: string[]; occupation: string[]; location: string[]; techSavviness: string[]; ethnicity: string[]; } const formSchema = z.object({ researchBrief: z.string().min(10, { message: "Research brief must be at least 10 characters.", }), focusGroupName: z.string().min(3, { message: "Focus group name must be at least 3 characters.", }), discussionTopics: z.string().min(10, { message: "Discussion topics are required.", }), creativeAssets: z.instanceof(FileList).optional(), duration: z.string().min(1, { message: "Duration is required.", }), llm_model: z.string().optional(), }); // Sample discussion guide sections - We'll keep this but fetch real personas from the database // Sample discussion guide sections const sampleGuide = { introduction: "Welcome to our focus group discussion. Today we'll be exploring your experiences and opinions on [product/service]. There are no right or wrong answers, we're just interested in your honest thoughts.", warmup: "Let's start by introducing ourselves and sharing a bit about your background and daily routines relevant to this topic.", exploration: "Now, let's dive deeper into your experiences with similar products. What features do you find most valuable? What frustrations have you encountered?", creative: "We'll now show you some concepts and get your feedback. Please be honest and specific in your reactions.", conclusion: "To wrap up, I'd like to hear your final thoughts on what we've discussed today and any additional insights you'd like to share." }; interface FocusGroupModeratorProps { draftToEdit?: any | null; onDraftSaved?: () => void; } export default function FocusGroupModerator({ draftToEdit, onDraftSaved }: FocusGroupModeratorProps = {}) { console.log('FocusGroupModerator component rendering, draftToEdit:', draftToEdit); const navigate = useNavigate(); const [activeTab, setActiveTab] = useState('setup'); const [isGenerating, setIsGenerating] = useState(false); const [guideGenerationComplete, setGuideGenerationComplete] = useState(false); const [guideGenerationError, setGuideGenerationError] = useState(false); const [discussionGuide, setDiscussionGuide] = useState(null); const [draftFocusGroupId, setDraftFocusGroupId] = useState(null); // Ref to access current discussionGuide in callbacks without adding it as dependency const discussionGuideRef = useRef(discussionGuide); discussionGuideRef.current = discussionGuide; // Track if we've already loaded the draft to prevent re-loading const draftLoadedRef = useRef(false); // Helper function to determine if discussion guide is JSON format const isJsonFormat = (guide: any): boolean => { return guide && typeof guide === 'object' && guide.title && guide.sections; }; const [selectedParticipants, setSelectedParticipants] = useState([]); const [uploadedAssets, setUploadedAssets] = useState([]); const [personas, setPersonas] = useState([]); const [isLoadingPersonas, setIsLoadingPersonas] = useState(false); // Download state const [isDownloadingGuide, setIsDownloadingGuide] = useState(false); // Folder state const [folders, setFolders] = useState([]); const [selectedFolder, setSelectedFolder] = useState(DEFAULT_FOLDER_ID); const [isCreatingFolder, setIsCreatingFolder] = useState(false); const [newFolderName, setNewFolderName] = useState(''); const [folderToRename, setFolderToRename] = useState(null); const [renameFolderName, setRenameFolderName] = useState(''); // Filter state const [searchTerm, setSearchTerm] = useState(''); const [isFilterOpen, setIsFilterOpen] = useState(false); const [activeFilters, setActiveFilters] = useState({ age: [], gender: [], occupation: [], location: [], techSavviness: [], ethnicity: [], }); const [workingFilters, setWorkingFilters] = useState({ age: [], gender: [], occupation: [], location: [], techSavviness: [], ethnicity: [], }); // Persona details modal state const [isPersonaModalOpen, setIsPersonaModalOpen] = useState(false); const [selectedPersonaForModal, setSelectedPersonaForModal] = useState(null); // Handler for opening persona details modal const handleOpenPersonaModal = (persona: Persona) => { setSelectedPersonaForModal(persona); setIsPersonaModalOpen(true); }; // Handler for closing persona details modal const handleClosePersonaModal = () => { setIsPersonaModalOpen(false); setSelectedPersonaForModal(null); }; // Function to collect unique filter options from personas const getFilterOptions = (personas: any[]) => { const options = { age: new Set(), gender: new Set(), occupation: new Set(), location: new Set(), techSavviness: new Set(), ethnicity: new Set(), }; personas.forEach(persona => { // Add each attribute to the corresponding set if (persona.age) options.age.add(persona.age); if (persona.gender) options.gender.add(persona.gender); if (persona.occupation) options.occupation.add(persona.occupation); if (persona.location) options.location.add(persona.location); // Map numeric ranges to descriptive labels if (persona.techSavviness !== undefined) { const techLevel = persona.techSavviness < 30 ? 'Low (0-30)' : persona.techSavviness < 70 ? 'Medium (31-70)' : 'High (71-100)'; options.techSavviness.add(techLevel); } // Convert boolean values to string labels if (persona.ethnicity) options.ethnicity.add(persona.ethnicity); }); // Convert sets to sorted arrays return { age: Array.from(options.age).sort(), gender: Array.from(options.gender).sort(), occupation: Array.from(options.occupation).sort(), location: Array.from(options.location).sort(), techSavviness: Array.from(options.techSavviness).sort((a, b) => { const order = ['Low (0-30)', 'Medium (31-70)', 'High (71-100)']; return order.indexOf(a) - order.indexOf(b); }), ethnicity: Array.from(options.ethnicity).sort(), }; }; // Get filtered filter options based on current selections const getFilteredOptions = (currentCategory: keyof FilterState) => { // Create a temporary filter state without the current category const tempFilters = {...workingFilters}; tempFilters[currentCategory] = []; // Clear the current category // Filter the personas using the temporary filters const eligiblePersonas = personas.filter(persona => { // For folder filtering let matchesFolder = true; if (selectedFolder !== DEFAULT_FOLDER_ID) { matchesFolder = false; if (persona.folderId === selectedFolder) { matchesFolder = true; } else { const folder = folders.find(f => f.id === selectedFolder); if (folder && folder.personaIds.includes(persona.id)) { matchesFolder = true; } } } if (!matchesFolder) return false; // Apply all filters except the one for the current category return Object.entries(tempFilters).every(([category, values]) => { if (values.length === 0) return true; // Skip empty filter arrays const cat = category as keyof FilterState; if (cat === 'techSavviness' && persona.techSavviness !== undefined) { const techLevel = persona.techSavviness < 30 ? 'Low (0-30)' : persona.techSavviness < 70 ? 'Medium (31-70)' : 'High (71-100)'; return values.includes(techLevel); } else if (cat === 'age' && persona.age) { return values.includes(persona.age); } else if (cat === 'gender' && persona.gender) { return values.includes(persona.gender); } else if (cat === 'occupation' && persona.occupation) { return values.includes(persona.occupation); } else if (cat === 'location' && persona.location) { return values.includes(persona.location); } else if (cat === 'ethnicity' && persona.ethnicity) { return values.includes(persona.ethnicity); } return true; // No matching filter for this category }); }); // Now get the filter options from the filtered personas return getFilterOptions(eligiblePersonas); }; // Apply filters function const applyFilters = () => { setIsFilterOpen(false); setTimeout(() => { setActiveFilters({...workingFilters}); }, 0); }; // Reset working filters const handleResetFilters = () => { setWorkingFilters({ age: [], gender: [], occupation: [], location: [], techSavviness: [], ethnicity: [], }); }; // Toggle a filter value const toggleFilter = (category: keyof FilterState, value: string) => { setWorkingFilters(prev => { const newFilters = {...prev}; // If value is already selected, remove it if (newFilters[category].includes(value)) { newFilters[category] = newFilters[category].filter(v => v !== value); } else { // Otherwise add it newFilters[category] = [...newFilters[category], value]; } return newFilters; }); }; // Folder management functions const createNewFolder = () => { if (!newFolderName.trim()) { toast.error("Please enter a folder name"); return; } const newFolder: Folder = { id: `folder-${Date.now()}`, name: newFolderName.trim(), personaIds: [] }; setFolders([...folders, newFolder]); setNewFolderName(''); setIsCreatingFolder(false); toast.success(`Folder "${newFolderName}" created`); }; const cancelFolderCreation = () => { setNewFolderName(''); setIsCreatingFolder(false); }; const startRenameFolder = (folder: Folder) => { setFolderToRename(folder); setRenameFolderName(folder.name); }; const completeRenameFolder = () => { if (!folderToRename || !renameFolderName.trim()) { setFolderToRename(null); return; } const updatedFolders = folders.map(folder => folder.id === folderToRename.id ? { ...folder, name: renameFolderName.trim() } : folder ); setFolders(updatedFolders); setFolderToRename(null); toast.success(`Folder renamed to "${renameFolderName}"`); }; const cancelRenameFolder = () => { setFolderToRename(null); setRenameFolderName(''); }; // Function to synchronize personas with folders (ensures folder data is consistent) const syncPersonasWithFolders = (personas: any[], currentFolders: Folder[]) => { // For each persona that has a folderId, make sure it's in the corresponding folder's personaIds personas.forEach(persona => { if (persona.folderId) { const folder = currentFolders.find(f => f.id === persona.folderId); if (folder && !folder.personaIds.includes(persona.id)) { // Add this persona to the folder's personaIds folder.personaIds.push(persona.id); } } }); // Remove any persona IDs that don't correspond to actual personas currentFolders.forEach(folder => { // Clean up the personaIds array to remove undefined, null, and empty values folder.personaIds = folder.personaIds.filter(id => { // First filter out undefined/null values if (!id) { console.log(`Removing invalid personaId (${id}) from folder ${folder.name}`); return false; } // Then check if the ID matches an actual persona const persona = personas.find(p => { // Check if either id or _id property matches return p.id === id || p._id === id; }); if (!persona) { console.log(`Removing non-existent personaId ${id} from folder ${folder.name}`); } return !!persona; // Only keep IDs that match an existing persona }); }); // This function no longer removes personas from folders if they have a different folderId // This allows personas to appear in multiple folders return currentFolders; }; // Fetch personas from the database when component mounts useEffect(() => { const fetchPersonas = async () => { setIsLoadingPersonas(true); try { const response = await personasApi.getAll(); console.log("Fetched personas for FocusGroupModerator:", response.data); if (Array.isArray(response.data) && response.data.length > 0) { setPersonas(response.data); } else { console.warn("No personas returned from API or invalid format", response.data); toast.warning("No participants available"); } } catch (error) { console.error("Error fetching personas:", error); toast.error("Failed to load participants"); } finally { setIsLoadingPersonas(false); } }; // Load folders from localStorage first const storedFolders = localStorage.getItem('persona-folders'); let loadedFolders: Folder[] = []; if (storedFolders) { try { loadedFolders = JSON.parse(storedFolders); setFolders(loadedFolders); } catch (error) { console.error("Failed to parse stored folders:", error); } } fetchPersonas(); }, []); // Effect to save folders to localStorage when they change useEffect(() => { if (folders.length > 0) { console.log("Saving folders to localStorage:", folders); localStorage.setItem('persona-folders', JSON.stringify(folders)); } }, [folders]); // Effect to sync persona-folder relationships when either changes useEffect(() => { if (personas.length > 0 && folders.length > 0) { console.log("Running folder sync with personas:", personas.length, "and folders:", folders.length); const updatedFolders = syncPersonasWithFolders(personas, [...folders]); // Only update if there are actual changes to avoid infinite loop if (JSON.stringify(updatedFolders) !== JSON.stringify(folders)) { console.log("Updating folders after sync"); setFolders(updatedFolders); } else { console.log("No folder changes after sync"); } } }, [personas, folders.length]); console.log('About to initialize form with useForm hook'); const form = useForm>({ resolver: zodResolver(formSchema), defaultValues: { researchBrief: "", focusGroupName: "", discussionTopics: "", duration: "60", llm_model: "gemini-2.5-pro", }, }); console.log('Form initialized successfully'); // Effect to load draft data when editing an existing draft useEffect(() => { if (draftToEdit && !draftLoadedRef.current) { console.log("Loading draft focus group:", draftToEdit); draftLoadedRef.current = true; // Mark as loaded to prevent re-loading // Set the draft ID setDraftFocusGroupId(draftToEdit.id || draftToEdit._id); // Load form data if available if (draftToEdit.name) { form.setValue('focusGroupName', draftToEdit.name); } if (draftToEdit.description || draftToEdit.objective) { form.setValue('researchBrief', draftToEdit.description || draftToEdit.objective || ''); } if (draftToEdit.topic) { form.setValue('discussionTopics', draftToEdit.topic); } if (draftToEdit.duration) { form.setValue('duration', draftToEdit.duration.toString()); } // Load discussion guide if available if (draftToEdit.discussionGuide) { setDiscussionGuide(draftToEdit.discussionGuide); // If we have a discussion guide, start on the review tab setActiveTab('review'); } // Load selected participants if available if (draftToEdit.participants && Array.isArray(draftToEdit.participants)) { setSelectedParticipants(draftToEdit.participants); } toast.success("Draft focus group loaded", { description: "Continue editing your focus group setup" }); } }, [draftToEdit, form]); // Function to generate a discussion guide via the API const generateDiscussionGuide = async (values: z.infer, focusGroupId?: string): Promise => { // Reset states setIsGenerating(true); setGuideGenerationComplete(false); setGuideGenerationError(false); try { // Prepare data for API request const requestData = { name: values.focusGroupName, description: values.researchBrief, objective: values.researchBrief, topic: values.discussionTopics, duration: parseInt(values.duration), llm_model: values.llm_model }; // Call API to generate discussion guide, with focus group ID if available const response = focusGroupId ? await focusGroupsApi.generateDiscussionGuideForGroup(focusGroupId, requestData) : await focusGroupsApi.generateDiscussionGuide(requestData); // Check if we got a successful response with a discussion guide if (response.data && response.data.discussionGuide) { setGuideGenerationComplete(true); return response.data.discussionGuide; } else { throw new Error("Failed to generate discussion guide"); } } catch (error) { console.error("Error generating discussion guide:", error); setGuideGenerationError(true); // Extract error message from axios error response let errorMessage = 'Unknown error occurred'; if (error?.response?.data?.error) { errorMessage = error.response.data.error; } else if (error?.message) { errorMessage = error.message; } if (errorMessage.includes('500') || errorMessage.includes('internal error') || errorMessage.includes('Internal Server Error')) { toast.error("AI service temporarily unavailable", { description: "The discussion guide generator is experiencing issues. Please try again in a few minutes.", action: { label: "Retry", onClick: () => generateDiscussionGuide(values) } }); } else { toast.error("Failed to generate discussion guide", { description: errorMessage, action: { label: "Retry", onClick: () => generateDiscussionGuide(values) } }); } // Fallback to template if API fails const guide = ` # Discussion Guide: ${values.focusGroupName} ## Introduction (5 minutes) ${sampleGuide.introduction} ## Warm-up Questions (10 minutes) ${sampleGuide.warmup} ## ${values.discussionTopics.split(',')[0]} Exploration (15 minutes) ${sampleGuide.exploration} ## Creative Testing (20 minutes) ${sampleGuide.creative} ${values.creativeAssets && values.creativeAssets.length > 0 ? `We'll be reviewing ${values.creativeAssets.length} creative assets.` : ''} ## Conclusion (10 minutes) ${sampleGuide.conclusion} ## Research Brief Context ${values.researchBrief} `; return guide; } // Note: Don't set isGenerating to false here - let the progress bar handle it }; const handleGuideProgressComplete = () => { setIsGenerating(false); setGuideGenerationComplete(false); setGuideGenerationError(false); }; async function onSubmit(values: z.infer) { try { // First, save focus group to database to get an ID for asset uploads let focusGroupId = draftFocusGroupId; if (!focusGroupId) { const draftData = { name: values.focusGroupName, status: 'draft', participants: selectedParticipants, participants_count: selectedParticipants.length, date: new Date().toISOString(), duration: parseInt(values.duration), topic: values.discussionTopics.split(',')[0].trim().toLowerCase().replace(/\s+/g, '-'), description: values.researchBrief, objective: values.researchBrief, llm_model: values.llm_model, }; const savedDraft = await focusGroupsApi.create(draftData); focusGroupId = savedDraft.data.focus_group_id || savedDraft.data.id || savedDraft.data._id; setDraftFocusGroupId(focusGroupId); console.log("Draft focus group created for asset upload:", savedDraft, "with ID:", focusGroupId); } // Handle creative assets upload if any if (values.creativeAssets && values.creativeAssets.length > 0 && focusGroupId) { try { const formData = new FormData(); Array.from(values.creativeAssets).forEach(file => { formData.append('assets', file); }); const uploadResponse = await focusGroupsApi.uploadAssets(focusGroupId, formData); const uploadResult = uploadResponse.data; console.log("Assets uploaded successfully:", uploadResult); toast.success(`${uploadResult.uploaded_assets} asset(s) uploaded successfully`, { description: "Assets will be included in the discussion guide", }); // Store uploaded asset info for display const assets = Array.from(values.creativeAssets); setUploadedAssets(assets); } catch (uploadError: any) { console.error("Asset upload failed:", uploadError); // Handle specific error codes from backend const errorData = uploadError.response?.data; let errorTitle = "Asset upload failed"; let errorDescription = "Some assets could not be uploaded"; if (errorData?.code === 'TEMP_DIR_ERROR') { errorTitle = "Upload temporarily unavailable"; errorDescription = "Server storage issue. Please try again in a moment."; } else if (errorData?.code === 'UPLOAD_SYSTEM_FAILURE') { errorTitle = "Upload system unavailable"; errorDescription = "Critical server issue. Please contact support."; } else if (errorData?.can_retry) { errorTitle = "Upload failed - can retry"; errorDescription = errorData?.details || "Please try uploading again."; } toast.error(errorTitle, { description: errorDescription, }); // Continue with discussion guide generation even if upload fails console.log("Continuing without assets due to upload failure"); } } // Update focus group with current form values before generating guide // This ensures the backend uses the latest model selection if (focusGroupId) { try { const preUpdateData = { name: values.focusGroupName, participants: selectedParticipants, participants_count: selectedParticipants.length, duration: parseInt(values.duration), topic: values.discussionTopics.split(',')[0].trim().toLowerCase().replace(/\s+/g, '-'), description: values.researchBrief, objective: values.researchBrief, llm_model: values.llm_model }; await focusGroupsApi.update(focusGroupId, preUpdateData); console.log("Focus group updated with latest form values before guide generation"); console.log(`🔄 Updated focus group ${focusGroupId} with model: ${values.llm_model}`); } catch (error) { console.error("Failed to update focus group before guide generation:", error); // Continue anyway, as the generateDiscussionGuide will use form values as fallback } } // Generate discussion guide based on form input (after database is updated) const guide = await generateDiscussionGuide(values, focusGroupId); setDiscussionGuide(guide); // Update the focus group with the discussion guide try { const updateData = { name: values.focusGroupName, status: 'draft', participants: selectedParticipants, participants_count: selectedParticipants.length, date: new Date().toISOString(), duration: parseInt(values.duration), topic: values.discussionTopics.split(',')[0].trim().toLowerCase().replace(/\s+/g, '-'), description: values.researchBrief, objective: values.researchBrief, llm_model: values.llm_model, discussionGuide: guide }; await focusGroupsApi.update(focusGroupId, updateData); console.log("Focus group updated with discussion guide"); toast.success("Progress saved as draft", { description: "Your focus group setup has been automatically saved", }); } catch (error) { console.error("Failed to update focus group with discussion guide:", error); toast.error("Failed to save draft", { description: "Discussion guide generated, but draft save failed", }); } // Move to review tab after successful generation setActiveTab('review'); toast.success("Discussion guide generated", { description: "Review and edit before proceeding", }); } catch (error) { console.error("Error in focus group creation flow:", error); toast.error("Focus group creation failed", { description: error.message || "An unexpected error occurred", }); } } // Filtered personas based on search term, folder selection, and active filters const filteredPersonas = (() => { const filtered = personas.filter(persona => { // Text search matching const matchesSearch = ( persona.name.toLowerCase().includes(searchTerm.toLowerCase()) || (persona.occupation && persona.occupation.toLowerCase().includes(searchTerm.toLowerCase())) || (persona.location && persona.location.toLowerCase().includes(searchTerm.toLowerCase())) ); // Apply additional filter criteria const matchesFilters = // Match age filter (activeFilters.age.length === 0 || activeFilters.age.includes(persona.age)) && // Match gender filter (activeFilters.gender.length === 0 || activeFilters.gender.includes(persona.gender)) && // Match occupation filter (activeFilters.occupation.length === 0 || activeFilters.occupation.includes(persona.occupation)) && // Match location filter (activeFilters.location.length === 0 || activeFilters.location.includes(persona.location)) && // Match ethnicity filter (activeFilters.ethnicity.length === 0 || (persona.ethnicity && activeFilters.ethnicity.includes(persona.ethnicity))) && // Match tech savviness filter (convert numeric value to text bucket) (activeFilters.techSavviness.length === 0 || (persona.techSavviness !== undefined && activeFilters.techSavviness.includes( persona.techSavviness < 30 ? 'Low (0-30)' : persona.techSavviness < 70 ? 'Medium (31-70)' : 'High (71-100)' ))) && true; // Folder filtering let matchesFolder = true; // Only filter by folder if not the default "All Personas" folder if (selectedFolder !== DEFAULT_FOLDER_ID) { matchesFolder = false; // Start with false and set to true if it matches // First try using the folderId property if (persona.folderId === selectedFolder) { matchesFolder = true; } else { // Fall back to the folder's personaIds array const folder = folders.find(f => f.id === selectedFolder); if (folder) { // Filter out any undefined/null IDs from the personaIds array const validFolderIds = folder.personaIds.filter(id => !!id); // Check both id and _id properties to ensure compatibility with both formats const personaId = persona.id || persona._id; if (validFolderIds.includes(personaId)) { matchesFolder = true; } } } } return matchesSearch && matchesFilters && matchesFolder; }); // Debug log filtered results console.log(`Filtered personas: ${filtered.length}/${personas.length}`); console.log(`Selected folder: ${selectedFolder === DEFAULT_FOLDER_ID ? 'All Personas' : folders.find(f => f.id === selectedFolder)?.name || selectedFolder}`); if (selectedFolder !== DEFAULT_FOLDER_ID) { const folder = folders.find(f => f.id === selectedFolder); if (folder) { // Filter out undefined/null personaIds const validPersonaIds = folder.personaIds.filter(id => !!id); console.log(`Folder details: ${folder.name}, ID: ${folder.id}, Contains: ${validPersonaIds.length} valid personas`); console.log("Folder personaIds (valid only):", validPersonaIds); const personasWithFolderId = personas.filter(p => p.folderId === selectedFolder); console.log(`Personas with folderId matching this folder: ${personasWithFolderId.length}`); const personasInFolderArray = personas.filter(p => { const personaId = p.id || p._id; return folder.personaIds.includes(personaId); }); console.log(`Personas in folder's personaIds array: ${personasInFolderArray.length}`); } } return filtered; })(); const handleParticipantSelection = (id: string) => { console.log("Toggling selection for participant ID:", id); setSelectedParticipants(prev => { const isSelected = prev.includes(id); console.log("Current selection:", { id, isCurrentlySelected: isSelected, currentSelections: [...prev] }); // Toggle selection const newSelection = isSelected ? prev.filter(pId => pId !== id) : [...prev, id]; console.log("New selection:", newSelection); // Auto-save participant selection if we have a draft if (draftFocusGroupId && discussionGuide) { saveDraftParticipants(newSelection); } return newSelection; }); }; // Function to auto-save participant changes to draft const saveDraftParticipants = async (participants: string[]) => { if (!draftFocusGroupId) return; try { const values = form.getValues(); const draftData = { name: values.focusGroupName, status: 'draft', participants: participants, participants_count: participants.length, date: new Date().toISOString(), duration: parseInt(values.duration), topic: values.discussionTopics.split(',')[0].trim().toLowerCase().replace(/\s+/g, '-'), description: values.researchBrief, objective: values.researchBrief, discussionGuide: discussionGuide }; await focusGroupsApi.update(draftFocusGroupId, draftData); console.log("Participant selection auto-saved to draft"); } catch (error) { console.error("Failed to auto-save participant selection:", error); // Don't show toast for auto-save failures to avoid spam } }; const handleAssetUpload = (files: FileList | null) => { if (files && files.length > 0) { const newAssets = Array.from(files); setUploadedAssets(prev => [...prev, ...newAssets]); toast.success(`${newAssets.length} asset(s) uploaded`, { description: "Assets will be included in the focus group", }); } }; // Function to save the focus group to the database const saveFocusGroup = async () => { try { // Get form values const values = form.getValues(); const focusGroupData = { name: values.focusGroupName, status: 'in-progress', participants: selectedParticipants, participants_count: selectedParticipants.length, date: new Date().toISOString(), duration: parseInt(values.duration), topic: values.discussionTopics.split(',')[0].trim().toLowerCase().replace(/\s+/g, '-'), discussionGuide: discussionGuide }; // Use the API from lib/api.ts to save the focus group const response = await focusGroupsApi.create(focusGroupData); const result = response.data; console.log("Focus group created successfully:", result); return result.focus_group_id; } catch (error) { console.error('Error saving focus group:', error); throw error; } }; // Function to download discussion guide as markdown const handleDownloadDiscussionGuide = useCallback(async () => { if (!discussionGuideRef.current) { toast.error("No discussion guide available", { description: "Please generate a discussion guide first" }); return; } setIsDownloadingGuide(true); try { // Use the client-side download utility const { downloadDiscussionGuideAsMarkdown } = await import('@/utils/discussionGuideMarkdown'); const formValues = form.getValues(); downloadDiscussionGuideAsMarkdown(discussionGuideRef.current, formValues.focusGroupName); toast.success("Discussion guide downloaded", { description: "The guide has been saved to your downloads folder" }); } catch (error) { console.error('Error downloading discussion guide:', error); toast.error("Download failed", { description: "Unable to download the discussion guide. Please try again." }); } finally { setIsDownloadingGuide(false); } }, [form]); // Only depend on form, use ref for discussionGuide // Stable callback for saving discussion guide changes const handleSaveDiscussionGuide = useCallback(async (updatedGuide: any) => { console.log('📝 handleSaveDiscussionGuide called with:', updatedGuide); setDiscussionGuide(updatedGuide); toast.success('Discussion guide updated', { description: 'Your changes have been saved.' }); }, []); // Stable dummy callbacks for optional props const handleSectionSelect = useCallback(() => {}, []); const handleSetPosition = useCallback(() => {}, []); const handleStartFocusGroup = async () => { // Validate form data if (!form.getValues().focusGroupName) { toast.error("Missing focus group name", { description: "Please provide a name for the focus group", }); return; } if (!discussionGuide) { toast.error("Missing discussion guide", { description: "Please generate a discussion guide first", }); return; } if (selectedParticipants.length < 1) { toast.error("Not enough participants", { description: "Please select at least one participant for the focus group", }); return; } console.log("Starting focus group with participants:", selectedParticipants); try { // Show loading toast toast.loading("Creating focus group..."); // Save the focus group to the database first let focusGroupId; if (draftFocusGroupId) { // Update existing draft to in-progress status const values = form.getValues(); const updatedFocusGroupData = { name: values.focusGroupName, status: 'in-progress', participants: selectedParticipants, participants_count: selectedParticipants.length, date: new Date().toISOString(), duration: parseInt(values.duration), topic: values.discussionTopics.split(',')[0].trim().toLowerCase().replace(/\s+/g, '-'), description: values.researchBrief, objective: values.researchBrief, discussionGuide: discussionGuide }; const response = await focusGroupsApi.update(draftFocusGroupId, updatedFocusGroupData); focusGroupId = draftFocusGroupId; console.log("Draft focus group updated to in-progress:", response); // Notify parent component that draft was saved if (onDraftSaved) { onDraftSaved(); } } else { // Create new focus group focusGroupId = await saveFocusGroup(); } // Dismiss loading toast and show success toast.dismiss(); toast.success("Focus group created successfully", { description: "The AI moderator is now running the session", }); // Navigate to the focus group session navigate(`/focus-groups/${focusGroupId}`); } catch (error: any) { // Dismiss loading toast toast.dismiss(); // Show specific error message if available const errorMessage = error?.message || "Unknown error"; console.error("Failed to start focus group:", error); toast.error("Failed to create focus group", { description: "Please try again or check your connection", }); } }; return (

AI Focus Group Moderator

{/* Progress Bar - Consistent top placement for discussion guide generation */} {isGenerating && (
)} Setup Review & Edit Participants
( Focus Group Name Give your focus group a descriptive name )} />
( Research Brief