Add accessible cost estimate chip and phase breakdown in podcast header

This commit is contained in:
ي
2026-04-19 16:39:49 +05:30
parent 5f13ee5f7b
commit 280159669b
3 changed files with 189 additions and 22 deletions

View File

@@ -1,7 +1,8 @@
import React, { useState, useCallback, useEffect } from "react"; import React, { useState, useCallback, useEffect, useMemo } from "react";
import { shouldSkipOnboarding } from '../../utils/demoMode'; import { shouldSkipOnboarding } from '../../utils/demoMode';
import { Box, Paper, Stack, Alert, Divider, CircularProgress, alpha, Dialog, DialogTitle, DialogContent, DialogActions, Button, Typography } from "@mui/material"; import { Box, Paper, Stack, Alert, Divider, CircularProgress, alpha, Dialog, DialogTitle, DialogContent, DialogActions, Button, Typography } from "@mui/material";
import { usePodcastProjectState } from "../../hooks/usePodcastProjectState"; import { usePodcastProjectState } from "../../hooks/usePodcastProjectState";
import { PodcastCostEst } from "./types";
import { CreateModal } from "./CreateModal"; import { CreateModal } from "./CreateModal";
import { AnalysisPanel } from "./AnalysisPanel"; import { AnalysisPanel } from "./AnalysisPanel";
import { ScriptEditor } from "./ScriptEditor"; import { ScriptEditor } from "./ScriptEditor";
@@ -68,6 +69,50 @@ const PodcastDashboard: React.FC = () => {
}); });
const [showRegenModal, setShowRegenModal] = useState(false); const [showRegenModal, setShowRegenModal] = useState(false);
const headerCostEst = useMemo<PodcastCostEst | null>(() => {
const defaultBreakdown: PodcastCostEst["breakdown"] = [
{ phase: "Analyze", cost: 0 },
{ phase: "Gather", cost: 0 },
{ phase: "Write", cost: 0 },
{ phase: "Produce", cost: 0 },
];
if (!estimate && !research?.costEst) {
return null;
}
const breakdownMap = new Map(defaultBreakdown.map((item) => [item.phase, item.cost]));
if (research?.costEst?.breakdown?.length) {
research.costEst.breakdown.forEach((item) => {
breakdownMap.set(item.phase, Number(item.cost) || 0);
});
}
if (estimate) {
const gatherCost = breakdownMap.get("Gather") || 0;
const produceCost = breakdownMap.get("Produce") || 0;
if (gatherCost === 0 && estimate.researchCost > 0) {
breakdownMap.set("Gather", estimate.researchCost);
}
if (produceCost === 0) {
breakdownMap.set("Produce", estimate.ttsCost + estimate.avatarCost + estimate.videoCost);
}
}
const breakdown: PodcastCostEst["breakdown"] = defaultBreakdown.map((item) => ({
phase: item.phase,
cost: breakdownMap.get(item.phase) || 0,
}));
const total = breakdown.reduce((sum, item) => sum + item.cost, 0);
return {
total,
breakdown,
currency: "USD",
last_updated: research?.costEst?.last_updated || new Date().toISOString(),
};
}, [estimate, research?.costEst]);
const handleSelectProject = useCallback(async (projectId: string) => { const handleSelectProject = useCallback(async (projectId: string) => {
try { try {
@@ -122,6 +167,7 @@ const PodcastDashboard: React.FC = () => {
...(scriptData ? [2] : []), ...(scriptData ? [2] : []),
...(renderJobs.some(j => j.status === "completed") ? [3] : []), ...(renderJobs.some(j => j.status === "completed") ? [3] : []),
]} ]}
costEst={headerCostEst}
onStepClick={(step) => { onStepClick={(step) => {
// Handle step clicks - could navigate to different views // Handle step clicks - could navigate to different views
}} }}

View File

@@ -1,18 +1,18 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { Stack, Typography, Box, IconButton, Menu, MenuItem, Divider, ListItemIcon, ListItemText, Collapse } from "@mui/material"; import { Stack, Typography, Box, IconButton, Menu, MenuItem, Divider, ListItemIcon, ListItemText, Collapse, Chip, Popover, ButtonBase, useMediaQuery } from "@mui/material";
import { import {
Mic as MicIcon, Mic as MicIcon,
Menu as MenuIcon, Menu as MenuIcon,
Close as CloseIcon, Close as CloseIcon,
AutoAwesome as AutoAwesomeIcon, AttachMoney as AttachMoneyIcon,
LibraryMusic as LibraryMusicIcon, LibraryMusic as LibraryMusicIcon,
Folder as FolderIcon, Folder as FolderIcon,
Help as HelpIcon, Help as HelpIcon,
Add as AddIcon, Add as AddIcon,
BarChart as BarChartIcon,
} from "@mui/icons-material"; } from "@mui/icons-material";
import { useTheme } from "@mui/material/styles";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { PrimaryButton } from "../ui"; import { PodcastCostEst } from "../types";
import HeaderControls from "../../shared/HeaderControls"; import HeaderControls from "../../shared/HeaderControls";
import { ProgressStepper } from "./ProgressStepper"; import { ProgressStepper } from "./ProgressStepper";
@@ -22,12 +22,22 @@ interface HeaderProps {
activeStep?: number; activeStep?: number;
completedSteps?: number[]; completedSteps?: number[];
onStepClick?: (stepIndex: number) => void; onStepClick?: (stepIndex: number) => void;
costEst?: PodcastCostEst | null;
} }
export const Header: React.FC<HeaderProps> = ({ onShowProjects, onNewEpisode, activeStep = -1, completedSteps = [], onStepClick }) => { const COST_PHASE_ORDER: PodcastCostEst["breakdown"][number]["phase"][] = ["Analyze", "Gather", "Write", "Produce"];
export const Header: React.FC<HeaderProps> = ({ onShowProjects, onNewEpisode, activeStep = -1, completedSteps = [], onStepClick, costEst }) => {
const navigate = useNavigate(); const navigate = useNavigate();
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [costAnchorEl, setCostAnchorEl] = useState<null | HTMLElement>(null);
const [isMobileCostOpen, setIsMobileCostOpen] = useState(false);
const isMenuOpen = Boolean(anchorEl); const isMenuOpen = Boolean(anchorEl);
const isCostOpen = Boolean(costAnchorEl);
const costTriggerId = "podcast-cost-est-trigger";
const costBreakdownId = "podcast-cost-est-breakdown";
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => { const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget); setAnchorEl(event.currentTarget);
@@ -57,6 +67,35 @@ export const Header: React.FC<HeaderProps> = ({ onShowProjects, onNewEpisode, ac
onNewEpisode(); onNewEpisode();
}; };
const handleCostToggle = (event: React.MouseEvent<HTMLElement>) => {
if (!costEst) return;
if (isMobile) {
setIsMobileCostOpen((prev) => !prev);
return;
}
setCostAnchorEl((prev) => (prev ? null : event.currentTarget));
};
const handleCostKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
if (!costEst) return;
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
if (isMobile) {
setIsMobileCostOpen((prev) => !prev);
} else {
setCostAnchorEl((prev) => (prev ? null : event.currentTarget));
}
} else if (event.key === "Escape") {
event.preventDefault();
setCostAnchorEl(null);
setIsMobileCostOpen(false);
}
};
const handleCloseCostPopover = () => {
setCostAnchorEl(null);
};
return ( return (
<Box <Box
sx={{ sx={{
@@ -118,7 +157,35 @@ export const Header: React.FC<HeaderProps> = ({ onShowProjects, onNewEpisode, ac
</Stack> </Stack>
{/* Right side - Hamburger Menu + HeaderControls + Create */} {/* Right side - Hamburger Menu + HeaderControls + Create */}
<Stack direction="row" spacing={1} alignItems="center"> <Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap" justifyContent="flex-end">
{costEst && (
<ButtonBase
id={costTriggerId}
onClick={handleCostToggle}
onKeyDown={handleCostKeyDown}
aria-label={`Cost Est total ${costEst.total.toFixed(2)} dollars. ${isMobile ? "Press to expand cost breakdown." : "Press to open cost breakdown."}`}
aria-describedby={costBreakdownId}
aria-expanded={isMobile ? isMobileCostOpen : isCostOpen}
sx={{ borderRadius: 999 }}
>
<Chip
icon={<AttachMoneyIcon sx={{ fontSize: "0.95rem !important" }} />}
label={`Cost Est $${costEst.total.toFixed(2)}`}
size="small"
sx={{
cursor: "pointer",
background: "rgba(245, 158, 11, 0.12)",
color: "#92400e",
fontWeight: 700,
border: "1px solid rgba(245, 158, 11, 0.3)",
"& .MuiChip-label": {
px: 1.2,
},
}}
/>
</ButtonBase>
)}
{/* Header Controls (alerts + user) */} {/* Header Controls (alerts + user) */}
<HeaderControls colorMode="light" showAlerts={true} showUser={true} /> <HeaderControls colorMode="light" showAlerts={true} showUser={true} />
@@ -235,6 +302,74 @@ export const Header: React.FC<HeaderProps> = ({ onShowProjects, onNewEpisode, ac
</Stack> </Stack>
</Stack> </Stack>
{costEst && (
<>
<Popover
open={!isMobile && isCostOpen}
anchorEl={costAnchorEl}
onClose={handleCloseCostPopover}
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
transformOrigin={{ vertical: "top", horizontal: "right" }}
PaperProps={{
id: costBreakdownId,
sx: {
mt: 1,
p: 1.5,
minWidth: 220,
borderRadius: 2,
border: "1px solid rgba(245, 158, 11, 0.25)",
background: "#fffbeb",
},
}}
>
<Stack spacing={1}>
{COST_PHASE_ORDER.map((phase) => {
const phaseCost = costEst.breakdown.find((item) => item.phase === phase)?.cost || 0;
return (
<Stack key={phase} direction="row" justifyContent="space-between" gap={2}>
<Typography variant="body2" sx={{ color: "#78350f", fontWeight: 600 }}>
{phase}
</Typography>
<Typography variant="body2" sx={{ color: "#92400e", fontWeight: 700 }}>
${phaseCost.toFixed(2)}
</Typography>
</Stack>
);
})}
</Stack>
</Popover>
<Collapse in={isMobile && isMobileCostOpen} timeout={250}>
<Box
id={costBreakdownId}
sx={{
mt: 1.5,
p: 1.5,
borderRadius: 2,
border: "1px solid rgba(245, 158, 11, 0.2)",
bgcolor: "#fffbeb",
}}
>
<Stack spacing={0.75}>
{COST_PHASE_ORDER.map((phase) => {
const phaseCost = costEst.breakdown.find((item) => item.phase === phase)?.cost || 0;
return (
<Stack key={phase} direction="row" justifyContent="space-between" gap={2}>
<Typography variant="body2" sx={{ color: "#78350f", fontWeight: 600 }}>
{phase}
</Typography>
<Typography variant="body2" sx={{ color: "#92400e", fontWeight: 700 }}>
${phaseCost.toFixed(2)}
</Typography>
</Stack>
);
})}
</Stack>
</Box>
</Collapse>
</>
)}
{/* Progress Stepper - integrated into header when active */} {/* Progress Stepper - integrated into header when active */}
<Collapse in={activeStep >= 0} timeout={400}> <Collapse in={activeStep >= 0} timeout={400}>
<Box sx={{ mt: 1.5 }}> <Box sx={{ mt: 1.5 }}>
@@ -247,4 +382,4 @@ export const Header: React.FC<HeaderProps> = ({ onShowProjects, onNewEpisode, ac
</Collapse> </Collapse>
</Box> </Box>
); );
}; };

View File

@@ -3,7 +3,6 @@ import { Stack, Typography, Chip, Divider, Box, alpha, Paper, Stepper, Step, Ste
import { import {
Insights as InsightsIcon, Insights as InsightsIcon,
Search as SearchIcon, Search as SearchIcon,
AttachMoney as AttachMoneyIcon,
EditNote as EditNoteIcon, EditNote as EditNoteIcon,
Article as ArticleIcon, Article as ArticleIcon,
AutoAwesome as AutoAwesomeIcon, AutoAwesome as AutoAwesomeIcon,
@@ -130,19 +129,6 @@ export const ResearchSummary: React.FC<ResearchSummaryProps> = ({
}} }}
/> />
)} )}
{research.costEst?.total !== undefined && (
<Chip
icon={<AttachMoneyIcon sx={{ fontSize: "0.875rem !important" }} />}
label={`$${research.costEst.total.toFixed(3)}`}
size="small"
sx={{
background: alpha("#f59e0b", 0.1),
color: "#d97706",
fontWeight: 600,
border: "1px solid rgba(245, 158, 11, 0.2)",
}}
/>
)}
</Stack> </Stack>
</Stack> </Stack>