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 / TripConsentForm.tsx

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

2,117 lines 77.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Trip Consent Form
3 *
4 * Form component for creating and editing consent forms.
5 * This is part of the premium module - functionality provided by Yatra Pro.
6 *
7 * @package Yatra
8 * @since 3.0.0
9 */
10
11 import React, { useState, useEffect } from "react";
12 import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
13 import {
14 Card,
15 CardContent,
16 CardHeader,
17 CardTitle,
18 } from "../components/ui/card";
19 import { Button } from "../components/ui/button";
20 import { Input } from "../components/ui/input";
21 import { Select } from "../components/ui/select";
22 import { PageHeader } from "../components/common/PageHeader";
23 import { ApplicableTripSelector } from "../components/shared/ApplicableTripSelector";
24 import { useToast } from "../components/ui/toast";
25 import { apiClient } from "../lib/api-client";
26 import { __ } from "../lib/i18n";
27 import {
28 ArrowLeft,
29 Save,
30 FileSignature,
31 Loader2,
32 Plus,
33 Trash2,
34 GripVertical,
35 ChevronDown,
36 ChevronUp,
37 Type,
38 CheckSquare,
39 AlignLeft,
40 List,
41 Calendar,
42 Mail,
43 Phone,
44 Hash,
45 Sparkles,
46 } from "lucide-react";
47
48 // Types
49 interface FormField {
50 id: string;
51 type:
52 | "text"
53 | "textarea"
54 | "checkbox"
55 | "select"
56 | "date"
57 | "email"
58 | "phone"
59 | "number";
60 label: string;
61 placeholder?: string;
62 required: boolean;
63 enabled: boolean;
64 order: number;
65 width: "full" | "half";
66 options?: { value: string; label: string }[];
67 }
68
69 interface ContentBlock {
70 id: string;
71 type: "heading" | "paragraph" | "terms";
72 title: string;
73 content: string;
74 order: number;
75 }
76
77 interface ConsentFormData {
78 id?: number;
79 name: string;
80 description: string;
81 status: "publish" | "draft" | "archived" | "trash";
82 require_signature: boolean;
83 require_initials: boolean;
84 applicable_to: "all" | "specific_trips";
85 send_to: "all_travelers" | "lead_traveler" | "primary_contact";
86 trip_ids: number[];
87 send_before_days: number;
88 reminder_days: string;
89 expiry_hours: number | null;
90 fields: FormField[];
91 content_blocks: ContentBlock[];
92 }
93
94 const defaultFormData: ConsentFormData = {
95 name: "",
96 description: "",
97 status: "publish",
98 require_signature: true,
99 require_initials: false,
100 applicable_to: "all",
101 send_to: "all_travelers",
102 trip_ids: [],
103 send_before_days: 7,
104 reminder_days: "3,1",
105 expiry_hours: null,
106 fields: [],
107 content_blocks: [],
108 };
109
110 const fieldTypeIcons: Record<string, React.ElementType> = {
111 text: Type,
112 textarea: AlignLeft,
113 checkbox: CheckSquare,
114 select: List,
115 date: Calendar,
116 email: Mail,
117 phone: Phone,
118 number: Hash,
119 };
120
121 // Check if module is available
122 const isModuleAvailable = (): boolean => {
123 const yatraAdmin = (window as any)?.yatraAdmin;
124 return Boolean(yatraAdmin?.isPro && yatraAdmin?.tripConsentEnabled);
125 };
126
127 // Generate unique ID
128 const generateId = () =>
129 `field_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
130
131 // Interactive Signature Pad Preview Component
132 interface SignaturePadPreviewProps {
133 id: string;
134 placeholder: string;
135 onContentChange?: (hasContent: boolean) => void;
136 canvasRef?: React.RefObject<HTMLCanvasElement>;
137 }
138
139 const SignaturePadPreview: React.FC<SignaturePadPreviewProps> = ({
140 id,
141 placeholder,
142 onContentChange,
143 canvasRef: externalCanvasRef,
144 }) => {
145 const internalCanvasRef = React.useRef<HTMLCanvasElement>(null);
146 const canvasRef = externalCanvasRef || internalCanvasRef;
147 const [isDrawing, setIsDrawing] = React.useState(false);
148 const [hasContent, setHasContent] = React.useState(false);
149
150 const updateContent = (value: boolean) => {
151 setHasContent(value);
152 onContentChange?.(value);
153 };
154
155 const getCoordinates = (e: React.MouseEvent | React.TouchEvent) => {
156 const canvas = canvasRef.current;
157 if (!canvas) return { x: 0, y: 0 };
158
159 const rect = canvas.getBoundingClientRect();
160 if ("touches" in e) {
161 return {
162 x: e.touches[0].clientX - rect.left,
163 y: e.touches[0].clientY - rect.top,
164 };
165 }
166 return {
167 x: e.clientX - rect.left,
168 y: e.clientY - rect.top,
169 };
170 };
171
172 const startDrawing = (e: React.MouseEvent | React.TouchEvent) => {
173 const canvas = canvasRef.current;
174 const ctx = canvas?.getContext("2d");
175 if (!ctx) return;
176
177 setIsDrawing(true);
178 updateContent(true);
179 const { x, y } = getCoordinates(e);
180 ctx.beginPath();
181 ctx.moveTo(x, y);
182 };
183
184 const draw = (e: React.MouseEvent | React.TouchEvent) => {
185 if (!isDrawing) return;
186 const canvas = canvasRef.current;
187 const ctx = canvas?.getContext("2d");
188 if (!ctx) return;
189
190 const { x, y } = getCoordinates(e);
191 ctx.lineTo(x, y);
192 ctx.strokeStyle = "#1f2937";
193 ctx.lineWidth = 2;
194 ctx.lineCap = "round";
195 ctx.lineJoin = "round";
196 ctx.stroke();
197 };
198
199 const stopDrawing = () => {
200 setIsDrawing(false);
201 };
202
203 const clearCanvas = () => {
204 const canvas = canvasRef.current;
205 const ctx = canvas?.getContext("2d");
206 if (!ctx || !canvas) return;
207 ctx.clearRect(0, 0, canvas.width, canvas.height);
208 updateContent(false);
209 };
210
211 React.useEffect(() => {
212 const canvas = canvasRef.current;
213 if (canvas) {
214 const rect = canvas.getBoundingClientRect();
215 canvas.width = rect.width;
216 canvas.height = rect.height;
217 }
218 // eslint-disable-next-line react-hooks/exhaustive-deps
219 }, []);
220
221 return (
222 <div className="relative">
223 <canvas
224 ref={canvasRef}
225 id={id}
226 className="w-full h-32 border-2 border-gray-300 dark:border-gray-600 rounded-lg bg-white cursor-crosshair touch-none"
227 onMouseDown={startDrawing}
228 onMouseMove={draw}
229 onMouseUp={stopDrawing}
230 onMouseLeave={stopDrawing}
231 onTouchStart={startDrawing}
232 onTouchMove={draw}
233 onTouchEnd={stopDrawing}
234 />
235 {!hasContent && (
236 <div className="absolute inset-0 flex items-center justify-center pointer-events-none">
237 <p className="text-gray-400 text-sm">{placeholder}</p>
238 </div>
239 )}
240 {hasContent && (
241 <button
242 type="button"
243 onClick={clearCanvas}
244 className="absolute top-2 right-2 px-2 py-1 text-xs bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-600 dark:text-gray-300 rounded"
245 >
246 {__("Clear")}
247 </button>
248 )}
249 </div>
250 );
251 };
252
253 const TripConsentForm: React.FC = () => {
254 const [formData, setFormData] = useState<ConsentFormData>(defaultFormData);
255 const [errors, setErrors] = useState<Record<string, string>>({});
256 const [activeTab, setActiveTab] = useState<
257 "general" | "fields" | "content" | "settings" | "preview"
258 >("general");
259 const [expandedField, setExpandedField] = useState<string | null>(null);
260 const [testEmail, setTestEmail] = useState<string>("");
261 const { showToast } = useToast();
262 const queryClient = useQueryClient();
263
264 // Get ID from URL for edit mode
265 const urlParams = new URLSearchParams(window.location.search);
266 const formId = urlParams.get("id");
267 const isEditMode = !!formId;
268
269 // Fetch existing form for edit mode
270 const { data: existingFormResponse, isLoading: isLoadingForm } = useQuery({
271 queryKey: ["consent-form", formId],
272 queryFn: async () => {
273 const response = await apiClient.get(`/consent-forms/${formId}`);
274 return response;
275 },
276 enabled: isEditMode && isModuleAvailable(),
277 });
278
279 // Update form data when existing form is loaded
280 useEffect(() => {
281 // API returns { success: true, data: form }
282 const existingForm = existingFormResponse?.data || existingFormResponse;
283 if (existingForm && existingForm.id) {
284 // Sort fields by order to preserve the saved order
285 const fields = existingForm.form_config?.fields || [];
286 const sortedFields = [...fields].sort(
287 (a, b) => (a.order ?? 0) - (b.order ?? 0),
288 );
289
290 // Sort content blocks by order as well
291 const contentBlocks = existingForm.form_config?.content_blocks || [];
292 const sortedContentBlocks = [...contentBlocks].sort(
293 (a, b) => (a.order ?? 0) - (b.order ?? 0),
294 );
295
296 setFormData({
297 ...defaultFormData,
298 id: existingForm.id,
299 name: existingForm.name || "",
300 description: existingForm.description || "",
301 status: existingForm.status || "publish",
302 require_signature: existingForm.require_signature ?? true,
303 require_initials: existingForm.require_initials ?? false,
304 applicable_to: existingForm.applicable_to || "all",
305 send_to: existingForm.send_to || "all_travelers",
306 trip_ids: existingForm.trip_ids || [],
307 send_before_days: existingForm.send_before_days || 7,
308 reminder_days: existingForm.reminder_days || "3,1",
309 expiry_hours: existingForm.expiry_hours || null,
310 fields: sortedFields,
311 content_blocks: sortedContentBlocks,
312 });
313 }
314 }, [existingFormResponse]);
315
316 // Save mutation
317 const saveMutation = useMutation({
318 mutationFn: async (data: ConsentFormData) => {
319 // Ensure fields have correct order values and all required properties
320 const fieldsWithOrder = data.fields.map((field, index) => ({
321 id: field.id,
322 type: field.type,
323 label: field.label || "",
324 placeholder: field.placeholder || "",
325 required: field.required ?? false,
326 enabled: field.enabled ?? true,
327 order: index,
328 width: field.width || "full",
329 options: field.options || undefined,
330 }));
331
332 // Ensure content blocks have correct order values and all required fields
333 const contentBlocksWithOrder = data.content_blocks.map(
334 (block, index) => ({
335 id: block.id,
336 type: block.type,
337 title: block.title || "",
338 content: block.content || "",
339 order: index,
340 }),
341 );
342
343 const payload = {
344 name: data.name,
345 description: data.description,
346 status: data.status,
347 require_signature: data.require_signature,
348 require_initials: data.require_initials,
349 applicable_to: data.applicable_to,
350 send_to: data.send_to,
351 trip_ids: data.trip_ids,
352 send_before_days: data.send_before_days,
353 reminder_days: data.reminder_days,
354 expiry_hours: data.expiry_hours,
355 form_config: {
356 fields: fieldsWithOrder,
357 content_blocks: contentBlocksWithOrder,
358 },
359 };
360
361 if (isEditMode) {
362 return await apiClient.put(`/consent-forms/${formId}`, payload);
363 }
364 return await apiClient.post("/consent-forms", payload);
365 },
366 onSuccess: (response: any) => {
367 queryClient.invalidateQueries({ queryKey: ["consent-forms"] });
368 showToast(
369 isEditMode
370 ? __("Consent form updated successfully")
371 : __("Consent form created successfully"),
372 "success",
373 );
374
375 if (isEditMode) {
376 return;
377 }
378
379 const newId = response?.data?.id ?? response?.id ?? response?.form?.id;
380 if (!newId) {
381 navigateBack();
382 return;
383 }
384
385 const params = new URLSearchParams(window.location.search);
386 params.set("action", "edit");
387 params.set("id", String(newId));
388 params.set("subpage", "trips");
389 params.set("tab", "trip-consent");
390 window.history.pushState(
391 {},
392 "",
393 `${window.location.pathname}?${params.toString()}`,
394 );
395 window.dispatchEvent(new PopStateEvent("popstate"));
396 },
397 onError: (error: any) => {
398 showToast(error?.message || __("Failed to save consent form"), "error");
399 },
400 });
401
402 // Test email mutation
403 const sendTestEmailMutation = useMutation({
404 mutationFn: async (email: string) => {
405 const payload = {
406 email,
407 form_data: {
408 ...formData,
409 fields: formData.fields.map((field, index) => ({
410 ...field,
411 order: index,
412 })),
413 content_blocks: formData.content_blocks.map((block, index) => ({
414 ...block,
415 order: index,
416 })),
417 },
418 };
419 return await apiClient.post("/consent-forms/send-test-email", payload);
420 },
421 onSuccess: () => {
422 showToast(__("Test email sent successfully"), "success");
423 setTestEmail("");
424 },
425 onError: (error: any) => {
426 showToast(error?.message || __("Failed to send test email"), "error");
427 },
428 });
429
430 const handleSendTestEmail = () => {
431 if (!testEmail) {
432 showToast(__("Please enter an email address"), "warning");
433 return;
434 }
435 sendTestEmailMutation.mutate(testEmail);
436 };
437
438 const navigateBack = () => {
439 const params = new URLSearchParams(window.location.search);
440 params.delete("action");
441 params.delete("id");
442 params.set("subpage", "trips");
443 params.set("tab", "trip-consent");
444 window.history.pushState({}, "", `${window.location.pathname}?${params}`);
445 window.dispatchEvent(new PopStateEvent("popstate"));
446 };
447
448 const handleSubmit = (e: React.FormEvent) => {
449 e.preventDefault();
450
451 // Validate
452 const newErrors: Record<string, string> = {};
453 if (!formData.name.trim()) {
454 newErrors.name = __("Form name is required");
455 }
456
457 if (Object.keys(newErrors).length > 0) {
458 setErrors(newErrors);
459 return;
460 }
461
462 saveMutation.mutate(formData);
463 };
464
465 const addField = (type: FormField["type"]) => {
466 const newField: FormField = {
467 id: generateId(),
468 type,
469 label: "",
470 placeholder: "",
471 required: false,
472 enabled: true,
473 order: formData.fields.length,
474 width: "full",
475 options: type === "select" ? [{ value: "", label: "" }] : undefined,
476 };
477 setFormData({ ...formData, fields: [...formData.fields, newField] });
478 setExpandedField(newField.id);
479 };
480
481 const updateField = (fieldId: string, updates: Partial<FormField>) => {
482 setFormData({
483 ...formData,
484 fields: formData.fields.map((f) =>
485 f.id === fieldId ? { ...f, ...updates } : f,
486 ),
487 });
488 };
489
490 const removeField = (fieldId: string) => {
491 setFormData({
492 ...formData,
493 fields: formData.fields.filter((f) => f.id !== fieldId),
494 });
495 };
496
497 const moveField = (fromIndex: number, toIndex: number) => {
498 const newFields = [...formData.fields];
499 const [movedField] = newFields.splice(fromIndex, 1);
500 newFields.splice(toIndex, 0, movedField);
501 // Update order values
502 const reorderedFields = newFields.map((field, index) => ({
503 ...field,
504 order: index,
505 }));
506 setFormData({ ...formData, fields: reorderedFields });
507 };
508
509 // Drag and drop state
510 const [draggedFieldIndex, setDraggedFieldIndex] = useState<number | null>(
511 null,
512 );
513 const [dragOverFieldIndex, setDragOverFieldIndex] = useState<number | null>(
514 null,
515 );
516
517 const handleDragStart = (e: React.DragEvent, index: number) => {
518 setDraggedFieldIndex(index);
519 e.dataTransfer.effectAllowed = "move";
520 e.dataTransfer.setData("text/plain", index.toString());
521 };
522
523 const handleDragOver = (e: React.DragEvent, index: number) => {
524 e.preventDefault();
525 e.dataTransfer.dropEffect = "move";
526 if (draggedFieldIndex !== null && draggedFieldIndex !== index) {
527 setDragOverFieldIndex(index);
528 }
529 };
530
531 const handleDragLeave = () => {
532 setDragOverFieldIndex(null);
533 };
534
535 const handleDrop = (e: React.DragEvent, toIndex: number) => {
536 e.preventDefault();
537 if (draggedFieldIndex !== null && draggedFieldIndex !== toIndex) {
538 moveField(draggedFieldIndex, toIndex);
539 }
540 setDraggedFieldIndex(null);
541 setDragOverFieldIndex(null);
542 };
543
544 const handleDragEnd = () => {
545 setDraggedFieldIndex(null);
546 setDragOverFieldIndex(null);
547 };
548
549 const addContentBlock = (type: ContentBlock["type"]) => {
550 const newBlock: ContentBlock = {
551 id: generateId(),
552 type,
553 title: "",
554 content: "",
555 order: formData.content_blocks.length,
556 };
557 setFormData({
558 ...formData,
559 content_blocks: [...formData.content_blocks, newBlock],
560 });
561 };
562
563 const updateContentBlock = (
564 blockId: string,
565 updates: Partial<ContentBlock>,
566 ) => {
567 setFormData({
568 ...formData,
569 content_blocks: formData.content_blocks.map((b) =>
570 b.id === blockId ? { ...b, ...updates } : b,
571 ),
572 });
573 };
574
575 const removeContentBlock = (blockId: string) => {
576 setFormData({
577 ...formData,
578 content_blocks: formData.content_blocks.filter((b) => b.id !== blockId),
579 });
580 };
581
582 // Generate a simple pre-populated consent form
583 const generateSimpleForm = () => {
584 const simpleFields: FormField[] = [
585 {
586 id: generateId(),
587 type: "text",
588 label: __("Full Name"),
589 placeholder: __("Enter your full legal name"),
590 required: true,
591 enabled: true,
592 order: 0,
593 width: "half",
594 },
595 {
596 id: generateId(),
597 type: "email",
598 label: __("Email Address"),
599 placeholder: __("Enter your email address"),
600 required: true,
601 enabled: true,
602 order: 1,
603 width: "half",
604 },
605 {
606 id: generateId(),
607 type: "phone",
608 label: __("Phone Number"),
609 placeholder: __("Enter your phone number"),
610 required: true,
611 enabled: true,
612 order: 2,
613 width: "half",
614 },
615 {
616 id: generateId(),
617 type: "date",
618 label: __("Date of Birth"),
619 placeholder: "",
620 required: true,
621 enabled: true,
622 order: 3,
623 width: "half",
624 },
625 {
626 id: generateId(),
627 type: "text",
628 label: __("Emergency Contact Name"),
629 placeholder: __("Name of emergency contact"),
630 required: true,
631 enabled: true,
632 order: 4,
633 width: "half",
634 },
635 {
636 id: generateId(),
637 type: "phone",
638 label: __("Emergency Contact Phone"),
639 placeholder: __("Emergency contact phone number"),
640 required: true,
641 enabled: true,
642 order: 5,
643 width: "half",
644 },
645 {
646 id: generateId(),
647 type: "textarea",
648 label: __("Medical Conditions or Allergies"),
649 placeholder: __(
650 "Please list any medical conditions, allergies, or dietary restrictions we should be aware of",
651 ),
652 required: false,
653 enabled: true,
654 order: 6,
655 width: "full",
656 },
657 {
658 id: generateId(),
659 type: "checkbox",
660 label: __(
661 "I confirm that I am physically fit to participate in this trip",
662 ),
663 placeholder: "",
664 required: true,
665 enabled: true,
666 order: 7,
667 width: "full",
668 },
669 ];
670
671 const simpleContentBlocks: ContentBlock[] = [
672 {
673 id: generateId(),
674 type: "heading",
675 title: __("Trip Participation Agreement"),
676 content: "",
677 order: 0,
678 },
679 {
680 id: generateId(),
681 type: "paragraph",
682 title: __("Introduction"),
683 content: __(
684 "By signing this consent form, you acknowledge that you have read, understood, and agree to the terms and conditions outlined below for your participation in the trip.",
685 ),
686 order: 1,
687 },
688 {
689 id: generateId(),
690 type: "terms",
691 title: __("Assumption of Risk"),
692 content: __(
693 "I understand that participation in this trip involves inherent risks, including but not limited to physical injury, illness, property damage, and other hazards. I voluntarily assume all risks associated with my participation and agree to hold harmless the trip organizers, guides, and affiliated parties from any liability arising from my participation.",
694 ),
695 order: 2,
696 },
697 {
698 id: generateId(),
699 type: "terms",
700 title: __("Medical Authorization"),
701 content: __(
702 "In the event of a medical emergency, I authorize the trip organizers to seek and consent to medical treatment on my behalf. I understand that I am responsible for any medical expenses incurred during the trip.",
703 ),
704 order: 3,
705 },
706 {
707 id: generateId(),
708 type: "terms",
709 title: __("Photo/Video Release"),
710 content: __(
711 "I grant permission to the trip organizers to use photographs and videos taken during the trip for promotional and marketing purposes without compensation.",
712 ),
713 order: 4,
714 },
715 {
716 id: generateId(),
717 type: "paragraph",
718 title: __("Agreement"),
719 content: __(
720 "By signing below, I confirm that I have read and understood this consent form, that I am at least 18 years of age (or have parental/guardian consent), and that I agree to abide by all rules and instructions provided by the trip organizers.",
721 ),
722 order: 5,
723 },
724 ];
725
726 setFormData({
727 ...formData,
728 name: formData.name || __("Trip Liability Waiver"),
729 description:
730 formData.description ||
731 __("Standard liability waiver and consent form for trip participants"),
732 require_signature: true,
733 require_initials: true,
734 fields: simpleFields,
735 content_blocks: simpleContentBlocks,
736 });
737
738 showToast(
739 __("Simple consent form generated! Review and customize as needed."),
740 "success",
741 );
742 };
743
744 // Premium gate
745 if (!isModuleAvailable()) {
746 return (
747 <div className="p-8 text-center">
748 <FileSignature className="w-16 h-16 mx-auto text-gray-400 mb-4" />
749 <h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">
750 {__("Trip Consent is a Premium Feature")}
751 </h2>
752 <p className="text-gray-600 dark:text-gray-400 mb-4">
753 {__("Upgrade to Yatra Pro to create and manage consent forms.")}
754 </p>
755 <Button
756 onClick={() =>
757 window.open(
758 "https://wpyatra.com/pricing?module=trip-consent",
759 "_blank",
760 )
761 }
762 >
763 {__("Upgrade to Pro")}
764 </Button>
765 </div>
766 );
767 }
768
769 if (isLoadingForm) {
770 return (
771 <div className="space-y-6">
772 {/* Header Skeleton */}
773 <div className="flex items-center justify-between">
774 <div>
775 <div className="h-8 w-48 bg-gray-200 dark:bg-gray-700 rounded animate-pulse mb-2" />
776 <div className="h-4 w-72 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
777 </div>
778 <div className="flex items-center gap-3">
779 <div className="h-10 w-24 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
780 <div className="h-10 w-32 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
781 </div>
782 </div>
783
784 {/* Tabs Skeleton */}
785 <div className="flex gap-2 border-b border-gray-200 dark:border-gray-700 pb-2">
786 {[1, 2, 3, 4, 5].map((i) => (
787 <div
788 key={i}
789 className="h-10 w-24 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"
790 />
791 ))}
792 </div>
793
794 {/* Content Skeleton - Two Column Layout */}
795 <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
796 {/* Main Content */}
797 <div className="lg:col-span-2 space-y-6">
798 <Card>
799 <CardHeader>
800 <div className="h-6 w-32 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
801 </CardHeader>
802 <CardContent className="space-y-4">
803 <div>
804 <div className="h-4 w-24 bg-gray-200 dark:bg-gray-700 rounded animate-pulse mb-2" />
805 <div className="h-10 w-full bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
806 </div>
807 <div>
808 <div className="h-4 w-28 bg-gray-200 dark:bg-gray-700 rounded animate-pulse mb-2" />
809 <div className="h-24 w-full bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
810 </div>
811 </CardContent>
812 </Card>
813 </div>
814
815 {/* Sidebar */}
816 <div className="space-y-6">
817 <Card>
818 <CardHeader>
819 <div className="h-6 w-20 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
820 </CardHeader>
821 <CardContent>
822 <div className="h-10 w-full bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
823 </CardContent>
824 </Card>
825 <Card>
826 <CardHeader>
827 <div className="h-6 w-40 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
828 </CardHeader>
829 <CardContent className="space-y-3">
830 <div className="flex items-center gap-3">
831 <div className="h-4 w-4 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
832 <div className="h-4 w-32 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
833 </div>
834 <div className="flex items-center gap-3">
835 <div className="h-4 w-4 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
836 <div className="h-4 w-28 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
837 </div>
838 </CardContent>
839 </Card>
840 <Card>
841 <CardHeader>
842 <div className="h-6 w-40 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
843 </CardHeader>
844 <CardContent className="space-y-3">
845 <div className="h-10 w-full bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
846 </CardContent>
847 </Card>
848 </div>
849 </div>
850 </div>
851 );
852 }
853
854 return (
855 <div className="space-y-6">
856 <PageHeader
857 title={isEditMode ? __("Edit Consent Form") : __("Create Consent Form")}
858 description={
859 isEditMode
860 ? __("Update your consent form settings and fields.")
861 : __("Create a new consent form for your trips.")
862 }
863 actions={
864 <div className="flex items-center gap-3">
865 <Button variant="outline" onClick={navigateBack}>
866 <ArrowLeft className="w-4 h-4 mr-2" />
867 {__("Back")}
868 </Button>
869 {!isEditMode && formData.fields.length === 0 && (
870 <Button
871 type="button"
872 variant="outline"
873 onClick={generateSimpleForm}
874 className="border-blue-300 text-blue-600 hover:bg-blue-50 dark:border-blue-700 dark:hover:bg-blue-900/20"
875 >
876 <Sparkles className="w-4 h-4 mr-2" />
877 {__("Generate Simple Form")}
878 </Button>
879 )}
880 <Button
881 onClick={handleSubmit}
882 disabled={saveMutation.isPending}
883 className="bg-blue-600 hover:bg-blue-700"
884 >
885 {saveMutation.isPending ? (
886 <Loader2 className="w-4 h-4 mr-2 animate-spin" />
887 ) : (
888 <Save className="w-4 h-4 mr-2" />
889 )}
890 {isEditMode ? __("Update Form") : __("Create Form")}
891 </Button>
892 </div>
893 }
894 />
895
896 <form onSubmit={handleSubmit}>
897 {/* Tabs */}
898 <div className="border-b border-gray-200 dark:border-gray-700 mb-6">
899 <nav className="flex gap-6">
900 {(
901 ["general", "fields", "content", "settings", "preview"] as const
902 ).map((tab) => (
903 <button
904 key={tab}
905 type="button"
906 onClick={() => setActiveTab(tab)}
907 className={`pb-3 text-sm font-medium border-b-2 transition-colors ${
908 activeTab === tab
909 ? "border-blue-600 text-blue-600"
910 : "border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
911 }`}
912 >
913 {tab === "general" && __("General")}
914 {tab === "fields" && __("Form Fields")}
915 {tab === "content" && __("Content Blocks")}
916 {tab === "settings" && __("Settings")}
917 {tab === "preview" && __("Preview")}
918 </button>
919 ))}
920 </nav>
921 </div>
922
923 {/* General Tab */}
924 {activeTab === "general" && (
925 <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
926 <div className="lg:col-span-2 space-y-6">
927 <Card>
928 <CardHeader>
929 <CardTitle>{__("Form Details")}</CardTitle>
930 </CardHeader>
931 <CardContent className="space-y-4">
932 <div>
933 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
934 {__("Form Name")} <span className="text-red-500">*</span>
935 </label>
936 <Input
937 value={formData.name}
938 onChange={(e) =>
939 setFormData({ ...formData, name: e.target.value })
940 }
941 placeholder={__("e.g., Liability Waiver")}
942 className={errors.name ? "border-red-500" : ""}
943 />
944 {errors.name && (
945 <p className="text-red-500 text-sm mt-1">{errors.name}</p>
946 )}
947 </div>
948
949 <div>
950 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
951 {__("Description")}
952 </label>
953 <textarea
954 value={formData.description}
955 onChange={(e) =>
956 setFormData({
957 ...formData,
958 description: e.target.value,
959 })
960 }
961 placeholder={__(
962 "Brief description of this consent form...",
963 )}
964 rows={3}
965 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"
966 />
967 </div>
968 </CardContent>
969 </Card>
970 </div>
971
972 <div className="space-y-6">
973 <Card>
974 <CardHeader>
975 <CardTitle>{__("Status")}</CardTitle>
976 </CardHeader>
977 <CardContent>
978 <Select
979 value={formData.status}
980 onChange={(e) =>
981 setFormData({
982 ...formData,
983 status: e.target.value as
984 | "publish"
985 | "draft"
986 | "archived"
987 | "trash",
988 })
989 }
990 >
991 <option value="publish">{__("Published")}</option>
992 <option value="draft">{__("Draft")}</option>
993 <option value="archived">{__("Archived")}</option>
994 <option value="trash">{__("Trash")}</option>
995 </Select>
996 </CardContent>
997 </Card>
998
999 <Card>
1000 <CardHeader>
1001 <CardTitle>{__("Signature Requirements")}</CardTitle>
1002 </CardHeader>
1003 <CardContent className="space-y-3">
1004 <label className="flex items-center gap-3 cursor-pointer">
1005 <input
1006 type="checkbox"
1007 checked={formData.require_signature}
1008 onChange={(e) =>
1009 setFormData({
1010 ...formData,
1011 require_signature: e.target.checked,
1012 })
1013 }
1014 className="w-4 h-4 rounded border-gray-300"
1015 />
1016 <span className="text-sm text-gray-700 dark:text-gray-300">
1017 {__("Require signature")}
1018 </span>
1019 </label>
1020 <label className="flex items-center gap-3 cursor-pointer">
1021 <input
1022 type="checkbox"
1023 checked={formData.require_initials}
1024 onChange={(e) =>
1025 setFormData({
1026 ...formData,
1027 require_initials: e.target.checked,
1028 })
1029 }
1030 className="w-4 h-4 rounded border-gray-300"
1031 />
1032 <span className="text-sm text-gray-700 dark:text-gray-300">
1033 {__("Require initials")}
1034 </span>
1035 </label>
1036 </CardContent>
1037 </Card>
1038
1039 <Card>
1040 <CardHeader className="pb-2">
1041 <CardTitle className="text-base">
1042 {__("Applicable To", "yatra")}
1043 </CardTitle>
1044 </CardHeader>
1045 <CardContent>
1046 <ApplicableTripSelector
1047 value={formData.applicable_to}
1048 onValueChange={(val) =>
1049 setFormData({ ...formData, applicable_to: val })
1050 }
1051 selectedTripIds={formData.trip_ids}
1052 onTripIdsChange={(ids) =>
1053 setFormData({ ...formData, trip_ids: ids })
1054 }
1055 description={__(
1056 "Choose whether this consent form applies to all trips or specific ones.",
1057 )}
1058 helperText={__(
1059 "Trips shown with ID for quick identification.",
1060 )}
1061 />
1062 </CardContent>
1063 </Card>
1064 </div>
1065 </div>
1066 )}
1067
1068 {/* Fields Tab */}
1069 {activeTab === "fields" && (
1070 <div className="space-y-6">
1071 <Card>
1072 <CardHeader>
1073 <CardTitle className="flex items-center justify-between">
1074 <span>{__("Form Fields")}</span>
1075 <div className="flex gap-2">
1076 {Object.entries(fieldTypeIcons).map(([type, Icon]) => (
1077 <Button
1078 key={type}
1079 type="button"
1080 variant="outline"
1081 size="sm"
1082 onClick={() => addField(type as FormField["type"])}
1083 title={type}
1084 >
1085 <Icon className="w-4 h-4" />
1086 </Button>
1087 ))}
1088 </div>
1089 </CardTitle>
1090 </CardHeader>
1091 <CardContent>
1092 {formData.fields.length === 0 ? (
1093 <div className="text-center py-8 text-gray-500">
1094 <Type className="w-12 h-12 mx-auto mb-3 opacity-50" />
1095 <p>
1096 {__(
1097 "No fields added yet. Click the buttons above to add fields.",
1098 )}
1099 </p>
1100 </div>
1101 ) : (
1102 <div className="space-y-3">
1103 {formData.fields.map((field, index) => {
1104 const FieldIcon = fieldTypeIcons[field.type] || Type;
1105 const isExpanded = expandedField === field.id;
1106 const isDragging = draggedFieldIndex === index;
1107 const isDragOver = dragOverFieldIndex === index;
1108
1109 return (
1110 <div
1111 key={field.id}
1112 draggable
1113 onDragStart={(e) => handleDragStart(e, index)}
1114 onDragOver={(e) => handleDragOver(e, index)}
1115 onDragLeave={handleDragLeave}
1116 onDrop={(e) => handleDrop(e, index)}
1117 onDragEnd={handleDragEnd}
1118 className={`border rounded-lg transition-all ${
1119 isDragging
1120 ? "opacity-50 border-blue-400 bg-blue-50 dark:bg-blue-900/20"
1121 : isDragOver
1122 ? "border-blue-500 border-2 bg-blue-50 dark:bg-blue-900/10"
1123 : "border-gray-200 dark:border-gray-700"
1124 }`}
1125 >
1126 <div
1127 className="flex items-center gap-3 p-3 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800"
1128 onClick={() =>
1129 setExpandedField(isExpanded ? null : field.id)
1130 }
1131 >
1132 <GripVertical className="w-4 h-4 text-gray-400 cursor-grab active:cursor-grabbing" />
1133 <FieldIcon className="w-4 h-4 text-blue-600" />
1134 <span className="flex-1 text-sm font-medium">
1135 {field.label || __("Untitled Field")}
1136 </span>
1137 <span className="text-xs text-gray-500 capitalize">
1138 {field.type}
1139 </span>
1140 {isExpanded ? (
1141 <ChevronUp className="w-4 h-4" />
1142 ) : (
1143 <ChevronDown className="w-4 h-4" />
1144 )}
1145 </div>
1146
1147 {isExpanded && (
1148 <div className="p-4 border-t border-gray-200 dark:border-gray-700 space-y-4">
1149 <div className="grid grid-cols-2 gap-4">
1150 <div>
1151 <label className="block text-sm font-medium mb-1">
1152 {__("Label")}
1153 </label>
1154 <Input
1155 value={field.label}
1156 onChange={(e) =>
1157 updateField(field.id, {
1158 label: e.target.value,
1159 })
1160 }
1161 placeholder={__("Field label")}
1162 />
1163 </div>
1164 <div>
1165 <label className="block text-sm font-medium mb-1">
1166 {__("Placeholder")}
1167 </label>
1168 <Input
1169 value={field.placeholder || ""}
1170 onChange={(e) =>
1171 updateField(field.id, {
1172 placeholder: e.target.value,
1173 })
1174 }
1175 placeholder={__("Placeholder text")}
1176 />
1177 </div>
1178 </div>
1179
1180 <div className="flex items-center gap-6">
1181 <label className="flex items-center gap-2 cursor-pointer">
1182 <input
1183 type="checkbox"
1184 checked={field.required}
1185 onChange={(e) =>
1186 updateField(field.id, {
1187 required: e.target.checked,
1188 })
1189 }
1190 className="w-4 h-4 rounded"
1191 />
1192 <span className="text-sm">
1193 {__("Required")}
1194 </span>
1195 </label>
1196 <label className="flex items-center gap-2 cursor-pointer">
1197 <input
1198 type="checkbox"
1199 checked={field.enabled}
1200 onChange={(e) =>
1201 updateField(field.id, {
1202 enabled: e.target.checked,
1203 })
1204 }
1205 className="w-4 h-4 rounded"
1206 />
1207 <span className="text-sm">
1208 {__("Enabled")}
1209 </span>
1210 </label>
1211 <Select
1212 value={field.width}
1213 onChange={(e) =>
1214 updateField(field.id, {
1215 width: e.target.value as "full" | "half",
1216 })
1217 }
1218 className="w-32"
1219 >
1220 <option value="full">
1221 {__("Full Width")}
1222 </option>
1223 <option value="half">
1224 {__("Half Width")}
1225 </option>
1226 </Select>
1227 </div>
1228
1229 {field.type === "select" && (
1230 <div>
1231 <label className="block text-sm font-medium mb-2">
1232 {__("Options")}
1233 </label>
1234 {field.options?.map((opt, idx) => (
1235 <div key={idx} className="flex gap-2 mb-2">
1236 <Input
1237 value={opt.value}
1238 onChange={(e) => {
1239 const newOptions = [
1240 ...(field.options || []),
1241 ];
1242 newOptions[idx] = {
1243 ...newOptions[idx],
1244 value: e.target.value,
1245 };
1246 updateField(field.id, {
1247 options: newOptions,
1248 });
1249 }}
1250 placeholder={__("Value")}
1251 className="flex-1"
1252 />
1253 <Input
1254 value={opt.label}
1255 onChange={(e) => {
1256 const newOptions = [
1257 ...(field.options || []),
1258 ];
1259 newOptions[idx] = {
1260 ...newOptions[idx],
1261 label: e.target.value,
1262 };
1263 updateField(field.id, {
1264 options: newOptions,
1265 });
1266 }}
1267 placeholder={__("Label")}
1268 className="flex-1"
1269 />
1270 <Button
1271 type="button"
1272 variant="ghost"
1273 size="sm"
1274 onClick={() => {
1275 const newOptions =
1276 field.options?.filter(
1277 (_, i) => i !== idx,
1278 );
1279 updateField(field.id, {
1280 options: newOptions,
1281 });
1282 }}
1283 >
1284 <Trash2 className="w-4 h-4 text-red-500" />
1285 </Button>
1286 </div>
1287 ))}
1288 <Button
1289 type="button"
1290 variant="outline"
1291 size="sm"
1292 onClick={() => {
1293 const newOptions = [
1294 ...(field.options || []),
1295 { value: "", label: "" },
1296 ];
1297 updateField(field.id, {
1298 options: newOptions,
1299 });
1300 }}
1301 >
1302 <Plus className="w-4 h-4 mr-1" />
1303 {__("Add Option")}
1304 </Button>
1305 </div>
1306 )}
1307
1308 <div className="flex justify-end">
1309 <Button
1310 type="button"
1311 variant="ghost"
1312 size="sm"
1313 onClick={() => removeField(field.id)}
1314 className="text-red-600 hover:text-red-700 hover:bg-red-50"
1315 >
1316 <Trash2 className="w-4 h-4 mr-1" />
1317 {__("Remove Field")}
1318 </Button>
1319 </div>
1320 </div>
1321 )}
1322 </div>
1323 );
1324 })}
1325 </div>
1326 )}
1327 </CardContent>
1328 </Card>
1329 </div>
1330 )}
1331
1332 {/* Content Tab */}
1333 {activeTab === "content" && (
1334 <div className="space-y-6">
1335 <Card>
1336 <CardHeader>
1337 <CardTitle className="flex items-center justify-between">
1338 <span>{__("Content Blocks")}</span>
1339 <div className="flex gap-2">
1340 <Button
1341 type="button"
1342 variant="outline"
1343 size="sm"
1344 onClick={() => addContentBlock("heading")}
1345 >
1346 {__("Add Heading")}
1347 </Button>
1348 <Button
1349 type="button"
1350 variant="outline"
1351 size="sm"
1352 onClick={() => addContentBlock("paragraph")}
1353 >
1354 {__("Add Paragraph")}
1355 </Button>
1356 <Button
1357 type="button"
1358 variant="outline"
1359 size="sm"
1360 onClick={() => addContentBlock("terms")}
1361 >
1362 {__("Add Terms")}
1363 </Button>
1364 </div>
1365 </CardTitle>
1366 </CardHeader>
1367 <CardContent>
1368 {formData.content_blocks.length === 0 ? (
1369 <div className="text-center py-8 text-gray-500">
1370 <AlignLeft className="w-12 h-12 mx-auto mb-3 opacity-50" />
1371 <p>
1372 {__(
1373 "No content blocks added yet. Add headings, paragraphs, or terms sections.",
1374 )}
1375 </p>
1376 </div>
1377 ) : (
1378 <div className="space-y-4">
1379 {formData.content_blocks.map((block) => (
1380 <div
1381 key={block.id}
1382 className="border border-gray-200 dark:border-gray-700 rounded-lg p-4"
1383 >
1384 <div className="flex items-center justify-between mb-3">
1385 <span className="text-sm font-medium text-blue-600 capitalize">
1386 {block.type}
1387 </span>
1388 <Button
1389 type="button"
1390 variant="ghost"
1391 size="sm"
1392 onClick={() => removeContentBlock(block.id)}
1393 className="text-red-600"
1394 >
1395 <Trash2 className="w-4 h-4" />
1396 </Button>
1397 </div>
1398 <div className="space-y-3">
1399 <Input
1400 value={block.title}
1401 onChange={(e) =>
1402 updateContentBlock(block.id, {
1403 title: e.target.value,
1404 })
1405 }
1406 placeholder={__("Title")}
1407 />
1408 <textarea
1409 value={block.content}
1410 onChange={(e) =>
1411 updateContentBlock(block.id, {
1412 content: e.target.value,
1413 })
1414 }
1415 placeholder={__("Content...")}
1416 rows={4}
1417 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"
1418 />
1419 </div>
1420 </div>
1421 ))}
1422 </div>
1423 )}
1424 </CardContent>
1425 </Card>
1426 </div>
1427 )}
1428
1429 {/* Settings Tab */}
1430 {activeTab === "settings" && (
1431 <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
1432 <Card>
1433 <CardHeader>
1434 <CardTitle>{__("Recipient Settings")}</CardTitle>
1435 </CardHeader>
1436 <CardContent className="space-y-4">
1437 <div>
1438 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
1439 {__("Send consent form to")}
1440 </label>
1441 <div className="space-y-3">
1442 <label className="flex items-start gap-3 p-3 border border-gray-200 dark:border-gray-700 rounded-lg cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800">
1443 <input
1444 type="radio"
1445 name="send_to"
1446 value="all_travelers"
1447 checked={formData.send_to === "all_travelers"}
1448 onChange={(e) =>
1449 setFormData({
1450 ...formData,
1451 send_to: e.target.value as any,
1452 })
1453 }
1454 className="mt-1"
1455 />
1456 <div>
1457 <span className="font-medium text-gray-900 dark:text-white">
1458 {__("All Travelers")}
1459 </span>
1460 <p className="text-xs text-gray-500 mt-0.5">
1461 {__(
1462 "Each traveler in the booking will receive their own consent form to sign individually.",
1463 )}
1464 </p>
1465 </div>
1466 </label>
1467 <label className="flex items-start gap-3 p-3 border border-gray-200 dark:border-gray-700 rounded-lg cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800">
1468 <input
1469 type="radio"
1470 name="send_to"
1471 value="lead_traveler"
1472 checked={formData.send_to === "lead_traveler"}
1473 onChange={(e) =>
1474 setFormData({
1475 ...formData,
1476 send_to: e.target.value as any,
1477 })
1478 }
1479 className="mt-1"
1480 />
1481 <div>
1482 <span className="font-medium text-gray-900 dark:text-white">
1483 {__("Lead Traveler Only")}
1484 </span>
1485 <p className="text-xs text-gray-500 mt-0.5">
1486 {__(
1487 "Only the first/lead traveler will receive the consent form.",
1488 )}
1489 </p>
1490 </div>
1491 </label>
1492 <label className="flex items-start gap-3 p-3 border border-gray-200 dark:border-gray-700 rounded-lg cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800">
1493 <input
1494 type="radio"
1495 name="send_to"
1496 value="primary_contact"
1497 checked={formData.send_to === "primary_contact"}
1498 onChange={(e) =>
1499 setFormData({
1500 ...formData,
1501 send_to: e.target.value as any,
1502 })
1503 }
1504 className="mt-1"
1505 />
1506 <div>
1507 <span className="font-medium text-gray-900 dark:text-white">
1508 {__("Primary Contact Only")}
1509 </span>
1510 <p className="text-xs text-gray-500 mt-0.5">
1511 {__(
1512 "Only the person who made the booking (primary contact) will receive the consent form.",
1513 )}
1514 </p>
1515 </div>
1516 </label>
1517 </div>
1518 </div>
1519 </CardContent>
1520 </Card>
1521
1522 <Card>
1523 <CardHeader>
1524 <CardTitle>{__("Delivery Settings")}</CardTitle>
1525 </CardHeader>
1526 <CardContent className="space-y-4">
1527 <div>
1528 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
1529 {__("Send before trip (days)")}
1530 </label>
1531 <Input
1532 type="number"
1533 value={formData.send_before_days}
1534 onChange={(e) =>
1535 setFormData({
1536 ...formData,
1537 send_before_days: parseInt(e.target.value) || 7,
1538 })
1539 }
1540 min={1}
1541 max={90}
1542 />
1543 <p className="text-xs text-gray-500 mt-1">
1544 {__(
1545 "Number of days before the trip to send the consent request.",
1546 )}
1547 </p>
1548 </div>
1549
1550 <div>
1551 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
1552 {__("Reminder days")}
1553 </label>
1554 <Input
1555 value={formData.reminder_days}
1556 onChange={(e) =>
1557 setFormData({
1558 ...formData,
1559 reminder_days: e.target.value,
1560 })
1561 }
1562 placeholder="3,1"
1563 />
1564 <p className="text-xs text-gray-500 mt-1">
1565 {__(
1566 "Comma-separated days before trip to send reminders (e.g., 3,1 for 3 days and 1 day before).",
1567 )}
1568 </p>
1569 </div>
1570
1571 <div className="border-t pt-4">
1572 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
1573 {__("Test Email")}
1574 </label>
1575 <div className="flex gap-2">
1576 <Input
1577 type="email"
1578 value={testEmail || ""}
1579 onChange={(e) => setTestEmail(e.target.value)}
1580 placeholder={__(
1581 "Enter email address to test consent email",
1582 )}
1583 className="flex-1"
1584 />
1585 <Button
1586 type="button"
1587 variant="outline"
1588 size="sm"
1589 onClick={handleSendTestEmail}
1590 disabled={!testEmail || sendTestEmailMutation.isPending}
1591 className="flex items-center gap-2"
1592 >
1593 {sendTestEmailMutation.isPending ? (
1594 <Loader2 className="w-4 h-4 animate-spin" />
1595 ) : (
1596 <Mail className="w-4 h-4" />
1597 )}
1598 {__("Send Test")}
1599 </Button>
1600 </div>
1601 <p className="text-xs text-gray-500 mt-1">
1602 {__(
1603 "Send a test consent email to preview how it will appear to customers.",
1604 )}
1605 </p>
1606 </div>
1607 </CardContent>
1608 </Card>
1609
1610 <Card>
1611 <CardHeader>
1612 <CardTitle>{__("Expiry Settings")}</CardTitle>
1613 </CardHeader>
1614 <CardContent className="space-y-4">
1615 <div>
1616 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
1617 {__("Link expiry (hours)")}
1618 </label>
1619 <Input
1620 type="number"
1621 value={formData.expiry_hours || ""}
1622 onChange={(e) =>
1623 setFormData({
1624 ...formData,
1625 expiry_hours: e.target.value
1626 ? parseInt(e.target.value)
1627 : null,
1628 })
1629 }
1630 placeholder={__("No expiry")}
1631 min={1}
1632 />
1633 <p className="text-xs text-gray-500 mt-1">
1634 {__(
1635 "Leave empty for no expiry. The consent link will expire after this many hours.",
1636 )}
1637 </p>
1638 </div>
1639 </CardContent>
1640 </Card>
1641 </div>
1642 )}
1643
1644 {/* Preview Tab */}
1645 {activeTab === "preview" && (
1646 <PreviewForm
1647 formData={formData}
1648 showToast={showToast}
1649 generateSimpleForm={generateSimpleForm}
1650 setActiveTab={setActiveTab}
1651 />
1652 )}
1653 </form>
1654 </div>
1655 );
1656 };
1657
1658 // Separate Preview Form Component with its own state
1659 interface PreviewFormProps {
1660 formData: ConsentFormData;
1661 showToast: (message: string, type: "success" | "error" | "info") => void;
1662 generateSimpleForm: () => void;
1663 setActiveTab: (
1664 tab: "general" | "fields" | "content" | "settings" | "preview",
1665 ) => void;
1666 }
1667
1668 const PreviewForm: React.FC<PreviewFormProps> = ({
1669 formData,
1670 showToast,
1671 generateSimpleForm,
1672 setActiveTab,
1673 }) => {
1674 const [previewValues, setPreviewValues] = React.useState<
1675 Record<string, string>
1676 >({});
1677 const [previewChecked, setPreviewChecked] = React.useState<
1678 Record<string, boolean>
1679 >({});
1680 const [hasSignature, setHasSignature] = React.useState(false);
1681 const [hasInitials, setHasInitials] = React.useState(false);
1682 const [validationErrors, setValidationErrors] = React.useState<string[]>([]);
1683 const [isSubmitting, setIsSubmitting] = React.useState(false);
1684 const [isSubmitted, setIsSubmitted] = React.useState(false);
1685 const [savedConsentId, setSavedConsentId] = React.useState<number | null>(
1686 null,
1687 );
1688
1689 // Refs for signature canvases to get data URLs
1690 const signatureCanvasRef = React.useRef<HTMLCanvasElement>(null);
1691 const initialsCanvasRef = React.useRef<HTMLCanvasElement>(null);
1692
1693 const handlePreviewSubmit = async () => {
1694 const errors: string[] = [];
1695
1696 // Validate required fields
1697 formData.fields
1698 .filter((f) => f.enabled && f.required)
1699 .forEach((field) => {
1700 if (field.type === "checkbox") {
1701 if (!previewChecked[field.id]) {
1702 errors.push(`"${field.label}" ${__("is required")}`);
1703 }
1704 } else {
1705 if (!previewValues[field.id]?.trim()) {
1706 errors.push(`"${field.label}" ${__("is required")}`);
1707 }
1708 }
1709 });
1710
1711 // Validate signature
1712 if (formData.require_signature && !hasSignature) {
1713 errors.push(__("Signature is required"));
1714 }
1715
1716 // Validate initials
1717 if (formData.require_initials && !hasInitials) {
1718 errors.push(__("Initials are required"));
1719 }
1720
1721 setValidationErrors(errors);
1722
1723 if (errors.length > 0) {
1724 showToast(__("Please fill in all required fields"), "error");
1725 return;
1726 }
1727
1728 // Get signature and initials data
1729 const signatureData =
1730 signatureCanvasRef.current?.toDataURL("image/png") || "";
1731 const initialsData =
1732 initialsCanvasRef.current?.toDataURL("image/png") || "";
1733
1734 // Build form data object with field labels
1735 const submittedFormData: Record<string, any> = {};
1736 formData.fields
1737 .filter((f) => f.enabled)
1738 .forEach((field) => {
1739 if (field.type === "checkbox") {
1740 submittedFormData[field.label] = previewChecked[field.id] || false;
1741 } else {
1742 submittedFormData[field.label] = previewValues[field.id] || "";
1743 }
1744 });
1745
1746 setIsSubmitting(true);
1747
1748 try {
1749 // Save to database via API
1750 const response = await apiClient.post("/signed-consents/preview", {
1751 form_id: formData.id || 0,
1752 form_version: 1,
1753 signer_name:
1754 previewValues["full_name"] ||
1755 previewValues["name"] ||
1756 __("Preview User", "yatra"),
1757 signer_email: previewValues["email"] || "[email protected]",
1758 form_data: submittedFormData,
1759 signature_data: formData.require_signature ? signatureData : null,
1760 initials_data: formData.require_initials ? initialsData : null,
1761 is_preview: true,
1762 });
1763
1764 if (response?.success) {
1765 setSavedConsentId(response.data?.id || null);
1766 setIsSubmitted(true);
1767 showToast(__("Consent form saved successfully!"), "success");
1768 } else {
1769 throw new Error(response?.message || __("Failed to save", "yatra"));
1770 }
1771 } catch (error: any) {
1772 console.error("Failed to save consent:", error);
1773 showToast(error?.message || __("Failed to save consent form"), "error");
1774 } finally {
1775 setIsSubmitting(false);
1776 }
1777 };
1778
1779 if (isSubmitted) {
1780 return (
1781 <div className="max-w-3xl mx-auto">
1782 <Card className="overflow-hidden">
1783 <CardContent className="p-12 text-center">
1784 <div className="w-20 h-20 bg-blue-100 dark:bg-blue-900/30 rounded-full flex items-center justify-center mx-auto mb-6">
1785 <CheckSquare className="w-10 h-10 text-blue-600" />
1786 </div>
1787 <h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-3">
1788 {__("Consent Form Saved!")}
1789 </h2>
1790 <p className="text-gray-600 dark:text-gray-400 mb-2">
1791 {__(
1792 "Your consent form submission has been saved to the database.",
1793 )}
1794 </p>
1795 {savedConsentId && (
1796 <p className="text-sm text-blue-600 dark:text-blue-400 mb-6">
1797 {__("Consent ID")}: #{savedConsentId}
1798 </p>
1799 )}
1800 <div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 text-left mb-6">
1801 <h3 className="font-medium text-gray-900 dark:text-white mb-2">
1802 {__("Saved Data:")}
1803 </h3>
1804 <ul className="text-sm text-gray-600 dark:text-gray-400 space-y-1">
1805 {formData.fields
1806 .filter((f) => f.enabled)
1807 .map((field) => (
1808 <li key={field.id}>
1809 <span className="font-medium">{field.label}:</span>{" "}
1810 {field.type === "checkbox"
1811 ? previewChecked[field.id]
1812 ? __("✓ Checked", "yatra")
1813 : __("✗ Not checked", "yatra")
1814 : previewValues[field.id] || "-"}
1815 </li>
1816 ))}
1817 {formData.require_signature && (
1818 <li>
1819 <span className="font-medium">{__("Signature")}:</span> ✓{" "}
1820 {__("Saved")}
1821 </li>
1822 )}
1823 {formData.require_initials && (
1824 <li>
1825 <span className="font-medium">{__("Initials")}:</span> ✓{" "}
1826 {__("Saved")}
1827 </li>
1828 )}
1829 </ul>
1830 </div>
1831 <div className="flex gap-3 justify-center">
1832 <Button
1833 type="button"
1834 variant="outline"
1835 onClick={() => {
1836 setIsSubmitted(false);
1837 setSavedConsentId(null);
1838 setPreviewValues({});
1839 setPreviewChecked({});
1840 setHasSignature(false);
1841 setHasInitials(false);
1842 }}
1843 >
1844 {__("Submit Another")}
1845 </Button>
1846 <Button
1847 type="button"
1848 onClick={() => {
1849 window.location.href = `${(window as any).yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=trips&tab=trip-consent`;
1850 }}
1851 >
1852 {__("View Signed Consents")}
1853 </Button>
1854 </div>
1855 </CardContent>
1856 </Card>
1857 </div>
1858 );
1859 }
1860
1861 return (
1862 <div className="max-w-3xl mx-auto">
1863 <Card className="overflow-hidden">
1864 <div className="bg-gradient-to-r from-blue-600 to-blue-800 p-6 text-white">
1865 <h2 className="text-2xl font-bold">
1866 {formData.name || __("Consent Form")}
1867 </h2>
1868 {formData.description && (
1869 <p className="mt-2 text-blue-100">{formData.description}</p>
1870 )}
1871 </div>
1872
1873 <CardContent className="p-6 space-y-8">
1874 {/* Validation Errors */}
1875 {validationErrors.length > 0 && (
1876 <div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
1877 <h4 className="text-red-800 dark:text-red-300 font-medium mb-2">
1878 {__("Please fix the following errors:")}
1879 </h4>
1880 <ul className="list-disc list-inside text-sm text-red-600 dark:text-red-400 space-y-1">
1881 {validationErrors.map((error, idx) => (
1882 <li key={idx}>{error}</li>
1883 ))}
1884 </ul>
1885 </div>
1886 )}
1887
1888 {/* Content Blocks */}
1889 {formData.content_blocks.length > 0 && (
1890 <div className="space-y-6">
1891 {formData.content_blocks
1892 .sort((a, b) => a.order - b.order)
1893 .map((block) => (
1894 <div
1895 key={block.id}
1896 className="prose dark:prose-invert max-w-none"
1897 >
1898 {block.type === "heading" && (
1899 <h3 className="text-xl font-semibold text-gray-900 dark:text-white border-b border-gray-200 dark:border-gray-700 pb-2">
1900 {block.title}
1901 </h3>
1902 )}
1903 {block.type === "paragraph" && (
1904 <div>
1905 {block.title && (
1906 <h4 className="text-lg font-medium text-gray-800 dark:text-gray-200 mb-2">
1907 {block.title}
1908 </h4>
1909 )}
1910 <p className="text-gray-600 dark:text-gray-400 leading-relaxed">
1911 {block.content}
1912 </p>
1913 </div>
1914 )}
1915 {block.type === "terms" && (
1916 <div className="bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
1917 <h4 className="text-lg font-medium text-gray-800 dark:text-gray-200 mb-2 flex items-center gap-2">
1918 <CheckSquare className="w-5 h-5 text-blue-600" />
1919 {block.title}
1920 </h4>
1921 <p className="text-gray-600 dark:text-gray-400 text-sm leading-relaxed">
1922 {block.content}
1923 </p>
1924 </div>
1925 )}
1926 </div>
1927 ))}
1928 </div>
1929 )}
1930
1931 {/* Form Fields */}
1932 {formData.fields.length > 0 && (
1933 <div className="space-y-4">
1934 <h3 className="text-lg font-semibold text-gray-900 dark:text-white border-b border-gray-200 dark:border-gray-700 pb-2">
1935 {__("Participant Information")}
1936 </h3>
1937 <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
1938 {formData.fields
1939 .filter((f) => f.enabled)
1940 .sort((a, b) => a.order - b.order)
1941 .map((field) => (
1942 <div
1943 key={field.id}
1944 className={field.width === "full" ? "md:col-span-2" : ""}
1945 >
1946 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
1947 {field.label || __("Untitled Field")}
1948 {field.required && (
1949 <span className="text-red-500 ml-1">*</span>
1950 )}
1951 </label>
1952 {field.type === "textarea" ? (
1953 <textarea
1954 placeholder={field.placeholder}
1955 value={previewValues[field.id] || ""}
1956 onChange={(e) =>
1957 setPreviewValues({
1958 ...previewValues,
1959 [field.id]: e.target.value,
1960 })
1961 }
1962 rows={3}
1963 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 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
1964 />
1965 ) : field.type === "checkbox" ? (
1966 <label className="flex items-start gap-3 cursor-pointer">
1967 <input
1968 type="checkbox"
1969 checked={previewChecked[field.id] || false}
1970 onChange={(e) =>
1971 setPreviewChecked({
1972 ...previewChecked,
1973 [field.id]: e.target.checked,
1974 })
1975 }
1976 className="mt-1 w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
1977 />
1978 <span className="text-sm text-gray-600 dark:text-gray-400">
1979 {field.label}
1980 </span>
1981 </label>
1982 ) : field.type === "select" ? (
1983 <select
1984 value={previewValues[field.id] || ""}
1985 onChange={(e) =>
1986 setPreviewValues({
1987 ...previewValues,
1988 [field.id]: e.target.value,
1989 })
1990 }
1991 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 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
1992 >
1993 <option value="">
1994 {field.placeholder || __("Select an option")}
1995 </option>
1996 {field.options?.map((opt, idx) => (
1997 <option key={idx} value={opt.value}>
1998 {opt.label}
1999 </option>
2000 ))}
2001 </select>
2002 ) : (
2003 <input
2004 type={field.type}
2005 placeholder={field.placeholder}
2006 value={previewValues[field.id] || ""}
2007 onChange={(e) =>
2008 setPreviewValues({
2009 ...previewValues,
2010 [field.id]: e.target.value,
2011 })
2012 }
2013 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 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
2014 />
2015 )}
2016 </div>
2017 ))}
2018 </div>
2019 </div>
2020 )}
2021
2022 {/* Signature Section */}
2023 {(formData.require_signature || formData.require_initials) && (
2024 <div className="space-y-6 border-t border-gray-200 dark:border-gray-700 pt-6">
2025 <h3 className="text-lg font-semibold text-gray-900 dark:text-white">
2026 {__("Signature")}
2027 </h3>
2028
2029 <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
2030 {formData.require_signature && (
2031 <div>
2032 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
2033 {__("Your Signature")}{" "}
2034 <span className="text-red-500">*</span>
2035 </label>
2036 <SignaturePadPreview
2037 id="signature-preview"
2038 placeholder={__("Draw your signature here")}
2039 onContentChange={setHasSignature}
2040 canvasRef={signatureCanvasRef}
2041 />
2042 </div>
2043 )}
2044
2045 {formData.require_initials && (
2046 <div>
2047 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
2048 {__("Initials")} <span className="text-red-500">*</span>
2049 </label>
2050 <SignaturePadPreview
2051 id="initials-preview"
2052 placeholder={__("Draw initials here")}
2053 onContentChange={setHasInitials}
2054 canvasRef={initialsCanvasRef}
2055 />
2056 </div>
2057 )}
2058 </div>
2059 </div>
2060 )}
2061
2062 {/* Submit Button */}
2063 <div className="pt-4">
2064 <Button
2065 type="button"
2066 onClick={handlePreviewSubmit}
2067 disabled={isSubmitting}
2068 className="w-full bg-blue-600 hover:bg-blue-700 text-white py-3"
2069 >
2070 {isSubmitting ? (
2071 <>
2072 <Loader2 className="w-4 h-4 mr-2 animate-spin" />
2073 {__("Submitting...")}
2074 </>
2075 ) : (
2076 __("Submit Consent Form")
2077 )}
2078 </Button>
2079 <p className="text-xs text-center text-gray-500 mt-2">
2080 {__(
2081 "This is an interactive preview. Test the form submission behavior.",
2082 )}
2083 </p>
2084 </div>
2085
2086 {/* Empty State */}
2087 {formData.fields.length === 0 &&
2088 formData.content_blocks.length === 0 && (
2089 <div className="text-center py-12">
2090 <FileSignature className="w-16 h-16 mx-auto text-gray-300 mb-4" />
2091 <h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
2092 {__("No content yet")}
2093 </h3>
2094 <p className="text-gray-500 dark:text-gray-400 mb-4">
2095 {__("Add form fields and content blocks to see the preview.")}
2096 </p>
2097 <Button
2098 type="button"
2099 variant="outline"
2100 onClick={() => {
2101 generateSimpleForm();
2102 setActiveTab("preview");
2103 }}
2104 >
2105 <Sparkles className="w-4 h-4 mr-2" />
2106 {__("Generate Simple Form")}
2107 </Button>
2108 </div>
2109 )}
2110 </CardContent>
2111 </Card>
2112 </div>
2113 );
2114 };
2115
2116 export default TripConsentForm;
2117