| 1 |
import React from "react"; |
| 2 |
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; |
| 3 |
import { |
| 4 |
Sparkles, |
| 5 |
RefreshCw, |
| 6 |
Mail, |
| 7 |
CreditCard, |
| 8 |
Plane, |
| 9 |
Inbox, |
| 10 |
ExternalLink, |
| 11 |
AlertTriangle, |
| 12 |
} from "lucide-react"; |
| 13 |
import { __ } from "../../lib/i18n"; |
| 14 |
import { aiApi, type AiDigest } from "../../api/ai-api"; |
| 15 |
import { isAiEligible, isAiModuleEnabled } from "../../lib/ai-availability"; |
| 16 |
|
| 17 |
/** |
| 18 |
* Dashboard "Today's Brief" — the most-visible piece of the operations |
| 19 |
* AI surface. Renders five distinct states cleanly: |
| 20 |
* |
| 21 |
* 1. `ready` — AI-generated paragraph + metric pills |
| 22 |
* 2. `all_caught_up` — green calm state, no LLM call needed |
| 23 |
* 3. `no_api_key` — friendly CTA pointing at AI settings |
| 24 |
* 4. `module_disabled` — CTA pointing at Modules page |
| 25 |
* 5. `upgrade_required` — soft upsell (Growth/Agency) |
| 26 |
* 6. `error` — shows the error + a retry button |
| 27 |
* |
| 28 |
* Always renders the metric pills (real numbers, no LLM needed) so the |
| 29 |
* card has value even when AI is unavailable. The prose is bonus. |
| 30 |
*/ |
| 31 |
const QUERY_KEY = ["ai-dashboard-digest"]; |
| 32 |
|
| 33 |
export const TodaysBriefCard: React.FC = () => { |
| 34 |
const queryClient = useQueryClient(); |
| 35 |
|
| 36 |
// Hooks must be called unconditionally on every render — see |
| 37 |
// rules-of-hooks. The gate that previously sat above these hook |
| 38 |
// calls (isAiEligible + isAiModuleEnabled) now runs AFTER all |
| 39 |
// hooks. Two gates, both must pass to render the card: |
| 40 |
// |
| 41 |
// 1. `isAiEligible()` — license tier unlocks AI at all. If false, |
| 42 |
// the card would just redirect to an upgrade page and is noise |
| 43 |
// for the operator. |
| 44 |
// |
| 45 |
// 2. `isAiModuleEnabled()` — the AI Assistant module is switched on |
| 46 |
// under Modules. If false, the card previously rendered the |
| 47 |
// whole gradient banner with an "Enable AI Assistant module..." |
| 48 |
// CTA inside, which surprised operators who had explicitly |
| 49 |
// decided not to enable AI. Hide it entirely instead — the |
| 50 |
// Modules page itself is the right entry point for "I want to |
| 51 |
// turn this on", not the dashboard. |
| 52 |
// |
| 53 |
// Cost of always calling these hooks: the useQuery fires even on |
| 54 |
// sites without AI eligibility — but with `enabled` gating |
| 55 |
// baked into useQuery we don't actually issue the network call. |
| 56 |
const aiAvailable = isAiEligible() && isAiModuleEnabled(); |
| 57 |
|
| 58 |
const { data, isLoading, isError } = useQuery<{ data: AiDigest }>({ |
| 59 |
queryKey: QUERY_KEY, |
| 60 |
queryFn: () => aiApi.getDashboardDigest(), |
| 61 |
staleTime: 15 * 60 * 1000, // matches server-side 30m cache; client refresh |
| 62 |
enabled: aiAvailable, |
| 63 |
}); |
| 64 |
|
| 65 |
const refresh = useMutation({ |
| 66 |
mutationFn: () => aiApi.refreshDashboardDigest(), |
| 67 |
onSuccess: (resp) => { |
| 68 |
queryClient.setQueryData(QUERY_KEY, resp); |
| 69 |
}, |
| 70 |
}); |
| 71 |
|
| 72 |
if (!aiAvailable) { |
| 73 |
return null; |
| 74 |
} |
| 75 |
|
| 76 |
if (isLoading) { |
| 77 |
return <SkeletonCard />; |
| 78 |
} |
| 79 |
|
| 80 |
const digest = data?.data; |
| 81 |
if (!digest) { |
| 82 |
return null; |
| 83 |
} |
| 84 |
|
| 85 |
return ( |
| 86 |
<div className="mb-6 overflow-hidden rounded-lg border border-blue-200 bg-gradient-to-r from-blue-50 to-blue-50 shadow-sm dark:border-blue-500/40 dark:from-blue-900/20 dark:to-blue-900/20"> |
| 87 |
{/* Header */} |
| 88 |
<div className="flex items-center justify-between px-5 py-3"> |
| 89 |
<div className="flex items-center gap-2"> |
| 90 |
<div className="rounded-md bg-blue-600 p-1.5 text-white"> |
| 91 |
<Sparkles className="h-4 w-4" /> |
| 92 |
</div> |
| 93 |
<div> |
| 94 |
<div className="text-sm font-semibold text-gray-900 dark:text-white"> |
| 95 |
{__("Today's Brief", "yatra")} |
| 96 |
</div> |
| 97 |
<div className="text-[11px] text-gray-500 dark:text-gray-400"> |
| 98 |
{digest.cached |
| 99 |
? __( |
| 100 |
"Last updated a few minutes ago — refreshes when something material changes.", |
| 101 |
"yatra", |
| 102 |
) |
| 103 |
: digest.generated_at |
| 104 |
? __("Just now", "yatra") |
| 105 |
: __("Operations summary", "yatra")} |
| 106 |
</div> |
| 107 |
</div> |
| 108 |
</div> |
| 109 |
{digest.state === "ready" && ( |
| 110 |
<button |
| 111 |
type="button" |
| 112 |
onClick={() => refresh.mutate()} |
| 113 |
disabled={refresh.isPending} |
| 114 |
title={__("Regenerate brief", "yatra")} |
| 115 |
className="inline-flex items-center gap-1 rounded-md border border-transparent bg-white/70 px-2 py-1 text-xs text-blue-700 hover:bg-white dark:bg-gray-800/70 dark:text-blue-300 dark:hover:bg-gray-800" |
| 116 |
> |
| 117 |
<RefreshCw |
| 118 |
className={`h-3.5 w-3.5 ${refresh.isPending ? "animate-spin" : ""}`} |
| 119 |
/> |
| 120 |
{__("Refresh", "yatra")} |
| 121 |
</button> |
| 122 |
)} |
| 123 |
</div> |
| 124 |
|
| 125 |
{/* Metric pills — always rendered, even when AI is unavailable */} |
| 126 |
<div className="grid grid-cols-2 gap-2 px-5 pb-3 sm:grid-cols-4"> |
| 127 |
<MetricPill |
| 128 |
icon={Inbox} |
| 129 |
label={__("Unanswered enquiries", "yatra")} |
| 130 |
value={digest.metrics.enquiries_unresponded_count} |
| 131 |
tooltip={__( |
| 132 |
"How many enquiries are still waiting for a reply from the operator. Click to open the Enquiries list and respond.", |
| 133 |
"yatra", |
| 134 |
)} |
| 135 |
link="admin.php?page=yatra&subpage=enquiries" |
| 136 |
/> |
| 137 |
<MetricPill |
| 138 |
icon={Mail} |
| 139 |
label={__("Longest wait (hrs)", "yatra")} |
| 140 |
value={digest.metrics.enquiries_oldest_waiting_hours} |
| 141 |
tooltip={__( |
| 142 |
"Hours the OLDEST unanswered enquiry has been waiting for a reply. A high number means at least one customer hasn't heard back for that long — answer it before it goes cold.", |
| 143 |
"yatra", |
| 144 |
)} |
| 145 |
tone="amber" |
| 146 |
/> |
| 147 |
<MetricPill |
| 148 |
icon={CreditCard} |
| 149 |
label={__("Pending payments", "yatra")} |
| 150 |
value={digest.metrics.bookings_payment_pending_count} |
| 151 |
tooltip={__( |
| 152 |
"Bookings whose payment has not been received yet. Click to open the Payments page and follow up.", |
| 153 |
"yatra", |
| 154 |
)} |
| 155 |
link="admin.php?page=yatra&subpage=payments" |
| 156 |
/> |
| 157 |
<MetricPill |
| 158 |
icon={Plane} |
| 159 |
label={__("Departing in 7d", "yatra")} |
| 160 |
value={digest.metrics.bookings_departing_soon_count} |
| 161 |
tooltip={__( |
| 162 |
"Bookings whose departure date falls within the next 7 days. Useful for last-minute prep — final reminders, group lists, paperwork.", |
| 163 |
"yatra", |
| 164 |
)} |
| 165 |
link="admin.php?page=yatra&subpage=departures" |
| 166 |
/> |
| 167 |
</div> |
| 168 |
|
| 169 |
{/* Brief body */} |
| 170 |
<div className="border-t border-blue-100 bg-white/40 px-5 py-3 dark:border-blue-500/30 dark:bg-gray-900/30"> |
| 171 |
{isError && ( |
| 172 |
<BriefError |
| 173 |
onRetry={() => refresh.mutate()} |
| 174 |
retrying={refresh.isPending} |
| 175 |
/> |
| 176 |
)} |
| 177 |
{!isError && ( |
| 178 |
<BriefBody |
| 179 |
digest={digest} |
| 180 |
onRetry={() => refresh.mutate()} |
| 181 |
retrying={refresh.isPending} |
| 182 |
/> |
| 183 |
)} |
| 184 |
</div> |
| 185 |
</div> |
| 186 |
); |
| 187 |
}; |
| 188 |
|
| 189 |
/* -------------------------------------------------------------------------- */ |
| 190 |
/* Sub-renderers */ |
| 191 |
/* -------------------------------------------------------------------------- */ |
| 192 |
|
| 193 |
const BriefBody: React.FC<{ |
| 194 |
digest: AiDigest; |
| 195 |
onRetry: () => void; |
| 196 |
retrying: boolean; |
| 197 |
}> = ({ digest, onRetry, retrying }) => { |
| 198 |
if (digest.state === "all_caught_up") { |
| 199 |
return ( |
| 200 |
<p className="text-sm text-emerald-700 dark:text-emerald-300"> |
| 201 |
✓{" "} |
| 202 |
{__( |
| 203 |
"You're caught up. Nothing urgent needs attention right now.", |
| 204 |
"yatra", |
| 205 |
)} |
| 206 |
</p> |
| 207 |
); |
| 208 |
} |
| 209 |
|
| 210 |
if (digest.state === "upgrade_required") { |
| 211 |
return ( |
| 212 |
<CtaLine |
| 213 |
message={__( |
| 214 |
"Today's Brief is part of the AI Assistant module — available on Growth and Agency plans.", |
| 215 |
"yatra", |
| 216 |
)} |
| 217 |
href="admin.php?page=yatra&subpage=license" |
| 218 |
label={__("View plans", "yatra")} |
| 219 |
/> |
| 220 |
); |
| 221 |
} |
| 222 |
|
| 223 |
// The `module_disabled` state is unreachable here — the parent |
| 224 |
// <TodaysBriefCard> returns null before this body renders when the |
| 225 |
// module is off. Server may still send this state during a brief |
| 226 |
// race window between module toggle + client cache; treat it the |
| 227 |
// same as no-API-key (caller wants AI but something isn't ready). |
| 228 |
|
| 229 |
if (digest.state === "no_api_key") { |
| 230 |
return ( |
| 231 |
<CtaLine |
| 232 |
message={__( |
| 233 |
"Add an OpenAI or Anthropic key to unlock the AI summary. Numbers above don't need a key.", |
| 234 |
"yatra", |
| 235 |
)} |
| 236 |
href="admin.php?page=yatra&subpage=ai-assistant" |
| 237 |
label={__("Open AI settings", "yatra")} |
| 238 |
/> |
| 239 |
); |
| 240 |
} |
| 241 |
|
| 242 |
if (digest.state === "error") { |
| 243 |
return ( |
| 244 |
<div className="flex items-start gap-2 text-sm text-red-700 dark:text-red-300"> |
| 245 |
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" /> |
| 246 |
<div className="flex-1"> |
| 247 |
<div> |
| 248 |
{__("Couldn't generate the brief.", "yatra")}{" "} |
| 249 |
{digest.error ? `(${digest.error})` : ""} |
| 250 |
</div> |
| 251 |
<button |
| 252 |
type="button" |
| 253 |
onClick={onRetry} |
| 254 |
disabled={retrying} |
| 255 |
className="mt-1 inline-flex items-center gap-1 rounded border border-red-200 px-2 py-0.5 text-xs hover:bg-red-50 dark:border-red-500/40 dark:hover:bg-red-900/20" |
| 256 |
> |
| 257 |
{retrying ? __("Retrying…", "yatra") : __("Retry", "yatra")} |
| 258 |
</button> |
| 259 |
</div> |
| 260 |
</div> |
| 261 |
); |
| 262 |
} |
| 263 |
|
| 264 |
// ready |
| 265 |
return ( |
| 266 |
<p className="whitespace-pre-wrap text-sm leading-relaxed text-gray-800 dark:text-gray-200"> |
| 267 |
{digest.text || |
| 268 |
__("AI summary is empty for today — try refreshing.", "yatra")} |
| 269 |
</p> |
| 270 |
); |
| 271 |
}; |
| 272 |
|
| 273 |
const BriefError: React.FC<{ onRetry: () => void; retrying: boolean }> = ({ |
| 274 |
onRetry, |
| 275 |
retrying, |
| 276 |
}) => ( |
| 277 |
<div className="flex items-start gap-2 text-sm text-red-700 dark:text-red-300"> |
| 278 |
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" /> |
| 279 |
<div className="flex-1"> |
| 280 |
<div>{__("Failed to load today's brief.", "yatra")}</div> |
| 281 |
<button |
| 282 |
type="button" |
| 283 |
onClick={onRetry} |
| 284 |
disabled={retrying} |
| 285 |
className="mt-1 inline-flex items-center gap-1 rounded border border-red-200 px-2 py-0.5 text-xs hover:bg-red-50 dark:border-red-500/40 dark:hover:bg-red-900/20" |
| 286 |
> |
| 287 |
{retrying ? __("Retrying…", "yatra") : __("Retry", "yatra")} |
| 288 |
</button> |
| 289 |
</div> |
| 290 |
</div> |
| 291 |
); |
| 292 |
|
| 293 |
const CtaLine: React.FC<{ message: string; href: string; label: string }> = ({ |
| 294 |
message, |
| 295 |
href, |
| 296 |
label, |
| 297 |
}) => ( |
| 298 |
<div className="flex flex-wrap items-center justify-between gap-2 text-sm text-gray-700 dark:text-gray-200"> |
| 299 |
<span>{message}</span> |
| 300 |
<a |
| 301 |
href={href} |
| 302 |
className="inline-flex items-center gap-1 rounded-md bg-blue-600 px-2 py-1 text-xs font-medium text-white hover:bg-blue-700" |
| 303 |
> |
| 304 |
{label} |
| 305 |
<ExternalLink className="h-3 w-3" /> |
| 306 |
</a> |
| 307 |
</div> |
| 308 |
); |
| 309 |
|
| 310 |
const MetricPill: React.FC<{ |
| 311 |
icon: React.ComponentType<{ className?: string }>; |
| 312 |
label: string; |
| 313 |
value: string; |
| 314 |
tone?: "default" | "amber"; |
| 315 |
link?: string; |
| 316 |
/** Hover hint — surfaces via `title=`, since the brief already has a |
| 317 |
lot of moving parts and a dedicated popover is overkill. */ |
| 318 |
tooltip?: string; |
| 319 |
}> = ({ icon: Icon, label, value, tone = "default", link, tooltip }) => { |
| 320 |
const n = parseInt(value, 10); |
| 321 |
const safe = Number.isFinite(n) ? n : 0; |
| 322 |
const accent = |
| 323 |
tone === "amber" && safe > 24 |
| 324 |
? "text-amber-700 dark:text-amber-300" |
| 325 |
: tone === "amber" && safe > 8 |
| 326 |
? "text-yellow-700 dark:text-yellow-300" |
| 327 |
: "text-gray-900 dark:text-white"; |
| 328 |
|
| 329 |
const body = ( |
| 330 |
<div |
| 331 |
className="flex items-center gap-2 rounded-md bg-white px-2.5 py-2 shadow-sm ring-1 ring-blue-100 hover:ring-blue-300 dark:bg-gray-900/60 dark:ring-blue-500/30" |
| 332 |
title={tooltip} |
| 333 |
> |
| 334 |
<Icon className="h-4 w-4 text-blue-500" /> |
| 335 |
<div> |
| 336 |
<div className={`text-base font-semibold leading-none ${accent}`}> |
| 337 |
{safe.toLocaleString()} |
| 338 |
</div> |
| 339 |
<div className="text-[10px] uppercase tracking-wide text-gray-500 dark:text-gray-400"> |
| 340 |
{label} |
| 341 |
</div> |
| 342 |
</div> |
| 343 |
</div> |
| 344 |
); |
| 345 |
|
| 346 |
if (link && safe > 0) { |
| 347 |
return ( |
| 348 |
<a href={link} className="transition-transform hover:scale-[1.02]"> |
| 349 |
{body} |
| 350 |
</a> |
| 351 |
); |
| 352 |
} |
| 353 |
return body; |
| 354 |
}; |
| 355 |
|
| 356 |
const SkeletonCard: React.FC = () => ( |
| 357 |
<div className="mb-6 overflow-hidden rounded-lg border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-700 dark:bg-gray-800"> |
| 358 |
<div className="flex items-center gap-2"> |
| 359 |
<div className="h-6 w-6 animate-pulse rounded bg-blue-100 dark:bg-blue-900/40" /> |
| 360 |
<div className="h-4 w-32 animate-pulse rounded bg-gray-100 dark:bg-gray-700" /> |
| 361 |
</div> |
| 362 |
<div className="mt-3 grid grid-cols-2 gap-2 sm:grid-cols-4"> |
| 363 |
{[0, 1, 2, 3].map((i) => ( |
| 364 |
<div |
| 365 |
key={i} |
| 366 |
className="h-12 animate-pulse rounded bg-gray-100 dark:bg-gray-700" |
| 367 |
/> |
| 368 |
))} |
| 369 |
</div> |
| 370 |
<div className="mt-3 h-4 w-full animate-pulse rounded bg-gray-100 dark:bg-gray-700" /> |
| 371 |
<div className="mt-1 h-4 w-2/3 animate-pulse rounded bg-gray-100 dark:bg-gray-700" /> |
| 372 |
</div> |
| 373 |
); |
| 374 |
|
| 375 |
export default TodaysBriefCard; |
| 376 |
|