PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.10
Yatra – Travel Booking & Tour Operator Software v3.0.10
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 2.0.10 All 83 releases
yatra / resources / js / pages / EnquiryForm.tsx

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

538 lines 19.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Enquiry Form Page
3 * Create or edit enquiry in a clean, minimal SaaS-style design
4 */
5
6 import React, { useMemo, useEffect, useState } from "react";
7 import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
8 import { ArrowLeft, Save, Loader2 } from "lucide-react";
9 import { __ } from "../lib/i18n";
10 import { apiService } from "../lib/api-client";
11 import { usePermissions } from "../hooks/usePermissions";
12 import { Button } from "../components/ui/button";
13 import { Input } from "../components/ui/input";
14 import { DatePicker } from "../components/ui/date-picker";
15 import { Select } from "../components/ui/select";
16 import { useToast } from "../components/ui/toast";
17 import { PageHeader } from "../components/common/PageHeader";
18 import {
19 Card,
20 CardContent,
21 CardHeader,
22 CardTitle,
23 } from "../components/ui/card";
24 import { ConditionalRender } from "../components/ui/conditional-render";
25 import { useNavigate } from "../hooks/useNavigate";
26 import { Skeleton } from "../components/ui/skeleton";
27 import { EnquiryReplyAffordance } from "../components/ai/EnquiryReplyAffordance";
28 import { Sparkles } from "lucide-react";
29
30 interface Enquiry {
31 id: number;
32 name: string;
33 email: string;
34 phone?: string;
35 trip_id?: number;
36 trip_title?: string;
37 message: string;
38 travelers_count?: number;
39 travel_date?: string;
40 status:
41 | "new"
42 | "pending"
43 | "responded"
44 | "closed"
45 | "converted"
46 | "read"
47 | "archived"
48 | "spam"
49 | "trash";
50 response_notes?: string;
51 created_at: string;
52 responded_at?: string;
53 }
54
55 interface Trip {
56 id: number;
57 title: string;
58 }
59
60 const EnquiryForm: React.FC = () => {
61 const { can } = usePermissions();
62 const { navigate } = useNavigate();
63 const queryClient = useQueryClient();
64 const { showToast } = useToast();
65
66 // Form state
67 const [formData, setFormData] = useState<Partial<Enquiry>>({
68 name: "",
69 email: "",
70 phone: "",
71 trip_id: undefined,
72 message: "",
73 travelers_count: 1,
74 travel_date: "",
75 status: "new",
76 response_notes: "",
77 });
78 // Tracks whether the AI is currently drafting a reply so the banner
79 // above the textarea can show / hide. Lives in EnquiryForm (not in
80 // EnquiryReplyAffordance) so the textarea + banner can render side by
81 // side without the affordance owning the layout.
82 const [aiDrafting, setAiDrafting] = useState(false);
83
84 // Get enquiry id from URL (null for create, number for edit)
85 const enquiryId = useMemo(() => {
86 const params = new URLSearchParams(window.location.search);
87 const id = params.get("id");
88 return id ? parseInt(id) : null;
89 }, []);
90
91 const isEdit = !!enquiryId;
92
93 // Fetch enquiry data for editing
94 const { data: enquiry, isLoading: isLoadingEnquiry } =
95 useQuery<Enquiry | null>({
96 queryKey: ["enquiry", enquiryId],
97 queryFn: async () => {
98 if (!enquiryId) return null;
99 const response = await apiService.getEnquiry(enquiryId!);
100 // Some endpoints return { success, data }, others return the object directly
101 return (response as any)?.data ?? response;
102 },
103 enabled: !!enquiryId && can("yatra_respond_to_enquiries"),
104 });
105
106 // Fetch trips for dropdown
107 const { data: tripsData } = useQuery({
108 queryKey: ["trips-select"],
109 queryFn: async () => {
110 return await apiService.getTrips({ per_page: 100 });
111 },
112 enabled: can("yatra_respond_to_enquiries"),
113 });
114
115 // Normalize trips response to array
116 const trips: Trip[] = Array.isArray((tripsData as any)?.data)
117 ? (tripsData as any).data
118 : Array.isArray(tripsData)
119 ? (tripsData as Trip[])
120 : [];
121
122 // Update form when enquiry data loads
123 useEffect(() => {
124 if (enquiry) {
125 setFormData({
126 name: enquiry.name || "",
127 email: enquiry.email || "",
128 phone: enquiry.phone || "",
129 trip_id: enquiry.trip_id,
130 message: enquiry.message || "",
131 travelers_count: enquiry.travelers_count || 1,
132 travel_date: enquiry.travel_date || "",
133 status: enquiry.status || "new",
134 response_notes: enquiry.response_notes || "",
135 });
136 }
137 }, [enquiry]);
138
139 // Save mutation
140 const saveMutation = useMutation({
141 mutationFn: async (data: Partial<Enquiry>) => {
142 return isEdit
143 ? await apiService.updateEnquiry(enquiryId!, data)
144 : await apiService.createEnquiry(data);
145 },
146 onSuccess: () => {
147 queryClient.invalidateQueries({ queryKey: ["enquiries"] });
148 queryClient.invalidateQueries({ queryKey: ["enquiry", enquiryId] });
149 showToast(__("Enquiry saved successfully.", "yatra"), "success");
150 navigate({ subpage: "enquiries" });
151 },
152 });
153
154 const handleBack = () => {
155 navigate({ subpage: "enquiries" });
156 };
157
158 const handleSubmit = (e: React.FormEvent) => {
159 e.preventDefault();
160 saveMutation.mutate(formData);
161 };
162
163 const handleChange = (field: keyof Enquiry, value: any) => {
164 setFormData((prev) => ({ ...prev, [field]: value }));
165 };
166
167 if (isEdit && isLoadingEnquiry) {
168 return (
169 <div className="space-y-3">
170 <PageHeader
171 title={__("Edit Enquiry", "yatra")}
172 actions={
173 <div className="flex items-center gap-2">
174 <Skeleton className="h-10 w-24" />
175 <Skeleton className="h-10 w-32" />
176 </div>
177 }
178 />
179 <div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
180 <div className="lg:col-span-2 space-y-3">
181 <Card>
182 <CardHeader className="pb-2">
183 <Skeleton className="h-5 w-40" />
184 </CardHeader>
185 <CardContent className="space-y-4">
186 <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
187 <div>
188 <Skeleton className="h-3 w-16 mb-2" />
189 <Skeleton className="h-11 w-full" />
190 </div>
191 <div>
192 <Skeleton className="h-3 w-16 mb-2" />
193 <Skeleton className="h-11 w-full" />
194 </div>
195 </div>
196 <div>
197 <Skeleton className="h-3 w-20 mb-2" />
198 <Skeleton className="h-32 w-full" />
199 </div>
200 </CardContent>
201 </Card>
202 </div>
203 <div className="space-y-3">
204 <Card>
205 <CardHeader className="pb-2">
206 <Skeleton className="h-5 w-24" />
207 </CardHeader>
208 <CardContent className="space-y-4">
209 <Skeleton className="h-11 w-full" />
210 <Skeleton className="h-11 w-full" />
211 </CardContent>
212 </Card>
213 </div>
214 </div>
215 </div>
216 );
217 }
218
219 return (
220 <div className="space-y-3">
221 <PageHeader
222 title={
223 isEdit ? __("Edit Enquiry", "yatra") : __("Create Enquiry", "yatra")
224 }
225 description={
226 isEdit
227 ? __("Update enquiry details", "yatra")
228 : __("Add a new enquiry", "yatra")
229 }
230 actions={
231 <div className="flex items-center gap-2">
232 <Button
233 variant="outline"
234 onClick={handleBack}
235 className="flex items-center gap-2"
236 >
237 <ArrowLeft className="w-4 h-4" />
238 {__("Back", "yatra")}
239 </Button>
240 <Button
241 onClick={handleSubmit}
242 disabled={saveMutation.isPending}
243 className="flex items-center gap-2"
244 >
245 {saveMutation.isPending ? (
246 <Loader2 className="w-4 h-4 animate-spin" />
247 ) : (
248 <Save className="w-4 h-4" />
249 )}
250 {saveMutation.isPending
251 ? __("Saving...", "yatra")
252 : __("Save", "yatra")}
253 </Button>
254 </div>
255 }
256 />
257
258 <ConditionalRender capability="yatra_respond_to_enquiries">
259 <form onSubmit={handleSubmit}>
260 {saveMutation.isError && (
261 <div className="mb-3 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">
262 {saveMutation.error?.message ||
263 __("Failed to save enquiry. Please try again.", "yatra")}
264 </div>
265 )}
266
267 <div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
268 {/* Main Content */}
269 <div className="lg:col-span-2 space-y-3">
270 {/* Customer Information */}
271 <Card>
272 <CardHeader className="pb-2">
273 <CardTitle className="text-base">
274 {__("Customer Information", "yatra")}
275 </CardTitle>
276 </CardHeader>
277 <CardContent className="space-y-4">
278 <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
279 <div>
280 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
281 {__("Name", "yatra")}{" "}
282 <span className="text-red-500">*</span>
283 </label>
284 <Input
285 value={formData.name || ""}
286 onChange={(e) => handleChange("name", e.target.value)}
287 placeholder={__("Customer name", "yatra")}
288 required
289 />
290 </div>
291 <div>
292 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
293 {__("Email", "yatra")}{" "}
294 <span className="text-red-500">*</span>
295 </label>
296 <Input
297 type="email"
298 value={formData.email || ""}
299 onChange={(e) => handleChange("email", e.target.value)}
300 placeholder={__("[email protected]", "yatra")}
301 required
302 />
303 </div>
304 </div>
305 <div>
306 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
307 {__("Phone", "yatra")}
308 </label>
309 <Input
310 type="tel"
311 value={formData.phone || ""}
312 onChange={(e) => handleChange("phone", e.target.value)}
313 placeholder={__("Phone number", "yatra")}
314 />
315 </div>
316 </CardContent>
317 </Card>
318
319 {/* Enquiry Details */}
320 <Card>
321 <CardHeader className="pb-2">
322 <CardTitle className="text-base">
323 {__("Enquiry Details", "yatra")}
324 </CardTitle>
325 </CardHeader>
326 <CardContent className="space-y-4">
327 <div>
328 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
329 {__("Message", "yatra")}{" "}
330 <span className="text-red-500">*</span>
331 </label>
332 <textarea
333 value={formData.message || ""}
334 onChange={(e) => handleChange("message", e.target.value)}
335 placeholder={__("Enquiry message...", "yatra")}
336 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"
337 rows={5}
338 required
339 />
340 </div>
341 <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
342 <div>
343 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
344 {__("Number of Travelers", "yatra")}
345 </label>
346 <Input
347 type="number"
348 min="1"
349 value={formData.travelers_count || ""}
350 onChange={(e) =>
351 handleChange(
352 "travelers_count",
353 parseInt(e.target.value, 10) || 1,
354 )
355 }
356 placeholder="1"
357 />
358 </div>
359 <div>
360 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
361 {__("Preferred Travel Date", "yatra")}
362 </label>
363 <DatePicker
364 value={formData.travel_date || ""}
365 onChange={(val) => handleChange("travel_date", val)}
366 placeholder={__("Select date", "yatra")}
367 />
368 </div>
369 </div>
370 </CardContent>
371 </Card>
372
373 {/* Response Notes */}
374 {isEdit && (
375 <Card>
376 <CardHeader className="pb-2">
377 <div className="flex items-center justify-between">
378 <CardTitle className="text-base">
379 {__("Response Notes", "yatra")}
380 </CardTitle>
381 {enquiryId && (
382 <EnquiryReplyAffordance
383 enquiryId={Number(enquiryId)}
384 value={formData.response_notes || ""}
385 onAccept={(text) =>
386 handleChange("response_notes", text)
387 }
388 autoDraftOnMount={
389 !(formData.response_notes || "").trim() &&
390 (formData.status === "new" ||
391 !formData.responded_at)
392 }
393 onDraftingChange={setAiDrafting}
394 />
395 )}
396 </div>
397 </CardHeader>
398 <CardContent>
399 {aiDrafting && (
400 <div className="mb-2 flex items-center gap-2 rounded-md border border-purple-200 bg-purple-50/60 px-2 py-1 text-xs text-purple-700 dark:border-purple-500/40 dark:bg-purple-900/20 dark:text-purple-200">
401 <Sparkles className="h-3.5 w-3.5 animate-pulse" />
402 {__(
403 "AI is drafting a reply based on this enquiry, the trip details, and the customer's history…",
404 "yatra",
405 )}
406 </div>
407 )}
408 <textarea
409 value={formData.response_notes || ""}
410 onChange={(e) =>
411 handleChange("response_notes", e.target.value)
412 }
413 placeholder={__(
414 "Internal notes about responses to this enquiry...",
415 "yatra",
416 )}
417 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"
418 rows={6}
419 />
420 </CardContent>
421 </Card>
422 )}
423 </div>
424
425 {/* Sidebar */}
426 <div className="space-y-3">
427 {/* Status */}
428 <Card>
429 <CardHeader className="pb-2">
430 <CardTitle className="text-base">
431 {__("Status", "yatra")}
432 </CardTitle>
433 </CardHeader>
434 <CardContent>
435 <Select
436 value={formData.status || "new"}
437 onChange={(e) => handleChange("status", e.target.value)}
438 >
439 <option value="new">{__("New", "yatra")}</option>
440 <option value="pending">{__("Pending", "yatra")}</option>
441 <option value="responded">
442 {__("Responded", "yatra")}
443 </option>
444 <option value="converted">
445 {__("Converted", "yatra")}
446 </option>
447 <option value="closed">{__("Closed", "yatra")}</option>
448 <option value="spam">{__("Spam", "yatra")}</option>
449 <option value="trash">{__("Trash", "yatra")}</option>
450 </Select>
451 </CardContent>
452 </Card>
453
454 {/* Related Trip */}
455 <Card>
456 <CardHeader className="pb-2">
457 <CardTitle className="text-base">
458 {__("Related Trip", "yatra")}
459 </CardTitle>
460 </CardHeader>
461 <CardContent>
462 <Select
463 value={formData.trip_id?.toString() || ""}
464 onChange={(e) =>
465 handleChange(
466 "trip_id",
467 e.target.value ? parseInt(e.target.value) : undefined,
468 )
469 }
470 >
471 <option value="">{__("No trip selected", "yatra")}</option>
472 {trips.map((trip) => (
473 <option key={trip.id} value={trip.id.toString()}>
474 {trip.title}
475 </option>
476 ))}
477 </Select>
478 </CardContent>
479 </Card>
480
481 {/* Created Info */}
482 {isEdit && enquiry && (
483 <Card>
484 <CardHeader className="pb-2">
485 <CardTitle className="text-base">
486 {__("Information", "yatra")}
487 </CardTitle>
488 </CardHeader>
489 <CardContent className="space-y-3 text-sm">
490 <div>
491 <span className="text-gray-500 dark:text-gray-400">
492 {__("Created:", "yatra")}
493 </span>
494 <p className="text-gray-900 dark:text-white mt-0.5">
495 {new Date(enquiry.created_at).toLocaleDateString(
496 "en-US",
497 {
498 year: "numeric",
499 month: "long",
500 day: "numeric",
501 hour: "2-digit",
502 minute: "2-digit",
503 },
504 )}
505 </p>
506 </div>
507 {enquiry.responded_at && (
508 <div>
509 <span className="text-gray-500 dark:text-gray-400">
510 {__("Responded:", "yatra")}
511 </span>
512 <p className="text-gray-900 dark:text-white mt-0.5">
513 {new Date(enquiry.responded_at).toLocaleDateString(
514 "en-US",
515 {
516 year: "numeric",
517 month: "long",
518 day: "numeric",
519 hour: "2-digit",
520 minute: "2-digit",
521 },
522 )}
523 </p>
524 </div>
525 )}
526 </CardContent>
527 </Card>
528 )}
529 </div>
530 </div>
531 </form>
532 </ConditionalRender>
533 </div>
534 );
535 };
536
537 export default EnquiryForm;
538