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:
parent
4bf325483e
commit
22b3ec19a5
12 changed files with 2363 additions and 2178 deletions
File diff suppressed because it is too large
Load diff
182
src/components/focus-group-session/CopyGuideDialog.tsx
Normal file
182
src/components/focus-group-session/CopyGuideDialog.tsx
Normal 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;
|
||||
217
src/components/focus-group-session/FolderSidebar.tsx
Normal file
217
src/components/focus-group-session/FolderSidebar.tsx
Normal 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;
|
||||
159
src/components/focus-group-session/ParticipantsTab.tsx
Normal file
159
src/components/focus-group-session/ParticipantsTab.tsx
Normal 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;
|
||||
141
src/components/focus-group-session/PersonaFilterDialog.tsx
Normal file
141
src/components/focus-group-session/PersonaFilterDialog.tsx
Normal 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;
|
||||
155
src/components/focus-group-session/ReviewTab.tsx
Normal file
155
src/components/focus-group-session/ReviewTab.tsx
Normal 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;
|
||||
292
src/components/focus-group-session/SetupTab.tsx
Normal file
292
src/components/focus-group-session/SetupTab.tsx
Normal 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;
|
||||
29
src/components/ui/SaveStatusIndicator.tsx
Normal file
29
src/components/ui/SaveStatusIndicator.tsx
Normal 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;
|
||||
206
src/hooks/useDiscussionGuideGeneration.ts
Normal file
206
src/hooks/useDiscussionGuideGeneration.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
203
src/hooks/useFocusGroupAutoSave.ts
Normal file
203
src/hooks/useFocusGroupAutoSave.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
157
src/hooks/useFolderManagement.ts
Normal file
157
src/hooks/useFolderManagement.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
240
src/hooks/usePersonaFiltering.ts
Normal file
240
src/hooks/usePersonaFiltering.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue