2116 lines
84 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|