import { useState, useEffect, useCallback, useRef } from 'react'; import { useNavigate, useLocation } from 'react-router-dom'; import { useNavigation } from '@/contexts/NavigationContext'; 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, Info } from 'lucide-react'; import { toast } from 'sonner'; import { personasApi, focusGroupsApi, foldersApi } from '@/lib/api'; import GenerationProgressBar from '@/components/ui/GenerationProgressBar'; import DiscussionGuideViewer from './focus-group-session/DiscussionGuideViewer'; import AssetUploader from '@/components/AssetUploader'; 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 { Persona } from "@/types/persona"; // Define folder interface (database-compatible) interface Folder { _id: string; id?: string; // Legacy compatibility name: string; created_at?: string; created_by?: string; updated_at?: 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.", }), duration: z.string().min(1, { message: "Duration is required.", }), llm_model: z.string().optional(), reasoning_effort: z.string().optional(), verbosity: 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; preSelectedParticipants?: string[]; } export default function FocusGroupModerator({ draftToEdit, onDraftSaved, preSelectedParticipants = [] }: FocusGroupModeratorProps = {}) { console.log('FocusGroupModerator component rendering, draftToEdit:', draftToEdit); const navigate = useNavigate(); const location = useLocation(); const { setPreviousRoute, navigationState, clearNavigationState } = useNavigation(); 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); // Track if discussion guide is being edited to prevent updates during editing const [isEditingGuide, setIsEditingGuide] = useState(false); // 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 [backendAssets, setBackendAssets] = useState([]); const [isLoadingAssets, setIsLoadingAssets] = useState(false); 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: [], }); // Auto-save state management const [autoSaveStatus, setAutoSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle'); const [lastSavedData, setLastSavedData] = useState(null); const [saveRetryCount, setSaveRetryCount] = useState(0); const debouncedSaveTimerRef = useRef(null); const isSavingRef = useRef(false); const isLoadingDraftRef = useRef(false); // Handler for persona view details navigation const handlePersonaViewDetails = (persona: Persona) => { // Set navigation context with current focus group state setPreviousRoute('/focus-groups', { focusGroupId: draftFocusGroupId, focusGroupTab: 'participants', isNewFocusGroup: !draftToEdit, focusGroupData: { name: form.getValues('name'), description: form.getValues('description'), selectedParticipants: selectedParticipants, discussionGuide: discussionGuide, } }); // Navigate to persona profile page navigate(`/synthetic-users/${persona.id}`); }; // 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 (persona-centric storage) let matchesFolder = true; if (selectedFolder !== DEFAULT_FOLDER_ID) { matchesFolder = false; // Check if persona belongs to selected folder using persona-centric storage // Check persona.folder_ids array (new approach) if (persona.folder_ids && Array.isArray(persona.folder_ids)) { matchesFolder = persona.folder_ids.includes(selectedFolder); } // Legacy support for single folder_id properties if (!matchesFolder && (persona.folder_id === selectedFolder || persona.folderId === selectedFolder)) { 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; }); }; // Function to fetch folders from database const fetchFolders = async () => { try { const response = await foldersApi.getAll(); const serverFolders = response.data; // Convert server folder format to match frontend expectations const processedFolders: Folder[] = serverFolders.map((folder: any) => ({ ...folder, id: folder._id // Add legacy id field for compatibility })); setFolders(processedFolders); return processedFolders; } catch (error) { console.error("Error fetching folders:", error); toast.error("Failed to load folders"); setFolders([]); return []; } }; // Folder management functions const createNewFolder = async () => { if (!newFolderName.trim()) { toast.error("Please enter a folder name"); return; } try { const response = await foldersApi.create({ name: newFolderName.trim() }); // Refresh folders from server await fetchFolders(); setNewFolderName(''); setIsCreatingFolder(false); toast.success(`Folder "${newFolderName}" created`); } catch (error) { console.error("Error creating folder:", error); toast.error("Failed to create folder"); } }; const cancelFolderCreation = () => { setNewFolderName(''); setIsCreatingFolder(false); }; const startRenameFolder = (folder: Folder) => { setFolderToRename(folder); setRenameFolderName(folder.name); }; const completeRenameFolder = async () => { if (!folderToRename || !renameFolderName.trim()) { setFolderToRename(null); return; } try { await foldersApi.update(folderToRename._id, { name: renameFolderName.trim() }); // Refresh folders from server await fetchFolders(); setFolderToRename(null); toast.success(`Folder renamed to "${renameFolderName}"`); } catch (error) { console.error("Error renaming folder:", error); toast.error("Failed to rename folder"); setFolderToRename(null); } }; const cancelRenameFolder = () => { setFolderToRename(null); setRenameFolderName(''); }; // Note: Synchronization is no longer needed with persona-centric storage // Folder membership is stored in persona.folder_ids array server-side // Fetch personas and folders 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); } }; const loadData = async () => { // Load both folders and personas from database await Promise.all([ fetchFolders(), fetchPersonas() ]); }; loadData(); }, []); // Note: Folder management is now handled server-side with persona-centric storage // No localStorage saving or manual synchronization needed 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", reasoning_effort: "medium", verbosity: "medium", }, }); console.log('Form initialized successfully'); // Simplified auto-save trigger function - only debounced save on changes const triggerAutoSave = () => { if (activeTab !== 'setup' || isLoadingDraftRef.current) return; // Clear existing debounced timer if (debouncedSaveTimerRef.current) { clearTimeout(debouncedSaveTimerRef.current); } // Schedule debounced save - create inline function to avoid dependencies debouncedSaveTimerRef.current = setTimeout(async () => { if (isSavingRef.current) return; const values = form.getValues(); const currentData = { name: values.focusGroupName || '', description: values.researchBrief || '', objective: values.researchBrief || '', topic: values.discussionTopics || '', duration: values.duration ? parseInt(values.duration) : 60, llm_model: values.llm_model || 'gemini-2.5-pro', reasoning_effort: values.reasoning_effort || 'medium', verbosity: values.verbosity || 'medium', participants: selectedParticipants, participants_count: selectedParticipants.length, status: 'draft', date: new Date().toISOString(), uploadedAssets: backendAssets.map(a => a.filename || a.original_name || 'unknown') }; if (lastSavedData && JSON.stringify(currentData) === JSON.stringify(lastSavedData)) { return; // No changes } if (!currentData.name && !currentData.description && !currentData.topic) { return; // Don't save empty form } isSavingRef.current = true; setAutoSaveStatus('saving'); try { // Use draftFocusGroupId from state, or fall back to draftToEdit ID if available let focusGroupId = draftFocusGroupId || (draftToEdit?.id || draftToEdit?._id); console.log("Auto-save: draftFocusGroupId =", draftFocusGroupId); console.log("Auto-save: draftToEdit ID =", draftToEdit?.id || draftToEdit?._id); console.log("Auto-save: using focusGroupId =", focusGroupId); console.log("Auto-save: llm_model in currentData =", currentData.llm_model); console.log("Auto-save: duration in currentData =", currentData.duration); if (!focusGroupId) { console.log("Auto-save: Creating NEW focus group (no existing ID)"); const response = await focusGroupsApi.create(currentData); focusGroupId = response.data.focus_group_id || response.data.id || response.data._id; setDraftFocusGroupId(focusGroupId); console.log("Auto-save: Created new draft with ID:", focusGroupId); } else { console.log("Auto-save: Updating existing focus group:", focusGroupId); await focusGroupsApi.update(focusGroupId, currentData); console.log("Auto-save: Updated existing draft:", focusGroupId); } setLastSavedData(currentData); setAutoSaveStatus('saved'); setSaveRetryCount(0); setTimeout(() => { setAutoSaveStatus('idle'); }, 2000); } catch (error) { console.error("Auto-save failed:", error); setAutoSaveStatus('error'); setSaveRetryCount(prev => prev + 1); if (saveRetryCount < 3) { const retryDelay = Math.pow(2, saveRetryCount) * 2000; setTimeout(() => { triggerAutoSave(); }, retryDelay); } else { toast.error("Auto-save failed", { description: "Your changes may not be saved. Please check your connection.", }); } } finally { isSavingRef.current = false; } }, 2000); }; // Function to fetch backend assets const fetchBackendAssets = async (focusGroupId: string) => { try { setIsLoadingAssets(true); const response = await focusGroupsApi.getAssets(focusGroupId); setBackendAssets(response.data.assets || []); } catch (error) { console.error("Error fetching backend assets:", error); toast.error("Failed to load asset information"); } finally { setIsLoadingAssets(false); } }; // Function to update asset name const updateAssetName = async (focusGroupId: string, filename: string, newName: string) => { try { await focusGroupsApi.updateAssetName(focusGroupId, filename, newName); // Update local state setBackendAssets(prev => prev.map(asset => asset.filename === filename ? { ...asset, user_assigned_name: newName } : asset )); toast.success("Asset name updated"); } catch (error) { console.error("Error updating asset name:", error); toast.error("Failed to update asset name"); } }; // Watch for form field changes to trigger auto-save const watchedFields = form.watch(); // Use refs to track previous values to prevent unnecessary saves const prevWatchedFieldsRef = useRef(''); const prevSelectedParticipantsRef = useRef(''); // Effect to handle form field changes and trigger auto-save useEffect(() => { const currentWatchedFields = JSON.stringify(watchedFields); if (activeTab === 'setup' && currentWatchedFields !== prevWatchedFieldsRef.current) { prevWatchedFieldsRef.current = currentWatchedFields; triggerAutoSave(); } }, [watchedFields, activeTab]); // Effect to handle participant changes useEffect(() => { const currentParticipants = JSON.stringify(selectedParticipants); if (activeTab === 'setup' && currentParticipants !== prevSelectedParticipantsRef.current) { prevSelectedParticipantsRef.current = currentParticipants; triggerAutoSave(); } }, [selectedParticipants, activeTab]); // Asset uploads are now handled immediately via AssetUploader component // Effect to clear timers when leaving setup tab or component unmounts useEffect(() => { if (activeTab !== 'setup') { // Clear debounced timer when leaving setup tab if (debouncedSaveTimerRef.current) { clearTimeout(debouncedSaveTimerRef.current); } } // Cleanup timer on unmount return () => { if (debouncedSaveTimerRef.current) { clearTimeout(debouncedSaveTimerRef.current); } }; }, [activeTab]); // Effect to load draft data when editing an existing draft useEffect(() => { console.log("Draft loading effect - draftToEdit:", draftToEdit, "draftLoadedRef.current:", draftLoadedRef.current); // Reset loaded flag when draftToEdit changes if (!draftToEdit) { draftLoadedRef.current = false; return; } if (draftToEdit && !draftLoadedRef.current) { console.log("Loading draft focus group:", draftToEdit); isLoadingDraftRef.current = true; // Prevent auto-save during loading draftLoadedRef.current = true; // Mark as loaded to prevent re-loading // Set the draft ID const draftId = draftToEdit.id || draftToEdit._id; setDraftFocusGroupId(draftId); console.log("Setting draft ID from draftToEdit:", draftId); // Load backend assets for this focus group if (draftId) { fetchBackendAssets(draftId); } // 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()); } if (draftToEdit.llm_model) { form.setValue('llm_model', draftToEdit.llm_model); } if (draftToEdit.reasoning_effort) { form.setValue('reasoning_effort', draftToEdit.reasoning_effort); } if (draftToEdit.verbosity) { form.setValue('verbosity', draftToEdit.verbosity); } // Load discussion guide if available if (draftToEdit.discussionGuide) { setDiscussionGuide(draftToEdit.discussionGuide); // If we have a discussion guide and no navigation state override, start on the review tab if (!navigationState.focusGroupTab || navigationState.previousRoute !== '/focus-groups') { setActiveTab('review'); } } // Load selected participants if available if (draftToEdit.participants && Array.isArray(draftToEdit.participants)) { setSelectedParticipants(draftToEdit.participants); } // Set lastSavedData to current draft state to prevent immediate auto-save const currentDraftData = { name: draftToEdit.name || '', description: draftToEdit.description || draftToEdit.objective || '', objective: draftToEdit.description || draftToEdit.objective || '', topic: draftToEdit.topic || '', duration: draftToEdit.duration || 60, llm_model: draftToEdit.llm_model || 'gemini-2.5-pro', reasoning_effort: draftToEdit.reasoning_effort || 'medium', verbosity: draftToEdit.verbosity || 'medium', participants: draftToEdit.participants || [], participants_count: (draftToEdit.participants || []).length, status: 'draft', date: draftToEdit.date || new Date().toISOString(), uploadedAssets: backendAssets.map(a => a.filename || a.original_name || 'unknown') }; setLastSavedData(currentDraftData); console.log("Set lastSavedData to current draft:", currentDraftData); toast.success("Draft focus group loaded", { description: "Continue editing your focus group setup" }); // Allow auto-save after loading is complete setTimeout(() => { isLoadingDraftRef.current = false; // Ensure initial form state is captured after loading const initialFormState = JSON.stringify(form.getValues()); prevWatchedFieldsRef.current = initialFormState; }, 1000); // Give it a second to settle } }, [draftToEdit, form]); // Effect to handle pre-selected participants from persona list useEffect(() => { if (preSelectedParticipants.length > 0) { console.log("Pre-selected participants received:", preSelectedParticipants); setSelectedParticipants(preSelectedParticipants); // Auto-switch to participants tab to show the pre-selected personas setActiveTab('participants'); } }, [preSelectedParticipants]); // Handle navigation state to set the correct tab when returning from persona details useEffect(() => { if (navigationState.focusGroupTab && navigationState.previousRoute === '/focus-groups') { // Use setTimeout to ensure this runs after other tab-setting logic setTimeout(() => { setActiveTab(navigationState.focusGroupTab); // Clear navigation state after using it clearNavigationState(); }, 0); } }, [navigationState.focusGroupTab, draftToEdit, clearNavigationState]); // Also depend on draftToEdit so this runs after draft loading // Initialize refs on mount for new focus groups (not editing drafts) useEffect(() => { if (!draftToEdit) { setTimeout(() => { isLoadingDraftRef.current = false; // Ensure initial form state is captured for new focus groups const initialFormState = JSON.stringify(form.getValues()); prevWatchedFieldsRef.current = initialFormState; }, 500); // Allow initial render to complete } }, [draftToEdit, form]); // Save Status Indicator Component const SaveStatusIndicator = () => { if (autoSaveStatus === 'idle') return null; const statusConfig = { saving: { text: 'Saving...', className: 'text-blue-600 bg-blue-50' }, saved: { text: 'All changes saved', className: 'text-green-600 bg-green-50' }, error: { text: 'Save failed - retrying...', className: 'text-red-600 bg-red-50' } }; const config = statusConfig[autoSaveStatus]; return (
{config.text}
); }; // 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, reasoning_effort: values.reasoning_effort, verbosity: values.verbosity }; // 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) } }); } // Don't provide fallback template - throw the error to prevent showing dummy guide throw error; } // 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 { // Use existing focus group ID or create new draft for discussion guide generation 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, reasoning_effort: values.reasoning_effort, verbosity: values.verbosity, }; 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 discussion guide generation:", savedDraft, "with ID:", focusGroupId); } // Assets are now uploaded immediately via AssetUploader component // No need to handle asset uploads here // 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, reasoning_effort: values.reasoning_effort, verbosity: values.verbosity }; 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 } } try { // 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, reasoning_effort: values.reasoning_effort, verbosity: values.verbosity, 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 (guideError) { console.error("Discussion guide generation failed:", guideError); // Don't set discussion guide or move to review tab // Show error message with instruction to go back to setup tab and try again toast.error("Discussion guide generation failed", { description: "Please go back to the setup tab and try generating again. Check your inputs and try a different AI model if the issue persists.", duration: 8000, // Show longer so user can read the instruction }); // Stay on current tab (setup) so user can try again return; } } 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 (persona-centric storage) 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 // Check if persona belongs to selected folder using persona-centric storage // Primary approach: Check persona.folder_ids array if (persona.folder_ids && Array.isArray(persona.folder_ids)) { matchesFolder = persona.folder_ids.includes(selectedFolder); } // Legacy support: Check single folder ID properties if (!matchesFolder) { if (persona.folder_id === selectedFolder || persona.folderId === selectedFolder) { 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 || f.id === selectedFolder)?.name || selectedFolder}`); if (selectedFolder !== DEFAULT_FOLDER_ID) { const folder = folders.find(f => f._id === selectedFolder || f.id === selectedFolder); if (folder) { // Count personas that belong to this folder (persona-centric approach) const personasInFolder = personas.filter(p => { // Check folder_ids array if (p.folder_ids && Array.isArray(p.folder_ids)) { return p.folder_ids.includes(selectedFolder); } // Check legacy single folder properties return p.folder_id === selectedFolder || p.folderId === selectedFolder; }); console.log(`Folder details: ${folder.name}, ID: ${folder._id}, Contains: ${personasInFolder.length} personas`); console.log(`Personas in this folder:`, personasInFolder.map(p => p.name)); } } 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 will be triggered by the useEffect watching selectedParticipants return newSelection; }); }; // Asset upload is now handled by AssetUploader component // This function is no longer needed // 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); // Only update the discussion guide state if we're not currently editing // This prevents re-renders that would cause focus loss during editing if (!isEditingGuide) { setDiscussionGuide(updatedGuide); toast.success('Discussion guide updated', { description: 'Your changes have been saved.' }); } else { // During editing, just update the ref so the latest version is available discussionGuideRef.current = updatedGuide; console.log('📝 Skipping discussionGuide state update during editing to preserve focus'); } }, [isEditingGuide]); // Handle editing state changes from DiscussionGuideViewer const handleEditingStateChange = useCallback((editing: boolean) => { console.log('📝 Discussion guide editing state changed:', editing); setIsEditingGuide(editing); // When editing ends, update the discussion guide state with the latest version if (!editing && discussionGuideRef.current) { console.log('📝 Updating discussionGuide state after editing ended'); setDiscussionGuide(discussionGuideRef.current); } }, []); // Stable dummy callbacks for optional props const handleSectionSelect = 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 ( <> {/* Auto-save Status Indicator */}

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