PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.10
Yatra – Travel Booking & Tour Operator Software v3.0.10
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 / pages / ViewEnquiry.tsx

ViewEnquiry.tsx in Yatra – Travel Booking & Tour Operator Software 3.0.10, at resources/js/pages/ViewEnquiry.tsx

613 lines 21.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * View Enquiry Page
3 * Display enquiry details in a clean, minimal SaaS-style design
4 */
5
6 import React, { useMemo, useState } from "react";
7 import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
8 import {
9 ArrowLeft,
10 Mail,
11 Phone,
12 Calendar,
13 MessageSquare,
14 Edit,
15 ExternalLink,
16 MapPin,
17 Users,
18 Send,
19 Loader2,
20 } from "lucide-react";
21 import { __ } from "../lib/i18n";
22 import { toDateValue } from "../lib/dateFormat";
23 import { apiService } from "../lib/api-client";
24 import { usePermissions } from "../hooks/usePermissions";
25 import { Button } from "../components/ui/button";
26 import { PageHeader } from "../components/common/PageHeader";
27 import {
28 Card,
29 CardContent,
30 CardHeader,
31 CardTitle,
32 } from "../components/ui/card";
33 import { ConditionalRender } from "../components/ui/conditional-render";
34 import { EnquiryReplyAffordance } from "../components/ai/EnquiryReplyAffordance";
35 import { Badge } from "../components/ui/badge";
36 import { useNavigate } from "../hooks/useNavigate";
37 import { Skeleton } from "../components/ui/skeleton";
38 import { Modal } from "../components/ui/modal";
39 import { useToast } from "../components/ui/toast";
40
41 interface Enquiry {
42 id: number;
43 name: string;
44 email: string;
45 phone?: string;
46 trip_id?: number;
47 trip_title?: string;
48 trip_slug?: string;
49 message: string;
50 travelers_count?: number;
51 travel_date?: string;
52 status:
53 | "new"
54 | "pending"
55 | "responded"
56 | "closed"
57 | "converted"
58 | ""
59 | string;
60 created_at: string;
61 responded_at?: string;
62 response?: string;
63 }
64
65 const ViewEnquiry: React.FC = () => {
66 const { can } = usePermissions();
67 const { navigate } = useNavigate();
68 const queryClient = useQueryClient();
69 const { showToast } = useToast();
70 const [respondDialogOpen, setRespondDialogOpen] = useState(false);
71 const [responseMessage, setResponseMessage] = useState("");
72
73 // Get enquiry id from URL
74 const enquiryId = useMemo(() => {
75 const params = new URLSearchParams(window.location.search);
76 return params.get("id") ? parseInt(params.get("id") || "0") : null;
77 }, []);
78
79 // Fetch enquiry data from API
80 const {
81 data: enquiry,
82 isLoading,
83 error,
84 } = useQuery<Enquiry | null>({
85 queryKey: ["enquiry", enquiryId],
86 queryFn: async () => {
87 if (!enquiryId) return null;
88 const response = await apiService.getEnquiry(enquiryId!);
89 // Some endpoints return { success, data }, others return the object directly
90 return (response as any)?.data ?? response;
91 },
92 enabled: !!enquiryId && can("yatra_view_enquiries"),
93 });
94
95 // Respond mutation
96 const respondMutation = useMutation({
97 mutationFn: async ({ id, message }: { id: number; message: string }) => {
98 return await apiService.respondToEnquiry(id, { response: message });
99 },
100 onSuccess: () => {
101 queryClient.invalidateQueries({ queryKey: ["enquiry", enquiryId] });
102 setRespondDialogOpen(false);
103 setResponseMessage("");
104 showToast(__("Response sent successfully.", "yatra"), "success");
105 },
106 });
107
108 const formatDate = (dateString: string) => {
109 // toDateValue: a date-only travel_date must not roll back a day in a
110 // behind-UTC zone (datetime values like created_at pass through unchanged).
111 return toDateValue(dateString).toLocaleDateString("en-US", {
112 weekday: "long",
113 year: "numeric",
114 month: "long",
115 day: "numeric",
116 });
117 };
118
119 const getStatusBadge = (status: string) => {
120 const statusMap: Record<string, { className: string; label: string }> = {
121 new: {
122 className:
123 "bg-blue-100 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400",
124 label: __("New", "yatra"),
125 },
126 pending: {
127 className:
128 "bg-orange-100 text-orange-700 dark:bg-orange-900/20 dark:text-orange-400",
129 label: __("Pending", "yatra"),
130 },
131 responded: {
132 className:
133 "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/20 dark:text-yellow-400",
134 label: __("Responded", "yatra"),
135 },
136 converted: {
137 className:
138 "bg-green-100 text-green-700 dark:bg-green-900/20 dark:text-green-400",
139 label: __("Converted", "yatra"),
140 },
141 closed: {
142 className:
143 "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-400",
144 label: __("Closed", "yatra"),
145 },
146 };
147
148 const statusInfo = statusMap[status] || {
149 className:
150 "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-400",
151 label: status,
152 };
153
154 return (
155 <Badge className={`text-sm ${statusInfo.className}`}>
156 {statusInfo.label}
157 </Badge>
158 );
159 };
160
161 const handleBack = () => {
162 navigate({ subpage: "enquiries" });
163 };
164
165 const handleEdit = () => {
166 if (enquiryId) {
167 navigate({ subpage: "enquiries", action: "edit", id: enquiryId });
168 }
169 };
170
171 const handleViewTrip = () => {
172 if (enquiry?.trip_id) {
173 navigate({
174 subpage: "trips",
175 tab: "all",
176 action: "edit",
177 id: enquiry.trip_id,
178 });
179 }
180 };
181
182 const handleRespond = () => {
183 setResponseMessage("");
184 setRespondDialogOpen(true);
185 };
186
187 const sendResponse = () => {
188 if (enquiryId && responseMessage.trim()) {
189 respondMutation.mutate({
190 id: enquiryId,
191 message: responseMessage,
192 });
193 }
194 };
195
196 if (isLoading) {
197 return (
198 <div className="space-y-3">
199 <PageHeader
200 title={__("Enquiry Details", "yatra")}
201 actions={
202 <div className="flex items-center gap-2">
203 <Skeleton className="h-10 w-24" />
204 <Skeleton className="h-10 w-32" />
205 </div>
206 }
207 />
208 <div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
209 <div className="lg:col-span-2 space-y-3">
210 <Card>
211 <CardHeader className="pb-2">
212 <div className="flex items-center justify-between">
213 <Skeleton className="h-5 w-40" />
214 <Skeleton className="h-6 w-20 rounded-full" />
215 </div>
216 </CardHeader>
217 <CardContent className="space-y-4">
218 <div>
219 <Skeleton className="h-3 w-16 mb-2" />
220 <Skeleton className="h-24 w-full rounded-lg" />
221 </div>
222 <div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-4 border-t border-gray-200 dark:border-gray-700">
223 <div>
224 <Skeleton className="h-3 w-32 mb-2" />
225 <Skeleton className="h-5 w-16" />
226 </div>
227 <div>
228 <Skeleton className="h-3 w-36 mb-2" />
229 <Skeleton className="h-5 w-40" />
230 </div>
231 </div>
232 </CardContent>
233 </Card>
234 </div>
235 <div className="space-y-3">
236 <Card>
237 <CardHeader className="pb-2">
238 <Skeleton className="h-5 w-24" />
239 </CardHeader>
240 <CardContent className="space-y-3">
241 <Skeleton className="h-5 w-32" />
242 <Skeleton className="h-4 w-48" />
243 <Skeleton className="h-4 w-36" />
244 </CardContent>
245 </Card>
246 <Card>
247 <CardHeader className="pb-2">
248 <Skeleton className="h-5 w-20" />
249 </CardHeader>
250 <CardContent className="space-y-3">
251 <div>
252 <Skeleton className="h-3 w-20 mb-2" />
253 <Skeleton className="h-5 w-40" />
254 </div>
255 </CardContent>
256 </Card>
257 </div>
258 </div>
259 </div>
260 );
261 }
262 if (error || !enquiry) {
263 return (
264 <div className="space-y-3">
265 <PageHeader
266 title={__("Enquiry Not Found", "yatra")}
267 actions={
268 <Button
269 variant="outline"
270 onClick={handleBack}
271 className="flex items-center gap-2"
272 >
273 <ArrowLeft className="w-4 h-4" />
274 {__("Back to Enquiries", "yatra")}
275 </Button>
276 }
277 />
278 <Card>
279 <CardContent className="p-8 text-center text-red-500">
280 {__(
281 "Enquiry not found or you do not have permission to view it.",
282 "yatra",
283 )}
284 </CardContent>
285 </Card>
286 </div>
287 );
288 }
289
290 return (
291 <div className="space-y-3">
292 <PageHeader
293 title={__("Enquiry Details", "yatra")}
294 description={__("View complete enquiry information", "yatra")}
295 actions={
296 <div className="flex items-center gap-2">
297 <Button
298 variant="outline"
299 onClick={handleBack}
300 className="flex items-center gap-2"
301 >
302 <ArrowLeft className="w-4 h-4" />
303 {__("Back", "yatra")}
304 </Button>
305 <ConditionalRender capability="yatra_respond_to_enquiries">
306 <div className="flex items-center gap-2">
307 {enquiry.status !== "responded" &&
308 enquiry.status !== "closed" && (
309 <Button
310 variant="outline"
311 onClick={handleRespond}
312 className="flex items-center gap-2 text-blue-600 border-blue-300 hover:bg-blue-50 dark:text-blue-400 dark:border-blue-700 dark:hover:bg-blue-900/20"
313 >
314 <Send className="w-4 h-4" />
315 {__("Send Response", "yatra")}
316 </Button>
317 )}
318 <Button
319 onClick={handleEdit}
320 className="flex items-center gap-2"
321 >
322 <Edit className="w-4 h-4" />
323 {__("Edit Enquiry", "yatra")}
324 </Button>
325 </div>
326 </ConditionalRender>
327 </div>
328 }
329 />
330
331 <ConditionalRender capability="yatra_view_enquiries">
332 <div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
333 {/* Main Content */}
334 <div className="lg:col-span-2 space-y-3">
335 {/* Enquiry Information */}
336 <Card>
337 <CardHeader className="pb-2">
338 <div className="flex items-center justify-between">
339 <CardTitle className="text-base">
340 {__("Enquiry Information", "yatra")}
341 </CardTitle>
342 {getStatusBadge(enquiry.status)}
343 </div>
344 </CardHeader>
345 <CardContent className="space-y-4">
346 <div>
347 <label className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-2 block">
348 {__("Message", "yatra")}
349 </label>
350 <div className="flex items-start gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
351 <MessageSquare className="w-4 h-4 text-gray-400 mt-0.5 flex-shrink-0" />
352 <p className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap flex-1">
353 {enquiry.message}
354 </p>
355 </div>
356 </div>
357
358 <div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-4 border-t border-gray-200 dark:border-gray-700">
359 {enquiry.travelers_count && (
360 <div>
361 <label className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">
362 {__("Number of Travelers", "yatra")}
363 </label>
364 <div className="mt-1 flex items-center gap-2">
365 <Users className="w-4 h-4 text-gray-400" />
366 <p className="text-sm font-medium text-gray-900 dark:text-white">
367 {enquiry.travelers_count}
368 </p>
369 </div>
370 </div>
371 )}
372 {enquiry.travel_date && (
373 <div>
374 <label className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">
375 {__("Preferred Travel Date", "yatra")}
376 </label>
377 <div className="mt-1 flex items-center gap-2">
378 <Calendar className="w-4 h-4 text-gray-400" />
379 <p className="text-sm font-medium text-gray-900 dark:text-white">
380 {formatDate(enquiry.travel_date)}
381 </p>
382 </div>
383 </div>
384 )}
385 </div>
386
387 {enquiry.response && (
388 <div className="pt-4 border-t border-gray-200 dark:border-gray-700">
389 <label className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-2 block">
390 {__("Response", "yatra")}
391 </label>
392 <p className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap">
393 {enquiry.response}
394 </p>
395 </div>
396 )}
397 </CardContent>
398 </Card>
399
400 {/* Trip Information */}
401 {enquiry.trip_title && (
402 <Card>
403 <CardHeader className="pb-2">
404 <div className="flex items-center justify-between">
405 <CardTitle className="text-base">
406 {__("Related Trip", "yatra")}
407 </CardTitle>
408 {enquiry.trip_id && (
409 <Button
410 variant="ghost"
411 size="sm"
412 onClick={handleViewTrip}
413 className="flex items-center gap-1.5 text-sm"
414 >
415 {__("View Trip", "yatra")}
416 <ExternalLink className="w-3.5 h-3.5" />
417 </Button>
418 )}
419 </div>
420 </CardHeader>
421 <CardContent className="space-y-4">
422 <div>
423 <label className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">
424 {__("Trip", "yatra")}
425 </label>
426 <div className="mt-1 flex items-center gap-2">
427 <MapPin className="w-4 h-4 text-gray-400" />
428 <p className="text-sm font-medium text-gray-900 dark:text-white">
429 {enquiry.trip_title}
430 </p>
431 </div>
432 </div>
433 </CardContent>
434 </Card>
435 )}
436 </div>
437
438 {/* Sidebar */}
439 <div className="space-y-3">
440 {/* Customer Information */}
441 <Card>
442 <CardHeader className="pb-2">
443 <CardTitle className="text-base">
444 {__("Customer", "yatra")}
445 </CardTitle>
446 </CardHeader>
447 <CardContent className="space-y-3">
448 <div>
449 <p className="text-sm font-medium text-gray-900 dark:text-white">
450 {enquiry.name}
451 </p>
452 </div>
453 <div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
454 <Mail className="w-4 h-4" />
455 <span>{enquiry.email}</span>
456 </div>
457 {enquiry.phone && (
458 <div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
459 <Phone className="w-4 h-4" />
460 <span>{enquiry.phone}</span>
461 </div>
462 )}
463 </CardContent>
464 </Card>
465
466 {/* Enquiry Timeline */}
467 <Card>
468 <CardHeader className="pb-2">
469 <CardTitle className="text-base">
470 {__("Timeline", "yatra")}
471 </CardTitle>
472 </CardHeader>
473 <CardContent className="space-y-3">
474 <div>
475 <label className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">
476 {__("Submitted", "yatra")}
477 </label>
478 <p className="mt-1 text-sm text-gray-900 dark:text-white">
479 {formatDate(enquiry.created_at)}
480 </p>
481 </div>
482 {enquiry.responded_at && (
483 <div>
484 <label className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">
485 {__("Responded", "yatra")}
486 </label>
487 <p className="mt-1 text-sm text-gray-900 dark:text-white">
488 {formatDate(enquiry.responded_at)}
489 </p>
490 </div>
491 )}
492 </CardContent>
493 </Card>
494 </div>
495 </div>
496 </ConditionalRender>
497
498 {/* Respond Dialog via shared Modal */}
499 <Modal
500 isOpen={respondDialogOpen && !!enquiry}
501 onClose={() => {
502 if (!respondMutation.isPending) setRespondDialogOpen(false);
503 }}
504 title={__("Respond to Enquiry", "yatra")}
505 description={
506 enquiry ? (
507 <span>
508 {__("Send a response to", "yatra")}{" "}
509 <strong>{enquiry.name}</strong>
510 </span>
511 ) : null
512 }
513 size="md"
514 footer={
515 <div className="flex gap-2 justify-end">
516 <Button
517 variant="outline"
518 onClick={() => setRespondDialogOpen(false)}
519 disabled={respondMutation.isPending}
520 >
521 {__("Cancel", "yatra")}
522 </Button>
523 <Button
524 onClick={sendResponse}
525 disabled={respondMutation.isPending || !responseMessage.trim()}
526 >
527 {respondMutation.isPending ? (
528 <>
529 <Loader2 className="w-4 h-4 mr-2 animate-spin" />
530 {__("Sending...", "yatra")}
531 </>
532 ) : (
533 <>
534 <Send className="w-4 h-4 mr-2" />
535 {__("Send Response", "yatra")}
536 </>
537 )}
538 </Button>
539 </div>
540 }
541 >
542 {enquiry && (
543 <div className="space-y-4">
544 {/* Customer Info */}
545 <div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-3 space-y-2">
546 <div className="flex items-center gap-2 text-sm">
547 <Mail className="w-4 h-4 text-gray-400" />
548 <span className="text-gray-600 dark:text-gray-400">
549 {enquiry.email}
550 </span>
551 </div>
552 {enquiry.trip_title && (
553 <div className="flex items-center gap-2 text-sm">
554 <MapPin className="w-4 h-4 text-gray-400" />
555 <span className="text-gray-600 dark:text-gray-400">
556 {enquiry.trip_title}
557 </span>
558 </div>
559 )}
560 <div className="text-sm text-gray-500 dark:text-gray-400 border-t border-gray-200 dark:border-gray-700 pt-2 mt-2">
561 <strong>{__("Original Message:", "yatra")}</strong>
562 <p className="mt-1 text-gray-600 dark:text-gray-400">
563 {enquiry.message}
564 </p>
565 </div>
566 </div>
567
568 {/* Response Message — label + AI sparkle on the same
569 row so the operator sees the "Generate with AI"
570 option right where they're about to type. Uses the
571 same EnquiryReplyAffordance component the admin
572 EnquiryForm uses, so behavior (Generate / Warmer /
573 Shorter variants, sensitive-data toggle) is identical
574 across the two surfaces. */}
575 <div>
576 <div className="mb-1.5 flex items-center justify-between gap-2">
577 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
578 {__("Your Response", "yatra")}{" "}
579 <span className="text-red-500">*</span>
580 </label>
581 {enquiryId && (
582 <EnquiryReplyAffordance
583 enquiryId={Number(enquiryId)}
584 value={responseMessage}
585 onAccept={(text) => setResponseMessage(text)}
586 />
587 )}
588 </div>
589 <textarea
590 value={responseMessage}
591 onChange={(e) => setResponseMessage(e.target.value)}
592 placeholder={__("Type your response here...", "yatra")}
593 className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
594 rows={5}
595 disabled={respondMutation.isPending}
596 />
597 </div>
598
599 {respondMutation.isError && (
600 <div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg text-sm text-red-600 dark:text-red-400">
601 {respondMutation.error?.message ||
602 __("Failed to send response. Please try again.", "yatra")}
603 </div>
604 )}
605 </div>
606 )}
607 </Modal>
608 </div>
609 );
610 };
611
612 export default ViewEnquiry;
613