Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions frontend/src/components/ErrorBoundary.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,35 @@ function makeErrorId() {
return `ERR-${ts}-${rand}`;
}

// Best-effort, fire-and-forget crash report to the dedicated /api/analytics
// sink. Self-contained (no page-chunk imports) so the root backstop never
// depends on a module that may itself have failed to load. Wrapped so a
// reporting failure can NEVER take the app down — this is observability only.
function reportCrash({ errorId, error, errorInfo, scope }) {
try {
const payload = JSON.stringify({
event: 'react_error_boundary',
errorId,
scope: scope || undefined,
message: error?.toString?.() ?? 'unknown',
componentStack: errorInfo?.componentStack ?? undefined,
path: typeof window !== 'undefined' ? window.location.pathname : undefined,
});
if (typeof navigator !== 'undefined' && navigator.sendBeacon) {
navigator.sendBeacon('/api/analytics', new Blob([payload], { type: 'application/json' }));
} else if (typeof window !== 'undefined' && typeof window.fetch === 'function') {
window.fetch('/api/analytics', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: payload,
keepalive: true,
}).catch(() => {});
}
} catch {
// Crash reporting must never interrupt the fallback UI.
}
}

class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
Expand All @@ -51,6 +80,9 @@ class ErrorBoundary extends React.Component {
// Always log to console so it's visible in DevTools / Cloud Run logs.
console.error(`[ErrorBoundary ${errorId}]`, error, errorInfo);

// Best-effort crash report to /api/analytics. Self-wrapped; never throws.
reportCrash({ errorId, error, errorInfo, scope: this.props.scope });

// Call the global Sentry hook if observability layer is attached.
if (typeof window !== 'undefined' && typeof window.__SENTRY_HOOK__ === 'function') {
try {
Expand Down
46 changes: 26 additions & 20 deletions frontend/src/pages/AdStudio.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -996,25 +996,31 @@ export default function AdStudio({ onBack }) {
<p className="text-xs text-gray-400 mb-2">Create AI voiceover audio from your script (Google Cloud TTS)</p>

{/* Voice selector - grouped by tier */}
<div className="tho-voice-selector">
<span className="text-xs text-gray-500">Voice:</span>
<select
className="tho-input tho-select"
value={selectedVoice}
onChange={e => setSelectedVoice(e.target.value)}
>
{['Studio', 'Neural2', 'News', 'Wavenet'].map(tier => (
<optgroup key={tier} label={tier === 'Neural2' ? 'Neural2 (Recommended)' : tier}>
{voices.filter(v => v.tier === tier).map(v => (
<option key={v.id} value={v.id}>
{v.name} — {v.description} ({v.style})
</option>
))}
</optgroup>
))}
</select>
</div>

{voices.length === 0 ? (
<div className="tho-readiness-note">
No voiceover voices are available right now. Check your Google Cloud TTS configuration and retry.
</div>
) : (
<div className="tho-voice-selector">
<span className="text-xs text-gray-500">Voice:</span>
<select
className="tho-input tho-select"
value={selectedVoice}
onChange={e => setSelectedVoice(e.target.value)}
>
{['Studio', 'Neural2', 'News', 'Wavenet'].map(tier => (
<optgroup key={tier} label={tier === 'Neural2' ? 'Neural2 (Recommended)' : tier}>
{voices.filter(v => v.tier === tier).map(v => (
<option key={v.id} value={v.id}>
{v.name} — {v.description} ({v.style})
</option>
))}
</optgroup>
))}
</select>
</div>
)}

{/* Generate button */}
<button
className="tho-btn tho-btn-secondary w-full mt-2 flex items-center justify-center gap-2"
Expand Down Expand Up @@ -1124,7 +1130,7 @@ export default function AdStudio({ onBack }) {
</button>
{aiReadiness && !aiReadiness.ready && (
<div className="tho-readiness-note">
GCP AI needs attention: {(aiReadiness.requirements || []).join(', ') || 'check project and SDK configuration'}
GenAI Clip is disabled until GCP AI is ready: {(aiReadiness.requirements || []).join(', ') || 'check project and SDK configuration'}
</div>
)}

Expand Down
124 changes: 121 additions & 3 deletions frontend/src/pages/Analytics.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,22 @@ const MetricCard = ({ title, value, icon, trend, trendUp, accent, subtitle }) =>
</div>
);

// Distinct from an empty/"no data yet" state: this section's fetch failed, so we
// genuinely don't know whether there's data. Offer a retry.
const SectionError = ({ onRetry, compact }) => (
<div className={`flex flex-col items-center justify-center text-center ${compact ? 'py-6' : 'h-80'}`}>
<AlertCircle size={compact ? 20 : 32} className="text-amber-500 mb-2" />
<p className="text-sm text-gray-700 mb-3">Couldn&apos;t load this section</p>
<button
onClick={onRetry}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-blue-600 border border-blue-200 rounded-md hover:bg-blue-50 transition"
>
<RefreshCw size={14} />
Retry
</button>
</div>
);

const TimeRangeSelector = ({ value, onChange }) => (
<div className="flex bg-gray-50 rounded-lg p-1">
{['7d', '30d', '90d', 'all'].map((range) => (
Expand Down Expand Up @@ -53,6 +69,9 @@ export default function Analytics() {
const [timeSeriesData, setTimeSeriesData] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
// Per-section load failures, so a failed section reads "couldn't load"
// rather than silently looking like "no data yet".
const [sectionErrors, setSectionErrors] = useState({});
const [timeRange, setTimeRange] = useState('30d');
const [activeTab, setActiveTab] = useState('overview');

Expand All @@ -69,6 +88,17 @@ export default function Analytics() {
adminFetch(`/api/analytics/events?range=${timeRange}`).catch(() => null),
]);

// A null response = the fetch threw; a non-ok response = the server
// rejected it. Either way the section failed to load (distinct from
// a successful response that simply has no data yet).
const failed = {
leads: !leadsResp?.ok,
documents: !docsResp?.ok,
inventory: !invResp?.ok,
chat: !chatResp?.ok,
events: !eventsResp?.ok,
Comment on lines +94 to +99

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Treat analytics error payloads as failed sections

When an analytics endpoint hits its exception handler, the backend returns {"error": ...} with the default HTTP 200 (for example main.py's analytics routes), so resp.ok is still true here. In that scenario this code sets the stats state to the error object and leaves sectionErrors false, so the new retry/error UI still renders as zeros or empty data instead of surfacing the failed section. Parse the JSON for an error/expected shape, or have the server return a non-2xx status.

Useful? React with 👍 / 👎.

};

if (leadsResp?.ok) {
const data = await leadsResp.json();
setLeadStats(data);
Expand All @@ -90,6 +120,14 @@ export default function Analytics() {
const data = await eventsResp.json();
setEventStats(data);
}

setSectionErrors(failed);

// Only hard-fail the whole page if every section failed; otherwise
// surface the failures inline so the loaded sections still render.
if (Object.values(failed).every(Boolean)) {
setError('Unable to load analytics data. Please try again.');
}
} catch (e) {
console.error('Analytics fetch failed:', e);
setError('Unable to load analytics data. Please try again.');
Expand Down Expand Up @@ -217,6 +255,31 @@ export default function Analytics() {
{/* OVERVIEW TAB */}
{activeTab === 'overview' && (
<>
{/* Section-load failures — distinct from zero/empty data */}
{(sectionErrors.leads || sectionErrors.documents || sectionErrors.chat || sectionErrors.inventory) && (
<div className="flex items-center justify-between gap-4 bg-amber-50 border border-amber-200 rounded-xl px-4 py-3">
<div className="flex items-center gap-2 text-sm text-amber-800">
<AlertCircle size={18} className="shrink-0" />
<span>
Some sections couldn&apos;t load{' '}
({[
sectionErrors.leads && 'leads',
sectionErrors.documents && 'documents',
sectionErrors.chat && 'chat',
sectionErrors.inventory && 'inventory',
].filter(Boolean).join(', ')}). The values below may be incomplete.
</span>
</div>
<button
onClick={fetchData}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-amber-800 border border-amber-300 rounded-md hover:bg-amber-100 transition shrink-0"
>
<RefreshCw size={14} />
Retry
</button>
</div>
)}

{/* Key Metrics */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<MetricCard
Expand Down Expand Up @@ -346,6 +409,8 @@ export default function Analytics() {
</PieChart>
</ResponsiveContainer>
</div>
) : sectionErrors.leads ? (
<SectionError onRetry={fetchData} />
) : (
<div className="h-80 flex items-center justify-center text-gray-600">
<p>No status data available</p>
Expand All @@ -356,6 +421,9 @@ export default function Analytics() {
{/* Engagement Breakdown */}
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-200">
<h3 className="text-lg font-bold text-gray-900 mb-6">Lead Engagement</h3>
{sectionErrors.leads && engagementData.length === 0 ? (
<SectionError onRetry={fetchData} />
) : (
<div className="h-80 w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={engagementData} layout="vertical">
Expand All @@ -380,6 +448,7 @@ export default function Analytics() {
</BarChart>
</ResponsiveContainer>
</div>
)}
</div>
</div>

Expand Down Expand Up @@ -432,6 +501,13 @@ export default function Analytics() {
</td>
</tr>
))}
{sectionErrors.leads && Object.keys(leadStats?.by_status || {}).length === 0 && (
<tr>
<td colSpan="4" className="py-8">
<SectionError onRetry={fetchData} compact />
</td>
</tr>
)}
</tbody>
</table>
</div>
Expand Down Expand Up @@ -465,6 +541,8 @@ export default function Analytics() {
</ul>
</div>
</div>
) : sectionErrors.events ? (
<SectionError onRetry={fetchData} compact />
) : (
<p className="text-sm text-gray-500">No site events captured yet for this period — they accrue as visitors browse homes and open forms.</p>
)}
Expand All @@ -475,6 +553,21 @@ export default function Analytics() {
{/* DOCUMENTS TAB */}
{activeTab === 'documents' && (
<>
{sectionErrors.documents && (
<div className="flex items-center justify-between gap-4 bg-amber-50 border border-amber-200 rounded-xl px-4 py-3">
<div className="flex items-center gap-2 text-sm text-amber-800">
<AlertCircle size={18} className="shrink-0" />
<span>Couldn&apos;t load document analytics. The values below may be incomplete.</span>
</div>
<button
onClick={fetchData}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-amber-800 border border-amber-300 rounded-md hover:bg-amber-100 transition shrink-0"
>
<RefreshCw size={14} />
Retry
</button>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<MetricCard
title="Total Documents"
Expand Down Expand Up @@ -512,6 +605,8 @@ export default function Analytics() {
</BarChart>
</ResponsiveContainer>
</div>
) : sectionErrors.documents ? (
<SectionError onRetry={fetchData} />
) : (
<div className="h-80 flex items-center justify-center text-gray-600">
<p>No document data available</p>
Expand All @@ -538,7 +633,11 @@ export default function Analytics() {
</div>
))}
{(!documentStats?.recent || documentStats.recent.length === 0) && (
<p className="text-center text-gray-600 py-8">No recent activity</p>
sectionErrors.documents ? (
<SectionError onRetry={fetchData} compact />
) : (
<p className="text-center text-gray-600 py-8">No recent activity</p>
)
)}
</div>
</div>
Expand All @@ -549,6 +648,21 @@ export default function Analytics() {
{/* INVENTORY TAB */}
{activeTab === 'inventory' && (
<>
{sectionErrors.inventory && (
<div className="flex items-center justify-between gap-4 bg-amber-50 border border-amber-200 rounded-xl px-4 py-3">
<div className="flex items-center gap-2 text-sm text-amber-800">
<AlertCircle size={18} className="shrink-0" />
<span>Couldn&apos;t load inventory analytics. The values below may be incomplete.</span>
</div>
<button
onClick={fetchData}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-amber-800 border border-amber-300 rounded-md hover:bg-amber-100 transition shrink-0"
>
<RefreshCw size={14} />
Retry
</button>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
<MetricCard
title="Total Homes"
Expand Down Expand Up @@ -618,8 +732,12 @@ export default function Analytics() {
))}
{topHomesData.length === 0 && (
<tr>
<td colSpan="4" className="py-8 text-center text-gray-600">
No view data available
<td colSpan="4" className="py-8">
{sectionErrors.inventory ? (
<SectionError onRetry={fetchData} compact />
) : (
<p className="text-center text-gray-600">No view data available</p>
)}
</td>
</tr>
)}
Expand Down
Loading
Loading