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 { 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
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user