import React from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Sparkles, RefreshCw, Mail, CreditCard, Plane, Inbox, ExternalLink, AlertTriangle, } from "lucide-react"; import { __ } from "../../lib/i18n"; import { aiApi, type AiDigest } from "../../api/ai-api"; import { isAiEligible, isAiModuleEnabled } from "../../lib/ai-availability"; /** * Dashboard "Today's Brief" — the most-visible piece of the operations * AI surface. Renders five distinct states cleanly: * * 1. `ready` — AI-generated paragraph + metric pills * 2. `all_caught_up` — green calm state, no LLM call needed * 3. `no_api_key` — friendly CTA pointing at AI settings * 4. `module_disabled` — CTA pointing at Modules page * 5. `upgrade_required` — soft upsell (Growth/Agency) * 6. `error` — shows the error + a retry button * * Always renders the metric pills (real numbers, no LLM needed) so the * card has value even when AI is unavailable. The prose is bonus. */ const QUERY_KEY = ["ai-dashboard-digest"]; export const TodaysBriefCard: React.FC = () => { const queryClient = useQueryClient(); // Hooks must be called unconditionally on every render — see // rules-of-hooks. The gate that previously sat above these hook // calls (isAiEligible + isAiModuleEnabled) now runs AFTER all // hooks. Two gates, both must pass to render the card: // // 1. `isAiEligible()` — license tier unlocks AI at all. If false, // the card would just redirect to an upgrade page and is noise // for the operator. // // 2. `isAiModuleEnabled()` — the AI Assistant module is switched on // under Modules. If false, the card previously rendered the // whole gradient banner with an "Enable AI Assistant module..." // CTA inside, which surprised operators who had explicitly // decided not to enable AI. Hide it entirely instead — the // Modules page itself is the right entry point for "I want to // turn this on", not the dashboard. // // Cost of always calling these hooks: the useQuery fires even on // sites without AI eligibility — but with `enabled` gating // baked into useQuery we don't actually issue the network call. const aiAvailable = isAiEligible() && isAiModuleEnabled(); const { data, isLoading, isError } = useQuery<{ data: AiDigest }>({ queryKey: QUERY_KEY, queryFn: () => aiApi.getDashboardDigest(), staleTime: 15 * 60 * 1000, // matches server-side 30m cache; client refresh enabled: aiAvailable, }); const refresh = useMutation({ mutationFn: () => aiApi.refreshDashboardDigest(), onSuccess: (resp) => { queryClient.setQueryData(QUERY_KEY, resp); }, }); if (!aiAvailable) { return null; } if (isLoading) { return ; } const digest = data?.data; if (!digest) { return null; } return (
{/* Header */}
{__("Today's Brief", "yatra")}
{digest.cached ? __( "Last updated a few minutes ago — refreshes when something material changes.", "yatra", ) : digest.generated_at ? __("Just now", "yatra") : __("Operations summary", "yatra")}
{digest.state === "ready" && ( )}
{/* Metric pills — always rendered, even when AI is unavailable */}
{/* Brief body */}
{isError && ( refresh.mutate()} retrying={refresh.isPending} /> )} {!isError && ( refresh.mutate()} retrying={refresh.isPending} /> )}
); }; /* -------------------------------------------------------------------------- */ /* Sub-renderers */ /* -------------------------------------------------------------------------- */ const BriefBody: React.FC<{ digest: AiDigest; onRetry: () => void; retrying: boolean; }> = ({ digest, onRetry, retrying }) => { if (digest.state === "all_caught_up") { return (

✓{" "} {__( "You're caught up. Nothing urgent needs attention right now.", "yatra", )}

); } if (digest.state === "upgrade_required") { return ( ); } // The `module_disabled` state is unreachable here — the parent // returns null before this body renders when the // module is off. Server may still send this state during a brief // race window between module toggle + client cache; treat it the // same as no-API-key (caller wants AI but something isn't ready). if (digest.state === "no_api_key") { return ( ); } if (digest.state === "error") { return (
{__("Couldn't generate the brief.", "yatra")}{" "} {digest.error ? `(${digest.error})` : ""}
); } // ready return (

{digest.text || __("AI summary is empty for today — try refreshing.", "yatra")}

); }; const BriefError: React.FC<{ onRetry: () => void; retrying: boolean }> = ({ onRetry, retrying, }) => (
{__("Failed to load today's brief.", "yatra")}
); const CtaLine: React.FC<{ message: string; href: string; label: string }> = ({ message, href, label, }) => (
{message} {label}
); const MetricPill: React.FC<{ icon: React.ComponentType<{ className?: string }>; label: string; value: string; tone?: "default" | "amber"; link?: string; /** Hover hint — surfaces via `title=`, since the brief already has a lot of moving parts and a dedicated popover is overkill. */ tooltip?: string; }> = ({ icon: Icon, label, value, tone = "default", link, tooltip }) => { const n = parseInt(value, 10); const safe = Number.isFinite(n) ? n : 0; const accent = tone === "amber" && safe > 24 ? "text-amber-700 dark:text-amber-300" : tone === "amber" && safe > 8 ? "text-yellow-700 dark:text-yellow-300" : "text-gray-900 dark:text-white"; const body = (
{safe.toLocaleString()}
{label}
); if (link && safe > 0) { return ( {body} ); } return body; }; const SkeletonCard: React.FC = () => (
{[0, 1, 2, 3].map((i) => (
))}
); export default TodaysBriefCard;