PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.13
Yatra – Travel Booking & Tour Operator Software v3.0.13
3.0.17 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 All 85 releases
yatra / resources / js / components / ai / EnquiryReplyAffordance.tsx

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

330 lines 11.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import React, { useCallback, useEffect, useRef, useState } from "react";
2 import {
3 Sparkles,
4 Wand2,
5 Heart,
6 Scissors,
7 Check,
8 X,
9 Loader2,
10 AlertTriangle,
11 Shield,
12 ShieldOff,
13 } from "lucide-react";
14 import { __ } from "../../lib/i18n";
15 import { aiApi, type EnquiryReplyVariant } from "../../api/ai-api";
16 import {
17 isAiEligible,
18 isAiModuleEnabled,
19 isAiReady,
20 } from "../../lib/ai-availability";
21
22 /**
23 * Sparkle affordance for the enquiry "Response notes" textarea. Unlike
24 * the trip-content version (AiFieldAffordance), this one knows it lives
25 * on an enquiry — so it talks to the server-side EnquiryContext loader,
26 * which pulls trip + customer history without us shipping that data
27 * through the browser.
28 *
29 * Behaviour:
30 * - Auto-draft mode: when the enquiry has no existing reply AND the
31 * prop `autoDraftOnMount` is true, fires a draft on mount so the
32 * operator opens the form to a pre-filled reply.
33 * - Manual: sparkle button opens a tiny menu with Generate / Warmer /
34 * Shorter — only Warmer / Shorter need an existing draft.
35 * - Sensitive-data toggle in the menu: opt-in flag for sending the
36 * customer's medical / dietary / special-needs fields to the LLM.
37 */
38 interface EnquiryReplyAffordanceProps {
39 enquiryId: number;
40 /** Current textarea value — used for warmer/shorter, and to decide
41 * whether to skip the auto-draft. */
42 value: string;
43 /** Called with the AI-drafted reply once the operator accepts it. */
44 onAccept: (text: string) => void;
45 /** Called as soon as a draft starts streaming back — lets the parent
46 * show a "draft in progress" banner above the field. */
47 onDraftingChange?: (isDrafting: boolean) => void;
48 /** Auto-fire a generate when this is the first time opening an
49 * unanswered enquiry. */
50 autoDraftOnMount?: boolean;
51 className?: string;
52 }
53
54 export const EnquiryReplyAffordance: React.FC<EnquiryReplyAffordanceProps> = ({
55 enquiryId,
56 value,
57 onAccept,
58 onDraftingChange,
59 autoDraftOnMount = false,
60 className = "",
61 }) => {
62 const [menuOpen, setMenuOpen] = useState(false);
63 const [isRunning, setIsRunning] = useState(false);
64 const [error, setError] = useState<string | null>(null);
65 const [preview, setPreview] = useState<{
66 text: string;
67 variant: EnquiryReplyVariant;
68 } | null>(null);
69 const [includeSensitive, setIncludeSensitive] = useState(false);
70 const wrapperRef = useRef<HTMLDivElement | null>(null);
71 const autoDraftedRef = useRef(false);
72
73 // Hard gate — same logic as AiFieldAffordance for consistent behaviour.
74 // Computed early so hooks below can reference it via dep arrays, but
75 // the actual early-return must run AFTER every hook below (rules-of-hooks).
76 const gateBlocks = !isAiEligible() || !isAiModuleEnabled();
77 const ready = isAiReady();
78
79 // Close on outside click.
80 useEffect(() => {
81 if (!menuOpen) return;
82 const onDoc = (e: MouseEvent) => {
83 if (
84 wrapperRef.current &&
85 !wrapperRef.current.contains(e.target as Node)
86 ) {
87 setMenuOpen(false);
88 }
89 };
90 document.addEventListener("mousedown", onDoc);
91 return () => document.removeEventListener("mousedown", onDoc);
92 }, [menuOpen]);
93
94 const runDraft = useCallback(
95 async (variant: EnquiryReplyVariant) => {
96 if (!ready) {
97 setError(
98 __(
99 "Add an API key in Yatra → AI Assistant before drafting replies.",
100 "yatra",
101 ),
102 );
103 return;
104 }
105 setMenuOpen(false);
106 setError(null);
107 setIsRunning(true);
108 onDraftingChange?.(true);
109 try {
110 const res = await aiApi.draftEnquiryReply(enquiryId, {
111 variant,
112 current_value: value,
113 include_sensitive: includeSensitive,
114 });
115 setPreview({ text: res.text, variant });
116 } catch (e: any) {
117 setError(extractError(e));
118 } finally {
119 setIsRunning(false);
120 onDraftingChange?.(false);
121 }
122 },
123 [enquiryId, value, includeSensitive, ready, onDraftingChange],
124 );
125
126 // Auto-draft once on mount when the operator hasn't typed anything yet.
127 useEffect(() => {
128 if (!autoDraftOnMount || autoDraftedRef.current) return;
129 if (!ready) return;
130 if (value.trim() !== "") return;
131 if (enquiryId <= 0) return;
132 autoDraftedRef.current = true;
133 runDraft("fresh");
134 // eslint-disable-next-line react-hooks/exhaustive-deps
135 }, [autoDraftOnMount, ready, enquiryId]);
136
137 const accept = () => {
138 if (!preview) return;
139 onAccept(preview.text);
140 setPreview(null);
141 };
142
143 const tryAgain = () => {
144 if (!preview) return;
145 runDraft(preview.variant);
146 };
147
148 const hasValue = value.trim() !== "";
149
150 // Gate render after every hook has been called above. See top-of-file note.
151 if (gateBlocks) {
152 return null;
153 }
154
155 return (
156 <div ref={wrapperRef} className={`relative inline-block ${className}`}>
157 <button
158 type="button"
159 onClick={() => {
160 if (!ready) {
161 setError(
162 __("Add an API key in Yatra → AI Assistant first.", "yatra"),
163 );
164 return;
165 }
166 if (preview) return;
167 setMenuOpen((v) => !v);
168 setError(null);
169 }}
170 disabled={isRunning}
171 title={__("AI: draft a reply", "yatra")}
172 className={`inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs font-medium transition-colors ${
173 isRunning
174 ? "border-blue-200 bg-blue-50 text-blue-500 dark:border-blue-500/40 dark:bg-blue-900/20 dark:text-blue-300"
175 : "border-blue-300 bg-blue-50 text-blue-700 hover:border-blue-400 hover:bg-blue-100 dark:border-blue-500/40 dark:bg-blue-900/20 dark:text-blue-200 dark:hover:bg-blue-900/40"
176 }`}
177 aria-label={__("Draft reply with AI", "yatra")}
178 >
179 {isRunning ? (
180 <Loader2 className="h-3.5 w-3.5 animate-spin" />
181 ) : (
182 <Sparkles className="h-3.5 w-3.5" />
183 )}
184 {__("Draft reply", "yatra")}
185 </button>
186
187 {menuOpen && !preview && (
188 <div
189 className="absolute right-0 z-30 mt-1 w-64 rounded-md border border-gray-200 bg-white p-1 text-sm shadow-lg dark:border-gray-700 dark:bg-gray-800"
190 role="menu"
191 >
192 <button
193 type="button"
194 onClick={() => runDraft("fresh")}
195 className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-gray-700 hover:bg-blue-50 dark:text-gray-200 dark:hover:bg-blue-900/30"
196 >
197 <Wand2 className="h-4 w-4 text-blue-500" />
198 {hasValue
199 ? __("Regenerate from scratch", "yatra")
200 : __("Generate reply", "yatra")}
201 </button>
202 {hasValue && (
203 <>
204 <button
205 type="button"
206 onClick={() => runDraft("warmer")}
207 className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-gray-700 hover:bg-blue-50 dark:text-gray-200 dark:hover:bg-blue-900/30"
208 >
209 <Heart className="h-4 w-4 text-blue-400" />
210 {__("Make warmer", "yatra")}
211 </button>
212 <button
213 type="button"
214 onClick={() => runDraft("shorter")}
215 className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-gray-700 hover:bg-blue-50 dark:text-gray-200 dark:hover:bg-blue-900/30"
216 >
217 <Scissors className="h-4 w-4 text-blue-400" />
218 {__("Make shorter", "yatra")}
219 </button>
220 </>
221 )}
222
223 <div className="my-1 border-t border-gray-100 dark:border-gray-700" />
224 <label className="flex items-start gap-2 px-2 py-1.5 text-xs text-gray-600 dark:text-gray-300">
225 <input
226 type="checkbox"
227 className="mt-0.5"
228 checked={includeSensitive}
229 onChange={(e) => setIncludeSensitive(e.target.checked)}
230 />
231 <span>
232 {includeSensitive ? (
233 <ShieldOff className="mr-1 inline h-3 w-3 text-amber-500" />
234 ) : (
235 <Shield className="mr-1 inline h-3 w-3 text-green-500" />
236 )}
237 {__(
238 "Include dietary / medical / special-needs in prompt",
239 "yatra",
240 )}
241 </span>
242 </label>
243 </div>
244 )}
245
246 {preview && (
247 <div className="absolute right-0 z-30 mt-1 w-[480px] max-w-[90vw] rounded-md border border-blue-200 bg-white p-3 text-sm shadow-xl dark:border-blue-500/40 dark:bg-gray-800">
248 <div className="mb-2 flex items-center gap-2 text-xs font-medium text-blue-600 dark:text-blue-300">
249 <Sparkles className="h-3.5 w-3.5" />
250 {preview.variant === "fresh"
251 ? __("AI drafted this reply", "yatra")
252 : preview.variant === "warmer"
253 ? __("AI rewrote to be warmer", "yatra")
254 : __("AI rewrote to be shorter", "yatra")}
255 </div>
256 <pre className="max-h-72 overflow-y-auto whitespace-pre-wrap rounded border border-gray-100 bg-gray-50 p-2 text-xs text-gray-800 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-200">
257 {preview.text || (
258 <span className="text-gray-400">
259 {__("(empty response)", "yatra")}
260 </span>
261 )}
262 </pre>
263 <div className="mt-2 flex items-center justify-end gap-2">
264 <button
265 type="button"
266 onClick={tryAgain}
267 className="rounded border border-gray-200 px-2 py-1 text-xs text-gray-600 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
268 >
269 {__("Try again", "yatra")}
270 </button>
271 <button
272 type="button"
273 onClick={() => setPreview(null)}
274 className="inline-flex items-center gap-1 rounded border border-gray-200 px-2 py-1 text-xs text-gray-600 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
275 >
276 <X className="h-3 w-3" />
277 {__("Discard", "yatra")}
278 </button>
279 <button
280 type="button"
281 onClick={accept}
282 disabled={!preview.text}
283 className="inline-flex items-center gap-1 rounded bg-blue-600 px-3 py-1 text-xs font-medium text-white hover:bg-blue-700 disabled:opacity-50"
284 >
285 <Check className="h-3 w-3" />
286 {__("Use this reply", "yatra")}
287 </button>
288 </div>
289 </div>
290 )}
291
292 {error && !preview && (
293 <div
294 className="absolute right-0 z-30 mt-1 w-72 rounded-md border border-red-200 bg-white p-2 text-xs text-red-700 shadow-lg dark:border-red-500/40 dark:bg-gray-800 dark:text-red-300"
295 role="alert"
296 >
297 <div className="flex items-start gap-2">
298 <AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
299 <div className="flex-1">{error}</div>
300 <button
301 type="button"
302 onClick={() => setError(null)}
303 className="text-red-400 hover:text-red-600"
304 aria-label={__("Dismiss", "yatra")}
305 >
306 <X className="h-3.5 w-3.5" />
307 </button>
308 </div>
309 </div>
310 )}
311 </div>
312 );
313 };
314
315 function extractError(e: any): string {
316 if (!e) return "AI request failed.";
317 const data = e?.response?.data ?? e?.data ?? null;
318 if (
319 data &&
320 typeof data === "object" &&
321 typeof (data as any).message === "string"
322 ) {
323 return (data as any).message;
324 }
325 if (typeof e?.message === "string") return e.message;
326 return String(e);
327 }
328
329 export default EnquiryReplyAffordance;
330