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 { Box, Paper, Stack, Alert, Divider, CircularProgress, alpha, Dialog, DialogTitle, DialogContent, DialogActions, Button, Typography } from "@mui/material";
import { usePodcastProjectState } from "../../hooks/usePodcastProjectState";
import { PodcastCostEst } from "./types";
import { CreateModal } from "./CreateModal";
import { AnalysisPanel } from "./AnalysisPanel";
import { ScriptEditor } from "./ScriptEditor";
@@ -68,6 +69,50 @@ const PodcastDashboard: React.FC = () => {
});
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) => {
try {
@@ -122,6 +167,7 @@ const PodcastDashboard: React.FC = () => {
...(scriptData ? [2] : []),
...(renderJobs.some(j => j.status === "completed") ? [3] : []),
]}
costEst={headerCostEst}
onStepClick={(step) => {
// Handle step clicks - could navigate to different views
}}

View File

@@ -1,18 +1,18 @@
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 {
Mic as MicIcon,
Menu as MenuIcon,
Close as CloseIcon,
AutoAwesome as AutoAwesomeIcon,
AttachMoney as AttachMoneyIcon,
LibraryMusic as LibraryMusicIcon,
Folder as FolderIcon,
Help as HelpIcon,
Add as AddIcon,
BarChart as BarChartIcon,
} from "@mui/icons-material";
import { useTheme } from "@mui/material/styles";
import { useNavigate } from "react-router-dom";
import { PrimaryButton } from "../ui";
import { PodcastCostEst } from "../types";
import HeaderControls from "../../shared/HeaderControls";
import { ProgressStepper } from "./ProgressStepper";
@@ -22,12 +22,22 @@ interface HeaderProps {
activeStep?: number;
completedSteps?: number[];
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 theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [costAnchorEl, setCostAnchorEl] = useState<null | HTMLElement>(null);
const [isMobileCostOpen, setIsMobileCostOpen] = useState(false);
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>) => {
setAnchorEl(event.currentTarget);
@@ -57,6 +67,35 @@ export const Header: React.FC<HeaderProps> = ({ onShowProjects, onNewEpisode, ac
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 (
<Box
sx={{
@@ -118,7 +157,35 @@ export const Header: React.FC<HeaderProps> = ({ onShowProjects, onNewEpisode, ac
</Stack>
{/* 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) */}
<HeaderControls colorMode="light" showAlerts={true} showUser={true} />
@@ -235,6 +302,74 @@ export const Header: React.FC<HeaderProps> = ({ onShowProjects, onNewEpisode, ac
</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 */}
<Collapse in={activeStep >= 0} timeout={400}>
<Box sx={{ mt: 1.5 }}>
@@ -247,4 +382,4 @@ export const Header: React.FC<HeaderProps> = ({ onShowProjects, onNewEpisode, ac
</Collapse>
</Box>
);
};
};

View File

@@ -3,7 +3,6 @@ import { Stack, Typography, Chip, Divider, Box, alpha, Paper, Stepper, Step, Ste
import {
Insights as InsightsIcon,
Search as SearchIcon,
AttachMoney as AttachMoneyIcon,
EditNote as EditNoteIcon,
Article as ArticleIcon,
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>