/** * Trip Consent Form * * Form component for creating and editing consent forms. * This is part of the premium module - functionality provided by Yatra Pro. * * @package Yatra * @since 3.0.0 */ import React, { useState, useEffect } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { Card, CardContent, CardHeader, CardTitle, } from "../components/ui/card"; import { Button } from "../components/ui/button"; import { Input } from "../components/ui/input"; import { Select } from "../components/ui/select"; import { PageHeader } from "../components/common/PageHeader"; import { ApplicableTripSelector } from "../components/shared/ApplicableTripSelector"; import { useToast } from "../components/ui/toast"; import { apiClient } from "../lib/api-client"; import { __ } from "../lib/i18n"; import { ArrowLeft, Save, FileSignature, Loader2, Plus, Trash2, GripVertical, ChevronDown, ChevronUp, Type, CheckSquare, AlignLeft, List, Calendar, Mail, Phone, Hash, Sparkles, } from "lucide-react"; // Types interface FormField { id: string; type: | "text" | "textarea" | "checkbox" | "select" | "date" | "email" | "phone" | "number"; label: string; placeholder?: string; required: boolean; enabled: boolean; order: number; width: "full" | "half"; options?: { value: string; label: string }[]; } interface ContentBlock { id: string; type: "heading" | "paragraph" | "terms"; title: string; content: string; order: number; } interface ConsentFormData { id?: number; name: string; description: string; status: "publish" | "draft" | "archived" | "trash"; require_signature: boolean; require_initials: boolean; applicable_to: "all" | "specific_trips"; send_to: "all_travelers" | "lead_traveler" | "primary_contact"; trip_ids: number[]; send_before_days: number; reminder_days: string; expiry_hours: number | null; fields: FormField[]; content_blocks: ContentBlock[]; } const defaultFormData: ConsentFormData = { name: "", description: "", status: "publish", require_signature: true, require_initials: false, applicable_to: "all", send_to: "all_travelers", trip_ids: [], send_before_days: 7, reminder_days: "3,1", expiry_hours: null, fields: [], content_blocks: [], }; const fieldTypeIcons: Record = { text: Type, textarea: AlignLeft, checkbox: CheckSquare, select: List, date: Calendar, email: Mail, phone: Phone, number: Hash, }; // Check if module is available const isModuleAvailable = (): boolean => { const yatraAdmin = (window as any)?.yatraAdmin; return Boolean(yatraAdmin?.isPro && yatraAdmin?.tripConsentEnabled); }; // Generate unique ID const generateId = () => `field_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; // Interactive Signature Pad Preview Component interface SignaturePadPreviewProps { id: string; placeholder: string; onContentChange?: (hasContent: boolean) => void; canvasRef?: React.RefObject; } const SignaturePadPreview: React.FC = ({ id, placeholder, onContentChange, canvasRef: externalCanvasRef, }) => { const internalCanvasRef = React.useRef(null); const canvasRef = externalCanvasRef || internalCanvasRef; const [isDrawing, setIsDrawing] = React.useState(false); const [hasContent, setHasContent] = React.useState(false); const updateContent = (value: boolean) => { setHasContent(value); onContentChange?.(value); }; const getCoordinates = (e: React.MouseEvent | React.TouchEvent) => { const canvas = canvasRef.current; if (!canvas) return { x: 0, y: 0 }; const rect = canvas.getBoundingClientRect(); if ("touches" in e) { return { x: e.touches[0].clientX - rect.left, y: e.touches[0].clientY - rect.top, }; } return { x: e.clientX - rect.left, y: e.clientY - rect.top, }; }; const startDrawing = (e: React.MouseEvent | React.TouchEvent) => { const canvas = canvasRef.current; const ctx = canvas?.getContext("2d"); if (!ctx) return; setIsDrawing(true); updateContent(true); const { x, y } = getCoordinates(e); ctx.beginPath(); ctx.moveTo(x, y); }; const draw = (e: React.MouseEvent | React.TouchEvent) => { if (!isDrawing) return; const canvas = canvasRef.current; const ctx = canvas?.getContext("2d"); if (!ctx) return; const { x, y } = getCoordinates(e); ctx.lineTo(x, y); ctx.strokeStyle = "#1f2937"; ctx.lineWidth = 2; ctx.lineCap = "round"; ctx.lineJoin = "round"; ctx.stroke(); }; const stopDrawing = () => { setIsDrawing(false); }; const clearCanvas = () => { const canvas = canvasRef.current; const ctx = canvas?.getContext("2d"); if (!ctx || !canvas) return; ctx.clearRect(0, 0, canvas.width, canvas.height); updateContent(false); }; React.useEffect(() => { const canvas = canvasRef.current; if (canvas) { const rect = canvas.getBoundingClientRect(); canvas.width = rect.width; canvas.height = rect.height; } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return (
{!hasContent && (

{placeholder}

)} {hasContent && ( )}
); }; const TripConsentForm: React.FC = () => { const [formData, setFormData] = useState(defaultFormData); const [errors, setErrors] = useState>({}); const [activeTab, setActiveTab] = useState< "general" | "fields" | "content" | "settings" | "preview" >("general"); const [expandedField, setExpandedField] = useState(null); const [testEmail, setTestEmail] = useState(""); const { showToast } = useToast(); const queryClient = useQueryClient(); // Get ID from URL for edit mode const urlParams = new URLSearchParams(window.location.search); const formId = urlParams.get("id"); const isEditMode = !!formId; // Fetch existing form for edit mode const { data: existingFormResponse, isLoading: isLoadingForm } = useQuery({ queryKey: ["consent-form", formId], queryFn: async () => { const response = await apiClient.get(`/consent-forms/${formId}`); return response; }, enabled: isEditMode && isModuleAvailable(), }); // Update form data when existing form is loaded useEffect(() => { // API returns { success: true, data: form } const existingForm = existingFormResponse?.data || existingFormResponse; if (existingForm && existingForm.id) { // Sort fields by order to preserve the saved order const fields = existingForm.form_config?.fields || []; const sortedFields = [...fields].sort( (a, b) => (a.order ?? 0) - (b.order ?? 0), ); // Sort content blocks by order as well const contentBlocks = existingForm.form_config?.content_blocks || []; const sortedContentBlocks = [...contentBlocks].sort( (a, b) => (a.order ?? 0) - (b.order ?? 0), ); setFormData({ ...defaultFormData, id: existingForm.id, name: existingForm.name || "", description: existingForm.description || "", status: existingForm.status || "publish", require_signature: existingForm.require_signature ?? true, require_initials: existingForm.require_initials ?? false, applicable_to: existingForm.applicable_to || "all", send_to: existingForm.send_to || "all_travelers", trip_ids: existingForm.trip_ids || [], send_before_days: existingForm.send_before_days || 7, reminder_days: existingForm.reminder_days || "3,1", expiry_hours: existingForm.expiry_hours || null, fields: sortedFields, content_blocks: sortedContentBlocks, }); } }, [existingFormResponse]); // Save mutation const saveMutation = useMutation({ mutationFn: async (data: ConsentFormData) => { // Ensure fields have correct order values and all required properties const fieldsWithOrder = data.fields.map((field, index) => ({ id: field.id, type: field.type, label: field.label || "", placeholder: field.placeholder || "", required: field.required ?? false, enabled: field.enabled ?? true, order: index, width: field.width || "full", options: field.options || undefined, })); // Ensure content blocks have correct order values and all required fields const contentBlocksWithOrder = data.content_blocks.map( (block, index) => ({ id: block.id, type: block.type, title: block.title || "", content: block.content || "", order: index, }), ); const payload = { name: data.name, description: data.description, status: data.status, require_signature: data.require_signature, require_initials: data.require_initials, applicable_to: data.applicable_to, send_to: data.send_to, trip_ids: data.trip_ids, send_before_days: data.send_before_days, reminder_days: data.reminder_days, expiry_hours: data.expiry_hours, form_config: { fields: fieldsWithOrder, content_blocks: contentBlocksWithOrder, }, }; if (isEditMode) { return await apiClient.put(`/consent-forms/${formId}`, payload); } return await apiClient.post("/consent-forms", payload); }, onSuccess: (response: any) => { queryClient.invalidateQueries({ queryKey: ["consent-forms"] }); showToast( isEditMode ? __("Consent form updated successfully") : __("Consent form created successfully"), "success", ); if (isEditMode) { return; } const newId = response?.data?.id ?? response?.id ?? response?.form?.id; if (!newId) { navigateBack(); return; } const params = new URLSearchParams(window.location.search); params.set("action", "edit"); params.set("id", String(newId)); params.set("subpage", "trips"); params.set("tab", "trip-consent"); window.history.pushState( {}, "", `${window.location.pathname}?${params.toString()}`, ); window.dispatchEvent(new PopStateEvent("popstate")); }, onError: (error: any) => { showToast(error?.message || __("Failed to save consent form"), "error"); }, }); // Test email mutation const sendTestEmailMutation = useMutation({ mutationFn: async (email: string) => { const payload = { email, form_data: { ...formData, fields: formData.fields.map((field, index) => ({ ...field, order: index, })), content_blocks: formData.content_blocks.map((block, index) => ({ ...block, order: index, })), }, }; return await apiClient.post("/consent-forms/send-test-email", payload); }, onSuccess: () => { showToast(__("Test email sent successfully"), "success"); setTestEmail(""); }, onError: (error: any) => { showToast(error?.message || __("Failed to send test email"), "error"); }, }); const handleSendTestEmail = () => { if (!testEmail) { showToast(__("Please enter an email address"), "warning"); return; } sendTestEmailMutation.mutate(testEmail); }; const navigateBack = () => { const params = new URLSearchParams(window.location.search); params.delete("action"); params.delete("id"); params.set("subpage", "trips"); params.set("tab", "trip-consent"); window.history.pushState({}, "", `${window.location.pathname}?${params}`); window.dispatchEvent(new PopStateEvent("popstate")); }; const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); // Validate const newErrors: Record = {}; if (!formData.name.trim()) { newErrors.name = __("Form name is required"); } if (Object.keys(newErrors).length > 0) { setErrors(newErrors); return; } saveMutation.mutate(formData); }; const addField = (type: FormField["type"]) => { const newField: FormField = { id: generateId(), type, label: "", placeholder: "", required: false, enabled: true, order: formData.fields.length, width: "full", options: type === "select" ? [{ value: "", label: "" }] : undefined, }; setFormData({ ...formData, fields: [...formData.fields, newField] }); setExpandedField(newField.id); }; const updateField = (fieldId: string, updates: Partial) => { setFormData({ ...formData, fields: formData.fields.map((f) => f.id === fieldId ? { ...f, ...updates } : f, ), }); }; const removeField = (fieldId: string) => { setFormData({ ...formData, fields: formData.fields.filter((f) => f.id !== fieldId), }); }; const moveField = (fromIndex: number, toIndex: number) => { const newFields = [...formData.fields]; const [movedField] = newFields.splice(fromIndex, 1); newFields.splice(toIndex, 0, movedField); // Update order values const reorderedFields = newFields.map((field, index) => ({ ...field, order: index, })); setFormData({ ...formData, fields: reorderedFields }); }; // Drag and drop state const [draggedFieldIndex, setDraggedFieldIndex] = useState( null, ); const [dragOverFieldIndex, setDragOverFieldIndex] = useState( null, ); const handleDragStart = (e: React.DragEvent, index: number) => { setDraggedFieldIndex(index); e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", index.toString()); }; const handleDragOver = (e: React.DragEvent, index: number) => { e.preventDefault(); e.dataTransfer.dropEffect = "move"; if (draggedFieldIndex !== null && draggedFieldIndex !== index) { setDragOverFieldIndex(index); } }; const handleDragLeave = () => { setDragOverFieldIndex(null); }; const handleDrop = (e: React.DragEvent, toIndex: number) => { e.preventDefault(); if (draggedFieldIndex !== null && draggedFieldIndex !== toIndex) { moveField(draggedFieldIndex, toIndex); } setDraggedFieldIndex(null); setDragOverFieldIndex(null); }; const handleDragEnd = () => { setDraggedFieldIndex(null); setDragOverFieldIndex(null); }; const addContentBlock = (type: ContentBlock["type"]) => { const newBlock: ContentBlock = { id: generateId(), type, title: "", content: "", order: formData.content_blocks.length, }; setFormData({ ...formData, content_blocks: [...formData.content_blocks, newBlock], }); }; const updateContentBlock = ( blockId: string, updates: Partial, ) => { setFormData({ ...formData, content_blocks: formData.content_blocks.map((b) => b.id === blockId ? { ...b, ...updates } : b, ), }); }; const removeContentBlock = (blockId: string) => { setFormData({ ...formData, content_blocks: formData.content_blocks.filter((b) => b.id !== blockId), }); }; // Generate a simple pre-populated consent form const generateSimpleForm = () => { const simpleFields: FormField[] = [ { id: generateId(), type: "text", label: __("Full Name"), placeholder: __("Enter your full legal name"), required: true, enabled: true, order: 0, width: "half", }, { id: generateId(), type: "email", label: __("Email Address"), placeholder: __("Enter your email address"), required: true, enabled: true, order: 1, width: "half", }, { id: generateId(), type: "phone", label: __("Phone Number"), placeholder: __("Enter your phone number"), required: true, enabled: true, order: 2, width: "half", }, { id: generateId(), type: "date", label: __("Date of Birth"), placeholder: "", required: true, enabled: true, order: 3, width: "half", }, { id: generateId(), type: "text", label: __("Emergency Contact Name"), placeholder: __("Name of emergency contact"), required: true, enabled: true, order: 4, width: "half", }, { id: generateId(), type: "phone", label: __("Emergency Contact Phone"), placeholder: __("Emergency contact phone number"), required: true, enabled: true, order: 5, width: "half", }, { id: generateId(), type: "textarea", label: __("Medical Conditions or Allergies"), placeholder: __( "Please list any medical conditions, allergies, or dietary restrictions we should be aware of", ), required: false, enabled: true, order: 6, width: "full", }, { id: generateId(), type: "checkbox", label: __( "I confirm that I am physically fit to participate in this trip", ), placeholder: "", required: true, enabled: true, order: 7, width: "full", }, ]; const simpleContentBlocks: ContentBlock[] = [ { id: generateId(), type: "heading", title: __("Trip Participation Agreement"), content: "", order: 0, }, { id: generateId(), type: "paragraph", title: __("Introduction"), content: __( "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.", ), order: 1, }, { id: generateId(), type: "terms", title: __("Assumption of Risk"), content: __( "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.", ), order: 2, }, { id: generateId(), type: "terms", title: __("Medical Authorization"), content: __( "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.", ), order: 3, }, { id: generateId(), type: "terms", title: __("Photo/Video Release"), content: __( "I grant permission to the trip organizers to use photographs and videos taken during the trip for promotional and marketing purposes without compensation.", ), order: 4, }, { id: generateId(), type: "paragraph", title: __("Agreement"), content: __( "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.", ), order: 5, }, ]; setFormData({ ...formData, name: formData.name || __("Trip Liability Waiver"), description: formData.description || __("Standard liability waiver and consent form for trip participants"), require_signature: true, require_initials: true, fields: simpleFields, content_blocks: simpleContentBlocks, }); showToast( __("Simple consent form generated! Review and customize as needed."), "success", ); }; // Premium gate if (!isModuleAvailable()) { return (

{__("Trip Consent is a Premium Feature")}

{__("Upgrade to Yatra Pro to create and manage consent forms.")}

); } if (isLoadingForm) { return (
{/* Header Skeleton */}
{/* Tabs Skeleton */}
{[1, 2, 3, 4, 5].map((i) => (
))}
{/* Content Skeleton - Two Column Layout */}
{/* Main Content */}
{/* Sidebar */}
); } return (
{!isEditMode && formData.fields.length === 0 && ( )}
} />
{/* Tabs */}
{/* General Tab */} {activeTab === "general" && (
{__("Form Details")}
setFormData({ ...formData, name: e.target.value }) } placeholder={__("e.g., Liability Waiver")} className={errors.name ? "border-red-500" : ""} /> {errors.name && (

{errors.name}

)}