PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.16
Yatra – Travel Booking & Tour Operator Software v3.0.16
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.16, at resources/js/pages/ViewEnquiry.tsx

619 lines 21.7 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 completed: {
137 className:
138 "bg-teal-100 text-teal-700 dark:bg-teal-900/20 dark:text-teal-400",
139 label: __("Completed", "yatra"),
140 },
141 converted: {
142 className:
143 "bg-green-100 text-green-700 dark:bg-green-900/20 dark:text-green-400",
144 label: __("Converted", "yatra"),
145 },
146 closed: {
147 className:
148 "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-400",
149 label: __("Closed", "yatra"),
150 },
151 };
152
153 const statusInfo = statusMap[status] || {
154 className:
155 "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-400",
156 label: status,
157 };
158
159 return (
160 <Badge className={`text-sm ${statusInfo.className}`}>
161 {statusInfo.label}
162 </Badge>
163 );
164 };
165
166 const handleBack = () => {
167 navigate({ subpage: "enquiries" });
168 };
169
170 const handleEdit = () => {
171 if (enquiryId) {
172 navigate({ subpage: "enquiries", action: "edit", id: enquiryId });
173 }
174 };
175
176 const handleViewTrip = () => {
177 if (enquiry?.trip_id) {
178 navigate({
179 subpage: "trips",
180 tab: "all",
181 action: "edit",
182 id: enquiry.trip_id,
183 });
184 }
185 };
186
187 const handleRespond = () => {
188 setResponseMessage("");
189 setRespondDialogOpen(true);
190 };
191
192 const sendResponse = () => {
193 if (enquiryId && responseMessage.trim()) {
194 respondMutation.mutate({
195 id: enquiryId,
196 message: responseMessage,
197 });
198 }
199 };
200
201 if (isLoading) {
202 return (
203 <div className="space-y-3">
204 <PageHeader
205 title={__("Enquiry Details", "yatra")}
206 actions={
207 <div className="flex items-center gap-2">
208 <Skeleton className="h-10 w-24" />
209 <Skeleton className="h-10 w-32" />
210 </div>
211 }
212 />
213 <div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
214 <div className="lg:col-span-2 space-y-3">
215 <Card>
216 <CardHeader className="pb-2">
217 <div className="flex items-center justify-between">
218 <Skeleton className="h-5 w-40" />
219 <Skeleton className="h-6 w-20 rounded-full" />
220 </div>
221 </CardHeader>
222 <CardContent className="space-y-4">
223 <div>
224 <Skeleton className="h-3 w-16 mb-2" />
225 <Skeleton className="h-24 w-full rounded-lg" />
226 </div>
227 <div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-4 border-t border-gray-200 dark:border-gray-700">
228 <div>
229 <Skeleton className="h-3 w-32 mb-2" />
230 <Skeleton className="h-5 w-16" />
231 </div>
232 <div>
233 <Skeleton className="h-3 w-36 mb-2" />
234 <Skeleton className="h-5 w-40" />
235 </div>
236 </div>
237 </CardContent>
238 </Card>
239 </div>
240 <div className="space-y-3">
241 <Card>
242 <CardHeader className="pb-2">
243 <Skeleton className="h-5 w-24" />
244 </CardHeader>
245 <CardContent className="space-y-3">
246 <Skeleton className="h-5 w-32" />
247 <Skeleton className="h-4 w-48" />
248 <Skeleton className="h-4 w-36" />
249 </CardContent>
250 </Card>
251 <Card>
252 <CardHeader className="pb-2">
253 <Skeleton className="h-5 w-20" />
254 </CardHeader>
255 <CardContent className="space-y-3">
256 <div>
257 <Skeleton className="h-3 w-20 mb-2" />
258 <Skeleton className="h-5 w-40" />
259 </div>
260 </CardContent>
261 </Card>
262 </div>
263 </div>
264 </div>
265 );
266 }
267 if (error || !enquiry) {
268 return (
269 <div className="space-y-3">
270 <PageHeader
271 title={__("Enquiry Not Found", "yatra")}
272 actions={
273 <Button
274 variant="outline"
275 onClick={handleBack}
276 className="flex items-center gap-2"
277 >
278 <ArrowLeft className="w-4 h-4" />
279 {__("Back to Enquiries", "yatra")}
280 </Button>
281 }
282 />
283 <Card>
284 <CardContent className="p-8 text-center text-red-500">
285 {__(
286 "Enquiry not found or you do not have permission to view it.",
287 "yatra",
288 )}
289 </CardContent>
290 </Card>
291 </div>
292 );
293 }
294
295 return (
296 <div className="space-y-3">
297 <PageHeader
298 title={__("Enquiry Details", "yatra")}
299 description={__("View complete enquiry information", "yatra")}
300 actions={
301 <div className="flex items-center gap-2">
302 <Button
303 variant="outline"
304 onClick={handleBack}
305 className="flex items-center gap-2"
306 >
307 <ArrowLeft className="w-4 h-4" />
308 {__("Back", "yatra")}
309 </Button>
310 <ConditionalRender capability="yatra_respond_to_enquiries">
311 <div className="flex items-center gap-2">
312 {enquiry.status !== "responded" &&
313 enquiry.status !== "completed" &&
314 enquiry.status !== "closed" && (
315 <Button
316 variant="outline"
317 onClick={handleRespond}
318 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"
319 >
320 <Send className="w-4 h-4" />
321 {__("Send Response", "yatra")}
322 </Button>
323 )}
324 <Button
325 onClick={handleEdit}
326 className="flex items-center gap-2"
327 >
328 <Edit className="w-4 h-4" />
329 {__("Edit Enquiry", "yatra")}
330 </Button>
331 </div>
332 </ConditionalRender>
333 </div>
334 }
335 />
336
337 <ConditionalRender capability="yatra_view_enquiries">
338 <div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
339 {/* Main Content */}
340 <div className="lg:col-span-2 space-y-3">
341 {/* Enquiry Information */}
342 <Card>
343 <CardHeader className="pb-2">
344 <div className="flex items-center justify-between">
345 <CardTitle className="text-base">
346 {__("Enquiry Information", "yatra")}
347 </CardTitle>
348 {getStatusBadge(enquiry.status)}
349 </div>
350 </CardHeader>
351 <CardContent className="space-y-4">
352 <div>
353 <label className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-2 block">
354 {__("Message", "yatra")}
355 </label>
356 <div className="flex items-start gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
357 <MessageSquare className="w-4 h-4 text-gray-400 mt-0.5 flex-shrink-0" />
358 <p className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap flex-1">
359 {enquiry.message}
360 </p>
361 </div>
362 </div>
363
364 <div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-4 border-t border-gray-200 dark:border-gray-700">
365 {enquiry.travelers_count && (
366 <div>
367 <label className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">
368 {__("Number of Travelers", "yatra")}
369 </label>
370 <div className="mt-1 flex items-center gap-2">
371 <Users className="w-4 h-4 text-gray-400" />
372 <p className="text-sm font-medium text-gray-900 dark:text-white">
373 {enquiry.travelers_count}
374 </p>
375 </div>
376 </div>
377 )}
378 {enquiry.travel_date && (
379 <div>
380 <label className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">
381 {__("Preferred Travel Date", "yatra")}
382 </label>
383 <div className="mt-1 flex items-center gap-2">
384 <Calendar className="w-4 h-4 text-gray-400" />
385 <p className="text-sm font-medium text-gray-900 dark:text-white">
386 {formatDate(enquiry.travel_date)}
387 </p>
388 </div>
389 </div>
390 )}
391 </div>
392
393 {enquiry.response && (
394 <div className="pt-4 border-t border-gray-200 dark:border-gray-700">
395 <label className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-2 block">
396 {__("Response", "yatra")}
397 </label>
398 <p className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap">
399 {enquiry.response}
400 </p>
401 </div>
402 )}
403 </CardContent>
404 </Card>
405
406 {/* Trip Information */}
407 {enquiry.trip_title && (
408 <Card>
409 <CardHeader className="pb-2">
410 <div className="flex items-center justify-between">
411 <CardTitle className="text-base">
412 {__("Related Trip", "yatra")}
413 </CardTitle>
414 {enquiry.trip_id && (
415 <Button
416 variant="ghost"
417 size="sm"
418 onClick={handleViewTrip}
419 className="flex items-center gap-1.5 text-sm"
420 >
421 {__("View Trip", "yatra")}
422 <ExternalLink className="w-3.5 h-3.5" />
423 </Button>
424 )}
425 </div>
426 </CardHeader>
427 <CardContent className="space-y-4">
428 <div>
429 <label className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">
430 {__("Trip", "yatra")}
431 </label>
432 <div className="mt-1 flex items-center gap-2">
433 <MapPin className="w-4 h-4 text-gray-400" />
434 <p className="text-sm font-medium text-gray-900 dark:text-white">
435 {enquiry.trip_title}
436 </p>
437 </div>
438 </div>
439 </CardContent>
440 </Card>
441 )}
442 </div>
443
444 {/* Sidebar */}
445 <div className="space-y-3">
446 {/* Customer Information */}
447 <Card>
448 <CardHeader className="pb-2">
449 <CardTitle className="text-base">
450 {__("Customer", "yatra")}
451 </CardTitle>
452 </CardHeader>
453 <CardContent className="space-y-3">
454 <div>
455 <p className="text-sm font-medium text-gray-900 dark:text-white">
456 {enquiry.name}
457 </p>
458 </div>
459 <div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
460 <Mail className="w-4 h-4" />
461 <span>{enquiry.email}</span>
462 </div>
463 {enquiry.phone && (
464 <div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
465 <Phone className="w-4 h-4" />
466 <span>{enquiry.phone}</span>
467 </div>
468 )}
469 </CardContent>
470 </Card>
471
472 {/* Enquiry Timeline */}
473 <Card>
474 <CardHeader className="pb-2">
475 <CardTitle className="text-base">
476 {__("Timeline", "yatra")}
477 </CardTitle>
478 </CardHeader>
479 <CardContent className="space-y-3">
480 <div>
481 <label className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">
482 {__("Submitted", "yatra")}
483 </label>
484 <p className="mt-1 text-sm text-gray-900 dark:text-white">
485 {formatDate(enquiry.created_at)}
486 </p>
487 </div>
488 {enquiry.responded_at && (
489 <div>
490 <label className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">
491 {__("Responded", "yatra")}
492 </label>
493 <p className="mt-1 text-sm text-gray-900 dark:text-white">
494 {formatDate(enquiry.responded_at)}
495 </p>
496 </div>
497 )}
498 </CardContent>
499 </Card>
500 </div>
501 </div>
502 </ConditionalRender>
503
504 {/* Respond Dialog via shared Modal */}
505 <Modal
506 isOpen={respondDialogOpen && !!enquiry}
507 onClose={() => {
508 if (!respondMutation.isPending) setRespondDialogOpen(false);
509 }}
510 title={__("Respond to Enquiry", "yatra")}
511 description={
512 enquiry ? (
513 <span>
514 {__("Send a response to", "yatra")}{" "}
515 <strong>{enquiry.name}</strong>
516 </span>
517 ) : null
518 }
519 size="md"
520 footer={
521 <div className="flex gap-2 justify-end">
522 <Button
523 variant="outline"
524 onClick={() => setRespondDialogOpen(false)}
525 disabled={respondMutation.isPending}
526 >
527 {__("Cancel", "yatra")}
528 </Button>
529 <Button
530 onClick={sendResponse}
531 disabled={respondMutation.isPending || !responseMessage.trim()}
532 >
533 {respondMutation.isPending ? (
534 <>
535 <Loader2 className="w-4 h-4 mr-2 animate-spin" />
536 {__("Sending...", "yatra")}
537 </>
538 ) : (
539 <>
540 <Send className="w-4 h-4 mr-2" />
541 {__("Send Response", "yatra")}
542 </>
543 )}
544 </Button>
545 </div>
546 }
547 >
548 {enquiry && (
549 <div className="space-y-4">
550 {/* Customer Info */}
551 <div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-3 space-y-2">
552 <div className="flex items-center gap-2 text-sm">
553 <Mail className="w-4 h-4 text-gray-400" />
554 <span className="text-gray-600 dark:text-gray-400">
555 {enquiry.email}
556 </span>
557 </div>
558 {enquiry.trip_title && (
559 <div className="flex items-center gap-2 text-sm">
560 <MapPin className="w-4 h-4 text-gray-400" />
561 <span className="text-gray-600 dark:text-gray-400">
562 {enquiry.trip_title}
563 </span>
564 </div>
565 )}
566 <div className="text-sm text-gray-500 dark:text-gray-400 border-t border-gray-200 dark:border-gray-700 pt-2 mt-2">
567 <strong>{__("Original Message:", "yatra")}</strong>
568 <p className="mt-1 text-gray-600 dark:text-gray-400">
569 {enquiry.message}
570 </p>
571 </div>
572 </div>
573
574 {/* Response Message — label + AI sparkle on the same
575 row so the operator sees the "Generate with AI"
576 option right where they're about to type. Uses the
577 same EnquiryReplyAffordance component the admin
578 EnquiryForm uses, so behavior (Generate / Warmer /
579 Shorter variants, sensitive-data toggle) is identical
580 across the two surfaces. */}
581 <div>
582 <div className="mb-1.5 flex items-center justify-between gap-2">
583 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
584 {__("Your Response", "yatra")}{" "}
585 <span className="text-red-500">*</span>
586 </label>
587 {enquiryId && (
588 <EnquiryReplyAffordance
589 enquiryId={Number(enquiryId)}
590 value={responseMessage}
591 onAccept={(text) => setResponseMessage(text)}
592 />
593 )}
594 </div>
595 <textarea
596 value={responseMessage}
597 onChange={(e) => setResponseMessage(e.target.value)}
598 placeholder={__("Type your response here...", "yatra")}
599 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"
600 rows={5}
601 disabled={respondMutation.isPending}
602 />
603 </div>
604
605 {respondMutation.isError && (
606 <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">
607 {respondMutation.error?.message ||
608 __("Failed to send response. Please try again.", "yatra")}
609 </div>
610 )}
611 </div>
612 )}
613 </Modal>
614 </div>
615 );
616 };
617
618 export default ViewEnquiry;
619