semblance-dev/src/components/FocusGroupModerator.tsx

2116 lines
84 KiB
TypeScript

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<string | any | null>(null);
const [draftFocusGroupId, setDraftFocusGroupId] = useState<string | null>(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<string[]>([]);
const [backendAssets, setBackendAssets] = useState<any[]>([]);
const [isLoadingAssets, setIsLoadingAssets] = useState(false);
const [personas, setPersonas] = useState<any[]>([]);
const [isLoadingPersonas, setIsLoadingPersonas] = useState(false);
// Download state
const [isDownloadingGuide, setIsDownloadingGuide] = useState(false);
// Folder state
const [folders, setFolders] = useState<Folder[]>([]);
const [selectedFolder, setSelectedFolder] = useState<string>(DEFAULT_FOLDER_ID);
const [isCreatingFolder, setIsCreatingFolder] = useState(false);
const [newFolderName, setNewFolderName] = useState('');
const [folderToRename, setFolderToRename] = useState<Folder | null>(null);
const [renameFolderName, setRenameFolderName] = useState('');
// Filter state
const [searchTerm, setSearchTerm] = useState('');
const [isFilterOpen, setIsFilterOpen] = useState(false);
const [activeFilters, setActiveFilters] = useState<FilterState>({
age: [],
gender: [],
occupation: [],
location: [],
techSavviness: [],
ethnicity: [],
});
const [workingFilters, setWorkingFilters] = useState<FilterState>({
age: [],
gender: [],
occupation: [],
location: [],
techSavviness: [],
ethnicity: [],
});
// Auto-save state management
const [autoSaveStatus, setAutoSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
const [lastSavedData, setLastSavedData] = useState<any>(null);
const [saveRetryCount, setSaveRetryCount] = useState(0);
const debouncedSaveTimerRef = useRef<NodeJS.Timeout | null>(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<string>(),
gender: new Set<string>(),
occupation: new Set<string>(),
location: new Set<string>(),
techSavviness: new Set<string>(),
ethnicity: new Set<string>(),
};
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<z.infer<typeof formSchema>>({
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<string>('');
const prevSelectedParticipantsRef = useRef<string>('');
// 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 (
<div className={`fixed top-16 left-1/2 transform -translate-x-1/2 z-50 px-3 py-1 rounded-md text-sm font-medium border shadow-sm ${config.className}`}>
{config.text}
</div>
);
};
// Function to generate a discussion guide via the API
const generateDiscussionGuide = async (values: z.infer<typeof formSchema>, focusGroupId?: string): Promise<string> => {
// 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<typeof formSchema>) {
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 */}
<SaveStatusIndicator />
<div className="glass-panel rounded-xl p-6">
<div className="flex items-center gap-2 mb-6">
<MessageSquare className="h-5 w-5 text-primary" />
<h2 className="font-sf text-xl font-semibold">AI Focus Group Moderator</h2>
</div>
{/* Progress Bar - Consistent top placement for discussion guide generation */}
{isGenerating && (
<div className="mb-6">
<GenerationProgressBar
isActive={isGenerating}
isComplete={guideGenerationComplete}
hasError={guideGenerationError}
label="Generating discussion guide"
onComplete={handleGuideProgressComplete}
/>
</div>
)}
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-3 mb-6">
<TabsTrigger value="setup">Setup</TabsTrigger>
<TabsTrigger value="review">Review & Edit</TabsTrigger>
<TabsTrigger value="participants">Participants</TabsTrigger>
</TabsList>
<TabsContent value="setup">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="focusGroupName"
render={({ field }) => (
<FormItem>
<FormLabel>Focus Group Name</FormLabel>
<FormControl>
<Input placeholder="e.g., Mobile App UX Evaluation" {...field} />
</FormControl>
<FormDescription>
Give your focus group a descriptive name
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<FormField
control={form.control}
name="researchBrief"
render={({ field }) => (
<FormItem>
<FormLabel>Research Brief</FormLabel>
<FormControl>
<Textarea
placeholder="Describe your research objectives..."
className="h-36"
{...field}
/>
</FormControl>
<FormDescription>
Provide context about what you want to learn
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-6">
<FormField
control={form.control}
name="discussionTopics"
render={({ field }) => (
<FormItem>
<FormLabel>Discussion Topics</FormLabel>
<FormControl>
<Textarea
placeholder="List main topics to cover, separated by commas"
className="h-24"
{...field}
/>
</FormControl>
<FormDescription>
E.g., User experience, feature preferences, pain points
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="duration"
render={({ field }) => (
<FormItem>
<FormLabel>Duration (minutes)</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select duration" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="30">30 minutes</SelectItem>
<SelectItem value="45">45 minutes</SelectItem>
<SelectItem value="60">60 minutes</SelectItem>
<SelectItem value="90">90 minutes</SelectItem>
<SelectItem value="120">120 minutes</SelectItem>
</SelectContent>
</Select>
<FormDescription>
How long should the focus group session last?
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="llm_model"
render={({ field }) => (
<FormItem>
<FormLabel>AI Model</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select AI model" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="gemini-2.5-pro">Gemini 2.5 Pro</SelectItem>
<SelectItem value="gpt-4.1">GPT-4.1</SelectItem>
<SelectItem value="gpt-5">GPT-5</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Choose which AI model to use for generating responses and discussion guides
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* GPT-5 specific parameters - only show when GPT-5 is selected */}
{form.watch("llm_model") === "gpt-5" && (
<>
{/* Reasoning Effort Parameter */}
<FormField
control={form.control}
name="reasoning_effort"
render={({ field }) => (
<FormItem>
<FormLabel>Reasoning Effort</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select reasoning effort" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="minimal">Minimal - Fast responses</SelectItem>
<SelectItem value="low">Low - Quick thinking</SelectItem>
<SelectItem value="medium">Medium - Balanced (default)</SelectItem>
<SelectItem value="high">High - Deep reasoning</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Controls how much time GPT-5 spends thinking before responding
</FormDescription>
<div className="text-xs text-amber-600 font-medium mt-1">
Controls how much time GPT-5 spends thinking before responding
</div>
<FormMessage />
</FormItem>
)}
/>
{/* Verbosity Parameter */}
<FormField
control={form.control}
name="verbosity"
render={({ field }) => (
<FormItem>
<FormLabel>Response Verbosity</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select verbosity level" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="low">Low - Concise responses</SelectItem>
<SelectItem value="medium">Medium - Balanced length (default)</SelectItem>
<SelectItem value="high">High - Detailed responses</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Controls how detailed and lengthy GPT-5's responses will be
</FormDescription>
<div className="text-xs text-amber-600 font-medium mt-1">
Controls how much time GPT-5 spends thinking before responding
</div>
<FormMessage />
</FormItem>
)}
/>
</>
)}
</div>
</div>
{/* Asset Uploader */}
<div>
<label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 mb-2 block">
Creative Assets (Optional)
</label>
<AssetUploader
focusGroupId={draftFocusGroupId}
disabled={!draftFocusGroupId}
onUploadComplete={(assets) => {
setBackendAssets(assets);
// Trigger auto-save to update focus group metadata
}}
onUploadError={(error) => {
console.error('Asset upload error:', error);
// Error handling is already done in AssetUploader component
}}
onAssetsChange={(assets) => {
setBackendAssets(assets);
}}
maxAssets={10}
allowedTypes={['image/*', 'application/pdf', 'video/*']}
label="Upload Creative Assets"
description="Upload images, mockups, or product designs for testing"
/>
<p className="text-sm text-muted-foreground mt-2">
Upload visuals that you want feedback on during the session
</p>
</div>
<div className="space-y-3">
<div className="flex justify-end">
<Button
type="submit"
disabled={isGenerating}
className="min-w-32"
>
<MessageSquare className="mr-2 h-4 w-4" />
{isGenerating ? "Generating..." : "Generate Discussion Guide"}
</Button>
</div>
</div>
</form>
</Form>
</TabsContent>
<TabsContent value="review">
<div className="space-y-6">
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<h3 className="font-sf text-lg font-medium">AI-Generated Discussion Guide</h3>
{discussionGuide && (
<Badge variant="outline" className="text-xs">
{isJsonFormat(discussionGuide) ? 'Structured JSON' : 'Legacy Text'}
</Badge>
)}
</div>
</div>
<div className="prose max-w-none">
{discussionGuide ? (
<DiscussionGuideViewer
discussionGuide={discussionGuide}
showProgress={false}
collapsible={true}
defaultExpanded={true}
className="border-0"
onSave={handleSaveDiscussionGuide}
onDownload={handleDownloadDiscussionGuide}
onSectionSelect={handleSectionSelect}
isDownloading={isDownloadingGuide}
focusGroupId={draftFocusGroupId}
onEditingChange={handleEditingStateChange}
/>
) : (
<div className="bg-slate-50 p-4 rounded border text-center text-slate-600">
{guideGenerationError ? (
<div>
<p className="mb-2">Discussion guide generation failed.</p>
<p className="text-sm">Go back to the <strong>Setup</strong> tab and try generating again. Check your inputs and try a different AI model if the issue persists.</p>
</div>
) : (
<p>No discussion guide generated yet. Complete the setup and click "Generate Discussion Guide" to create one.</p>
)}
</div>
)}
</div>
</CardContent>
</Card>
{backendAssets.length > 0 && (
<Card>
<CardContent className="p-6">
<h3 className="font-sf text-lg font-medium mb-4">Creative Assets</h3>
<div className="space-y-3">
<p className="text-sm text-slate-600">
Assets that will be referenced in the discussion guide:
</p>
<div className="space-y-2">
{backendAssets.map((asset, index) => {
const displayName = asset.user_assigned_name || `Asset ${index + 1}`;
return (
<div key={asset.filename} className="flex items-center gap-3 p-3 border rounded-lg bg-slate-50">
{/* Asset preview */}
<div className="w-10 h-10 bg-slate-200 rounded flex items-center justify-center flex-shrink-0">
{asset.mime_type?.startsWith('image/') ? (
<img
src={focusGroupsApi.getAssetUrl(draftFocusGroupId, asset.filename)}
alt={displayName}
className="max-h-full max-w-full object-contain rounded"
/>
) : (
<FileText className="h-6 w-6 text-slate-600" />
)}
</div>
{/* Asset name */}
<div className="flex-grow">
<p className="font-medium text-sm">"{displayName}"</p>
<p className="text-xs text-slate-500">Will appear in discussion guide</p>
</div>
</div>
);
})}
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<p className="text-sm text-blue-700">
<strong>Note:</strong> To rename assets, go back to the Setup tab and click the edit icon next to each asset.
</p>
</div>
</div>
</CardContent>
</Card>
)}
<div className="flex justify-between">
<Button
variant="outline"
onClick={() => setActiveTab('setup')}
>
Back to Setup
</Button>
<Button
onClick={() => setActiveTab('participants')}
>
Select Participants
<Users className="ml-2 h-4 w-4" />
</Button>
</div>
</div>
</TabsContent>
<TabsContent value="participants">
<div className="flex flex-col md:flex-row gap-6">
{/* Folder sidebar */}
<div className="w-full md:w-64 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium">Folders</h3>
<Button
variant="ghost"
size="sm"
onClick={() => {
console.log("Clicked 'Create new folder' button");
setIsCreatingFolder(true);
}}
className="h-7 w-7 p-0"
>
<FolderPlus className="h-4 w-4" />
</Button>
</div>
<div className="space-y-1">
<button
onClick={() => {
console.log("Clicked 'All Personas' folder");
console.log("All personas count:", personas.length);
setSelectedFolder(DEFAULT_FOLDER_ID);
// Log filtered personas after state update
setTimeout(() => {
console.log(`Will show all ${personas.length} personas`);
}, 0);
}}
className={`w-full flex items-center space-x-2 px-3 py-2 text-sm rounded-md text-left transition-colors ${
selectedFolder === DEFAULT_FOLDER_ID
? 'bg-primary/10 text-primary font-medium'
: 'hover:bg-slate-100'
}`}
>
<Folder className="h-4 w-4" />
<span>All Personas</span>
</button>
{folders.map(folder => (
<div
key={folder._id}
className="flex items-center justify-between group"
>
{folderToRename && folderToRename._id === folder._id ? (
<div className="flex-1 flex items-center px-3 py-2 space-x-2">
<Folder className="h-4 w-4" />
<Input
value={renameFolderName}
onChange={e => setRenameFolderName(e.target.value)}
placeholder="Folder name"
className="h-7 text-sm"
autoFocus
onKeyDown={e => {
if (e.key === 'Enter') {
completeRenameFolder();
} else if (e.key === 'Escape') {
cancelRenameFolder();
}
}}
/>
<Button
size="sm"
variant="ghost"
onClick={() => {
console.log(`Confirming folder rename: "${folderToRename?.name}" to "${renameFolderName}"`);
completeRenameFolder();
}}
className="h-7 w-7 p-0"
>
<Check className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => {
console.log(`Cancelling rename of folder: "${folderToRename?.name}"`);
cancelRenameFolder();
}}
className="h-7 w-7 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
) : (
<>
<button
onClick={() => {
console.log(`Clicked folder: ${folder.name} (ID: ${folder._id})`);
// Count personas in 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(folder._id);
}
// Check legacy single folder properties
return p.folder_id === folder._id || p.folderId === folder._id;
});
console.log(`Current persona count in folder: ${personasInFolder.length}`);
console.log("All personas count:", personas.length);
setSelectedFolder(folder._id);
// Log filtered personas after state update
setTimeout(() => {
console.log(`Will show ${personasInFolder.length} personas after filtering`);
console.log("Filtered personas:", personasInFolder.map(p => p.name));
}, 0);
}}
className={`flex-1 flex items-center space-x-2 px-3 py-2 text-sm rounded-md text-left transition-colors ${
selectedFolder === folder._id
? 'bg-primary/10 text-primary font-medium'
: 'hover:bg-slate-100'
}`}
>
<Folder className="h-4 w-4" />
<span>{folder.name}</span>
<span className="text-muted-foreground text-xs ml-auto">
{personas.filter(p => {
if (p.folder_ids && Array.isArray(p.folder_ids)) {
return p.folder_ids.includes(folder._id);
}
return p.folder_id === folder._id || p.folderId === folder._id;
}).length}
</span>
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 opacity-0 group-hover:opacity-100"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => {
console.log(`Initiating rename for folder: ${folder.name} (ID: ${folder.id})`);
startRenameFolder(folder);
}}>
Rename
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)}
</div>
))}
{isCreatingFolder && (
<div className="flex items-center px-3 py-2 space-x-2">
<div className="flex-1 flex items-center space-x-2">
<Folder className="h-4 w-4" />
<Input
value={newFolderName}
onChange={e => setNewFolderName(e.target.value)}
placeholder="Folder name"
className="h-7 text-sm"
autoFocus
onKeyDown={e => {
if (e.key === 'Enter') {
createNewFolder();
} else if (e.key === 'Escape') {
cancelFolderCreation();
}
}}
/>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => {
console.log(`Confirming creation of new folder: "${newFolderName}"`);
createNewFolder();
}}
className="h-7 w-7 p-0"
>
<Check className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => {
console.log("Cancelling folder creation");
cancelFolderCreation();
}}
className="h-7 w-7 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
)}
</div>
</div>
{/* Main content */}
<div className="flex-1">
<Card className="mb-4">
<CardContent className="p-6">
<div className="flex flex-col space-y-6">
<div className="flex items-center justify-between">
<h3 className="font-sf text-lg font-medium">Select Participants</h3>
<div className="flex items-center">
<Users className="h-5 w-5 mr-2 text-muted-foreground" />
<span className="text-sm font-medium">
{selectedParticipants.length} of {filteredPersonas.length} selected
</span>
</div>
</div>
{/* Search and filter bar */}
<div className="flex flex-col sm:flex-row gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
<Input
placeholder="Search personas by name, occupation, or location..."
className="pl-10 bg-white"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<Button
variant="outline"
className="flex items-center gap-2"
onClick={() => setIsFilterOpen(true)}
>
<Filter className="h-4 w-4" />
<span>Filter{Object.values(activeFilters).some(arr => arr.length > 0) ? ` (${Object.values(activeFilters).reduce((count, arr) => count + arr.length, 0)})` : ''}</span>
</Button>
</div>
{isLoadingPersonas ? (
<div className="flex justify-center items-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
) : filteredPersonas.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredPersonas.map((persona) => {
// Ensure we have a valid ID (use MongoDB _id if available)
const personaId = persona._id || persona.id;
return (
<UserCard
key={personaId}
user={{
id: personaId,
_id: persona._id,
name: persona.name,
age: persona.age,
gender: persona.gender,
occupation: persona.occupation,
location: persona.location || 'Unknown',
techSavviness: persona.techSavviness || 50,
personality: persona.personality || 'No description available',
oceanTraits: persona.oceanTraits,
qualitativeAttributes: persona.qualitativeAttributes,
topPersonalityTraits: persona.topPersonalityTraits,
aiSynthesizedBio: persona.aiSynthesizedBio,
}}
selected={selectedParticipants.includes(personaId)}
onSelectionToggle={() => handleParticipantSelection(personaId)}
onViewDetails={handlePersonaViewDetails}
/>
);
})}
</div>
) : (
<div className="text-center py-12">
<p className="text-muted-foreground">No personas available matching your criteria.</p>
</div>
)}
</div>
</CardContent>
</Card>
<div className="flex justify-between">
<Button
variant="outline"
onClick={() => setActiveTab('review')}
>
Back to Review
</Button>
<Button
onClick={handleStartFocusGroup}
disabled={selectedParticipants.length < 1 || !discussionGuide}
>
<Play className="mr-2 h-4 w-4" />
Start Focus Group Session
</Button>
</div>
</div>
</div>
{/* Filter Dialog */}
<Dialog
open={isFilterOpen}
onOpenChange={(open) => {
if (!open) {
setIsFilterOpen(false);
} else {
setIsFilterOpen(open);
// When opening, set working filters to active filters
setWorkingFilters({...activeFilters});
}
}}
>
<DialogContent
className="max-w-4xl max-h-[80vh] overflow-y-auto"
>
<DialogHeader>
<DialogTitle>Filter Personas</DialogTitle>
<DialogDescription>
Select attributes to filter personas by. Multiple selections within a category use OR logic,
different categories use AND logic.
</DialogDescription>
</DialogHeader>
<div className="py-4 space-y-6">
{/* Display number of active filters */}
{Object.values(workingFilters).some(arr => arr.length > 0) && (
<div className="bg-muted/30 p-3 rounded-md">
<p className="text-sm text-muted-foreground">
{Object.values(workingFilters).reduce((count, arr) => count + arr.length, 0)} active filters
</p>
</div>
)}
{(() => {
// Get filter options from all personas (for default state)
const defaultFilterOptions = getFilterOptions(personas);
// Check if any filters are active
const noActiveFilters = Object.values(workingFilters).every(values => values.length === 0);
// Render filter section
const renderFilterSection = (
title: string,
category: keyof FilterState,
columns: number = 1
) => {
// Get options for this category based on other active filters
const filteredOptions = noActiveFilters ?
defaultFilterOptions[category] :
getFilteredOptions(category)[category];
// Include already selected options that might be filtered out
const selectedOptions = workingFilters[category];
// Combine filtered options with selected options
const combinedOptions = [...new Set([...filteredOptions, ...selectedOptions])].sort();
if (combinedOptions.length === 0) return null;
return (
<div className="mb-6">
<h3 className="text-sm font-medium mb-3">{title}</h3>
<div className={`grid grid-cols-1 ${columns === 2 ? 'sm:grid-cols-2' : columns === 3 ? 'sm:grid-cols-2 md:grid-cols-3' : ''} gap-2`}>
{combinedOptions.map(option => {
const isSelected = workingFilters[category].includes(option);
const isAvailable = filteredOptions.includes(option);
return (
<div
key={option}
className={`flex items-center space-x-2 ${!isAvailable && !isSelected ? 'opacity-50' : ''}`}
>
<Checkbox
id={`${category}-${option}`}
checked={isSelected}
onCheckedChange={() => toggleFilter(category, option)}
disabled={!isAvailable && !isSelected}
/>
<Label
htmlFor={`${category}-${option}`}
className="truncate overflow-hidden"
>
{option}
{isSelected && !isAvailable && (
<span className="ml-1 text-xs text-muted-foreground">(no matches)</span>
)}
</Label>
</div>
);
})}
</div>
</div>
);
};
return (
<>
{renderFilterSection('Gender', 'gender', 3)}
{renderFilterSection('Age', 'age', 3)}
{renderFilterSection('Ethnicity', 'ethnicity', 2)}
{renderFilterSection('Location', 'location', 2)}
{renderFilterSection('Occupation', 'occupation', 2)}
{renderFilterSection('Tech Savviness', 'techSavviness', 3)}
{/* Boolean Attributes */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
</div>
</>
);
})()}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={handleResetFilters}
>
Reset
</Button>
<Button onClick={applyFilters}>
Apply Filters
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</TabsContent>
</Tabs>
</div>
</>
);
}