import React, { useCallback, useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { AiReadinessBanner } from '../components/dashboard/AiReadinessBanner'; import { CreditBalanceBanner } from '../components/dashboard/CreditBalanceBanner'; import { LatestArticles } from '../components/dashboard/LatestArticles'; import { LatestGaps } from '../components/dashboard/LatestGaps'; import { LatestInsights } from '../components/dashboard/LatestInsights'; import { OverviewStatCards } from '../components/dashboard/OverviewStatCards'; import { ScoreBreakdown, type ScoreRow, } from '../components/dashboard/ScoreBreakdown'; import { ScoreRing } from '../components/dashboard/ScoreRing'; import { SetupProgressStepper } from '../components/dashboard/SetupProgressStepper'; import { TrialBanner } from '../components/dashboard/TrialBanner'; import { useAppStateContext } from '../context/user.data.context'; import { recomaze_ai_personalization_env } from '../env'; import { useCopilotDataChanged } from '../hooks/useCopilotDataChanged'; import { buildGreeting, computeAgentQuality, computeReadinessScore, displayNameFromDomain, } from '../lib/dashboard-score'; import BaseAgentService from '../service/BaseAgentService'; import { fetchCopilotQuests } from '../service/copilot/copilot.service'; import type { ICreditBalance } from '../service/credits/credits.interface'; import { getCreditBalance } from '../service/credits/credits.service'; import { MINUTES_PER_STEP, WORDPRESS_STEPS, } from '../service/setup-progress/setup-progress.constants'; import { getSetupProgress } from '../service/setup-progress/setup-progress.service'; import type { ISetupProgress } from '../service/setup-progress/setup-progress.interface'; interface IDashboardSummaryVisibility { has_scan: boolean; visibility_score: number; total_queries: number; queries_with_brand: number; share_of_voice: number; } interface IDashboardSummaryCatalog { has_job: boolean; state: 'pending' | 'processing' | 'completed' | 'failed' | null; overall_score: number; total_products: number; processed_products: number; critical_count: number; } interface IDashboardSummaryAgent { total_conversations: number; agent_gap_count: number; } interface IDashboardSummary { visibility: IDashboardSummaryVisibility; catalog: IDashboardSummaryCatalog; agent: IDashboardSummaryAgent; } const EMPTY_DASHBOARD_SUMMARY: IDashboardSummary = { visibility: { has_scan: false, visibility_score: 0, total_queries: 0, queries_with_brand: 0, share_of_voice: 0, }, catalog: { has_job: false, state: null, overall_score: 0, total_products: 0, processed_products: 0, critical_count: 0, }, agent: { total_conversations: 0, agent_gap_count: 0 }, }; const Dashboard = (): JSX.Element => { const { user, token, clientId } = useAppStateContext(); const navigate = useNavigate(); const [setupProgress, setSetupProgress] = useState( null ); const [summary, setSummary] = useState( EMPTY_DASHBOARD_SUMMARY ); const [liveCredits, setLiveCredits] = useState({}); // Quests pillar (5% of the score); stays 0 until the copilot quest overview loads. const [questPillarScore, setQuestPillarScore] = useState(0); useEffect(() => { if (!token) return; let cancelled = false; void (async () => { try { const balance = await getCreditBalance(token, clientId); if (!cancelled) setLiveCredits(balance); } catch { // Benign: credit balance unavailable for this store. } })(); return () => { cancelled = true; }; }, [token, clientId]); const refreshQuestScore = useCallback(async (): Promise => { if (!token || !clientId) return; const overview = await fetchCopilotQuests(token, clientId); setQuestPillarScore(overview?.quest_pillar_score ?? 0); }, [token, clientId]); useEffect(() => { void refreshQuestScore(); }, [refreshQuestScore]); const refreshSetupProgress = useCallback(async (): Promise => { if (!token) return; const fresh: ISetupProgress | null = await getSetupProgress(); if (fresh) setSetupProgress(fresh); }, [token]); useEffect(() => { refreshSetupProgress(); }, [refreshSetupProgress]); const refreshSummary = useCallback((): void => { if (!token) return; BaseAgentService.get('ai-readiness/dashboard-summary') .then((response: { data?: IDashboardSummary }) => setSummary(response?.data ?? EMPTY_DASHBOARD_SUMMARY) ) .catch(() => setSummary(EMPTY_DASHBOARD_SUMMARY)); }, [token]); useEffect(() => { refreshSummary(); }, [refreshSummary]); // A copilot write (e.g. a catalog content check) updates the Catalog Quality // pillar and the overall AI Readiness score shown here, so re-pull the // dashboard summary and setup progress when the copilot signals a change. useCopilotDataChanged(() => { refreshSummary(); void refreshSetupProgress(); void refreshQuestScore(); }); // The authoritative /v2/credits/balance endpoint wins over the user snapshot // so the credit banner always reflects the merchant's real allowance. const credits: ICreditBalance = { ...(user?.credits ?? {}), ...liveCredits, }; const domain: string = recomaze_ai_personalization_env?.domain ?? ''; const displayName: string = displayNameFromDomain(domain) || user?.name || ''; const greeting: string = buildGreeting(new Date(), displayName); const visibilityScore: number = summary.visibility.visibility_score; // Trust the backend's own scan flag rather than re-deriving "has a scan" // from query counts on the client. const visibilityHasScan: boolean = summary.visibility.has_scan; const catalog = summary.catalog; const catalogCompleted: boolean = catalog.state === 'completed'; const catalogRunning: boolean = catalog.state === 'pending' || catalog.state === 'processing'; const catalogScore: number = catalogCompleted ? catalog.overall_score : 0; const totalConversations: number = summary.agent.total_conversations; const conversationGapCount: number = summary.agent.agent_gap_count; const agentScore: number = computeAgentQuality({ totalConversations, gapCount: conversationGapCount, }); const { total, grade } = computeReadinessScore({ visibility: visibilityScore, catalog: catalogScore, agent: agentScore, quests: questPillarScore, }); const totalQueries: number = summary.visibility.total_queries; const queriesWithBrand: number = summary.visibility.queries_with_brand; const shareOfVoiceRaw: number = summary.visibility.share_of_voice; const sharePct: number = shareOfVoiceRaw <= 1 ? shareOfVoiceRaw * 100 : shareOfVoiceRaw; const visibilityTooltip: string = 'AEO score: how often AI search engines (ChatGPT, Gemini, Perplexity, Claude) mention your brand on the prompts we test. Weighted 45% of the AI Readiness score.'; const visibilityRow: ScoreRow = visibilityHasScan ? { label: 'AI Visibility', tag: 'AEO', tooltip: visibilityTooltip, score: visibilityScore, context: totalQueries > 0 ? `Appearing in ${queriesWithBrand} of ${totalQueries} test queries · ${sharePct.toFixed(0)}% share of voice` : 'Latest scan ready.', action: { kind: 'link', label: 'Improve', to: '/ai-visibility' }, } : { label: 'AI Visibility', tag: 'AEO', tooltip: visibilityTooltip, score: 0, context: 'Set up tracking prompts to start measuring visibility.', action: { kind: 'link', label: 'Finish setup', to: '/ai-visibility?settings=open', primary: true, }, }; const catalogTotal: number = catalog.total_products; const catalogProcessed: number = catalog.processed_products; const catalogCritical: number = catalog.critical_count; const catalogContext: string = catalogCompleted ? catalogCritical > 0 ? `${catalogProcessed} of ${catalogTotal} products checked · ${catalogCritical} need fixes` : `${catalogProcessed} of ${catalogTotal} products checked` : catalogRunning ? `Checking products... ${catalogProcessed}/${catalogTotal}` : 'Run a content check on 50 random products to score your catalog.'; const dispatchContentCheck = async (): Promise => { if (!token) return; const response: { data?: { job_id?: string }; success?: boolean; } = await BaseAgentService.post('ai-readiness/content-check', { plan: 'FREE', }); if (response?.success !== false) navigate('/catalog-quality'); }; const catalogRow: ScoreRow = { label: 'Catalog Quality', tag: 'GEO', tooltip: 'GEO score: how well your product titles and descriptions match the structure AI shopping assistants need to recommend you. Calibrated on a 50-product audit. Weighted 45% of the AI Readiness score.', score: catalogScore, context: catalogContext, action: catalogCompleted ? { kind: 'link', label: 'View report', to: '/catalog-quality' } : catalogRunning ? { kind: 'link', label: 'View progress', to: '/catalog-quality', primary: true, } : { kind: 'button', label: 'Run check', busyLabel: 'Dispatching...', primary: true, onClick: dispatchContentCheck, }, }; const agentRow: ScoreRow = { label: 'Agent Quality', tooltip: "How often the assistant handled customer requests without saying 'I do not know', 'we do not have that', or routing the customer elsewhere. Score = (1 - gaps / conversations) * 100. Weighted 5% of the AI Readiness score.", score: agentScore, context: totalConversations > 0 ? `${conversationGapCount} conversation gaps detected across ${totalConversations} conversations` : 'Get your first conversations to unlock this score.', action: { kind: 'link', label: 'Improve', to: '/agent-analytics?tab=gaps' }, }; const questsRow: ScoreRow = { label: 'Quests', tooltip: 'Daily quests you complete with Recomaze Co-pilot (setup, content, and fix tasks). Weighted 5% of the AI Readiness score.', score: questPillarScore, context: questPillarScore > 0 ? 'Keep completing daily quests with Recomaze Co-pilot.' : 'Complete daily quests with Recomaze Co-pilot to earn this.', }; const planLabel: string | null = user?.plan_snapshot ? formatPlanLabel(String(user.plan_snapshot)) : null; return (

{greeting}

{[ domain || displayName, planLabel ? `${planLabel} trial` : null, typeof user?.trial_days === 'number' && user.trial_days > 0 ? `Day ${Math.max(1, Math.min(7, 7 - user.trial_days + 1))} of trial` : null, ] .filter(Boolean) .join(' · ')}

AI traffic grew{' '} 393% last year, converts{' '} 42% better , and only{' '} 13% of retailers are ready to capture it. Your score shows{' '} how ready you are , across three essential dimensions.

{!isSetupComplete(setupProgress, WORDPRESS_STEPS) && ( )} {token && clientId && ( <> )}
); }; /** * @param {string} plan * @return {string} */ function formatPlanLabel(plan: string): string { return plan .toLowerCase() .split('_') .map(planWord => planWord.charAt(0).toUpperCase() + planWord.slice(1)) .join(' '); } /** * @param {ISetupProgress | null} progress * @param {ReadonlyArray<{ key: string }>} platformSteps * @return {number} */ function estimateMinutesLeft( progress: ISetupProgress | null, platformSteps: ReadonlyArray<{ key: string }> ): number { if (!progress?.steps) return platformSteps.length * MINUTES_PER_STEP; let priorComplete: boolean = true; let remaining: number = 0; for (const stepDefinition of platformSteps) { const stored: boolean = progress.steps[stepDefinition.key]?.completed ?? false; const effective: boolean = priorComplete && stored; if (!effective) remaining += 1; priorComplete = effective; } return remaining * MINUTES_PER_STEP; } /** * Whether every onboarding step is effectively complete. Uses the same * sequential gating as the stepper (a step only counts once it and every * earlier step are done), so the Dashboard can hide the stepper once the * merchant reaches the last step. * * @param {ISetupProgress | null} progress * @param {ReadonlyArray<{ key: string }>} platformSteps * @return {boolean} */ function isSetupComplete( progress: ISetupProgress | null, platformSteps: ReadonlyArray<{ key: string }> ): boolean { if (!progress?.steps || platformSteps.length === 0) return false; let priorComplete: boolean = true; for (const stepDefinition of platformSteps) { const stored: boolean = progress.steps[stepDefinition.key]?.completed ?? false; priorComplete = priorComplete && stored; if (!priorComplete) return false; } return true; } export default Dashboard;