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
This commit is contained in:
@@ -88,6 +88,16 @@ export const BrainstormButton: React.FC<BrainstormButtonProps> = ({
|
||||
pendingBrainstormRef.current = false;
|
||||
};
|
||||
|
||||
const handleReRun = async (newKeywords: string) => {
|
||||
if (newKeywords !== keywords) {
|
||||
onKeywordsChange(newKeywords);
|
||||
}
|
||||
const result = await brainstorm(newKeywords, undefined, true);
|
||||
if (result && onBrainstormResult) {
|
||||
onBrainstormResult(result);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
@@ -183,6 +193,8 @@ export const BrainstormButton: React.FC<BrainstormButtonProps> = ({
|
||||
isBrainstorming={isBrainstorming}
|
||||
progressMessage={progressMessage}
|
||||
onSelectSuggestion={handleSelectSuggestion}
|
||||
initialKeywords={keywords}
|
||||
onReRun={handleReRun}
|
||||
/>
|
||||
|
||||
{showConnectOverlay && (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { PieChart, Pie, Cell, Tooltip as ReTooltip, ResponsiveContainer } from 'recharts';
|
||||
import {
|
||||
ContentOpportunity,
|
||||
KeywordGap,
|
||||
@@ -22,6 +23,8 @@ interface GSCBrainstormModalProps {
|
||||
isBrainstorming: boolean;
|
||||
progressMessage?: string;
|
||||
onSelectSuggestion: (keyword: string) => void;
|
||||
initialKeywords: string;
|
||||
onReRun: (keywords: string) => void;
|
||||
}
|
||||
|
||||
const tabLabels = [
|
||||
@@ -46,8 +49,13 @@ export const GSCBrainstormModal: React.FC<GSCBrainstormModalProps> = ({
|
||||
isBrainstorming,
|
||||
progressMessage,
|
||||
onSelectSuggestion,
|
||||
initialKeywords,
|
||||
onReRun,
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = React.useState<TabKey>('Quick Wins');
|
||||
const [topicInput, setTopicInput] = React.useState(initialKeywords);
|
||||
|
||||
React.useEffect(() => setTopicInput(initialKeywords), [initialKeywords]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
@@ -87,10 +95,10 @@ export const GSCBrainstormModal: React.FC<GSCBrainstormModalProps> = ({
|
||||
style={{
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: '16px',
|
||||
width: '85vw',
|
||||
height: '85vh',
|
||||
maxWidth: '1200px',
|
||||
maxHeight: '900px',
|
||||
width: '90vw',
|
||||
height: '90vh',
|
||||
maxWidth: '1400px',
|
||||
maxHeight: '96vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
boxShadow: '0 16px 48px rgba(0,0,0,0.25)',
|
||||
@@ -100,26 +108,56 @@ export const GSCBrainstormModal: React.FC<GSCBrainstormModalProps> = ({
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
padding: '20px 28px', borderBottom: '1px solid #e8e8e8', flexShrink: 0,
|
||||
display: 'flex', alignItems: 'center', gap: '10px',
|
||||
padding: '12px 28px', borderBottom: '1px solid #e8e8e8', flexShrink: 0,
|
||||
}}>
|
||||
<div>
|
||||
<h3 style={{ margin: 0, fontSize: '20px', fontWeight: 600, color: '#1a1a1a' }}>
|
||||
Brainstorm Topics with GSC Data
|
||||
</h3>
|
||||
{summary?.site_url && (
|
||||
<p style={{ margin: '4px 0 0', fontSize: '13px', color: '#888' }}>
|
||||
{summary.site_url} · {summary.date_range?.start} to {summary.date_range?.end} ·{' '}
|
||||
{summary.total_keywords_analyzed} keywords
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<h3 style={{ margin: 0, fontSize: '16px', fontWeight: 600, color: '#1a1a1a', whiteSpace: 'nowrap', flexShrink: 0 }}>
|
||||
Brainstorm Topics
|
||||
</h3>
|
||||
<input
|
||||
value={topicInput}
|
||||
onChange={(e) => setTopicInput(e.target.value)}
|
||||
disabled={isBrainstorming}
|
||||
placeholder="Enter research topic or keywords..."
|
||||
style={{
|
||||
flex: 1, padding: '6px 10px', border: '1px solid #ddd',
|
||||
borderRadius: '6px', fontSize: '13px', color: '#333',
|
||||
backgroundColor: isBrainstorming ? '#f5f5f5' : '#fff',
|
||||
outline: 'none', minWidth: 0,
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
const trimmed = topicInput.trim();
|
||||
if (trimmed && trimmed.split(/\s+/).length >= 3 && !isBrainstorming) {
|
||||
onReRun(trimmed);
|
||||
}
|
||||
}}
|
||||
disabled={isBrainstorming || topicInput.trim().split(/\s+/).length < 3}
|
||||
title={
|
||||
topicInput.trim().split(/\s+/).length < 3
|
||||
? 'Enter at least 3 words'
|
||||
: 'Re-run brainstorm with these keywords (bypasses cache)'
|
||||
}
|
||||
style={{
|
||||
padding: '6px 14px', border: 'none', borderRadius: '6px',
|
||||
backgroundColor: isBrainstorming ? '#ccc' : '#1976d2',
|
||||
color: '#fff', fontSize: '12px', fontWeight: 600,
|
||||
cursor: isBrainstorming || topicInput.trim().split(/\s+/).length < 3 ? 'not-allowed' : 'pointer',
|
||||
whiteSpace: 'nowrap', transition: 'background-color 0.15s', flexShrink: 0,
|
||||
}}
|
||||
>{isBrainstorming ? 'Running...' : 'Re-Run'}</button>
|
||||
{summary?.site_url && (
|
||||
<span style={{ fontSize: '11px', color: '#999', flexShrink: 0, maxWidth: '180px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{summary.site_url.replace(/^https?:\/\//, '').slice(0, 30)}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'none', border: 'none', fontSize: '22px', cursor: 'pointer',
|
||||
color: '#999', padding: '4px 10px', borderRadius: '6px',
|
||||
transition: 'background-color 0.15s', lineHeight: 1,
|
||||
background: 'none', border: 'none', fontSize: '20px', cursor: 'pointer',
|
||||
color: '#999', padding: '2px 8px', borderRadius: '4px',
|
||||
transition: 'background-color 0.15s', lineHeight: 1, flexShrink: 0,
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f0f0'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||
@@ -300,71 +338,146 @@ export const GSCBrainstormModal: React.FC<GSCBrainstormModalProps> = ({
|
||||
/* Summary Dashboard */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Metric tooltips */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const METRIC_HELP: Record<string, string> = {
|
||||
Impressions: "How many times your site appeared in Google search results over the last 30 days. More impressions means more visibility — but you also want clicks.",
|
||||
Clicks: "How many times people actually clicked on your site in search results. Low clicks with high impressions means your titles or descriptions need improvement.",
|
||||
'Avg CTR': "Click-Through Rate — the percentage of people who saw your result and clicked it. Higher is better. The industry average is around 2-3%.",
|
||||
'Avg Position': "Your average ranking across all keywords. Position 1 is the top result. Positions 1-3 get most clicks; anything below page 1 (position 10+) gets very few.",
|
||||
'SEO Health': "A composite score from 0-100 based on your rankings, CTR, and keyword distribution. 70+ is good, 40-70 needs work, below 40 needs attention.",
|
||||
'Top 3': "Keywords ranking in the top 3 positions. These are your strongest pages — already visible to most searchers. Small improvements here can bring big gains.",
|
||||
'4-10': "Keywords on page 1 of Google (positions 4-10). These have good visibility but room to climb higher. Optimizing these can push you into the top 3.",
|
||||
'11-20': "Keywords on page 2 of Google. Searchers rarely go past page 1, so writing targeted content for these keywords could dramatically increase traffic.",
|
||||
'21+': "Keywords deep in search results. Low visibility, but often easier to rank for with focused content. These represent untapped traffic potential.",
|
||||
'Rank Distribution': "Shows where your keywords fall in Google's search results. A healthy profile has keywords spread across all ranges, with a focus on page 1.",
|
||||
};
|
||||
|
||||
const HelpIcon: React.FC<{ text: string }> = ({ text }) => {
|
||||
const [show, setShow] = React.useState(false);
|
||||
return (
|
||||
<span style={{ position: 'relative', display: 'inline-flex', alignItems: 'center', marginLeft: '3px' }}>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: '14px', height: '14px', borderRadius: '50%',
|
||||
backgroundColor: '#bbb', color: '#fff', fontSize: '10px',
|
||||
fontWeight: 700, cursor: 'help', lineHeight: '14px', userSelect: 'none',
|
||||
}}
|
||||
onMouseEnter={() => setShow(true)}
|
||||
onMouseLeave={() => setShow(false)}
|
||||
onClick={() => setShow(!show)}
|
||||
>?</span>
|
||||
{show && (
|
||||
<div style={{
|
||||
position: 'absolute', bottom: 'calc(100% + 6px)', left: '50%', transform: 'translateX(-50%)',
|
||||
backgroundColor: '#333', color: '#fff', padding: '8px 12px',
|
||||
borderRadius: '8px', fontSize: '12px', lineHeight: 1.5,
|
||||
maxWidth: '280px', width: 'max-content', textAlign: 'center',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.2)', zIndex: 100,
|
||||
}}>
|
||||
{text}
|
||||
<div style={{
|
||||
position: 'absolute', top: '100%', left: '50%', transform: 'translateX(-50%)',
|
||||
border: '6px solid transparent', borderTopColor: '#333',
|
||||
}} />
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const PIE_COLORS = ['#2e7d32', '#1565c0', '#f57c00', '#999'];
|
||||
|
||||
const SummaryDashboard: React.FC<{ summary: BrainstormSummary }> = ({ summary }) => {
|
||||
const dist = summary.keyword_distribution || {};
|
||||
const total = dist.positions_1_3 + dist.positions_4_10 + dist.positions_11_20 + dist.positions_21_plus || 1;
|
||||
const healthColor = summary.health_score >= 70 ? '#2e7d32' : summary.health_score >= 40 ? '#f57c00' : '#d32f2f';
|
||||
const ctrColor = summary.ctr_vs_benchmark >= 0 ? '#2e7d32' : '#d32f2f';
|
||||
|
||||
const pieData = [
|
||||
{ name: 'Top 3', value: dist.positions_1_3, pct: Math.round(dist.positions_1_3 / total * 100) },
|
||||
{ name: '4-10', value: dist.positions_4_10, pct: Math.round(dist.positions_4_10 / total * 100) },
|
||||
{ name: '11-20', value: dist.positions_11_20, pct: Math.round(dist.positions_11_20 / total * 100) },
|
||||
{ name: '21+', value: dist.positions_21_plus, pct: Math.round(dist.positions_21_plus / total * 100) },
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ borderBottom: '1px solid #e8e8e8', flexShrink: 0 }}>
|
||||
<div style={{
|
||||
display: 'flex', gap: '28px', padding: '14px 28px',
|
||||
backgroundColor: '#f8fbff', flexWrap: 'wrap',
|
||||
display: 'flex', alignItems: 'center', gap: '20px',
|
||||
padding: '10px 28px', backgroundColor: '#f8fbff',
|
||||
}}>
|
||||
<MetricBox label="Impressions" value={summary.total_impressions?.toLocaleString()} />
|
||||
<MetricBox label="Clicks" value={summary.total_clicks?.toLocaleString()} />
|
||||
<MetricBox
|
||||
label="Avg CTR"
|
||||
value={`${summary.avg_ctr}%`}
|
||||
sublabel={`vs 3.1% avg`}
|
||||
sublabelColor={ctrColor}
|
||||
driving
|
||||
/>
|
||||
<MetricBox label="Avg Position" value={`${summary.avg_position}`} />
|
||||
<MetricBox label="SEO Health" value={`${summary.health_score}/100`} valueColor={healthColor} driving />
|
||||
</div>
|
||||
{total > 1 && (
|
||||
<div style={{
|
||||
padding: '0 28px 12px', display: 'flex', gap: '16px',
|
||||
fontSize: '12px', color: '#666', flexWrap: 'wrap', alignItems: 'center',
|
||||
}}>
|
||||
<span style={{ fontSize: '11px', fontWeight: 500, color: '#999', textTransform: 'uppercase', letterSpacing: '0.5px' }}>
|
||||
Rank Distribution
|
||||
</span>
|
||||
<DistBadge label="Top 3" count={dist.positions_1_3} total={total} color="#2e7d32" />
|
||||
<DistBadge label="4-10" count={dist.positions_4_10} total={total} color="#1565c0" />
|
||||
<DistBadge label="11-20" count={dist.positions_11_20} total={total} color="#f57c00" />
|
||||
<DistBadge label="21+" count={dist.positions_21_plus} total={total} color="#999" />
|
||||
{/* Metric boxes */}
|
||||
<div style={{ display: 'flex', gap: '16px', flex: 1, flexWrap: 'wrap' }}>
|
||||
<MetricBox label="Impressions" value={summary.total_impressions?.toLocaleString()} tooltip={METRIC_HELP.Impressions} />
|
||||
<MetricBox label="Clicks" value={summary.total_clicks?.toLocaleString()} tooltip={METRIC_HELP.Clicks} />
|
||||
<MetricBox driving label="Avg CTR" value={`${summary.avg_ctr}%`} sublabel={`vs 3.1% avg`} sublabelColor={ctrColor} tooltip={METRIC_HELP['Avg CTR']} />
|
||||
<MetricBox label="Avg Position" value={`${summary.avg_position}`} tooltip={METRIC_HELP['Avg Position']} />
|
||||
<MetricBox driving label="SEO Health" value={`${summary.health_score}/100`} valueColor={healthColor} tooltip={METRIC_HELP['SEO Health']} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rank distribution pie chart */}
|
||||
{total > 1 && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', flexShrink: 0 }}>
|
||||
<div style={{ width: '80px', height: '80px' }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie data={pieData} cx="50%" cy="50%" innerRadius={22} outerRadius={36} dataKey="value" paddingAngle={2} stroke="none">
|
||||
{pieData.map((entry, idx) => (
|
||||
<Cell key={idx} fill={PIE_COLORS[idx]} />
|
||||
))}
|
||||
</Pie>
|
||||
<ReTooltip
|
||||
content={({ active, payload }) => {
|
||||
if (!active || !payload?.length) return null;
|
||||
const d = payload[0].payload;
|
||||
return (
|
||||
<div style={{ backgroundColor: '#333', color: '#fff', padding: '6px 10px', borderRadius: '6px', fontSize: '12px' }}>
|
||||
{d.name}: {d.value} keywords ({d.pct}%)
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px', fontSize: '11px' }}>
|
||||
{pieData.map((d, idx) => (
|
||||
<span key={idx} style={{ display: 'flex', alignItems: 'center', gap: '4px', color: '#666' }}>
|
||||
<span style={{ width: '8px', height: '8px', borderRadius: '50%', backgroundColor: PIE_COLORS[idx], display: 'inline-block', flexShrink: 0 }} />
|
||||
{d.name}: <strong>{d.value}</strong>
|
||||
<HelpIcon text={METRIC_HELP[d.name as keyof typeof METRIC_HELP] || ''} />
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MetricBox: React.FC<{
|
||||
label: string; value: string; valueColor?: string;
|
||||
sublabel?: string; sublabelColor?: string; driving?: boolean;
|
||||
}> = ({ label, value, valueColor, sublabel, sublabelColor, driving }) => (
|
||||
sublabel?: string; sublabelColor?: string; driving?: boolean; tooltip?: string;
|
||||
}> = ({ label, value, valueColor, sublabel, sublabelColor, driving, tooltip }) => (
|
||||
<div style={{
|
||||
textAlign: 'center', padding: driving ? '0 20px 0 0' : 0,
|
||||
textAlign: 'center', padding: driving ? '0 14px 0 0' : 0,
|
||||
borderRight: driving ? '1px solid #e0e0e0' : 'none',
|
||||
}}>
|
||||
<div style={{ fontSize: '20px', fontWeight: 700, color: valueColor || '#1a1a1a' }}>{value}</div>
|
||||
<div style={{ fontSize: '12px', color: '#888' }}>{label}</div>
|
||||
<div style={{ fontSize: '18px', fontWeight: 700, color: valueColor || '#1a1a1a', lineHeight: 1.2 }}>{value}</div>
|
||||
<div style={{ fontSize: '11px', color: '#888', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
{label}
|
||||
{tooltip && <HelpIcon text={tooltip} />}
|
||||
</div>
|
||||
{sublabel && <div style={{ fontSize: '10px', color: sublabelColor || '#999', fontWeight: 500 }}>{sublabel}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
const DistBadge: React.FC<{ label: string; count: number; total: number; color: string }> = ({ label, count, total, color }) => (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}>
|
||||
<span style={{
|
||||
width: '10px', height: '10px', borderRadius: '50%',
|
||||
backgroundColor: color, display: 'inline-block', flexShrink: 0,
|
||||
}} />
|
||||
<span>{label}: <strong>{count}</strong> <span style={{ color: '#999' }}>({Math.round(count / total * 100)}%)</span></span>
|
||||
</span>
|
||||
);
|
||||
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Quick Wins Tab */
|
||||
@@ -379,6 +492,7 @@ const QuickWinsTab: React.FC<{ wins: QuickWin[]; onSelect: (kw: string) => void
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
<p style={{ margin: '0 0 4px', fontSize: '14px', color: '#555' }}>
|
||||
These keywords are already on page 1. A small optimization push could land them in the top 3 — the highest-ROI opportunities available.
|
||||
<HelpIcon text="'Page 1' means Google's first search results page (positions 1-10). Being on page 1 is critical — over 90% of clicks go to page 1 results. Top 3 positions get the lion's share of those clicks." />
|
||||
</p>
|
||||
{wins.map((win, i) => (
|
||||
<div
|
||||
@@ -401,7 +515,7 @@ const QuickWinsTab: React.FC<{ wins: QuickWin[]; onSelect: (kw: string) => void
|
||||
</div>
|
||||
<p style={{ margin: '0 0 6px', fontSize: '13px', color: '#444', lineHeight: 1.5 }}>{win.reason}</p>
|
||||
<div style={{ fontSize: '12px', color: '#888' }}>
|
||||
{win.impressions.toLocaleString()} impressions · {win.current_ctr}% current CTR
|
||||
<InlineHelp text="Times your site appeared in Google search results">{(win.impressions.toLocaleString())} impressions</InlineHelp> · <InlineHelp text="Percentage of people who saw and clicked your result">{win.current_ctr}% CTR</InlineHelp>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -445,9 +559,9 @@ const OpportunitiesTab: React.FC<{ opportunities: ContentOpportunity[]; onSelect
|
||||
</div>
|
||||
<p style={{ margin: '0 0 8px', fontSize: '13px', color: '#444', lineHeight: 1.5 }}>{opp.opportunity}</p>
|
||||
<div style={{ display: 'flex', gap: '16px', fontSize: '12px', color: '#888', flexWrap: 'wrap' }}>
|
||||
<span>{opp.impressions.toLocaleString()} impressions</span>
|
||||
<span>Position {opp.current_position}</span>
|
||||
<span>{opp.current_ctr}% CTR</span>
|
||||
<InlineHelp text="How many times this keyword appeared in search results">{opp.impressions.toLocaleString()} impressions</InlineHelp>
|
||||
<InlineHelp text="Your average ranking for this keyword. Position 1 = top of Google.">Position {opp.current_position}</InlineHelp>
|
||||
<InlineHelp text="Click-Through Rate — the % of viewers who clicked on your result">{opp.current_ctr}% CTR</InlineHelp>
|
||||
<span style={{ color: '#2e7d32', fontWeight: 600 }}>+{opp.estimated_traffic_gain} clicks/mo potential</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -469,6 +583,7 @@ const GapsTab: React.FC<{ gaps: KeywordGap[]; onSelect: (kw: string) => void }>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
<p style={{ margin: '0 0 6px', fontSize: '14px', color: '#555' }}>
|
||||
These keywords rank between positions 4-20. Writing targeted content could push them to page 1 where CTR increases dramatically.
|
||||
<HelpIcon text="CTR (Click-Through Rate) jumps significantly on page 1 — the #1 result gets ~28% of clicks, while page 2 results get less than 1%. Moving from page 2 to page 1 can 10x your traffic." />
|
||||
</p>
|
||||
{gaps.map((gap, i) => (
|
||||
<div
|
||||
@@ -485,11 +600,11 @@ const GapsTab: React.FC<{ gaps: KeywordGap[]; onSelect: (kw: string) => void }>
|
||||
<div>
|
||||
<span style={{ fontWeight: 600, fontSize: '15px', color: '#1a1a1a' }}>{gap.keyword}</span>
|
||||
<div style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
|
||||
{gap.current_ctr}% CTR · {gap.clicks} clicks
|
||||
<InlineHelp text="Click-Through Rate — how often searchers click your result">{gap.current_ctr}% CTR</InlineHelp> · {gap.clicks} clicks
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right', fontSize: '12px' }}>
|
||||
<div style={{ color: gap.position <= 10 ? '#1565c0' : '#f57c00', fontWeight: 600 }}>Position #{gap.position.toFixed(0)}</div>
|
||||
<InlineHelp text="Position 1-10 is page 1 of Google, 11-20 is page 2">Position #{gap.position.toFixed(0)}</InlineHelp>
|
||||
<div style={{ color: '#2e7d32', fontWeight: 500 }}>+{gap.estimated_traffic_if_page1} clicks/mo if page 1</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -511,6 +626,7 @@ const PagesTab: React.FC<{ pages: PageOpportunity[] }> = ({ pages }) => {
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
<p style={{ margin: '0 0 4px', fontSize: '14px', color: '#555' }}>
|
||||
These pages get significant impressions but low click-through rates. Improving their titles and meta descriptions can boost clicks.
|
||||
<HelpIcon text="Meta descriptions are the short preview text under your page title in search results. A compelling meta description can double your CTR. Titles should include your target keyword and a value proposition." />
|
||||
</p>
|
||||
{pages.map((pg, i) => (
|
||||
<div key={i} style={{
|
||||
@@ -523,7 +639,7 @@ const PagesTab: React.FC<{ pages: PageOpportunity[] }> = ({ pages }) => {
|
||||
</div>
|
||||
<p style={{ margin: '0 0 8px', fontSize: '13px', color: '#555', lineHeight: 1.5 }}>{pg.reason}</p>
|
||||
<div style={{ fontSize: '12px', color: '#888' }}>
|
||||
{pg.impressions.toLocaleString()} impressions · {pg.clicks} clicks · Position {pg.current_position}
|
||||
<InlineHelp text="How many times this page appeared in search results">{pg.impressions.toLocaleString()} impressions</InlineHelp> · {pg.clicks} clicks · <InlineHelp text="Average search ranking for this page. Lower is better.">Position {pg.current_position}</InlineHelp>
|
||||
</div>
|
||||
<div style={{ fontSize: '11px', color: '#999', marginTop: '6px', wordBreak: 'break-all' }}>{pg.page}</div>
|
||||
</div>
|
||||
@@ -606,6 +722,35 @@ const RecommendationSection: React.FC<{ title: string; items: AIRecommendation[]
|
||||
/* Shared */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const InlineHelp: React.FC<{ text: string; children: React.ReactNode }> = ({ text, children }) => {
|
||||
const [show, setShow] = React.useState(false);
|
||||
return (
|
||||
<span style={{ position: 'relative', display: 'inline-flex', alignItems: 'center' }}>
|
||||
<span
|
||||
onMouseEnter={() => setShow(true)}
|
||||
onMouseLeave={() => setShow(false)}
|
||||
onClick={() => setShow(!show)}
|
||||
style={{ cursor: 'help', borderBottom: '1px dashed #bbb' }}
|
||||
>{children}</span>
|
||||
{show && (
|
||||
<span style={{
|
||||
position: 'absolute', bottom: 'calc(100% + 6px)', left: '50%', transform: 'translateX(-50%)',
|
||||
backgroundColor: '#333', color: '#fff', padding: '8px 12px',
|
||||
borderRadius: '8px', fontSize: '12px', lineHeight: 1.5,
|
||||
maxWidth: '260px', width: 'max-content', textAlign: 'center',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.2)', zIndex: 100, whiteSpace: 'normal',
|
||||
}}>
|
||||
{text}
|
||||
<span style={{
|
||||
position: 'absolute', top: '100%', left: '50%', transform: 'translateX(-50%)',
|
||||
border: '6px solid transparent', borderTopColor: '#333',
|
||||
}} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const Badge: React.FC<{ label: string; color: string }> = ({ label, color }) => (
|
||||
<span style={{
|
||||
fontSize: '11px', fontWeight: 600, padding: '3px 10px',
|
||||
|
||||
@@ -323,13 +323,7 @@ export const AnalysisPanel: React.FC<AnalysisPanelProps> = ({
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} sx={{ display: { xs: 'none', lg: 'flex' } }}>
|
||||
<Chip
|
||||
label={`Voice: $${estimate.ttsCost.toFixed(2)}`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ height: 20, fontSize: '0.7rem', color: "#64748b", borderColor: "rgba(0,0,0,0.15)", bgcolor: "rgba(0,0,0,0.02)" }}
|
||||
/>
|
||||
<Chip
|
||||
label={`Visuals: $${estimate.avatarCost.toFixed(2)}`}
|
||||
label={`Analysis: $${estimate.analysisCost.toFixed(2)}`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ height: 20, fontSize: '0.7rem', color: "#64748b", borderColor: "rgba(0,0,0,0.15)", bgcolor: "rgba(0,0,0,0.02)" }}
|
||||
@@ -340,6 +334,25 @@ export const AnalysisPanel: React.FC<AnalysisPanelProps> = ({
|
||||
variant="outlined"
|
||||
sx={{ height: 20, fontSize: '0.7rem', color: "#64748b", borderColor: "rgba(0,0,0,0.15)", bgcolor: "rgba(0,0,0,0.02)" }}
|
||||
/>
|
||||
<Chip
|
||||
label={`Script: $${estimate.scriptCost.toFixed(2)}`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ height: 20, fontSize: '0.7rem', color: "#64748b", borderColor: "rgba(0,0,0,0.15)", bgcolor: "rgba(0,0,0,0.02)" }}
|
||||
/>
|
||||
<Chip
|
||||
label={`Voice: $${(estimate.ttsCost + estimate.voiceCloneCost).toFixed(2)}`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
title={`Voice narration ($${estimate.ttsCost.toFixed(2)}) + cloning ($${estimate.voiceCloneCost.toFixed(2)})`}
|
||||
sx={{ height: 20, fontSize: '0.7rem', color: "#64748b", borderColor: "rgba(0,0,0,0.15)", bgcolor: "rgba(0,0,0,0.02)" }}
|
||||
/>
|
||||
<Chip
|
||||
label={`Visuals: $${(estimate.avatarCost + estimate.videoCost).toFixed(2)}`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ height: 20, fontSize: '0.7rem', color: "#64748b", borderColor: "rgba(0,0,0,0.15)", bgcolor: "rgba(0,0,0,0.02)" }}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
@@ -142,13 +142,7 @@ export const AnalysisPanelLayout: React.FC<{ children: React.ReactNode }> = ({ c
|
||||
)}
|
||||
<Stack direction="row" spacing={1} sx={{ display: { xs: 'none', lg: 'flex' } }}>
|
||||
<Chip
|
||||
label={`Voice: $${estimate.ttsCost.toFixed(2)}`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ height: 20, fontSize: '0.7rem', color: "#64748b", borderColor: "rgba(0,0,0,0.15)", bgcolor: "rgba(0,0,0,0.02)" }}
|
||||
/>
|
||||
<Chip
|
||||
label={`Visuals: $${estimate.avatarCost.toFixed(2)}`}
|
||||
label={`Analysis: $${estimate.analysisCost.toFixed(2)}`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ height: 20, fontSize: '0.7rem', color: "#64748b", borderColor: "rgba(0,0,0,0.15)", bgcolor: "rgba(0,0,0,0.02)" }}
|
||||
@@ -159,6 +153,25 @@ export const AnalysisPanelLayout: React.FC<{ children: React.ReactNode }> = ({ c
|
||||
variant="outlined"
|
||||
sx={{ height: 20, fontSize: '0.7rem', color: "#64748b", borderColor: "rgba(0,0,0,0.15)", bgcolor: "rgba(0,0,0,0.02)" }}
|
||||
/>
|
||||
<Chip
|
||||
label={`Script: $${estimate.scriptCost.toFixed(2)}`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ height: 20, fontSize: '0.7rem', color: "#64748b", borderColor: "rgba(0,0,0,0.15)", bgcolor: "rgba(0,0,0,0.02)" }}
|
||||
/>
|
||||
<Chip
|
||||
label={`Voice: $${(estimate.ttsCost + estimate.voiceCloneCost).toFixed(2)}`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
title={`Voice narration ($${estimate.ttsCost.toFixed(2)}) + cloning ($${estimate.voiceCloneCost.toFixed(2)})`}
|
||||
sx={{ height: 20, fontSize: '0.7rem', color: "#64748b", borderColor: "rgba(0,0,0,0.15)", bgcolor: "rgba(0,0,0,0.02)" }}
|
||||
/>
|
||||
<Chip
|
||||
label={`Visuals: $${(estimate.avatarCost + estimate.videoCost).toFixed(2)}`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ height: 20, fontSize: '0.7rem', color: "#64748b", borderColor: "rgba(0,0,0,0.15)", bgcolor: "rgba(0,0,0,0.02)" }}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
@@ -162,7 +162,7 @@ useEffect(() => {
|
||||
}, [topicInput]);
|
||||
|
||||
// Cost estimate state - compatible with TopicUrlInput props
|
||||
type EstimateType = number | { ttsCost: number; avatarCost: number; videoCost: number; researchCost: number; total: number; } | null;
|
||||
type EstimateType = number | { analysisCost: number; researchCost: number; scriptCost: number; ttsCost: number; voiceCloneCost: number; avatarCost: number; videoCost: number; total: number; } | null;
|
||||
const [estimatedCost, setEstimatedCost] = useState<EstimateType>(null);
|
||||
const [costEstimateLoading, setCostEstimateLoading] = useState(false);
|
||||
|
||||
|
||||
@@ -39,10 +39,13 @@ interface TopicUrlInputProps {
|
||||
categoryResearchLoading?: boolean;
|
||||
// Estimated cost - can be a number (from pre-estimate) or object (from analyze response)
|
||||
estimatedCost?: number | {
|
||||
analysisCost: number;
|
||||
researchCost: number;
|
||||
scriptCost: number;
|
||||
ttsCost: number;
|
||||
voiceCloneCost: number;
|
||||
avatarCost: number;
|
||||
videoCost: number;
|
||||
researchCost: number;
|
||||
total: number;
|
||||
} | null;
|
||||
duration?: number;
|
||||
|
||||
@@ -90,13 +90,21 @@ const PodcastDashboard: React.FC = () => {
|
||||
}
|
||||
|
||||
if (estimate) {
|
||||
const analyzeCost = breakdownMap.get("Analyze") || 0;
|
||||
const gatherCost = breakdownMap.get("Gather") || 0;
|
||||
const writeCost = breakdownMap.get("Write") || 0;
|
||||
const produceCost = breakdownMap.get("Produce") || 0;
|
||||
if (analyzeCost === 0 && estimate.analysisCost > 0) {
|
||||
breakdownMap.set("Analyze", estimate.analysisCost);
|
||||
}
|
||||
if (gatherCost === 0 && estimate.researchCost > 0) {
|
||||
breakdownMap.set("Gather", estimate.researchCost);
|
||||
}
|
||||
if (writeCost === 0 && estimate.scriptCost > 0) {
|
||||
breakdownMap.set("Write", estimate.scriptCost);
|
||||
}
|
||||
if (produceCost === 0) {
|
||||
breakdownMap.set("Produce", estimate.ttsCost + estimate.avatarCost + estimate.videoCost);
|
||||
breakdownMap.set("Produce", estimate.ttsCost + estimate.voiceCloneCost + estimate.avatarCost + estimate.videoCost);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,15 +31,9 @@ export const EstimateCard: React.FC<EstimateCardProps> = ({ estimate }) => {
|
||||
<Divider sx={{ borderColor: "rgba(0,0,0,0.08)" }} />
|
||||
<Stack direction="row" spacing={2} flexWrap="wrap" useFlexGap>
|
||||
<Chip
|
||||
label={`Voice: $${estimate.ttsCost.toFixed(2)}`}
|
||||
label={`Analysis: $${estimate.analysisCost.toFixed(2)}`}
|
||||
size="small"
|
||||
title="Voice narration cost"
|
||||
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
|
||||
/>
|
||||
<Chip
|
||||
label={`Visuals: $${estimate.avatarCost.toFixed(2)}`}
|
||||
size="small"
|
||||
title="Avatar/video cost"
|
||||
title="Topic analysis cost"
|
||||
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
|
||||
/>
|
||||
<Chip
|
||||
@@ -48,6 +42,24 @@ export const EstimateCard: React.FC<EstimateCardProps> = ({ estimate }) => {
|
||||
title="Research and fact-checking cost"
|
||||
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
|
||||
/>
|
||||
<Chip
|
||||
label={`Script: $${estimate.scriptCost.toFixed(2)}`}
|
||||
size="small"
|
||||
title="Script generation cost"
|
||||
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
|
||||
/>
|
||||
<Chip
|
||||
label={`Voice: $${(estimate.ttsCost + estimate.voiceCloneCost).toFixed(2)}`}
|
||||
size="small"
|
||||
title={`Voice narration ($${estimate.ttsCost.toFixed(2)}) + cloning ($${estimate.voiceCloneCost.toFixed(2)})`}
|
||||
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
|
||||
/>
|
||||
<Chip
|
||||
label={`Visuals: $${(estimate.avatarCost + estimate.videoCost).toFixed(2)}`}
|
||||
size="small"
|
||||
title="Avatar and video cost"
|
||||
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</GlassyCard>
|
||||
|
||||
@@ -147,10 +147,13 @@ export type PodcastAnalysis = {
|
||||
};
|
||||
|
||||
export type PodcastEstimate = {
|
||||
analysisCost: number;
|
||||
researchCost: number;
|
||||
scriptCost: number;
|
||||
ttsCost: number;
|
||||
voiceCloneCost: number;
|
||||
avatarCost: number;
|
||||
videoCost: number;
|
||||
researchCost: number;
|
||||
total: number;
|
||||
voiceName?: string;
|
||||
isCustomVoice?: boolean;
|
||||
|
||||
@@ -39,7 +39,12 @@ const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
|
||||
const isFeatureLimited = shouldSkipOnboarding();
|
||||
const defaultRoute = getDefaultLandingRoute();
|
||||
const isOnDefaultRoute = typeof location?.pathname === 'string' && location.pathname.startsWith(defaultRoute);
|
||||
const allowAccess = isOnboardingComplete || localComplete || (isFeatureLimited && isOnDefaultRoute);
|
||||
|
||||
// Allow access to utility pages regardless of onboarding status
|
||||
const bypassRoutes = ['/billing', '/pricing', '/onboarding'];
|
||||
const isBypassRoute = typeof location?.pathname === 'string' && bypassRoutes.some(route => location.pathname.startsWith(route));
|
||||
|
||||
const allowAccess = isOnboardingComplete || localComplete || (isFeatureLimited && isOnDefaultRoute) || isBypassRoute;
|
||||
|
||||
// Wait for Clerk to load before any redirect decisions
|
||||
if (!isLoaded) {
|
||||
|
||||
@@ -20,7 +20,7 @@ interface UserBadgeProps {
|
||||
const UserBadge: React.FC<UserBadgeProps> = ({ colorMode = 'light' }) => {
|
||||
const { user, isSignedIn } = useUser();
|
||||
const { signOut } = useClerk();
|
||||
const { subscription, refreshSubscription } = useSubscription();
|
||||
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);
|
||||
@@ -131,12 +131,18 @@ const UserBadge: React.FC<UserBadgeProps> = ({ colorMode = 'light' }) => {
|
||||
label={getPlanLabel()}
|
||||
size="small"
|
||||
sx={{
|
||||
bgcolor: `${getPlanColor()}20`,
|
||||
border: `1px solid ${getPlanColor()}`,
|
||||
color: getPlanColor(),
|
||||
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 },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -236,13 +242,13 @@ const UserBadge: React.FC<UserBadgeProps> = ({ colorMode = 'light' }) => {
|
||||
<IconButton
|
||||
onClick={handleRefreshPlan}
|
||||
size="small"
|
||||
disabled={isRefreshing}
|
||||
disabled={isRefreshing || loading}
|
||||
sx={{
|
||||
color: '#6b7280',
|
||||
'&:hover': { bgcolor: '#e5e7eb' },
|
||||
}}
|
||||
>
|
||||
{isRefreshing ? <CircularProgress size={16} /> : <RefreshIcon fontSize="small" />}
|
||||
{(isRefreshing || loading) ? <CircularProgress size={16} /> : <RefreshIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
@@ -289,10 +295,10 @@ const UserBadge: React.FC<UserBadgeProps> = ({ colorMode = 'light' }) => {
|
||||
|
||||
<Divider sx={{ mx: 2 }} />
|
||||
|
||||
<MenuItem onClick={() => { handleClose(); saveNavigationState(window.location.pathname); window.location.href = '/pricing'; }} sx={{ mx: 1, borderRadius: 1, color: '#374151', '&:hover': { bgcolor: '#f3f4f6' } }}>
|
||||
<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, color: '#374151', '&:hover': { bgcolor: '#f3f4f6' } }}>
|
||||
<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' } }}>
|
||||
|
||||
@@ -151,7 +151,7 @@ export const SubscriptionProvider: React.FC<SubscriptionProviderProps> = ({ chil
|
||||
|
||||
if (process.env.NODE_ENV === 'development') console.log('SubscriptionContext: Checking subscription for user:', userId);
|
||||
const response = await apiClient.get(`/api/subscription/status/${userId}`);
|
||||
let subscriptionData = response.data.data;
|
||||
const subscriptionData = response.data.data;
|
||||
|
||||
if (process.env.NODE_ENV === 'development') console.log('SubscriptionContext: Subscription data received:', { active: subscriptionData?.active, plan: subscriptionData?.plan });
|
||||
|
||||
@@ -191,21 +191,6 @@ export const SubscriptionProvider: React.FC<SubscriptionProviderProps> = ({ chil
|
||||
// Update ref immediately so callbacks can access latest value
|
||||
subscriptionRef.current = subscriptionData;
|
||||
|
||||
if (subscriptionData && (subscriptionData.plan === 'free' || subscriptionData.plan === 'none')) {
|
||||
try {
|
||||
const verifyResponse = await apiClient.get(`/api/subscription/verify-checkout/${userId}`);
|
||||
const verifiedData = verifyResponse.data?.data;
|
||||
if (verifiedData && verifiedData.plan && verifiedData.plan !== 'free' && verifiedData.plan !== 'none') {
|
||||
subscriptionData = { ...subscriptionData, ...verifiedData };
|
||||
setSubscription(subscriptionData);
|
||||
subscriptionRef.current = subscriptionData;
|
||||
console.log('SubscriptionContext: Plan corrected via Stripe re-verification:', verifiedData.plan);
|
||||
}
|
||||
} catch {
|
||||
// Silently ignore — Stripe may not be configured or user has no Stripe customer
|
||||
}
|
||||
}
|
||||
|
||||
// Check if subscription is expired/inactive and show modal
|
||||
// Show modal if subscription is inactive on initial load (when subscription was null before)
|
||||
// This ensures the modal shows when an end user navigates to the app
|
||||
@@ -393,6 +378,12 @@ export const SubscriptionProvider: React.FC<SubscriptionProviderProps> = ({ chil
|
||||
}
|
||||
}, [planSignature]);
|
||||
|
||||
// Ref so mount effect always calls latest verifyCheckout
|
||||
const verifyCheckoutRef = useRef(verifyCheckout);
|
||||
useEffect(() => {
|
||||
verifyCheckoutRef.current = verifyCheckout;
|
||||
}, [verifyCheckout]);
|
||||
|
||||
const showExpiredModal = useCallback(() => {
|
||||
setIsUsageLimitModal(false);
|
||||
setShowModal(true);
|
||||
@@ -721,6 +712,32 @@ export const SubscriptionProvider: React.FC<SubscriptionProviderProps> = ({ chil
|
||||
};
|
||||
}, []); // Remove checkSubscription dependency to prevent loop
|
||||
|
||||
// One-time Stripe sync after initial checkSubscription
|
||||
// Handles: Customer Portal returns, new subscriptions with delayed webhooks
|
||||
useEffect(() => {
|
||||
const pendingChange = sessionStorage.getItem('pending_subscription_change');
|
||||
if (pendingChange === 'true') {
|
||||
sessionStorage.removeItem('pending_subscription_change');
|
||||
}
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
const current = subscriptionRef.current;
|
||||
if (!current) return;
|
||||
const plan = (current.plan || '').toLowerCase();
|
||||
if (pendingChange === 'true' || plan === 'free' || plan === 'none') {
|
||||
console.log('[StripeSync] Syncing with Stripe after mount, reason:',
|
||||
pendingChange ? 'Customer Portal return' : 'free plan check');
|
||||
try {
|
||||
await verifyCheckoutRef.current();
|
||||
} catch {
|
||||
// verifyCheckout already logs errors internally
|
||||
}
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, []); // Only run on mount
|
||||
|
||||
const value: SubscriptionContextType = {
|
||||
subscription,
|
||||
loading,
|
||||
|
||||
@@ -28,7 +28,7 @@ interface UseGSCBrainstormReturn {
|
||||
aiRecommendations: AIRecommendations | null;
|
||||
summary: BrainstormSummary | null;
|
||||
connectGSC: () => Promise<void>;
|
||||
brainstorm: (keywords: string, siteUrl?: string) => Promise<BrainstormResult | null>;
|
||||
brainstorm: (keywords: string, siteUrl?: string, forceRefresh?: boolean) => Promise<BrainstormResult | null>;
|
||||
reset: () => void;
|
||||
progressMessage: string;
|
||||
}
|
||||
@@ -92,12 +92,34 @@ export const useGSCBrainstorm = (): UseGSCBrainstormReturn => {
|
||||
setProgressMessage('');
|
||||
};
|
||||
|
||||
const makeCacheKey = (keywords: string, siteUrl?: string) => {
|
||||
const norm = keywords.trim().toLowerCase().replace(/\s+/g, ' ').slice(0, 200);
|
||||
return `gsc_brainstorm_${norm}_${siteUrl || ''}`;
|
||||
};
|
||||
|
||||
const brainstorm = useCallback(
|
||||
async (keywords: string, siteUrl?: string): Promise<BrainstormResult | null> => {
|
||||
async (keywords: string, siteUrl?: string, forceRefresh?: boolean): Promise<BrainstormResult | null> => {
|
||||
setIsBrainstorming(true);
|
||||
setBrainstormError(null);
|
||||
startProgressMessages();
|
||||
|
||||
const cacheKey = makeCacheKey(keywords, siteUrl);
|
||||
|
||||
if (!forceRefresh) {
|
||||
try {
|
||||
const cached = typeof window !== 'undefined' ? localStorage.getItem(cacheKey) : null;
|
||||
if (cached) {
|
||||
const parsed: BrainstormResult = JSON.parse(cached);
|
||||
if (parsed && !parsed.error && parsed.content_opportunities?.length) {
|
||||
setBrainstormResult(parsed);
|
||||
stopProgressMessages();
|
||||
setIsBrainstorming(false);
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
} catch { /* cache read failed — proceed with API call */ }
|
||||
}
|
||||
|
||||
try {
|
||||
gscBrainstormAPI.setAuthTokenGetter(async () => {
|
||||
try {
|
||||
@@ -109,6 +131,9 @@ export const useGSCBrainstorm = (): UseGSCBrainstormReturn => {
|
||||
|
||||
const result = await gscBrainstormAPI.brainstorm(keywords, siteUrl);
|
||||
setBrainstormResult(result);
|
||||
if (result && !result.error) {
|
||||
try { localStorage.setItem(cacheKey, JSON.stringify(result)); } catch { /* quota exceeded */ }
|
||||
}
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
let message = 'Failed to brainstorm topics. Please try again.';
|
||||
|
||||
@@ -129,7 +129,7 @@ const deriveSegments = (option?: OptionLike): string[] => {
|
||||
|
||||
const toPodcastEstimate = (raw: any, voiceId?: string): PodcastEstimate | null => {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
const numeric = ["ttsCost", "avatarCost", "videoCost", "researchCost", "total"] as const;
|
||||
const numeric = ["analysisCost", "researchCost", "scriptCost", "ttsCost", "voiceCloneCost", "avatarCost", "videoCost", "total"] as const;
|
||||
if (numeric.some((key) => typeof raw[key] !== "number" || Number.isNaN(raw[key]))) {
|
||||
return null;
|
||||
}
|
||||
@@ -156,10 +156,13 @@ const toPodcastEstimate = (raw: any, voiceId?: string): PodcastEstimate | null =
|
||||
].includes(voiceId)
|
||||
);
|
||||
return {
|
||||
analysisCost: raw.analysisCost,
|
||||
researchCost: raw.researchCost,
|
||||
scriptCost: raw.scriptCost,
|
||||
ttsCost: raw.ttsCost,
|
||||
voiceCloneCost: raw.voiceCloneCost,
|
||||
avatarCost: raw.avatarCost,
|
||||
videoCost: raw.videoCost,
|
||||
researchCost: raw.researchCost,
|
||||
total: raw.total,
|
||||
voiceName: isCustomVoice ? "My Voice Clone" : (!voiceId ? "Wise Woman" : voiceId.replace(/_/g, " ")),
|
||||
isCustomVoice,
|
||||
|
||||
Reference in New Issue
Block a user