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
271 lines
10 KiB
TypeScript
271 lines
10 KiB
TypeScript
import React from "react";
|
|
import { Box, Stack, Typography, Chip, Button, Divider } from "@mui/material";
|
|
import PsychologyIcon from "@mui/icons-material/Psychology";
|
|
import RefreshIcon from "@mui/icons-material/Refresh";
|
|
import EditIcon from "@mui/icons-material/Edit";
|
|
import SaveIcon from "@mui/icons-material/Save";
|
|
import CloseIcon from "@mui/icons-material/Close";
|
|
import MicIcon from "@mui/icons-material/Mic";
|
|
import { GlassyCard, glassyCardSx, SecondaryButton } from "../ui";
|
|
import { useAnalysisPanel, TabId } from "./AnalysisPanelContext";
|
|
import { PodcastEstimate } from "../types";
|
|
|
|
interface TabConfig {
|
|
id: TabId;
|
|
label: string;
|
|
icon: React.ReactNode;
|
|
}
|
|
|
|
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)",
|
|
},
|
|
});
|
|
|
|
export const AnalysisPanelLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
|
const {
|
|
activeTab,
|
|
setActiveTab,
|
|
isEditing,
|
|
setIsEditing,
|
|
editedAnalysis,
|
|
setEditedAnalysis,
|
|
analysis,
|
|
estimate,
|
|
onRegenerate,
|
|
onUpdateAnalysis,
|
|
} = useAnalysisPanel();
|
|
|
|
const tabs: TabConfig[] = [
|
|
{ id: "inputs", label: "Your Inputs", icon: <Box component="span" sx={{ display: "flex", alignItems: "center" }}>📥</Box> },
|
|
{ id: "audience", label: "Audience & Keywords", icon: <Box component="span" sx={{ display: "flex", alignItems: "center" }}>👥</Box> },
|
|
{ id: "outline", label: "Outline", icon: <Box component="span" sx={{ display: "flex", alignItems: "center" }}>📋</Box> },
|
|
{ id: "details", label: "Titles, Hook & CTA", icon: <Box component="span" sx={{ display: "flex", alignItems: "center" }}>📄</Box> },
|
|
{ id: "takeaways", label: "Takeaways", icon: <Box component="span" sx={{ display: "flex", alignItems: "center" }}>💡</Box> },
|
|
{ id: "guest", label: "Guest Talking Points", icon: <Box component="span" sx={{ display: "flex", alignItems: "center" }}>👤</Box> },
|
|
];
|
|
|
|
const handleSave = () => {
|
|
if (editedAnalysis && onUpdateAnalysis) {
|
|
onUpdateAnalysis(JSON.parse(JSON.stringify(editedAnalysis)));
|
|
}
|
|
setIsEditing(false);
|
|
};
|
|
|
|
const handleCancel = () => {
|
|
setIsEditing(false);
|
|
setEditedAnalysis(JSON.parse(JSON.stringify(analysis)));
|
|
};
|
|
|
|
return (
|
|
<GlassyCard
|
|
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",
|
|
}}
|
|
>
|
|
<Stack spacing={2.5}>
|
|
{/* Header Section */}
|
|
<Stack direction="row" justifyContent="space-between" alignItems="center" flexWrap="wrap" gap={1}>
|
|
<Stack direction="row" alignItems="center" gap={1.5} flex={1}>
|
|
<Box
|
|
sx={{
|
|
width: 40,
|
|
height: 40,
|
|
borderRadius: 2,
|
|
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
boxShadow: "0 4px 12px rgba(102, 126, 234, 0.3)",
|
|
}}
|
|
>
|
|
<PsychologyIcon sx={{ color: "#fff", fontSize: 22 }} />
|
|
</Box>
|
|
<Box>
|
|
<Typography variant="h6" sx={{ fontWeight: 700, color: "#1e293b", fontSize: "1.1rem" }}>
|
|
Personalize Your Podcast
|
|
</Typography>
|
|
</Box>
|
|
|
|
{/* Estimate Display */}
|
|
{estimate && (
|
|
<Stack direction="row" alignItems="center" spacing={1.5} sx={{ ml: 2 }}>
|
|
<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>
|
|
{estimate.voiceName && (
|
|
<Chip
|
|
icon={<PsychologyIcon sx={{ fontSize: "12px !important" }} />}
|
|
label={estimate.voiceName}
|
|
size="small"
|
|
variant="outlined"
|
|
sx={{
|
|
height: 20,
|
|
fontSize: '0.7rem',
|
|
color: estimate.isCustomVoice ? "#10b981" : "#6366f1",
|
|
borderColor: estimate.isCustomVoice ? "rgba(16, 185, 129, 0.3)" : "rgba(99, 102, 241, 0.2)",
|
|
bgcolor: estimate.isCustomVoice ? "rgba(16, 185, 129, 0.05)" : "rgba(99, 102, 241, 0.05)",
|
|
'& .MuiChip-icon': { color: estimate.isCustomVoice ? "#10b981" : "#6366f1" }
|
|
}}
|
|
/>
|
|
)}
|
|
<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.5} alignItems="center">
|
|
{/* Regenerate Button */}
|
|
<SecondaryButton
|
|
startIcon={<RefreshIcon />}
|
|
onClick={onRegenerate}
|
|
sx={{
|
|
background: "#fff",
|
|
border: "1px solid #e2e8f0",
|
|
color: "#475569",
|
|
fontWeight: 600,
|
|
fontSize: "0.8rem",
|
|
px: 2,
|
|
py: 0.75,
|
|
"&:hover": {
|
|
background: "#f8fafc",
|
|
borderColor: "#cbd5e1",
|
|
},
|
|
}}
|
|
>
|
|
Regenerate
|
|
</SecondaryButton>
|
|
|
|
{/* Edit/Save/Cancel Buttons */}
|
|
{isEditing ? (
|
|
<Stack direction="row" spacing={1}>
|
|
<Button
|
|
startIcon={<CloseIcon />}
|
|
onClick={handleCancel}
|
|
sx={{
|
|
color: "#64748b",
|
|
fontWeight: 600,
|
|
fontSize: "0.8rem",
|
|
px: 1.5,
|
|
}}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
startIcon={<SaveIcon />}
|
|
variant="contained"
|
|
onClick={handleSave}
|
|
sx={{
|
|
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
|
fontWeight: 600,
|
|
fontSize: "0.8rem",
|
|
px: 2,
|
|
}}
|
|
>
|
|
Save
|
|
</Button>
|
|
</Stack>
|
|
) : (
|
|
<Button
|
|
startIcon={<EditIcon />}
|
|
onClick={() => setIsEditing(true)}
|
|
sx={{
|
|
color: "#667eea",
|
|
fontWeight: 600,
|
|
fontSize: "0.8rem",
|
|
px: 1.5,
|
|
}}
|
|
>
|
|
Edit
|
|
</Button>
|
|
)}
|
|
</Stack>
|
|
</Stack>
|
|
|
|
{/* Tab Navigation */}
|
|
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
|
|
{tabs.map((tab) => (
|
|
<Box
|
|
key={tab.id}
|
|
onClick={() => setActiveTab(tab.id)}
|
|
sx={tabButtonStyles(activeTab === tab.id)}
|
|
>
|
|
<Stack direction="row" spacing={1} alignItems="center">
|
|
{tab.icon}
|
|
<Box>{tab.label}</Box>
|
|
</Stack>
|
|
</Box>
|
|
))}
|
|
</Stack>
|
|
|
|
{/* Content Area - Render children (tab content) */}
|
|
<Box sx={{ mt: 1 }}>
|
|
{children}
|
|
</Box>
|
|
</Stack>
|
|
</GlassyCard>
|
|
);
|
|
}; |