PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.5
Yatra – Travel Booking & Tour Operator Software v3.0.5
3.0.16 3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 All 84 releases
yatra / resources / js / components / ai / TodaysBriefCard.tsx

TodaysBriefCard.tsx in Yatra – Travel Booking & Tour Operator Software 3.0.5, at resources/js/components/ai/TodaysBriefCard.tsx

376 lines 13.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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