PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.2.7
Yatra – Travel Booking & Tour Operator Software v3.0.2.7
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.2.7, at resources/js/pages/TripConsentForm.tsx

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