Files
ALwrity/frontend/src/components/PodcastMaker/AnalysisPanel.tsx
ajaysi aaf94049da feat: validate podcast cost estimation accuracy, document per-token costs, and fix subscription/plan enforcement
Issue #543 — Validate Estimated Cost Accuracy (UI vs Backend)

Backend:
- cost_estimator.py uses pricing catalog (APIProviderPricing) as single source of truth
- All 7 cost components: analysis, research (search+LLM), script, TTS, voice clone, avatar, video
- initialize_default_pricing() runs on every app startup for auto-sync

Frontend cost estimation fixes:
- Added missing analysisCost, scriptCost, voiceCloneCost to PodcastEstimate type
- toPodcastEstimate() now extracts all 7 backend fields (was dropping 3)
- headerCostEst maps analysisCost->Analyze, scriptCost->Write, voiceCloneCost->Produce
- EstimateCard shows 5 chips: Analysis, Research, Script, Voice(TTS+clone), Visuals(avatar+video)
- Chip sum now equals backend total for all configurations

Subscription & plan fixes:
- Removed Stripe re-verification from checkSubscription() (downgrade regression fix #539)
- Added verifyCheckoutRef pattern for reliable mount-time checkout polling
- One-time Stripe sync effect with pending_subscription_change flag for Customer Portal returns
- Free plan limits: stability_calls 3->10, audio_calls 5->10 (supports 2 podcasts)
- Image enforcement uses actual provider (GPT_PROVIDER), not hardcoded Stability
- Billing/pricing pages bypass onboarding check in ProtectedRoute
- Gradient buttons + loading spinner on plan chip in UserBadge
- Added metadata-based Stripe lookup fallback (Issue #538)

Documentation:
- TESTING_GUIDE.md: comprehensive testing instructions for non-technical testers
  - Free plan limits, usage tracking, cost estimation formulas
  - 10 test cases for UI verification
  - Troubleshooting guide
  - Quick-reference cost formulas with all default rates

Cleanup: removed legacy ToBeMigrated directory (70+ files, ~22K LOC)
GSC Brainstorm: service, hook, modal, and UI components for blog topic brainstorming
2026-05-27 08:46:38 +05:30

583 lines
21 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect } from "react";
import { Stack, Box, Typography, Divider, Chip, alpha, Button, IconButton, Popover, TextField, Tooltip } from "@mui/material";
import PsychologyIcon from "@mui/icons-material/Psychology";
import PersonIcon from "@mui/icons-material/Person";
import EditIcon from "@mui/icons-material/Edit";
import SaveIcon from "@mui/icons-material/Save";
import CloseIcon from "@mui/icons-material/Close";
import InputIcon from "@mui/icons-material/Input";
import GroupsIcon from "@mui/icons-material/Groups";
import ListAltIcon from "@mui/icons-material/ListAlt";
import TipsIcon from "@mui/icons-material/Lightbulb";
import ArticleIcon from "@mui/icons-material/Article";
import BibleIcon from "@mui/icons-material/AutoFixHigh";
import RefreshIcon from "@mui/icons-material/Refresh";
import { PodcastAnalysis, PodcastEstimate, PodcastBible } from "./types";
import { GlassyCard, glassyCardSx, SecondaryButton } from "./ui";
import { aiApiClient } from "../../api/client";
import { InputsTab, AudienceTab, OutlineTab, EpisodeDetailsTab, TakeawaysTab, GuestTab } from "./AnalysisPanel/tabs";
interface AnalysisPanelProps {
analysis: PodcastAnalysis | null;
estimate: PodcastEstimate | null;
idea?: string;
duration?: number;
speakers?: number;
voiceName?: string;
podcastMode?: "audio_only" | "video_only" | "audio_video";
avatarUrl?: string | null;
avatarPrompt?: string | null;
bible?: PodcastBible | null;
onRegenerate?: () => void;
onUpdateAnalysis?: (updatedAnalysis: PodcastAnalysis) => void;
onUpdateBible?: (updatedBible: PodcastBible) => void;
}
type TabId = 'inputs' | 'audience' | 'outline' | 'details' | 'takeaways' | 'guest';
interface TabConfig {
id: TabId;
label: string;
icon: React.ReactNode;
}
const inputStyles = {
'& .MuiInputBase-input': {
color: '#111827 !important',
fontWeight: 500,
WebkitTextFillColor: '#111827 !important', // Fix for some browsers
},
'& .MuiInputLabel-root': {
color: '#4b5563 !important',
},
'& .MuiOutlinedInput-root': {
bgcolor: '#ffffff !important',
'& fieldset': {
borderColor: '#d1d5db !important',
},
'&:hover fieldset': {
borderColor: '#4f46e5 !important',
},
'&.Mui-focused fieldset': {
borderColor: '#4f46e5 !important',
}
},
'& .MuiSelect-select': {
color: '#111827 !important',
WebkitTextFillColor: '#111827 !important',
}
};
export const AnalysisPanel: React.FC<AnalysisPanelProps> = ({
analysis,
estimate,
idea,
duration,
speakers,
voiceName,
podcastMode,
avatarUrl,
avatarPrompt,
bible,
onRegenerate,
onUpdateAnalysis,
onUpdateBible
}) => {
const [activeTab, setActiveTab] = useState<TabId>('inputs');
const [avatarBlobUrl, setAvatarBlobUrl] = useState<string | null>(null);
const [avatarLoading, setAvatarLoading] = useState(false);
const [avatarError, setAvatarError] = useState(false);
const [bibleAnchorEl, setBibleAnchorEl] = useState<HTMLElement | null>(null);
// Edit states
const [isEditing, setIsEditing] = useState(false);
const [editedAnalysis, setEditedAnalysis] = useState<PodcastAnalysis | null>(null);
const [editedBible, setEditedBible] = useState<PodcastBible | null>(null);
const tabs: TabConfig[] = [
{ id: 'inputs', label: 'Your Inputs', icon: <InputIcon /> },
{ id: 'audience', label: 'Audience & Keywords', icon: <GroupsIcon /> },
{ id: 'outline', label: 'Outline', icon: <ListAltIcon /> },
{ id: 'details', label: 'Titles, Hook & CTA', icon: <ArticleIcon /> },
{ id: 'takeaways', label: 'Takeaways', icon: <TipsIcon /> },
{ id: 'guest', label: 'Guest Talking Points', icon: <PersonIcon /> },
];
const tabButtonStyles = (isActive: boolean) => ({
background: isActive
? "linear-gradient(135deg, #667eea 0%, #764ba2 100%)"
: "#f8fafc",
color: isActive ? "#fff" : "#475569",
border: isActive
? "none"
: "1px solid #e2e8f0",
borderRadius: 2.5,
px: 2.5,
py: 1.25,
fontSize: "0.8rem",
fontWeight: 600,
textTransform: "none" as const,
transition: "all 0.25s ease",
boxShadow: isActive
? "0 4px 12px rgba(102, 126, 234, 0.3)"
: "0 1px 2px rgba(0, 0, 0, 0.05)",
"&:hover": {
background: isActive
? "linear-gradient(135deg, #764ba2 0%, #667eea 100%)"
: "#e2e8f0",
transform: isActive ? "translateY(-1px)" : "none",
boxShadow: isActive
? "0 6px 16px rgba(102, 126, 234, 0.35)"
: "0 2px 4px rgba(0, 0, 0, 0.08)",
},
"&:active": {
transform: "translateY(0)",
},
});
// Sync editedAnalysis with analysis initially
useEffect(() => {
if (analysis && !editedAnalysis) {
setEditedAnalysis(JSON.parse(JSON.stringify(analysis)));
}
}, [analysis, editedAnalysis]);
const handleSave = () => {
if (editedAnalysis && onUpdateAnalysis) {
onUpdateAnalysis(JSON.parse(JSON.stringify(editedAnalysis)));
}
setIsEditing(false);
};
const handleCancel = () => {
setIsEditing(false);
setEditedAnalysis(JSON.parse(JSON.stringify(analysis)));
};
const updateExaConfig = (field: string, value: any) => {
if (!editedAnalysis) return;
setEditedAnalysis({
...editedAnalysis,
exaSuggestedConfig: {
...(editedAnalysis.exaSuggestedConfig || {}),
[field]: value
}
});
};
const handleAddKeyword = (keyword: string) => {
if (!editedAnalysis || !keyword.trim()) return;
if (editedAnalysis.topKeywords.includes(keyword.trim())) return;
setEditedAnalysis({
...editedAnalysis,
topKeywords: [...editedAnalysis.topKeywords, keyword.trim()]
});
};
const handleRemoveKeyword = (keyword: string) => {
if (!editedAnalysis) return;
setEditedAnalysis({
...editedAnalysis,
topKeywords: editedAnalysis.topKeywords.filter(k => k !== keyword)
});
};
const handleAddTitle = (title: string) => {
if (!editedAnalysis || !title.trim()) return;
setEditedAnalysis({
...editedAnalysis,
titleSuggestions: [...editedAnalysis.titleSuggestions, title.trim()]
});
};
const handleRemoveTitle = (title: string) => {
if (!editedAnalysis) return;
setEditedAnalysis({
...editedAnalysis,
titleSuggestions: editedAnalysis.titleSuggestions.filter(t => t !== title)
});
};
const handleUpdateOutline = (id: string | number, field: 'title' | 'segments', value: any) => {
if (!editedAnalysis) return;
setEditedAnalysis({
...editedAnalysis,
suggestedOutlines: editedAnalysis.suggestedOutlines.map(o =>
o.id === id ? { ...o, [field]: value } : o
)
});
};
// Load avatar image as blob for authenticated URLs
useEffect(() => {
if (!avatarUrl) {
setAvatarBlobUrl(null);
setAvatarError(false);
return;
}
// Check if it's already a blob URL
if (avatarUrl.startsWith('blob:')) {
setAvatarBlobUrl(avatarUrl);
return;
}
// Check if it's an authenticated endpoint
const isAuthenticatedEndpoint = avatarUrl.includes('/api/podcast/images/') || avatarUrl.includes('/api/podcast/avatar/');
let currentBlobUrl: string | null = null;
if (isAuthenticatedEndpoint) {
setAvatarLoading(true);
setAvatarError(false);
const loadAvatarBlob = async () => {
try {
const response = await aiApiClient.get(avatarUrl, { responseType: 'blob' });
const blobUrl = URL.createObjectURL(response.data);
currentBlobUrl = blobUrl;
setAvatarBlobUrl(blobUrl);
setAvatarError(false);
} catch (error) {
console.error('[AnalysisPanel] Failed to load avatar as blob:', error);
// Fallback: try with query token
try {
const token = localStorage.getItem('clerk_dashboard_token') || '';
if (token) {
const urlWithToken = `${avatarUrl}?token=${encodeURIComponent(token)}`;
setAvatarBlobUrl(urlWithToken);
} else {
setAvatarError(true);
}
} catch (fallbackError) {
console.error('[AnalysisPanel] Fallback avatar loading failed:', fallbackError);
setAvatarError(true);
}
} finally {
setAvatarLoading(false);
}
};
loadAvatarBlob();
// Cleanup blob URL on unmount or when avatarUrl changes
return () => {
if (currentBlobUrl && currentBlobUrl.startsWith('blob:')) {
URL.revokeObjectURL(currentBlobUrl);
}
// Also cleanup any previous blob URL from state
setAvatarBlobUrl((prev) => {
if (prev && prev.startsWith('blob:') && prev !== currentBlobUrl) {
URL.revokeObjectURL(prev);
}
return null;
});
};
} else {
// Direct URL, use as-is
setAvatarBlobUrl(avatarUrl);
}
}, [avatarUrl]);
if (!analysis) return null;
const currentAnalysis = isEditing && editedAnalysis ? editedAnalysis : analysis;
return (
<GlassyCard
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.28 }}
className="light-theme-container"
sx={{
...glassyCardSx,
background: "#ffffff",
border: "1px solid rgba(0,0,0,0.06)",
boxShadow: "0 10px 28px rgba(15,23,42,0.06)",
color: "#111827",
}}
aria-label="analysis-panel"
>
<Stack spacing={3}>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Stack direction="row" alignItems="center" spacing={2} flex={1}>
<Typography
variant="h6"
sx={{
color: "#1e293b",
fontWeight: 800,
display: "flex",
alignItems: "center",
gap: 1,
whiteSpace: "nowrap"
}}
>
<PsychologyIcon sx={{ color: "#4f46e5" }} />
AI Analysis
</Typography>
{estimate && (
<Stack direction="row" alignItems="center" spacing={1.5} sx={{ ml: 2, flex: 1, overflow: 'hidden' }}>
<Divider orientation="vertical" flexItem sx={{ height: 24, alignSelf: 'center', borderColor: "rgba(0,0,0,0.1)" }} />
<Typography variant="subtitle2" fontWeight={700} sx={{ color: "#4f46e5" }}>
Est. Cost: ${estimate.total.toFixed(2)}
</Typography>
<Stack direction="row" spacing={1} sx={{ display: { xs: 'none', lg: 'flex' } }}>
<Chip
label={`Analysis: $${estimate.analysisCost.toFixed(2)}`}
size="small"
variant="outlined"
sx={{ height: 20, fontSize: '0.7rem', color: "#64748b", borderColor: "rgba(0,0,0,0.15)", bgcolor: "rgba(0,0,0,0.02)" }}
/>
<Chip
label={`Research: $${estimate.researchCost.toFixed(2)}`}
size="small"
variant="outlined"
sx={{ height: 20, fontSize: '0.7rem', color: "#64748b", borderColor: "rgba(0,0,0,0.15)", bgcolor: "rgba(0,0,0,0.02)" }}
/>
<Chip
label={`Script: $${estimate.scriptCost.toFixed(2)}`}
size="small"
variant="outlined"
sx={{ height: 20, fontSize: '0.7rem', color: "#64748b", borderColor: "rgba(0,0,0,0.15)", bgcolor: "rgba(0,0,0,0.02)" }}
/>
<Chip
label={`Voice: $${(estimate.ttsCost + estimate.voiceCloneCost).toFixed(2)}`}
size="small"
variant="outlined"
title={`Voice narration ($${estimate.ttsCost.toFixed(2)}) + cloning ($${estimate.voiceCloneCost.toFixed(2)})`}
sx={{ height: 20, fontSize: '0.7rem', color: "#64748b", borderColor: "rgba(0,0,0,0.15)", bgcolor: "rgba(0,0,0,0.02)" }}
/>
<Chip
label={`Visuals: $${(estimate.avatarCost + estimate.videoCost).toFixed(2)}`}
size="small"
variant="outlined"
sx={{ height: 20, fontSize: '0.7rem', color: "#64748b", borderColor: "rgba(0,0,0,0.15)", bgcolor: "rgba(0,0,0,0.02)" }}
/>
</Stack>
</Stack>
)}
</Stack>
<Stack direction="row" spacing={1}>
{/* Bible Button */}
{bible && (
<Tooltip title="Podcast Bible - Hyper-personalized context">
<IconButton
onClick={(e) => setBibleAnchorEl(e.currentTarget)}
sx={{
bgcolor: bibleAnchorEl ? "linear-gradient(135deg, #667eea 0%, #764ba2 100%)" : "rgba(102, 126, 234, 0.1)",
border: "1px solid",
borderColor: bibleAnchorEl ? "transparent" : "rgba(102, 126, 234, 0.3)",
borderRadius: 2,
p: 1,
transition: "all 0.2s ease",
"&:hover": {
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
borderColor: "transparent",
},
}}
>
<BibleIcon sx={{ color: bibleAnchorEl ? "#fff" : "#667eea", fontSize: 20 }} />
</IconButton>
</Tooltip>
)}
{isEditing ? (
<>
<SecondaryButton
onClick={handleSave}
startIcon={<SaveIcon />}
sx={{
color: '#059669',
borderColor: '#10b981',
bgcolor: 'white',
fontWeight: 600,
'&:hover': { bgcolor: alpha('#10b981', 0.05) }
}}
>
Save Changes
</SecondaryButton>
<SecondaryButton
onClick={handleCancel}
startIcon={<CloseIcon />}
sx={{ color: '#4b5563', borderColor: '#d1d5db', bgcolor: 'white' }}
>
Cancel
</SecondaryButton>
</>
) : (
<>
<SecondaryButton
onClick={() => setIsEditing(true)}
startIcon={<EditIcon />}
sx={{ color: '#4f46e5', borderColor: '#4f46e5', bgcolor: 'white', fontWeight: 600 }}
>
Edit Analysis
</SecondaryButton>
<SecondaryButton
onClick={onRegenerate}
startIcon={<RefreshIcon />}
tooltip="Regenerate analysis with different parameters"
sx={{ color: '#4b5563', borderColor: '#d1d5db', bgcolor: 'white' }}
>
Regenerate
</SecondaryButton>
</>
)}
</Stack>
</Stack>
<Divider sx={{ borderColor: "rgba(0,0,0,0.06)" }} />
{/* AI Futuristic Tab Navigation */}
<Stack direction="row" flexWrap="wrap" gap={1} sx={{ mb: 2 }}>
{tabs.map((tab) => (
<Button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
startIcon={tab.icon}
sx={tabButtonStyles(activeTab === tab.id)}
>
{tab.label}
</Button>
))}
</Stack>
{/* Tab Content */}
<Box sx={{ minHeight: 300 }}>
{activeTab === 'inputs' && (
<InputsTab
idea={idea}
duration={duration}
speakers={speakers}
voiceName={voiceName}
podcastMode={podcastMode}
avatarUrl={avatarUrl}
avatarPrompt={avatarPrompt}
avatarBlobUrl={avatarBlobUrl}
avatarLoading={avatarLoading}
avatarError={avatarError}
/>
)}
{activeTab === 'audience' && (
<AudienceTab
analysis={currentAnalysis}
isEditing={isEditing}
editedAnalysis={editedAnalysis}
setEditedAnalysis={setEditedAnalysis}
handleRemoveKeyword={handleRemoveKeyword}
handleAddKeyword={handleAddKeyword}
handleRemoveTitle={handleRemoveTitle}
handleAddTitle={handleAddTitle}
handleUpdateOutline={handleUpdateOutline}
updateExaConfig={updateExaConfig}
/>
)}
{activeTab === 'outline' && (
<OutlineTab
analysis={currentAnalysis}
isEditing={isEditing}
onUpdateOutline={handleUpdateOutline}
/>
)}
{activeTab === 'details' && (
<EpisodeDetailsTab
analysis={currentAnalysis}
isEditing={isEditing}
handleRemoveTitle={handleRemoveTitle}
handleAddTitle={handleAddTitle}
/>
)}
{activeTab === 'takeaways' && (
<TakeawaysTab analysis={currentAnalysis} />
)}
{activeTab === 'guest' && (
<GuestTab analysis={currentAnalysis} />
)}
</Box>
{/* Bible Popover */}
<Popover
open={Boolean(bibleAnchorEl)}
anchorEl={bibleAnchorEl}
onClose={() => setBibleAnchorEl(null)}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
PaperProps={{
sx: {
mt: 1,
maxWidth: 420,
borderRadius: 3,
background: "linear-gradient(135deg, #1e293b 0%, #0f172a 100%)",
border: "1px solid rgba(102, 126, 234, 0.3)",
boxShadow: "0 10px 40px rgba(102, 126, 234, 0.25)",
},
}}
>
<Box sx={{ p: 2.5 }}>
<Stack spacing={2}>
<Stack direction="row" alignItems="center" spacing={1}>
<BibleIcon sx={{ color: "#a78bfa", fontSize: 24 }} />
<Typography variant="h6" sx={{ color: "#fff", fontWeight: 700 }}>
Podcast Bible
</Typography>
<Tooltip title="Hyper-personalized context derived from your onboarding data. This grounds all research and script generation.">
<IconButton size="small" sx={{ ml: 'auto' }}>
<Typography variant="caption" sx={{ color: "#94a3b8" }}></Typography>
</IconButton>
</Tooltip>
</Stack>
{/* Host Persona */}
<Box sx={{ p: 1.5, borderRadius: 2, bgcolor: "rgba(99, 102, 241, 0.1)", border: "1px solid rgba(99, 102, 241, 0.2)" }}>
<Typography variant="caption" sx={{ color: "#a78bfa", fontWeight: 600, mb: 0.5, display: "block" }}>
Host Persona
</Typography>
<Typography variant="body2" sx={{ color: "rgba(255,255,255,0.8)", fontSize: "0.8rem" }}>
{bible?.host?.name || "Not set"} {bible?.host?.background || "No background"} {bible?.host?.vocal_style || "No style"}
</Typography>
</Box>
{/* Audience DNA */}
<Box sx={{ p: 1.5, borderRadius: 2, bgcolor: "rgba(34, 197, 94, 0.1)", border: "1px solid rgba(34, 197, 94, 0.2)" }}>
<Typography variant="caption" sx={{ color: "#22c55e", fontWeight: 600, mb: 0.5, display: "block" }}>
Audience DNA
</Typography>
<Typography variant="body2" sx={{ color: "rgba(255,255,255,0.8)", fontSize: "0.8rem" }}>
{bible?.audience?.expertise_level || "General"} {(bible?.audience?.interests || []).slice(0, 3).join(", ") || "Various interests"}
</Typography>
</Box>
{/* Brand DNA */}
<Box sx={{ p: 1.5, borderRadius: 2, bgcolor: "rgba(249, 115, 22, 0.1)", border: "1px solid rgba(249, 115, 22, 0.2)" }}>
<Typography variant="caption" sx={{ color: "#f97316", fontWeight: 600, mb: 0.5, display: "block" }}>
Brand DNA
</Typography>
<Typography variant="body2" sx={{ color: "rgba(255,255,255,0.8)", fontSize: "0.8rem" }}>
{bible?.brand?.industry || "No industry"} {bible?.brand?.tone || "No tone"} {bible?.brand?.communication_style || "No style"}
</Typography>
</Box>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.5)", textAlign: "center", fontSize: "0.7rem" }}>
Podcast Bible personalizes all AI generation for your unique voice
</Typography>
</Stack>
</Box>
</Popover>
</Stack>
</GlassyCard>
);
};