All checks were successful
Deploy to Production / deploy (push) Successful in 2m23s
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>
384 lines
15 KiB
TypeScript
Executable file
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>
|
|
);
|
|
}
|