Files
ALwrity/frontend/src/components/shared/UserBadge.tsx
ajaysi aaf94049da feat: validate podcast cost estimation accuracy, document per-token costs, and fix subscription/plan enforcement
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
2026-05-27 08:46:38 +05:30

315 lines
11 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import { Avatar, Box, Menu, MenuItem, Typography, Tooltip, Chip, Divider, IconButton, CircularProgress } from '@mui/material';
import { useUser, useClerk } from '@clerk/clerk-react';
import { useSubscription } from '../../contexts/SubscriptionContext';
import SystemStatusIndicator from '../ContentPlanningDashboard/components/SystemStatusIndicator';
import UsageDashboard from './UsageDashboard';
import { isFeatureOnlyMode } from '../../utils/demoMode';
import {
apiClient,
isBackendCooldownActive,
logBackendCooldownSkipOnce,
} from '../../api/client';
import { saveNavigationState } from '../../utils/navigationState';
import { Refresh as RefreshIcon } from '@mui/icons-material';
interface UserBadgeProps {
colorMode?: 'light' | 'dark';
}
const UserBadge: React.FC<UserBadgeProps> = ({ colorMode = 'light' }) => {
const { user, isSignedIn } = useUser();
const { signOut } = useClerk();
const { subscription, refreshSubscription, loading } = useSubscription();
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const [systemStatus, setSystemStatus] = useState<'healthy' | 'warning' | 'critical' | 'unknown'>('unknown');
const [isRefreshing, setIsRefreshing] = useState(false);
const open = Boolean(anchorEl);
const initials = React.useMemo(() => {
const first = user?.firstName?.[0] || '';
const last = user?.lastName?.[0] || '';
return (first + last || user?.username?.[0] || user?.primaryEmailAddress?.emailAddress?.[0] || '?').toUpperCase();
}, [user]);
// Fetch system status for status bulb
useEffect(() => {
// Skip system status checks in feature-limited mode (endpoint not available)
if (isFeatureOnlyMode()) {
setSystemStatus('unknown');
return;
}
const fetchSystemStatus = async () => {
if (isBackendCooldownActive()) {
logBackendCooldownSkipOnce('UserBadge');
return;
}
try {
const response = await apiClient.get('/api/content-planning/monitoring/lightweight-stats');
const result = response.data;
if (result.status === 'success' && result.data) {
setSystemStatus(result.data.status || 'unknown');
}
} catch (err) {
// Silently fail for system status to avoid console noise
setSystemStatus('unknown');
}
};
fetchSystemStatus();
// Refresh every 120 seconds (2 minutes) to reduce load and avoid timeouts
const interval = setInterval(fetchSystemStatus, 120000);
return () => clearInterval(interval);
}, []);
if (!isSignedIn) return null;
// Get status bulb color
const getStatusBulbColor = () => {
switch (systemStatus) {
case 'healthy':
return '#4caf50'; // Green
case 'warning':
return '#ff9800'; // Orange
case 'critical':
return '#f44336'; // Red
default:
return '#757575'; // Gray for unknown
}
};
// Get plan display info
const getPlanColor = () => {
const plan = subscription?.plan?.toLowerCase() || 'free';
switch (plan) {
case 'free': return '#4caf50';
case 'basic': return '#2196f3';
case 'pro': return '#9c27b0';
case 'enterprise': return '#ff9800';
default: return '#757575';
}
};
const getPlanLabel = () => {
if (!subscription?.plan) return 'Free';
const plan = subscription.plan.toLowerCase();
if (plan === 'free') return 'Free';
if (plan === 'basic') return 'Basic';
if (plan === 'pro') return 'Pro';
if (plan === 'enterprise') return 'Enterprise';
return subscription.plan.charAt(0).toUpperCase() + subscription.plan.slice(1);
};
const handleOpen = (e: React.MouseEvent<HTMLElement>) => setAnchorEl(e.currentTarget);
const handleClose = () => setAnchorEl(null);
const handleRefreshPlan = async () => {
setIsRefreshing(true);
try {
await refreshSubscription();
} catch (err) {
console.error('Failed to refresh subscription:', err);
} finally {
setIsRefreshing(false);
}
};
const handleSignOut = async () => {
try {
await signOut();
} finally {
window.location.assign('/');
}
};
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{/* Subscription Plan Chip */}
<Chip
label={getPlanLabel()}
size="small"
sx={{
bgcolor: loading ? '#e5e7eb' : `${getPlanColor()}20`,
border: loading ? '1px solid #d1d5db' : `1px solid ${getPlanColor()}`,
color: loading ? '#9ca3af' : getPlanColor(),
fontWeight: 700,
fontSize: '0.75rem',
height: 24,
minWidth: loading ? 60 : 'auto',
animation: loading ? 'plan-pulse 1.5s ease-in-out infinite' : 'none',
'@keyframes plan-pulse': {
'0%, 100%': { opacity: 1 },
'50%': { opacity: 0.4 },
},
}}
/>
<Tooltip title="User Navigation Menu">
<Box sx={{ position: 'relative', display: 'inline-flex' }}>
<Avatar
onClick={handleOpen}
sx={{
width: 36,
height: 36,
cursor: 'pointer',
bgcolor: colorMode === 'dark' ? 'rgba(255,255,255,0.2)' : 'primary.main',
color: colorMode === 'dark' ? 'white' : 'white',
fontWeight: 700,
}}
src={user?.imageUrl || undefined}
>
{initials}
</Avatar>
{/* Status Bulb */}
<Box
sx={{
position: 'absolute',
bottom: 0,
right: 0,
width: 12,
height: 12,
borderRadius: '50%',
bgcolor: getStatusBulbColor(),
border: `2px solid ${colorMode === 'dark' ? '#1a1a1a' : 'white'}`,
boxShadow: `0 0 8px ${getStatusBulbColor()}80`,
animation: systemStatus === 'healthy' ? 'pulse 2s ease-in-out infinite' : 'none',
'@keyframes pulse': {
'0%, 100%': {
opacity: 1,
transform: 'scale(1)',
},
'50%': {
opacity: 0.8,
transform: 'scale(1.1)',
},
},
}}
/>
</Box>
</Tooltip>
<Menu
anchorEl={anchorEl}
open={open}
onClose={handleClose}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
PaperProps={{
sx: {
minWidth: 340,
maxWidth: 420,
maxHeight: '85vh',
overflow: 'auto',
bgcolor: '#ffffff',
border: '1px solid rgba(0,0,0,0.08)',
borderRadius: 3,
boxShadow: '0 8px 32px rgba(0,0,0,0.12), 0 2px 8px rgba(0,0,0,0.08)',
}
}}
>
{/* User Info Header */}
<Box sx={{ px: 2.5, py: 2, bgcolor: '#f8f9fb', borderBottom: '1px solid rgba(0,0,0,0.06)' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#1a1a2e', fontSize: '0.9rem' }}>
{user?.fullName || user?.username || 'User'}
</Typography>
<Typography variant="caption" sx={{ color: '#6b7280', fontSize: '0.75rem' }}>
{user?.primaryEmailAddress?.emailAddress}
</Typography>
</Box>
{/* Subscription Info */}
<Box sx={{ px: 2.5, py: 1.5, bgcolor: '#f8f9fb', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box>
<Typography variant="caption" sx={{ display: 'block', mb: 0.5, fontWeight: 600, color: '#6b7280', fontSize: '0.65rem', textTransform: 'uppercase', letterSpacing: '0.5px' }}>
Current Plan
</Typography>
<Chip
label={getPlanLabel()}
size="small"
sx={{
bgcolor: `${getPlanColor()}15`,
border: `1.5px solid ${getPlanColor()}40`,
color: getPlanColor(),
fontWeight: 700,
fontSize: '0.75rem',
height: 26,
}}
/>
</Box>
<Tooltip title="Refresh subscription status">
<IconButton
onClick={handleRefreshPlan}
size="small"
disabled={isRefreshing || loading}
sx={{
color: '#6b7280',
'&:hover': { bgcolor: '#e5e7eb' },
}}
>
{(isRefreshing || loading) ? <CircularProgress size={16} /> : <RefreshIcon fontSize="small" />}
</IconButton>
</Tooltip>
</Box>
<Divider sx={{ mx: 2 }} />
{/* System Status Indicator */}
<Box
sx={{
px: 2.5,
py: 1.5,
bgcolor: '#f8f9fb',
maxWidth: '100%',
overflow: 'hidden'
}}
onClick={(e) => e.stopPropagation()}
>
<Typography variant="caption" sx={{ display: 'block', mb: 1, fontWeight: 600, color: '#6b7280', fontSize: '0.65rem', textTransform: 'uppercase', letterSpacing: '0.5px' }}>
System Health
</Typography>
<Box sx={{ display: 'flex', justifyContent: 'center', '& > *': { transform: 'scale(0.85)' } }}>
<SystemStatusIndicator />
</Box>
</Box>
<Divider sx={{ mx: 2 }} />
{/* Usage Dashboard */}
<Box
sx={{
px: 2.5,
py: 1.5,
bgcolor: '#ffffff',
maxWidth: '100%',
overflow: 'auto'
}}
onClick={(e) => e.stopPropagation()}
>
<Typography variant="caption" sx={{ display: 'block', mb: 1, fontWeight: 600, color: '#6b7280', fontSize: '0.65rem', textTransform: 'uppercase', letterSpacing: '0.5px' }}>
Usage Statistics
</Typography>
<UsageDashboard compact={true} />
</Box>
<Divider sx={{ mx: 2 }} />
<MenuItem onClick={() => { handleClose(); saveNavigationState(window.location.pathname); sessionStorage.setItem('pending_subscription_change', 'true'); window.location.href = '/pricing'; }} sx={{ mx: 1, borderRadius: 1, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', color: '#ffffff', fontWeight: 600, mb: 0.5, '&:hover': { background: 'linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%)', boxShadow: '0 2px 8px rgba(99,102,241,0.4)' } }}>
Manage Subscription
</MenuItem>
<MenuItem onClick={() => { handleClose(); window.location.href = '/billing'; }} sx={{ mx: 1, borderRadius: 1, background: 'linear-gradient(135deg, #06b6d4 0%, #3b82f6 100%)', color: '#ffffff', fontWeight: 600, '&:hover': { background: 'linear-gradient(135deg, #0891b2 0%, #2563eb 100%)', boxShadow: '0 2px 8px rgba(6,182,212,0.4)' } }}>
View Costing Details
</MenuItem>
<MenuItem onClick={handleSignOut} sx={{ mx: 1, borderRadius: 1, color: '#6b7280', '&:hover': { bgcolor: '#fef2f2', color: '#ef4444' } }}>
Sign out
</MenuItem>
</Menu>
</Box>
);
};
export default UserBadge;