diff --git a/app/(app)/admin/marketing/aeo/page.tsx b/app/(app)/admin/marketing/aeo/page.tsx index 51b3d65b8..471bfaec4 100644 --- a/app/(app)/admin/marketing/aeo/page.tsx +++ b/app/(app)/admin/marketing/aeo/page.tsx @@ -54,7 +54,7 @@ export default async function AeoPage() {
-

{t('adminMarketingAeo.marketingAeo')}

+

{t('adminMarketingAeo.marketingAeo')}

{t('adminMarketingAeo.answerEngineOptimizationTrackTheQuestions')}

SEO → @@ -130,7 +130,7 @@ async function AdminAeoReadError() { return (
-

{t('aeo.marketingAeo')}

+

{t('aeo.marketingAeo')}

{t('aeo.trackCustomerQuestionsAndStructured')}

diff --git a/app/(app)/admin/marketing/campaigns/[id]/page.tsx b/app/(app)/admin/marketing/campaigns/[id]/page.tsx index 33e1be909..74c2a1d15 100644 --- a/app/(app)/admin/marketing/campaigns/[id]/page.tsx +++ b/app/(app)/admin/marketing/campaigns/[id]/page.tsx @@ -61,7 +61,7 @@ export default async function CampaignDetailPage({ params }: { params: Promise<{
-

{c.name}

+

{c.name}

{c.status}

{c.channel} · {c.type.replace('_', ' ')}

@@ -110,7 +110,7 @@ async function CampaignDetailReadError() { return (
-

{t('campaigns.marketingCampaign')}

+

{t('campaigns.marketingCampaign')}

{t('campaigns.reviewCampaignDetailsAudienceAnd')}

diff --git a/app/(app)/admin/marketing/lead-scores/page.tsx b/app/(app)/admin/marketing/lead-scores/page.tsx index 12c466bb1..65a9ba8dc 100644 --- a/app/(app)/admin/marketing/lead-scores/page.tsx +++ b/app/(app)/admin/marketing/lead-scores/page.tsx @@ -27,7 +27,7 @@ async function ReadFailure() { const t = await getTranslations(); return (
-

{t('leadScores.leadScores')}

+

{t('leadScores.leadScores')}

{t('leadScores.refreshLeadScores')}
@@ -89,7 +89,7 @@ export default async function LeadScoresPage() {
-

{t('adminMarketingLeadScores.leadScores')}

+

{t('adminMarketingLeadScores.leadScores')}

{t('leadScores.everyContactScored0100')}

diff --git a/app/(app)/admin/marketing/seo/page.tsx b/app/(app)/admin/marketing/seo/page.tsx index 35d75a0c8..714b90420 100644 --- a/app/(app)/admin/marketing/seo/page.tsx +++ b/app/(app)/admin/marketing/seo/page.tsx @@ -55,7 +55,7 @@ export default async function SeoPage() {
-

{t('adminMarketingSeo.marketingSeo')}

+

{t('adminMarketingSeo.marketingSeo')}

{t('adminMarketingSeo.trackSearchIntentKeywordsAndThe')}

AEO → @@ -222,7 +222,7 @@ async function AdminSeoReadError() { return (
-

{t('seo.marketingSeo')}

+

{t('seo.marketingSeo')}

{t('seo.trackSearchIntentAndIndexable')}

diff --git a/app/(app)/admin/marketing/visitor-intelligence/page.tsx b/app/(app)/admin/marketing/visitor-intelligence/page.tsx index 94c915af3..ada754f87 100644 --- a/app/(app)/admin/marketing/visitor-intelligence/page.tsx +++ b/app/(app)/admin/marketing/visitor-intelligence/page.tsx @@ -57,7 +57,7 @@ export default async function VisitorIntelligencePage() { console.error('[visitor-intelligence] analytics read failed', failedMetric.error); return (
-

{t('adminMarketingVisitorIntelligence.visitorIntelligence')}

+

{t('adminMarketingVisitorIntelligence.visitorIntelligence')}

{t('adminMarketingVisitorIntelligence.refreshVisitorIntelligence')}
@@ -96,7 +96,7 @@ export default async function VisitorIntelligencePage() { return (
-

{t('adminMarketingVisitorIntelligence.visitorIntelligence')}

+

{t('adminMarketingVisitorIntelligence.visitorIntelligence')}

The privacy-first acquisition funnel end-to-end — anonymous visitors becoming identified, profiled, scored, and engaged. All first-party and consent-gated; no fingerprinting. diff --git a/app/(app)/dashboard/assistant/page.tsx b/app/(app)/dashboard/assistant/page.tsx index dd0bde50a..301e26488 100644 --- a/app/(app)/dashboard/assistant/page.tsx +++ b/app/(app)/dashboard/assistant/page.tsx @@ -1,6 +1,7 @@ import type { Metadata } from 'next'; import { requireFeature } from '@/lib/supabase/auth'; import { AssistantModule } from '@/components/modules/assistant-module'; +import { getTranslations } from '@/lib/i18n/server'; export const metadata: Metadata = { title: 'AI Assistant' }; @@ -8,5 +9,13 @@ export const metadata: Metadata = { title: 'AI Assistant' }; // to any signed-in member; the monthly quota is enforced at the request layer. export default async function AssistantPage() { await requireFeature('/dashboard/assistant'); - return ; + const t = await getTranslations(); + // The module's own heading is an h2 because it also renders inside the AI + // orb on other pages; the route's h1 lives here, for screen readers. + return ( + <> +

{t('navShared.aiAssistant')}

+ + + ); } diff --git a/app/(app)/dashboard/grocery/page.tsx b/app/(app)/dashboard/grocery/page.tsx index 3ad6bf3e4..25f0d9c4e 100644 --- a/app/(app)/dashboard/grocery/page.tsx +++ b/app/(app)/dashboard/grocery/page.tsx @@ -1,8 +1,17 @@ import type { Metadata } from 'next'; import { ShoppingModule } from '@/components/modules/shopping-module'; +import { getTranslations } from '@/lib/i18n/server'; export const metadata: Metadata = { title: 'Shopping' }; -export default function GroceryPage() { - return ; +export default async function GroceryPage() { + const t = await getTranslations(); + // The lists module has no page title of its own (its headings are the list + // sidebar and the open list); the route's h1 lives here, for screen readers. + return ( + <> +

{t('grocery.groceries')}

+ + + ); } diff --git a/app/(app)/dashboard/paperwork/actions.ts b/app/(app)/dashboard/paperwork/actions.ts index b0339721b..541bc7774 100644 --- a/app/(app)/dashboard/paperwork/actions.ts +++ b/app/(app)/dashboard/paperwork/actions.ts @@ -285,7 +285,7 @@ export async function draftPaperworkReplyAction(itemId: string): Promise { const provider = await resolveProvider(); const completion = await provider.complete({ system, messages: [{ role: 'user', content: user }], tools: [], maxTokens: 400 }); diff --git a/app/(app)/marketplace/assistant-actions.ts b/app/(app)/marketplace/assistant-actions.ts index fb044246a..7fd48df63 100644 --- a/app/(app)/marketplace/assistant-actions.ts +++ b/app/(app)/marketplace/assistant-actions.ts @@ -87,7 +87,7 @@ export async function askMarketAssistantAction( // like. The wrapper sits inside the swallow so the row separates them. const text = await withAiRequest( scopeFromUserContext(ctx, sb), - { feature: 'marketplace.assistant', text: q.slice(0, 200) }, + { feature: 'marketplace.assistant', text: 'Marketplace assistant' }, async (obs) => { const provider = await resolveProvider(); const messages: AIMessage[] = [ diff --git a/app/(app)/missions/new/page.tsx b/app/(app)/missions/new/page.tsx index b8ab4b82b..75594ce10 100644 --- a/app/(app)/missions/new/page.tsx +++ b/app/(app)/missions/new/page.tsx @@ -39,6 +39,7 @@ export default async function NewMissionPage() { return (
{t('missionsNew.backToMissions')} +

{t('missions.newMission')}

({ id: m.id, name: m.display_name }))} /> diff --git a/app/(marketing)/f/[id]/page.tsx b/app/(marketing)/f/[id]/page.tsx index 0b482462f..1628aa861 100644 --- a/app/(marketing)/f/[id]/page.tsx +++ b/app/(marketing)/f/[id]/page.tsx @@ -58,6 +58,7 @@ export default async function PublicFormPage({ params }: { params: Promise<{ id: <>
- + {/* The form's own title is the page heading: this route has no other h1. */} +
{ const provider = await resolveProvider(); const completion = await provider.complete({ diff --git a/app/api/ai/chat/route.ts b/app/api/ai/chat/route.ts index 4ac7a3df4..c5c88ab29 100644 --- a/app/api/ai/chat/route.ts +++ b/app/api/ai/chat/route.ts @@ -160,7 +160,7 @@ export async function POST(req: NextRequest) { // errors still leaves the evidence. await withAiRequest( scopeFromUserContext(ctx, supabase), - { feature: 'chat.assistant', text: message, kind: 'feature', conversationId }, + { feature: 'chat.assistant', text: 'Assistant chat', kind: 'feature', conversationId }, async (obs) => { let content = ''; const actions: { name: string; args: Record; result: unknown }[] = []; diff --git a/app/api/ai/chef/route.ts b/app/api/ai/chef/route.ts index 359ec3eb9..ab2848044 100644 --- a/app/api/ai/chef/route.ts +++ b/app/api/ai/chef/route.ts @@ -100,7 +100,7 @@ export async function POST(req: Request) { // so the row can tell the three apart. const parsed = await withAiRequest( scopeFromUserContext(ctx, supabase), - { feature: 'meals.chef', text: request.slice(0, 200) }, + { feature: 'meals.chef', text: 'Recipe from the AI chef' }, async (obs) => { const completion = await (await resolveProvider()).complete({ system: buildChefSystem(), diff --git a/app/api/ai/home/diagnose/route.ts b/app/api/ai/home/diagnose/route.ts index dffbd9d7f..1c64457ae 100644 --- a/app/api/ai/home/diagnose/route.ts +++ b/app/api/ai/home/diagnose/route.ts @@ -69,7 +69,7 @@ export async function POST(req: Request) { try { text = await withAiRequest( scopeFromUserContext(ctx, supabase), - { feature: 'home.diagnose', text: `Diagnose ${assetName || category || 'home item'}` }, + { feature: 'home.diagnose', text: 'Diagnose a home item' }, async (obs) => { const completion = await (await resolveProvider()).complete({ system, messages: [{ role: 'user', content: userMsg }], tools: [] }); obs.used(completion.model ?? 'unknown', completion.usage); diff --git a/app/api/ai/home/find-pro/route.ts b/app/api/ai/home/find-pro/route.ts index b4951afac..76bf0aef4 100644 --- a/app/api/ai/home/find-pro/route.ts +++ b/app/api/ai/home/find-pro/route.ts @@ -62,7 +62,7 @@ export async function POST(req: Request) { try { text = await withAiRequest( scopeFromUserContext(ctx, supabase), - { feature: 'home.find-pro', text: `Hire ${tradeLabel}` }, + { feature: 'home.find-pro', text: 'Find a pro' }, async (obs) => { const completion = await (await resolveProvider()).complete({ system, messages: [{ role: 'user', content: userMsg }], tools: [] }); obs.used(completion.model ?? 'unknown', completion.usage); diff --git a/app/api/ai/home/forecast/route.ts b/app/api/ai/home/forecast/route.ts index e12c6dca1..d545e45a1 100644 --- a/app/api/ai/home/forecast/route.ts +++ b/app/api/ai/home/forecast/route.ts @@ -63,7 +63,7 @@ export async function POST(req: Request) { try { text = await withAiRequest( scopeFromUserContext(ctx, supabase), - { feature: 'home.forecast', text: `Maintenance outlook for ${assets.length} assets` }, + { feature: 'home.forecast', text: 'Maintenance outlook' }, async (obs) => { const completion = await (await resolveProvider()).complete({ system, messages: [{ role: 'user', content: `Home assets:\n${lines}` }], tools: [] }); obs.used(completion.model ?? 'unknown', completion.usage); diff --git a/app/api/ai/home/utility-savings/route.ts b/app/api/ai/home/utility-savings/route.ts index 0a800129f..1216843d8 100644 --- a/app/api/ai/home/utility-savings/route.ts +++ b/app/api/ai/home/utility-savings/route.ts @@ -101,7 +101,7 @@ export async function POST() { // reading a support ticket can tell it from "AI wasn't configured". recommendations = await withAiRequest( scopeFromUserContext(ctx, supabase), - { feature: 'home.utility-savings', text: `Utility savings across ${bills.length} bills` }, + { feature: 'home.utility-savings', text: 'Utility savings' }, async (obs) => { const completion = await (await resolveProvider()).complete({ system, messages: [{ role: 'user', content: userMsg }], tools: [] }); obs.used(completion.model ?? 'unknown', completion.usage); diff --git a/app/api/ai/insights/route.ts b/app/api/ai/insights/route.ts index a4aa97ea0..aa703b95f 100644 --- a/app/api/ai/insights/route.ts +++ b/app/api/ai/insights/route.ts @@ -119,7 +119,7 @@ export async function POST(req: Request) { try { const text = await withAiRequest( scopeFromUserContext(ctx, supabase), - { feature: `insights.${kind}`, text: `Insights: ${kind}` }, + { feature: `insights.${kind}`, text: 'Insights' }, async (obs) => { const provider = await resolveProvider(); const completion = await provider.complete({ diff --git a/app/api/ai/meals/nutrition/route.ts b/app/api/ai/meals/nutrition/route.ts index 27eda7d53..ba75a9244 100644 --- a/app/api/ai/meals/nutrition/route.ts +++ b/app/api/ai/meals/nutrition/route.ts @@ -171,7 +171,7 @@ export async function POST(req: Request) { try { parsed = await withAiRequest( scopeFromUserContext(ctx, supabase), - { feature: 'meals.nutrition', text: `Nutrition for a ${subjectType}` }, + { feature: 'meals.nutrition', text: 'Nutrition breakdown' }, async (obs) => { const completion = await (await resolveProvider()).complete({ system: SYSTEM, messages: [{ role: 'user', content: userMsg }], tools: [], maxTokens: 600, diff --git a/app/api/ai/trip/route.ts b/app/api/ai/trip/route.ts index 97b1314d1..5029094ce 100644 --- a/app/api/ai/trip/route.ts +++ b/app/api/ai/trip/route.ts @@ -60,7 +60,7 @@ export async function POST(req: NextRequest) { // look like "no API key configured" too. const parsed = await withAiRequest( scopeFromUserContext(ctx, supabase), - { feature: 'travel.research', text: `Research a trip to ${input.destination}` }, + { feature: 'travel.research', text: 'Research a trip' }, async (obs) => { const provider = await resolveProvider(); const completion = await provider.complete({ diff --git a/app/api/mkt/track/route.ts b/app/api/mkt/track/route.ts index 3f8709f0a..26c7a3cda 100644 --- a/app/api/mkt/track/route.ts +++ b/app/api/mkt/track/route.ts @@ -130,11 +130,28 @@ export async function POST(req: NextRequest) { country: clean(body.country), session_count: 1, }).select('id').single(); - if (createError) { + if (createError && createError.code === '23505') { + // Two events from a visitor's first page (the page view and the one + // fired beside it on /ai, say) both read "no visitor" and both insert; + // the loser hit the unique key and answered 503. The row it lost to is + // the visitor it was about to create: read it and record against it. + // Found by the 2026-09-27 page audit (P-08). + const { data: winner, error: winnerError } = await supabase + .from('mkt_visitors') + .select('id') + .eq('anonymous_id', anonymousId) + .maybeSingle(); + if (winnerError || !winner) { + console.error('[mkt-track] visitor re-read after a concurrent create failed', winnerError ?? 'no row'); + return NextResponse.json({ error: t('track.analyticsIsTemporarilyUnavailable') }, { status: 503 }); + } + visitorId = winner.id; + } else if (createError) { console.error('[mkt-track] visitor create failed', createError); return NextResponse.json({ error: t('track.analyticsIsTemporarilyUnavailable') }, { status: 503 }); + } else { + visitorId = created?.id ?? null; } - visitorId = created?.id ?? null; } if (!visitorId) return NextResponse.json({ error: t('track.couldNotRecordVisitor') }, { status: 500 }); diff --git a/app/api/vacations/ai/route.ts b/app/api/vacations/ai/route.ts index bd55e1979..d715169e0 100644 --- a/app/api/vacations/ai/route.ts +++ b/app/api/vacations/ai/route.ts @@ -136,7 +136,7 @@ Costs/planned are whole US dollars. Keep itinerary day numbers between 1 and ${r try { const built = await withAiRequest( scopeFromUserContext(ctx, supabase), - { feature: 'vacations.build', text: `Build a plan for ${nights} nights` }, + { feature: 'vacations.build', text: 'Build a vacation plan' }, async (obs) => { const provider = await resolveProvider(); const completion = await provider.complete({ system, messages: [{ role: 'user', content: user }], tools: [], maxTokens: 2000 }); diff --git a/components/app/free-tier-sidebar.tsx b/components/app/free-tier-sidebar.tsx index a57646383..e9f13e909 100644 --- a/components/app/free-tier-sidebar.tsx +++ b/components/app/free-tier-sidebar.tsx @@ -8,7 +8,7 @@ // (user_preferences.notification_prefs.sidebarNav) so the rail follows the // member across devices, with localStorage as an offline cache. -import { useCallback, useEffect, useId, useMemo, useState } from 'react'; +import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; import Link from 'next/link'; import { Star, ListPlus, ListX, Lock, RotateCcw } from 'lucide-react'; import { createClient } from '@/lib/supabase/client'; @@ -114,6 +114,8 @@ function useSidebarNav() { const [loaded, setLoaded] = useState(false); const [loadError, setLoadError] = useState(false); const [reload, setReload] = useState(0); + const networkRetries = useRef(0); + const retryTimer = useRef | null>(null); // Supabase is authoritative — reconcile once on mount (and on an explicit retry). useEffect(() => { @@ -124,6 +126,7 @@ function useSidebarNav() { // (a failed re-read un-confirms whatever an earlier read established, // as in Settings) and surface the failure. if (error) { setLoadError(true); setLoaded(false); return; } + networkRetries.current = 0; setLoadError(false); if (nav != null) { const clean = resolveNavKeys(nav, DEFAULT_SIDEBAR_NAV_KEYS, ALL_SERVICES_KEYS); @@ -139,13 +142,23 @@ function useSidebarNav() { // authoritative, and writable. setLoaded(true); }).catch((err) => { + // A server action the router cancelled (a client redirect landing while + // the read was in flight, e.g. /dashboard/vacations/ → /overview) + // rejects with a network TypeError while this sidebar stays mounted. + // That is not a read that failed: try again shortly, twice, before + // saying so. Anything else, or a third network failure, is reported. + if (active && err instanceof TypeError && networkRetries.current < 2) { + networkRetries.current += 1; + retryTimer.current = setTimeout(() => setReload((value) => value + 1), 800 * networkRetries.current); + return; + } console.error('[sidebar] preference read failed', err); if (active) { setLoadError(true); setLoaded(false); } }); - return () => { active = false; }; + return () => { active = false; if (retryTimer.current) clearTimeout(retryTimer.current); }; }, [reload]); - const retryLoad = useCallback(() => { setLoadError(false); setReload((value) => value + 1); }, []); + const retryLoad = useCallback(() => { networkRetries.current = 0; setLoadError(false); setReload((value) => value + 1); }, []); // Live update when a layout change is broadcast in this tab (Settings editor // saving, or the other FreeTierSidebar instance pinning). diff --git a/components/modules/behavior-module.tsx b/components/modules/behavior-module.tsx index 1d9b70bf4..25c41dfe1 100644 --- a/components/modules/behavior-module.tsx +++ b/components/modules/behavior-module.tsx @@ -147,7 +147,7 @@ export function BehaviorModule() { return (
-

{tr('behavior.behaviorParentingInsights')}

+

{tr('behavior.behaviorParentingInsights')}

{members.length > 0 && (