Add accessible cost estimate chip and phase breakdown in podcast header
This commit is contained in:
@@ -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
|
||||
}}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user