Refactor FocusGroupModerator into smaller components and hooks

Extract business logic and UI into reusable pieces:

Custom Hooks:
- useFocusGroupAutoSave: debounced auto-save with retry logic
- useFolderManagement: folder CRUD operations
- usePersonaFiltering: filter state and persona filtering
- useDiscussionGuideGeneration: guide generation and progress

UI Components:
- SaveStatusIndicator: auto-save status display
- FolderSidebar: folder list and management
- PersonaFilterDialog: persona filter modal
- CopyGuideDialog: copy guide from other focus groups

Tab Components:
- SetupTab: form and asset uploader
- ReviewTab: discussion guide viewer
- ParticipantsTab: persona selection grid

Reduces FocusGroupModerator from 2,396 to ~600 lines (75% reduction).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
michael 2025-12-04 09:11:21 -06:00
parent 4bf325483e
commit 22b3ec19a5
12 changed files with 2363 additions and 2178 deletions

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,182 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
Search,
FileText,
Loader2,
} from 'lucide-react';
import { toast } from 'sonner';
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { focusGroupsApi } from '@/lib/api';
interface CopyGuideDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onCopyGuide: (focusGroup: any) => void;
excludeFocusGroupId?: string | null;
}
export function CopyGuideDialog({
open,
onOpenChange,
onCopyGuide,
excludeFocusGroupId,
}: CopyGuideDialogProps) {
const [searchTerm, setSearchTerm] = useState('');
const [availableFocusGroups, setAvailableFocusGroups] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(false);
// Fetch available focus groups when dialog opens
const fetchAvailableFocusGroups = useCallback(async () => {
try {
setIsLoading(true);
const response = await focusGroupsApi.getAll();
// Filter to only include focus groups that have discussion guides
const focusGroupsWithGuides = response.data.filter((fg: any) =>
fg.discussionGuide &&
fg.discussionGuide !== null &&
fg.discussionGuide !== '' &&
fg._id !== excludeFocusGroupId
);
setAvailableFocusGroups(focusGroupsWithGuides);
} catch (error) {
console.error("Error fetching focus groups:", error);
toast.error("Failed to load available focus groups");
} finally {
setIsLoading(false);
}
}, [excludeFocusGroupId]);
// Fetch when dialog opens
useEffect(() => {
if (open) {
fetchAvailableFocusGroups();
}
}, [open, fetchAvailableFocusGroups]);
// Filter by search term
const filteredFocusGroups = availableFocusGroups.filter(fg =>
!searchTerm || fg.name.toLowerCase().includes(searchTerm.toLowerCase())
);
const handleSelectFocusGroup = (focusGroup: any) => {
onCopyGuide(focusGroup);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Copy Discussion Guide</DialogTitle>
<DialogDescription>
Select a focus group to copy its discussion guide from. Only focus groups with existing discussion guides are shown.
</DialogDescription>
</DialogHeader>
<div className="py-4 space-y-4">
{/* Search box */}
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
<Input
placeholder="Search focus groups by name..."
className="pl-10"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
{/* Loading state */}
{isLoading && (
<div className="flex justify-center items-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<span className="ml-2 text-muted-foreground">Loading focus groups...</span>
</div>
)}
{/* Focus groups list */}
{!isLoading && (
<div className="space-y-3 max-h-96 overflow-y-auto">
{filteredFocusGroups.length > 0 ? (
filteredFocusGroups.map((focusGroup) => (
<Card
key={focusGroup._id}
className="p-4 cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => handleSelectFocusGroup(focusGroup)}
>
<div className="space-y-2">
<div className="flex items-center justify-between">
<h3 className="font-medium text-sm">{focusGroup.name}</h3>
<Badge variant="outline" className="text-xs">
{focusGroup.participants_count || focusGroup.participants?.length || 0} participants
</Badge>
</div>
{focusGroup.description && (
<p className="text-xs text-muted-foreground line-clamp-2">
{focusGroup.description}
</p>
)}
<div className="flex items-center gap-4 text-xs text-muted-foreground">
{focusGroup.created_at && (
<span>Created {new Date(focusGroup.created_at).toLocaleDateString()}</span>
)}
{focusGroup.duration && (
<span>{focusGroup.duration} minutes</span>
)}
{focusGroup.status && (
<Badge variant="secondary" className="text-xs">
{focusGroup.status}
</Badge>
)}
</div>
</div>
</Card>
))
) : (
<div className="text-center py-8">
<FileText className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<p className="text-muted-foreground">
{searchTerm
? "No focus groups match your search criteria."
: "No focus groups with discussion guides found."}
</p>
{searchTerm && (
<Button
variant="link"
size="sm"
onClick={() => setSearchTerm('')}
className="mt-2"
>
Clear search
</Button>
)}
</div>
)}
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default CopyGuideDialog;

View file

@ -0,0 +1,217 @@
import React from 'react';
import {
Folder,
FolderPlus,
MoreHorizontal,
Check,
X,
} from 'lucide-react';
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu';
import { Folder as FolderType, DEFAULT_FOLDER_ID } from '@/hooks/useFolderManagement';
interface FolderSidebarProps {
folders: FolderType[];
selectedFolder: string;
onSelectFolder: (id: string) => void;
personas: any[];
// Folder creation
isCreatingFolder: boolean;
onStartCreateFolder: () => void;
newFolderName: string;
onNewFolderNameChange: (name: string) => void;
onConfirmCreateFolder: () => void;
onCancelCreateFolder: () => void;
// Folder renaming
folderToRename: FolderType | null;
renameFolderName: string;
onRenameFolderNameChange: (name: string) => void;
onStartRenameFolder: (folder: FolderType) => void;
onConfirmRenameFolder: () => void;
onCancelRenameFolder: () => void;
}
// Helper to count personas in a folder
function countPersonasInFolder(personas: any[], folderId: string): number {
return personas.filter(p => {
if (p.folder_ids && Array.isArray(p.folder_ids)) {
return p.folder_ids.includes(folderId);
}
return p.folder_id === folderId || p.folderId === folderId;
}).length;
}
export function FolderSidebar({
folders,
selectedFolder,
onSelectFolder,
personas,
isCreatingFolder,
onStartCreateFolder,
newFolderName,
onNewFolderNameChange,
onConfirmCreateFolder,
onCancelCreateFolder,
folderToRename,
renameFolderName,
onRenameFolderNameChange,
onStartRenameFolder,
onConfirmRenameFolder,
onCancelRenameFolder,
}: FolderSidebarProps) {
return (
<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={onStartCreateFolder}
className="h-7 w-7 p-0"
>
<FolderPlus className="h-4 w-4" />
</Button>
</div>
<div className="space-y-1">
{/* All Personas folder */}
<button
onClick={() => onSelectFolder(DEFAULT_FOLDER_ID)}
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>
{/* Individual folders */}
{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 => onRenameFolderNameChange(e.target.value)}
placeholder="Folder name"
className="h-7 text-sm"
autoFocus
onKeyDown={e => {
if (e.key === 'Enter') {
onConfirmRenameFolder();
} else if (e.key === 'Escape') {
onCancelRenameFolder();
}
}}
/>
<Button
size="sm"
variant="ghost"
onClick={onConfirmRenameFolder}
className="h-7 w-7 p-0"
>
<Check className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={onCancelRenameFolder}
className="h-7 w-7 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
) : (
<>
<button
onClick={() => onSelectFolder(folder._id)}
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">
{countPersonasInFolder(personas, folder._id)}
</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={() => onStartRenameFolder(folder)}>
Rename
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)}
</div>
))}
{/* New folder input */}
{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 => onNewFolderNameChange(e.target.value)}
placeholder="Folder name"
className="h-7 text-sm"
autoFocus
onKeyDown={e => {
if (e.key === 'Enter') {
onConfirmCreateFolder();
} else if (e.key === 'Escape') {
onCancelCreateFolder();
}
}}
/>
</div>
<Button
size="sm"
variant="ghost"
onClick={onConfirmCreateFolder}
className="h-7 w-7 p-0"
>
<Check className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={onCancelCreateFolder}
className="h-7 w-7 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
)}
</div>
</div>
);
}
export default FolderSidebar;

View file

@ -0,0 +1,159 @@
import React from 'react';
import {
Search,
Filter,
Users,
Loader2,
Play,
} from 'lucide-react';
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent } from "@/components/ui/card";
import UserCard from "@/components/UserCard";
import { Persona } from "@/types/persona";
import { FilterState } from '@/hooks/usePersonaFiltering';
interface ParticipantsTabProps {
personas: any[];
filteredPersonas: any[];
selectedParticipants: string[];
onParticipantToggle: (id: string) => void;
onViewDetails: (persona: Persona) => void;
searchTerm: string;
onSearchChange: (term: string) => void;
activeFilters: FilterState;
onOpenFilter: () => void;
isLoading: boolean;
// Folder sidebar passed as children or render prop
folderSidebar: React.ReactNode;
// Navigation
onNavigateToReview: () => void;
onStartFocusGroup: () => void;
canStart: boolean;
}
export function ParticipantsTab({
filteredPersonas,
selectedParticipants,
onParticipantToggle,
onViewDetails,
searchTerm,
onSearchChange,
activeFilters,
onOpenFilter,
isLoading,
folderSidebar,
onNavigateToReview,
onStartFocusGroup,
canStart,
}: ParticipantsTabProps) {
// Count active filters
const activeFilterCount = Object.values(activeFilters).reduce((count, arr) => count + arr.length, 0);
return (
<div className="flex flex-col md:flex-row gap-6">
{/* Folder sidebar */}
{folderSidebar}
{/* 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) => onSearchChange(e.target.value)}
/>
</div>
<Button
variant="outline"
className="flex items-center gap-2"
onClick={onOpenFilter}
>
<Filter className="h-4 w-4" />
<span>Filter{activeFilterCount > 0 ? ` (${activeFilterCount})` : ''}</span>
</Button>
</div>
{isLoading ? (
<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-2 gap-4">
{filteredPersonas.map((persona) => {
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={() => onParticipantToggle(personaId)}
onViewDetails={onViewDetails}
/>
);
})}
</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={onNavigateToReview}
>
Back to Review
</Button>
<Button
onClick={onStartFocusGroup}
disabled={!canStart}
>
<Play className="mr-2 h-4 w-4" />
Start Focus Group Session
</Button>
</div>
</div>
</div>
);
}
export default ParticipantsTab;

View file

@ -0,0 +1,141 @@
import React from 'react';
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { FilterState } from '@/hooks/usePersonaFiltering';
interface PersonaFilterDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
workingFilters: FilterState;
personas: any[];
getFilterOptions: (personas: any[]) => Record<keyof FilterState, string[]>;
getFilteredOptions: (category: keyof FilterState) => Record<keyof FilterState, string[]>;
onToggleFilter: (category: keyof FilterState, value: string) => void;
onApply: () => void;
onReset: () => void;
}
export function PersonaFilterDialog({
open,
onOpenChange,
workingFilters,
personas,
getFilterOptions,
getFilteredOptions,
onToggleFilter,
onApply,
onReset,
}: PersonaFilterDialogProps) {
// 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={() => onToggleFilter(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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<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>
)}
{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)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onReset}>
Reset
</Button>
<Button onClick={onApply}>
Apply Filters
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default PersonaFilterDialog;

View file

@ -0,0 +1,155 @@
import React from 'react';
import {
Users,
FileText,
} from 'lucide-react';
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import DiscussionGuideViewer from './DiscussionGuideViewer';
import { focusGroupsApi } from '@/lib/api';
interface ReviewTabProps {
discussionGuide: any | null;
backendAssets: any[];
draftFocusGroupId: string | null;
onSaveGuide: (guide: any) => void;
onDownloadGuide: () => void;
isDownloading: boolean;
guideGenerationState: {
hasError: boolean;
};
onEditingChange: (editing: boolean) => void;
onNavigateToSetup: () => void;
onNavigateToParticipants: () => void;
isJsonFormat: (guide: any) => boolean;
}
export function ReviewTab({
discussionGuide,
backendAssets,
draftFocusGroupId,
onSaveGuide,
onDownloadGuide,
isDownloading,
guideGenerationState,
onEditingChange,
onNavigateToSetup,
onNavigateToParticipants,
isJsonFormat,
}: ReviewTabProps) {
// Dummy handler for section select (not used in moderator view)
const handleSectionSelect = React.useCallback(() => {}, []);
return (
<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={onSaveGuide}
onDownload={onDownloadGuide}
onSectionSelect={handleSectionSelect}
isDownloading={isDownloading}
focusGroupId={draftFocusGroupId}
onEditingChange={onEditingChange}
/>
) : (
<div className="bg-slate-50 p-4 rounded border text-center text-slate-600">
{guideGenerationState.hasError ? (
<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={onNavigateToSetup}
>
Back to Setup
</Button>
<Button onClick={onNavigateToParticipants}>
Select Participants
<Users className="ml-2 h-4 w-4" />
</Button>
</div>
</div>
);
}
export default ReviewTab;

View file

@ -0,0 +1,292 @@
import React from 'react';
import { UseFormReturn } from 'react-hook-form';
import {
MessageSquare,
Copy,
} from 'lucide-react';
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import AssetUploader from '@/components/AssetUploader';
interface SetupTabProps {
form: UseFormReturn<any>;
onSubmit: (e: React.FormEvent) => void;
isGenerating: boolean;
draftFocusGroupId: string | null;
backendAssets: any[];
onAssetsChange: (assets: any[]) => void;
onCopyGuideClick: () => void;
}
export function SetupTab({
form,
onSubmit,
isGenerating,
draftFocusGroupId,
backendAssets,
onAssetsChange,
onCopyGuideClick,
}: SetupTabProps) {
const selectedModel = form.watch("llm_model");
return (
<>
<Form {...form}>
<form onSubmit={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-3-pro-preview">Gemini 3 Pro (Slow, best for most tasks)</SelectItem>
<SelectItem value="gpt-4.1">GPT-4.1 (Fast, best for speed)</SelectItem>
<SelectItem value="gpt-5">GPT-5 (Slow, best for complex tasks)</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Choose which AI model to use for generating responses, discussion guides, and thematic analysis
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* GPT-5 specific parameters */}
{selectedModel === "gpt-5" && (
<>
<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>
)}
/>
<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>
{/* Stimulus Uploader */}
<div>
<label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 mb-2 block">
Upload Your Stimulus (if any)
</label>
<AssetUploader
focusGroupId={draftFocusGroupId}
disabled={!draftFocusGroupId}
onUploadComplete={(assets) => {
onAssetsChange(assets);
}}
onUploadError={(error) => {
console.error('Asset upload error:', error);
}}
onAssetsChange={(assets) => {
onAssetsChange(assets);
}}
maxAssets={10}
maxFileSize={10}
allowedTypes={['image/*', 'application/pdf', 'video/*', 'text/*', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document']}
label="Upload Your Stimulus"
description="Provide any files you wish the moderator to use in the focus group session. This could include mockups, designs, documents or other materials for discussion. The moderator will write a discussion guide partially around these assets. You can edit this in the next step."
enableRenaming={true}
/>
</div>
</form>
</Form>
{/* Action buttons outside of form to prevent form submission issues */}
<div className="flex justify-end gap-3 mt-6">
<Button
type="button"
variant="outline"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onCopyGuideClick();
}}
disabled={isGenerating}
className="min-w-32"
>
<Copy className="mr-2 h-4 w-4" />
Copy Discussion Guide
</Button>
<Button
type="button"
onClick={onSubmit}
disabled={isGenerating}
className="min-w-32"
>
<MessageSquare className="mr-2 h-4 w-4" />
{isGenerating ? "Generating..." : "Generate Discussion Guide"}
</Button>
</div>
</>
);
}
export default SetupTab;

View file

@ -0,0 +1,29 @@
import React from 'react';
export type SaveStatus = 'idle' | 'saving' | 'saved' | 'error';
interface SaveStatusIndicatorProps {
status: SaveStatus;
}
const statusConfig = {
idle: null,
saving: { text: 'Saving...', className: 'text-blue-600 bg-blue-50 border-blue-200' },
saved: { text: 'All changes saved', className: 'text-green-600 bg-green-50 border-green-200' },
error: { text: 'Save failed - retrying...', className: 'text-red-600 bg-red-50 border-red-200' }
};
export function SaveStatusIndicator({ status }: SaveStatusIndicatorProps) {
if (status === 'idle') return null;
const config = statusConfig[status];
if (!config) return null;
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>
);
}
export default SaveStatusIndicator;

View file

@ -0,0 +1,206 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { UseFormReturn } from 'react-hook-form';
import { toast } from 'sonner';
import { focusGroupsApi } from '@/lib/api';
import { useCancellableGeneration } from '@/hooks/useCancellableGeneration';
import { getSocket } from '@/services/websocketServiceNew';
interface DiscussionGuideGenerationOptions {
form: UseFormReturn<any>;
}
interface DiscussionGuideGenerationReturn {
discussionGuide: any | null;
setDiscussionGuide: (guide: any | null) => void;
discussionGuideRef: React.MutableRefObject<any | null>;
isEditingGuide: boolean;
setIsEditingGuide: (editing: boolean) => void;
guideGenerationState: {
isGenerating: boolean;
isComplete: boolean;
hasError: boolean;
isCancelling: boolean;
taskId: string | null;
};
guideGenerationControls: {
startGeneration: () => void;
setTaskId: (id: string) => void;
completeGeneration: () => void;
failGeneration: (error: string) => void;
cancelGeneration: () => void;
resetGeneration: () => void;
};
isGuideProgressModalOpen: boolean;
setIsGuideProgressModalOpen: (open: boolean) => void;
isDownloadingGuide: boolean;
generateDiscussionGuide: (values: any, focusGroupId?: string) => Promise<string>;
handleSaveDiscussionGuide: (updatedGuide: any) => void;
handleDownloadDiscussionGuide: () => Promise<void>;
handleEditingStateChange: (editing: boolean) => void;
handleGuideProgressComplete: () => void;
isJsonFormat: (guide: any) => boolean;
}
export function useDiscussionGuideGeneration({
form,
}: DiscussionGuideGenerationOptions): DiscussionGuideGenerationReturn {
const socket = getSocket();
const [guideGenerationState, guideGenerationControls] = useCancellableGeneration('discussion guide generation', socket);
const [isGuideProgressModalOpen, setIsGuideProgressModalOpen] = useState(false);
const [discussionGuide, setDiscussionGuide] = useState<any | null>(null);
const [isEditingGuide, setIsEditingGuide] = useState(false);
const [isDownloadingGuide, setIsDownloadingGuide] = useState(false);
// Ref to access current discussionGuide in callbacks without adding it as dependency
const discussionGuideRef = useRef(discussionGuide);
discussionGuideRef.current = discussionGuide;
// Helper function to determine if discussion guide is JSON format
const isJsonFormat = useCallback((guide: any): boolean => {
return guide && typeof guide === 'object' && guide.title && guide.sections;
}, []);
// Function to generate a discussion guide via the API
const generateDiscussionGuide = useCallback(async (values: any, focusGroupId?: string): Promise<string> => {
guideGenerationControls.startGeneration();
setIsGuideProgressModalOpen(true);
try {
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
};
const response = focusGroupId
? await focusGroupsApi.generateDiscussionGuideForGroup(focusGroupId, requestData)
: await focusGroupsApi.generateDiscussionGuide(requestData);
if (response.data?.task_id) {
guideGenerationControls.setTaskId(response.data.task_id);
}
if (response.data && response.data.discussionGuide) {
guideGenerationControls.completeGeneration();
return response.data.discussionGuide;
} else {
throw new Error("Failed to generate discussion guide");
}
} catch (error: any) {
if (error.response?.status === 499) {
return '';
}
console.error("Error generating discussion guide:", error);
guideGenerationControls.failGeneration(error.message || 'Failed to generate discussion guide');
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, focusGroupId)
}
});
} else {
toast.error("Failed to generate discussion guide", {
description: errorMessage,
action: {
label: "Retry",
onClick: () => generateDiscussionGuide(values, focusGroupId)
}
});
}
throw error;
}
}, [guideGenerationControls]);
const handleGuideProgressComplete = useCallback(() => {
setIsGuideProgressModalOpen(false);
guideGenerationControls.resetGeneration();
}, [guideGenerationControls]);
// Stable callback for saving discussion guide changes
const handleSaveDiscussionGuide = useCallback((updatedGuide: any) => {
if (!isEditingGuide) {
setDiscussionGuide(updatedGuide);
toast.success('Discussion guide updated', {
description: 'Your changes have been saved.'
});
} else {
discussionGuideRef.current = updatedGuide;
}
}, [isEditingGuide]);
// Handle editing state changes from DiscussionGuideViewer
const handleEditingStateChange = useCallback((editing: boolean) => {
setIsEditingGuide(editing);
if (!editing && discussionGuideRef.current) {
setDiscussionGuide(discussionGuideRef.current);
}
}, []);
// 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 {
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]);
return {
discussionGuide,
setDiscussionGuide,
discussionGuideRef,
isEditingGuide,
setIsEditingGuide,
guideGenerationState,
guideGenerationControls,
isGuideProgressModalOpen,
setIsGuideProgressModalOpen,
isDownloadingGuide,
generateDiscussionGuide,
handleSaveDiscussionGuide,
handleDownloadDiscussionGuide,
handleEditingStateChange,
handleGuideProgressComplete,
isJsonFormat,
};
}

View file

@ -0,0 +1,203 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { UseFormReturn } from 'react-hook-form';
import { toast } from 'sonner';
import { focusGroupsApi } from '@/lib/api';
export type AutoSaveStatus = 'idle' | 'saving' | 'saved' | 'error';
interface AutoSaveData {
name: string;
description: string;
objective: string;
topic: string;
duration: number;
llm_model: string;
reasoning_effort: string;
verbosity: string;
participants: string[];
participants_count: number;
status: string;
date: string;
uploadedAssets: string[];
}
interface UseFocusGroupAutoSaveOptions {
form: UseFormReturn<any>;
selectedParticipants: string[];
backendAssets: any[];
draftFocusGroupId: string | null;
draftToEdit: any | null;
activeTab: string;
onDraftIdChange: (id: string) => void;
}
interface UseFocusGroupAutoSaveReturn {
autoSaveStatus: AutoSaveStatus;
lastSavedData: AutoSaveData | null;
setLastSavedData: (data: AutoSaveData | null) => void;
isLoadingDraft: boolean;
setIsLoadingDraft: (loading: boolean) => void;
}
export function useFocusGroupAutoSave({
form,
selectedParticipants,
backendAssets,
draftFocusGroupId,
draftToEdit,
activeTab,
onDraftIdChange,
}: UseFocusGroupAutoSaveOptions): UseFocusGroupAutoSaveReturn {
const [autoSaveStatus, setAutoSaveStatus] = useState<AutoSaveStatus>('idle');
const [lastSavedData, setLastSavedData] = useState<AutoSaveData | null>(null);
const [saveRetryCount, setSaveRetryCount] = useState(0);
const debouncedSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
const isSavingRef = useRef(false);
const isLoadingDraftRef = useRef(false);
// Use refs to track previous values to prevent unnecessary saves
const prevWatchedFieldsRef = useRef<string>('');
const prevSelectedParticipantsRef = useRef<string>('');
// Expose loading draft state
const setIsLoadingDraft = useCallback((loading: boolean) => {
isLoadingDraftRef.current = loading;
}, []);
// Simplified auto-save trigger function
const triggerAutoSave = useCallback(() => {
if (activeTab !== 'setup' || isLoadingDraftRef.current) return;
// Clear existing debounced timer
if (debouncedSaveTimerRef.current) {
clearTimeout(debouncedSaveTimerRef.current);
}
// Schedule debounced save
debouncedSaveTimerRef.current = setTimeout(async () => {
if (isSavingRef.current) return;
const values = form.getValues();
const currentData: AutoSaveData = {
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-3-pro-preview',
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 {
let focusGroupId = draftFocusGroupId || (draftToEdit?.id || draftToEdit?._id);
if (!focusGroupId) {
const response = await focusGroupsApi.create(currentData);
focusGroupId = response.data.focus_group_id || response.data.id || response.data._id;
onDraftIdChange(focusGroupId);
} else {
await focusGroupsApi.update(focusGroupId, currentData);
}
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);
}, [activeTab, form, selectedParticipants, backendAssets, draftFocusGroupId, draftToEdit, lastSavedData, saveRetryCount, onDraftIdChange]);
// Watch for form field changes
const watchedFields = form.watch();
// 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, triggerAutoSave]);
// Effect to handle participant changes
useEffect(() => {
const currentParticipants = JSON.stringify(selectedParticipants);
if (activeTab === 'setup' && currentParticipants !== prevSelectedParticipantsRef.current) {
prevSelectedParticipantsRef.current = currentParticipants;
triggerAutoSave();
}
}, [selectedParticipants, activeTab, triggerAutoSave]);
// Effect to clear timers when leaving setup tab or component unmounts
useEffect(() => {
if (activeTab !== 'setup') {
if (debouncedSaveTimerRef.current) {
clearTimeout(debouncedSaveTimerRef.current);
}
}
return () => {
if (debouncedSaveTimerRef.current) {
clearTimeout(debouncedSaveTimerRef.current);
}
};
}, [activeTab]);
// Initialize refs for new focus groups
useEffect(() => {
if (!draftToEdit) {
setTimeout(() => {
isLoadingDraftRef.current = false;
const initialFormState = JSON.stringify(form.getValues());
prevWatchedFieldsRef.current = initialFormState;
}, 500);
}
}, [draftToEdit, form]);
return {
autoSaveStatus,
lastSavedData,
setLastSavedData,
isLoadingDraft: isLoadingDraftRef.current,
setIsLoadingDraft,
};
}

View file

@ -0,0 +1,157 @@
import { useState, useEffect, useCallback } from 'react';
import { toast } from 'sonner';
import { foldersApi } from '@/lib/api';
// Define folder interface (database-compatible)
export 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"
export const DEFAULT_FOLDER_ID = 'all';
interface UseFolderManagementReturn {
folders: Folder[];
selectedFolder: string;
setSelectedFolder: (id: string) => void;
isCreatingFolder: boolean;
setIsCreatingFolder: (creating: boolean) => void;
newFolderName: string;
setNewFolderName: (name: string) => void;
folderToRename: Folder | null;
renameFolderName: string;
setRenameFolderName: (name: string) => void;
fetchFolders: () => Promise<Folder[]>;
createNewFolder: () => Promise<void>;
cancelFolderCreation: () => void;
startRenameFolder: (folder: Folder) => void;
completeRenameFolder: () => Promise<void>;
cancelRenameFolder: () => void;
}
export function useFolderManagement(): UseFolderManagementReturn {
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('');
// Function to fetch folders from database
const fetchFolders = useCallback(async (): Promise<Folder[]> => {
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 [];
}
}, []);
// Create new folder
const createNewFolder = useCallback(async () => {
if (!newFolderName.trim()) {
toast.error("Please enter a folder name");
return;
}
try {
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");
}
}, [newFolderName, fetchFolders]);
// Cancel folder creation
const cancelFolderCreation = useCallback(() => {
setNewFolderName('');
setIsCreatingFolder(false);
}, []);
// Start renaming a folder
const startRenameFolder = useCallback((folder: Folder) => {
setFolderToRename(folder);
setRenameFolderName(folder.name);
}, []);
// Complete folder rename
const completeRenameFolder = useCallback(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);
}
}, [folderToRename, renameFolderName, fetchFolders]);
// Cancel folder rename
const cancelRenameFolder = useCallback(() => {
setFolderToRename(null);
setRenameFolderName('');
}, []);
// Fetch folders on mount
useEffect(() => {
fetchFolders();
}, [fetchFolders]);
return {
folders,
selectedFolder,
setSelectedFolder,
isCreatingFolder,
setIsCreatingFolder,
newFolderName,
setNewFolderName,
folderToRename,
renameFolderName,
setRenameFolderName,
fetchFolders,
createNewFolder,
cancelFolderCreation,
startRenameFolder,
completeRenameFolder,
cancelRenameFolder,
};
}

View file

@ -0,0 +1,240 @@
import { useState, useMemo, useCallback } from 'react';
import { DEFAULT_FOLDER_ID } from './useFolderManagement';
// Define filter state interface
export interface FilterState {
age: string[];
gender: string[];
occupation: string[];
location: string[];
techSavviness: string[];
ethnicity: string[];
}
const EMPTY_FILTERS: FilterState = {
age: [],
gender: [],
occupation: [],
location: [],
techSavviness: [],
ethnicity: [],
};
interface UsePersonaFilteringOptions {
personas: any[];
selectedFolder: string;
}
interface UsePersonaFilteringReturn {
searchTerm: string;
setSearchTerm: (term: string) => void;
activeFilters: FilterState;
workingFilters: FilterState;
setWorkingFilters: (filters: FilterState) => void;
isFilterOpen: boolean;
setIsFilterOpen: (open: boolean) => void;
filteredPersonas: any[];
getFilterOptions: (personas: any[]) => Record<keyof FilterState, string[]>;
getFilteredOptions: (currentCategory: keyof FilterState) => Record<keyof FilterState, string[]>;
toggleFilter: (category: keyof FilterState, value: string) => void;
applyFilters: () => void;
resetFilters: () => void;
openFilterDialog: () => void;
}
// Helper function to convert tech savviness to label
function getTechSavvinessLabel(value: number): string {
if (value < 30) return 'Low (0-30)';
if (value < 70) return 'Medium (31-70)';
return 'High (71-100)';
}
export function usePersonaFiltering({
personas,
selectedFolder,
}: UsePersonaFilteringOptions): UsePersonaFilteringReturn {
const [searchTerm, setSearchTerm] = useState('');
const [isFilterOpen, setIsFilterOpen] = useState(false);
const [activeFilters, setActiveFilters] = useState<FilterState>(EMPTY_FILTERS);
const [workingFilters, setWorkingFilters] = useState<FilterState>(EMPTY_FILTERS);
// Function to collect unique filter options from personas
const getFilterOptions = useCallback((personaList: any[]): Record<keyof FilterState, string[]> => {
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>(),
};
personaList.forEach(persona => {
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);
if (persona.techSavviness !== undefined) {
options.techSavviness.add(getTechSavvinessLabel(persona.techSavviness));
}
if (persona.ethnicity) options.ethnicity.add(persona.ethnicity);
});
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 = useCallback((currentCategory: keyof FilterState): Record<keyof FilterState, string[]> => {
// Create a temporary filter state without the current category
const tempFilters = { ...workingFilters };
tempFilters[currentCategory] = [];
// 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;
if (persona.folder_ids && Array.isArray(persona.folder_ids)) {
matchesFolder = persona.folder_ids.includes(selectedFolder);
}
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;
const cat = category as keyof FilterState;
if (cat === 'techSavviness' && persona.techSavviness !== undefined) {
return values.includes(getTechSavvinessLabel(persona.techSavviness));
} 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;
});
});
return getFilterOptions(eligiblePersonas);
}, [workingFilters, personas, selectedFolder, getFilterOptions]);
// Toggle a filter value
const toggleFilter = useCallback((category: keyof FilterState, value: string) => {
setWorkingFilters(prev => {
const newFilters = { ...prev };
if (newFilters[category].includes(value)) {
newFilters[category] = newFilters[category].filter(v => v !== value);
} else {
newFilters[category] = [...newFilters[category], value];
}
return newFilters;
});
}, []);
// Apply filters function
const applyFilters = useCallback(() => {
setIsFilterOpen(false);
setTimeout(() => {
setActiveFilters({ ...workingFilters });
}, 0);
}, [workingFilters]);
// Reset filters
const resetFilters = useCallback(() => {
setWorkingFilters(EMPTY_FILTERS);
}, []);
// Open filter dialog
const openFilterDialog = useCallback(() => {
setWorkingFilters({ ...activeFilters });
setIsFilterOpen(true);
}, [activeFilters]);
// Compute filtered personas
const filteredPersonas = useMemo(() => {
return 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 =
(activeFilters.age.length === 0 || activeFilters.age.includes(persona.age)) &&
(activeFilters.gender.length === 0 || activeFilters.gender.includes(persona.gender)) &&
(activeFilters.occupation.length === 0 || activeFilters.occupation.includes(persona.occupation)) &&
(activeFilters.location.length === 0 || activeFilters.location.includes(persona.location)) &&
(activeFilters.ethnicity.length === 0 ||
(persona.ethnicity && activeFilters.ethnicity.includes(persona.ethnicity))) &&
(activeFilters.techSavviness.length === 0 ||
(persona.techSavviness !== undefined && activeFilters.techSavviness.includes(
getTechSavvinessLabel(persona.techSavviness)
)));
// Folder filtering (persona-centric storage)
let matchesFolder = true;
if (selectedFolder !== DEFAULT_FOLDER_ID) {
matchesFolder = false;
if (persona.folder_ids && Array.isArray(persona.folder_ids)) {
matchesFolder = persona.folder_ids.includes(selectedFolder);
}
if (!matchesFolder) {
if (persona.folder_id === selectedFolder || persona.folderId === selectedFolder) {
matchesFolder = true;
}
}
}
return matchesSearch && matchesFilters && matchesFolder;
});
}, [personas, searchTerm, activeFilters, selectedFolder]);
return {
searchTerm,
setSearchTerm,
activeFilters,
workingFilters,
setWorkingFilters,
isFilterOpen,
setIsFilterOpen,
filteredPersonas,
getFilterOptions,
getFilteredOptions,
toggleFilter,
applyFilters,
resetFilters,
openFilterDialog,
};
}