semblance_backup/src/components/FocusGroupModerator.tsx
2025-08-05 17:38:13 -05:00

1831 lines
71 KiB
TypeScript

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