cohorta/src/components/persona/PersonaProfile.tsx
Vadym Samoilenko e01569c412
All checks were successful
Deploy to Production / deploy (push) Successful in 2m23s
feat: commit all app changes — billing API, new auth, design overhaul
Includes frontend redesign (Navigation, billingApi), backend updates
(auth routes, admin routes, LLM service refactor), MSAL removal,
and dependency updates.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 19:04:43 +01:00

384 lines
15 KiB
TypeScript
Executable file

import { useState, useEffect, useCallback } from 'react';
import Navigation from '@/components/Navigation';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator
} from '@/components/ui/breadcrumb';
import { ArrowLeft, Edit, Home, Users, User, Download, Bot } from 'lucide-react';
import { useNavigation } from '@/contexts/NavigationContext';
import { focusGroupsApi, personasApi } from '@/lib/api';
import { toastService } from '@/lib/toast';
import { waitForTaskResult } from '@/lib/taskPolling';
import { PersonaSidebar } from './PersonaSidebar';
import { PersonaAttitudinalProfile } from './PersonaAttitudinalProfile';
import { PersonaPersonality } from './PersonaPersonality';
import { PersonaScenarios } from './PersonaScenarios';
import { PersonaGenerationPrompts } from './PersonaGenerationPrompts';
import { PersonaNotFound } from './PersonaNotFound';
import { PersonaProfileSkeleton } from './PersonaProfileSkeleton';
import PersonaEditor from './PersonaEditor';
import PersonaModificationModal from './PersonaModificationModal';
import { usePersonaDetails } from '@/hooks/usePersonaDetails';
export default function PersonaProfile() {
const {
currentPersona,
displayPersona,
isEditing,
isFromReview,
isLoading,
isReviewMode,
setIsEditing,
handleGoBack,
handleSaveEdit,
enterReviewMode,
exitReviewMode,
saveReviewedPersona
} = usePersonaDetails();
const { navigationState } = useNavigation();
const [focusGroupName, setFocusGroupName] = useState<string>('');
const [isExporting, setIsExporting] = useState(false);
const [isModificationModalOpen, setIsModificationModalOpen] = useState(false);
// Navigation blocking during review mode
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (isReviewMode) {
e.preventDefault();
e.returnValue = "You have unsaved changes. Your modifications will be lost if you leave.";
return e.returnValue;
}
};
if (isReviewMode) {
window.addEventListener('beforeunload', handleBeforeUnload);
}
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
};
}, [isReviewMode]);
// Fetch focus group name if coming from focus group session
useEffect(() => {
if (navigationState.focusGroupId && navigationState.previousRoute?.startsWith('/focus-groups/')) {
const fetchFocusGroupName = async () => {
try {
const response = await focusGroupsApi.getById(navigationState.focusGroupId);
if (response?.data?.name) {
setFocusGroupName(response.data.name);
}
} catch (error) {
console.error('Error fetching focus group name:', error);
}
};
fetchFocusGroupName();
}
}, [navigationState.focusGroupId, navigationState.previousRoute]);
// Determine if we should show breadcrumbs - only if we have both a focus group route and ID
// This ensures we don't show stale breadcrumb data from previous sessions
const showBreadcrumbs = navigationState.previousRoute?.startsWith('/focus-groups/') &&
navigationState.focusGroupId &&
Object.keys(navigationState).length > 0;
// Handle persona profile export
const handleExportProfile = async () => {
if (!currentPersona) return;
setIsExporting(true);
try {
toastService.info("Generating persona profile...", {
description: "Using GPT-5.4 to create a beautifully formatted markdown profile"
});
// Use the persona's MongoDB _id or fallback to id
const personaId = currentPersona._id || currentPersona.id;
// Call the export API with GPT-5.4
const response = await personasApi.exportProfile(personaId, {
llm_model: 'gpt-5.4',
temperature: 0.3
});
if (response.status === 202 && response.data?.task_id) {
const taskId = response.data.task_id;
const taskResult = await waitForTaskResult(taskId);
if (taskResult.status === 'completed' && taskResult.result) {
const { markdown_content, persona_name, model_used, warning } = taskResult.result;
if (markdown_content) {
const currentDate = new Date().toISOString().split('T')[0];
const safePersonaName = persona_name.replace(/[^a-zA-Z0-9\-\s]/g, '').replace(/\s+/g, '-').toLowerCase();
const filename = `${safePersonaName}-profile-${currentDate}.md`;
const element = document.createElement('a');
const file = new Blob([markdown_content], { type: 'text/markdown' });
element.href = URL.createObjectURL(file);
element.download = filename;
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
if (warning) {
toastService.success("Profile downloaded with fallback formatting", {
description: `${persona_name} profile saved as ${filename}`
});
} else {
const modelDisplay = model_used === 'gpt-5.4-mini' ? 'GPT-5.4 Mini' : 'GPT-5.4';
toastService.success("Profile downloaded successfully", {
description: `${persona_name} profile processed with ${modelDisplay} and saved as ${filename}`
});
}
}
} else if (taskResult.status === 'failed') {
throw new Error(taskResult.error || 'Export failed');
} else {
return; // cancelled
}
return;
}
// Fallback: sync response
const { markdown_content, persona_name, model_used, warning } = response.data;
if (markdown_content) {
// Generate filename with current date
const currentDate = new Date().toISOString().split('T')[0]; // YYYY-MM-DD format
const safePersonaName = persona_name.replace(/[^a-zA-Z0-9\-\s]/g, '').replace(/\s+/g, '-').toLowerCase();
const filename = `${safePersonaName}-profile-${currentDate}.md`;
// Create and download the file
const element = document.createElement('a');
const file = new Blob([markdown_content], { type: 'text/markdown' });
element.href = URL.createObjectURL(file);
element.download = filename;
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
// Show success toast
if (warning) {
toastService.success("Profile downloaded with fallback formatting", {
description: `${persona_name} profile saved as ${filename}`
});
} else {
const modelDisplay = model_used === 'gpt-5.4-mini' ? 'GPT-5.4 Mini' : 'GPT-5.4';
toastService.success("Profile downloaded successfully", {
description: `${persona_name} profile processed with ${modelDisplay} and saved as ${filename}`
});
}
} else {
throw new Error("No markdown content received");
}
} catch (error) {
console.error("Error exporting persona profile:", error);
// Show detailed error message
if (error.response) {
toastService.error("Failed to export profile", {
description: error.response.data?.error || "Server error occurred"
});
} else if (error.request) {
toastService.error("Network error", {
description: "Unable to connect to the server"
});
} else {
toastService.error("Export failed", {
description: error.message || "An unexpected error occurred"
});
}
} finally {
setIsExporting(false);
}
};
if (isLoading) {
return <PersonaProfileSkeleton />;
}
if (!currentPersona) {
return <PersonaNotFound />;
}
return (
<div className="min-h-screen bg-slate-50">
<Navigation />
<main className="pt-20 pb-16 px-4 sm:px-6 lg:px-8 max-w-7xl mx-auto">
{isEditing ? (
<PersonaEditor
persona={currentPersona}
onSave={handleSaveEdit}
onCancel={() => setIsEditing(false)}
/>
) : (
<>
{/* Breadcrumbs */}
{showBreadcrumbs && (
<div className="mb-4">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="/focus-groups" className="flex items-center">
<Home className="h-4 w-4 mr-1" />
Focus Groups
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink
href={`/focus-groups/${navigationState.focusGroupId}`}
className="flex items-center"
>
<Users className="h-4 w-4 mr-1" />
{focusGroupName || 'Focus Group Session'}
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage className="flex items-center">
<User className="h-4 w-4 mr-1" />
{currentPersona?.name || 'Participant'}
</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</div>
)}
{/* Review Mode Banner */}
{isReviewMode && (
<div className="mb-6 p-4 bg-amber-50 border-l-4 border-amber-400 rounded-r-lg">
<div className="flex items-center">
<div className="ml-3">
<p className="text-sm text-amber-800">
📝 <strong>Reviewing proposed changes to {displayPersona?.name}</strong> - Save or cancel to continue
</p>
</div>
</div>
</div>
)}
<div className="flex items-center mb-6 relative">
<Button
variant="ghost"
onClick={() => {
if (isReviewMode) {
if (confirm("You have unsaved changes. Your modifications will be lost if you leave. Do you want to continue?")) {
exitReviewMode();
handleGoBack();
}
} else {
handleGoBack();
}
}}
className="absolute left-0 top-0 flex items-center"
>
<ArrowLeft className="h-5 w-5" />
</Button>
<h1 className="font-sf text-3xl font-bold text-slate-900 ml-16">Persona Profile</h1>
<div className="absolute right-0 top-0 flex items-center gap-3">
{isReviewMode ? (
<>
<Button
variant="outline"
onClick={exitReviewMode}
className="hover-transition"
>
Cancel Revision
</Button>
<Button
onClick={saveReviewedPersona}
className="bg-green-600 hover:bg-green-700 text-white"
>
Save Revised Persona
</Button>
</>
) : (
<>
<Button
variant="outline"
onClick={handleExportProfile}
disabled={isExporting}
className="hover-transition"
>
<Download className="h-4 w-4 mr-2" />
{isExporting ? 'Generating...' : 'Download Profile'}
</Button>
<Button
variant="outline"
onClick={() => setIsModificationModalOpen(true)}
className="hover-transition"
>
<Bot className="h-4 w-4 mr-2" />
Modify with AI
</Button>
<Button onClick={() => setIsEditing(true)}>
<Edit className="h-4 w-4 mr-2" />
Edit Persona
</Button>
</>
)}
</div>
</div>
<div className={`grid grid-cols-1 lg:grid-cols-3 gap-6 mt-10 ${isReviewMode ? 'border-2 border-amber-400 rounded-lg p-4 bg-amber-50/30' : ''}`}>
<div className="lg:col-span-1">
<PersonaSidebar persona={displayPersona} />
</div>
<div className="lg:col-span-2">
<Tabs defaultValue="attitudinal-profile">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="attitudinal-profile">Attitudinal Profile</TabsTrigger>
<TabsTrigger value="personality">Personality</TabsTrigger>
<TabsTrigger value="scenarios">Scenarios</TabsTrigger>
<TabsTrigger value="generation-prompts">Persona Inputs</TabsTrigger>
</TabsList>
<TabsContent value="attitudinal-profile" className="mt-6">
<PersonaAttitudinalProfile persona={displayPersona} />
</TabsContent>
<TabsContent value="personality" className="mt-6">
<PersonaPersonality persona={displayPersona} />
</TabsContent>
<TabsContent value="scenarios" className="mt-6">
<PersonaScenarios persona={displayPersona} />
</TabsContent>
<TabsContent value="generation-prompts" className="mt-6">
<PersonaGenerationPrompts persona={displayPersona} />
</TabsContent>
</Tabs>
</div>
</div>
</>
)}
{/* Persona Modification Modal */}
{currentPersona && (
<PersonaModificationModal
persona={currentPersona}
isOpen={isModificationModalOpen}
onClose={() => setIsModificationModalOpen(false)}
onPersonaPreview={(modifiedPersona) => {
// Enter review mode with the modified persona data
enterReviewMode(modifiedPersona);
}}
/>
)}
</main>
</div>
);
}