From b9a7ad55bea5b3e8cae660dd9bea5362df84e3e3 Mon Sep 17 00:00:00 2001 From: arigatoexpress <95630102+arigatoexpress@users.noreply.github.com> Date: Tue, 16 Jun 2026 21:04:33 -0600 Subject: [PATCH] feat(frontend): surface silent failures + empty/disabled-state UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Staff-facing observability + clearer states (no happy-path change): - Analytics: distinguish a failed section (with retry) from genuine "no data" instead of swallowing fetch errors with .catch(()=>null) - AdStudio: empty-state for no scheduled posts / no voices, and a reason when GenAI generation is disabled by readiness - ErrorBoundary: best-effort fire-and-forget crash report to /api/analytics (cannot throw), fallback UI unchanged Customer lead forms — SOFT, non-blocking only (never gate a valid submission): - Contact: soft email-format hint; phone remains the only hard requirement - Appointments: explain disabled dates + soft email hint Frontend build: vite build clean. CRM unchanged (feedback already present). Co-Authored-By: Claude Opus 4.8 --- frontend/src/components/ErrorBoundary.jsx | 32 ++++++ frontend/src/pages/AdStudio.jsx | 46 ++++---- frontend/src/pages/Analytics.jsx | 124 +++++++++++++++++++++- frontend/src/pages/Appointments.jsx | 9 +- frontend/src/pages/Contact.jsx | 12 ++- 5 files changed, 197 insertions(+), 26 deletions(-) diff --git a/frontend/src/components/ErrorBoundary.jsx b/frontend/src/components/ErrorBoundary.jsx index 39bbbdd..91bbe07 100644 --- a/frontend/src/components/ErrorBoundary.jsx +++ b/frontend/src/components/ErrorBoundary.jsx @@ -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); @@ -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 { diff --git a/frontend/src/pages/AdStudio.jsx b/frontend/src/pages/AdStudio.jsx index 04307c6..d0cc00c 100644 --- a/frontend/src/pages/AdStudio.jsx +++ b/frontend/src/pages/AdStudio.jsx @@ -996,25 +996,31 @@ export default function AdStudio({ onBack }) {

Create AI voiceover audio from your script (Google Cloud TTS)

{/* Voice selector - grouped by tier */} -
- Voice: - -
- + {voices.length === 0 ? ( +
+ No voiceover voices are available right now. Check your Google Cloud TTS configuration and retry. +
+ ) : ( +
+ Voice: + +
+ )} + {/* Generate button */} + +); + const TimeRangeSelector = ({ value, onChange }) => (
{['7d', '30d', '90d', 'all'].map((range) => ( @@ -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'); @@ -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, + }; + if (leadsResp?.ok) { const data = await leadsResp.json(); setLeadStats(data); @@ -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.'); @@ -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) && ( +
+
+ + + Some sections couldn't load{' '} + ({[ + sectionErrors.leads && 'leads', + sectionErrors.documents && 'documents', + sectionErrors.chat && 'chat', + sectionErrors.inventory && 'inventory', + ].filter(Boolean).join(', ')}). The values below may be incomplete. + +
+ +
+ )} + {/* Key Metrics */}
+ ) : sectionErrors.leads ? ( + ) : (

No status data available

@@ -356,6 +421,9 @@ export default function Analytics() { {/* Engagement Breakdown */}

Lead Engagement

+ {sectionErrors.leads && engagementData.length === 0 ? ( + + ) : (
@@ -380,6 +448,7 @@ export default function Analytics() {
+ )}
@@ -432,6 +501,13 @@ export default function Analytics() { ))} + {sectionErrors.leads && Object.keys(leadStats?.by_status || {}).length === 0 && ( + + + + + + )}
@@ -465,6 +541,8 @@ export default function Analytics() { + ) : sectionErrors.events ? ( + ) : (

No site events captured yet for this period — they accrue as visitors browse homes and open forms.

)} @@ -475,6 +553,21 @@ export default function Analytics() { {/* DOCUMENTS TAB */} {activeTab === 'documents' && ( <> + {sectionErrors.documents && ( +
+
+ + Couldn't load document analytics. The values below may be incomplete. +
+ +
+ )}
+ ) : sectionErrors.documents ? ( + ) : (

No document data available

@@ -538,7 +633,11 @@ export default function Analytics() {
))} {(!documentStats?.recent || documentStats.recent.length === 0) && ( -

No recent activity

+ sectionErrors.documents ? ( + + ) : ( +

No recent activity

+ ) )} @@ -549,6 +648,21 @@ export default function Analytics() { {/* INVENTORY TAB */} {activeTab === 'inventory' && ( <> + {sectionErrors.inventory && ( +
+
+ + Couldn't load inventory analytics. The values below may be incomplete. +
+ +
+ )}
- - No view data available + + {sectionErrors.inventory ? ( + + ) : ( +

No view data available

+ )} )} diff --git a/frontend/src/pages/Appointments.jsx b/frontend/src/pages/Appointments.jsx index 456e250..17d6c32 100644 --- a/frontend/src/pages/Appointments.jsx +++ b/frontend/src/pages/Appointments.jsx @@ -125,6 +125,9 @@ const CalendarGrid = ({ selectedDate, onSelect }) => {
{cells}
+

+ Dimmed dates are unavailable — past days and dates more than 30 days out can't be booked online. Call {BUSINESS_PHONE} for dates further ahead. +

); }; @@ -253,9 +256,13 @@ const ContactForm = ({ formData, onChange, onSubmit, onBack, submitting }) => { autoComplete="email" value={formData.email} onChange={(e) => onChange({ ...formData, email: e.target.value })} - className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none" + className={`w-full px-4 py-2.5 border rounded-lg focus:ring-2 focus:ring-blue-500 outline-none ${formData.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email) ? 'border-amber-300' : 'border-gray-300'}`} placeholder="john@example.com" + aria-describedby={formData.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email) ? 'appt-email-hint' : undefined} /> + {formData.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email) && ( +

This doesn't look like a complete email — double-check it so we can send your confirmation. (Optional — you can still book without it.)

+ )}
diff --git a/frontend/src/pages/Contact.jsx b/frontend/src/pages/Contact.jsx index 6c5161c..d46e524 100644 --- a/frontend/src/pages/Contact.jsx +++ b/frontend/src/pages/Contact.jsx @@ -10,6 +10,9 @@ const Contact = ({ onBack }) => { const phoneDigits = formData.phone.replace(/\D/g, ''); const isPhoneValid = phoneDigits.length >= 10; + // Soft hint only — email is optional and NEVER gates submission. + const emailLooksValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email.trim()); + const showEmailHint = formData.email.trim() !== '' && !emailLooksValid; const handleSubmit = async (e) => { e.preventDefault(); @@ -44,7 +47,8 @@ const Contact = ({ onBack }) => {

Message Received!

-

Thank you for reaching out. A member of the {BUSINESS_NAME} family will contact you shortly.

+

Thank you for reaching out. A member of the {BUSINESS_NAME} family will contact you shortly.

+

We'll reach out at the phone number you provided.