PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.16
Yatra – Travel Booking & Tour Operator Software v3.0.16
3.0.16 3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 All 84 releases
yatra / resources / js / components / settings / BookingFormFieldsEditor.tsx

BookingFormFieldsEditor.tsx in Yatra – Travel Booking & Tour Operator Software 3.0.16, at resources/js/components/settings/BookingFormFieldsEditor.tsx

1,131 lines 45.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import React, { useEffect, useState } from "react";
2 import {
3 ArrowDown,
4 ArrowUp,
5 Edit2,
6 Eye,
7 EyeOff,
8 GripVertical,
9 Lock,
10 Plus,
11 Star,
12 Trash2,
13 } from "lucide-react";
14 import { __ } from "../../lib/i18n";
15 import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
16 import { Button } from "../ui/button";
17 import { Input } from "../ui/input";
18 import { Label } from "../ui/label";
19 import { Select } from "../ui/select";
20 import { ConfirmationDialog } from "../ui/confirmation-dialog";
21 import type { BookingFormType, FormFieldConfig } from "./booking-form-types";
22
23 /**
24 * The booking-form field builder: draggable field list, inline editor, and
25 * the Add New Field form. Used by Settings → Booking Form for each section's
26 * global fields, and by the per-trip Conditions popup for each condition's
27 * fields — the same component, so both are identical.
28 *
29 * Controlled: renders `fields`, reports every change through `onChange`.
30 */
31
32 export interface FieldOption {
33 value: string;
34 label: string;
35 }
36
37 interface BookingFormFieldsEditorProps {
38 formType: BookingFormType;
39 fields: FormFieldConfig[];
40 onChange: (fields: FormFieldConfig[]) => void;
41 fieldTypes: FieldOption[];
42 widthOptions: FieldOption[];
43 appliesToOptions: FieldOption[];
44 /** Extra controls rendered in the card header next to "Add Field". */
45 headerExtra?: React.ReactNode;
46 }
47
48 const withOrder = (fields: FormFieldConfig[]): FormFieldConfig[] =>
49 fields.map((field, i) => ({ ...field, order: i + 1 }));
50
51 export const BookingFormFieldsEditor: React.FC<
52 BookingFormFieldsEditorProps
53 > = ({
54 formType,
55 fields,
56 onChange,
57 fieldTypes,
58 widthOptions,
59 appliesToOptions,
60 headerExtra,
61 }) => {
62 const [editingField, setEditingField] = useState<string | null>(null);
63 // Draft for the Field ID input while editing an existing field. The field's
64 // `id` is its React key AND the value `editingField` tracks, so mutating it on
65 // every keystroke remounts the row (focus loss) and breaks the open-editor
66 // match (editor closes). We edit a local draft and commit once on blur/Enter.
67 const [fieldIdDraft, setFieldIdDraft] = useState<string | null>(null);
68 // Clear any pending Field ID draft whenever the edited field changes
69 // (open / close / switch) so a draft can never leak onto another field.
70 useEffect(() => {
71 setFieldIdDraft(null);
72 }, [editingField]);
73 const [showAddField, setShowAddField] = useState(false);
74 const [newField, setNewField] = useState<Partial<FormFieldConfig>>({
75 id: "",
76 type: "text",
77 label: "",
78 placeholder: "",
79 required: false,
80 enabled: true,
81 width: "full",
82 options: [],
83 applies_to: "all",
84 });
85
86 // Delete confirmation state
87 const [deleteConfirm, setDeleteConfirm] = useState<{
88 isOpen: boolean;
89 fieldId: string | null;
90 fieldLabel: string;
91 }>({
92 isOpen: false,
93 fieldId: null,
94 fieldLabel: "",
95 });
96
97 // Drag and drop state
98 const [draggedFieldId, setDraggedFieldId] = useState<string | null>(null);
99 const [dragOverFieldId, setDragOverFieldId] = useState<string | null>(null);
100
101 // Helper to generate ID from label
102 const generateIdFromLabel = (label: string): string => {
103 return label
104 .toLowerCase()
105 .replace(/\s+/g, "_")
106 .replace(/[^a-z0-9_]/g, "");
107 };
108
109 // Helper to sanitize ID input
110 const sanitizeId = (id: string): string => {
111 return id
112 .toLowerCase()
113 .replace(/\s+/g, "_")
114 .replace(/[^a-z0-9_]/g, "");
115 };
116
117 // Handle label change with auto ID generation
118 const handleNewFieldLabelChange = (label: string) => {
119 const autoId = generateIdFromLabel(label);
120 setNewField((prev) => ({
121 ...prev,
122 label,
123 // Only auto-generate ID if it hasn't been manually edited
124 id:
125 prev.id === "" || prev.id === generateIdFromLabel(prev.label || "")
126 ? autoId
127 : prev.id,
128 }));
129 };
130
131 const updateField = (fieldId: string, updates: Partial<FormFieldConfig>) => {
132 onChange(
133 fields.map((field) =>
134 field.id === fieldId ? { ...field, ...updates } : field,
135 ),
136 );
137 };
138
139 const toggleFieldEnabled = (fieldId: string) => {
140 const field = fields.find((f) => f.id === fieldId);
141 // Locked fields cannot be disabled
142 if (field && !field.locked) {
143 updateField(fieldId, { enabled: !field.enabled });
144 }
145 };
146
147 const toggleFieldRequired = (fieldId: string) => {
148 const field = fields.find((f) => f.id === fieldId);
149 if (field) {
150 updateField(fieldId, { required: !field.required });
151 }
152 };
153
154 const moveField = (fieldId: string, direction: "up" | "down") => {
155 const next = [...fields];
156 const index = next.findIndex((f) => f.id === fieldId);
157
158 if (direction === "up" && index > 0) {
159 [next[index - 1], next[index]] = [next[index], next[index - 1]];
160 } else if (direction === "down" && index < next.length - 1) {
161 [next[index], next[index + 1]] = [next[index + 1], next[index]];
162 }
163
164 onChange(withOrder(next));
165 };
166
167 // Drag and Drop handlers
168 const handleDragStart = (e: React.DragEvent, fieldId: string) => {
169 setDraggedFieldId(fieldId);
170 e.dataTransfer.effectAllowed = "move";
171 };
172
173 const handleDragOver = (e: React.DragEvent, fieldId: string) => {
174 e.preventDefault();
175 e.dataTransfer.dropEffect = "move";
176 if (fieldId !== draggedFieldId) {
177 setDragOverFieldId(fieldId);
178 }
179 };
180
181 const handleDragLeave = () => {
182 setDragOverFieldId(null);
183 };
184
185 const handleDrop = (e: React.DragEvent, targetFieldId: string) => {
186 e.preventDefault();
187 if (!draggedFieldId || draggedFieldId === targetFieldId) {
188 setDraggedFieldId(null);
189 setDragOverFieldId(null);
190 return;
191 }
192
193 const next = [...fields];
194 const draggedIndex = next.findIndex((f) => f.id === draggedFieldId);
195 const targetIndex = next.findIndex((f) => f.id === targetFieldId);
196
197 if (draggedIndex !== -1 && targetIndex !== -1) {
198 const [draggedField] = next.splice(draggedIndex, 1);
199 next.splice(targetIndex, 0, draggedField);
200 onChange(withOrder(next));
201 }
202
203 setDraggedFieldId(null);
204 setDragOverFieldId(null);
205 };
206
207 const handleDragEnd = () => {
208 setDraggedFieldId(null);
209 setDragOverFieldId(null);
210 };
211
212 const deleteField = (fieldId: string) => {
213 onChange(withOrder(fields.filter((f) => f.id !== fieldId)));
214 setDeleteConfirm({ isOpen: false, fieldId: null, fieldLabel: "" });
215 };
216
217 const addNewField = () => {
218 const isTextBlock = newField.type === "text_block";
219
220 let fieldId: string;
221 if (isTextBlock) {
222 // Display-only block: the admin supplies only content — no label or id.
223 // Generate a unique internal id so it can be ordered/stored like any field.
224 if (!newField.content || !newField.content.trim()) return;
225 let n = 1;
226 while (fields.some((f) => f.id === `text_block_${n}`)) n++;
227 fieldId = `text_block_${n}`;
228 } else {
229 if (!newField.label || !newField.id) return;
230 fieldId = sanitizeId(newField.id);
231 }
232
233 // Check if ID already exists
234 if (fields.some((f) => f.id === fieldId)) {
235 // TODO: Replace with toast notification when refactoring this nested component
236 window.alert(
237 "A field with this ID already exists. Please use a different ID.",
238 );
239 return;
240 }
241
242 const newFieldConfig: FormFieldConfig = {
243 id: fieldId,
244 type: (newField.type as FormFieldConfig["type"]) || "text",
245 label: newField.label || "",
246 placeholder: newField.placeholder || "",
247 required: newField.required || false,
248 enabled: true,
249 order: fields.length + 1,
250 width: (newField.width as FormFieldConfig["width"]) || "full",
251 // Per-traveler targeting only applies to the Traveler section.
252 ...(formType === "traveler_form"
253 ? { applies_to: newField.applies_to || "all" }
254 : {}),
255 // Phone fields carry the country-code toggle (default on).
256 ...(newField.type === "tel"
257 ? { show_country_code: newField.show_country_code !== false }
258 : {}),
259 };
260
261 // Add options if field type is select
262 if (
263 newField.type === "select" &&
264 newField.options &&
265 newField.options.length > 0
266 ) {
267 newFieldConfig.options = newField.options.filter(
268 (opt) => opt.value && opt.label,
269 );
270 }
271
272 // Text block: keep its display content; it can never be a required input.
273 if (isTextBlock) {
274 newFieldConfig.content = newField.content || "";
275 newFieldConfig.required = false;
276 }
277
278 onChange([...fields, newFieldConfig]);
279 setNewField({
280 id: "",
281 type: "text",
282 label: "",
283 placeholder: "",
284 required: false,
285 enabled: true,
286 width: "full",
287 options: [],
288 content: "",
289 });
290 setShowAddField(false);
291 };
292
293 return (
294 <>
295 {/* Form Fields */}
296 <Card>
297 <CardHeader>
298 {/* Explicit row wrapper: CardHeader's own column layout must not
299 stack the title above the buttons (it did inside the Conditions
300 popup). */}
301 <div className="flex flex-row flex-wrap items-center justify-between gap-2 w-full">
302 <CardTitle className="text-base">
303 {__("Form Fields", "yatra")}
304 </CardTitle>
305 <div className="flex items-center gap-2">
306 {headerExtra}
307 <Button
308 variant="outline"
309 size="sm"
310 onClick={() => setShowAddField(!showAddField)}
311 className="flex items-center gap-2"
312 data-testid="add-field-toggle"
313 >
314 <Plus className="w-4 h-4" />
315 {__("Add Field", "yatra")}
316 </Button>
317 </div>
318 </div>
319 </CardHeader>
320 <CardContent className="space-y-3">
321 {/* Add New Field Form */}
322 {showAddField && (
323 <div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800 mb-4">
324 <h4 className="font-medium text-sm mb-3">
325 {__("Add New Field", "yatra")}
326 </h4>
327 <div className="grid grid-cols-1 md:grid-cols-5 gap-3">
328 <div>
329 <Label className="text-xs">{__("Field Type", "yatra")}</Label>
330 <Select
331 value={newField.type || "text"}
332 onChange={(e) =>
333 setNewField((prev) => ({
334 ...prev,
335 type: e.target.value as FormFieldConfig["type"],
336 }))
337 }
338 className="mt-1"
339 >
340 {fieldTypes.map((type) => (
341 <option key={type.value} value={type.value}>
342 {type.label}
343 </option>
344 ))}
345 </Select>
346 </div>
347 {newField.type !== "text_block" && (
348 <div>
349 <Label className="text-xs">{__("Label", "yatra")} *</Label>
350 <Input
351 value={newField.label || ""}
352 onChange={(e) =>
353 handleNewFieldLabelChange(e.target.value)
354 }
355 placeholder="Field label"
356 className="mt-1"
357 />
358 </div>
359 )}
360 {newField.type !== "text_block" && (
361 <div>
362 <Label className="text-xs">
363 {__("Field ID", "yatra")} *
364 </Label>
365 <Input
366 value={newField.id || ""}
367 onChange={(e) =>
368 setNewField((prev) => ({
369 ...prev,
370 id: sanitizeId(e.target.value),
371 }))
372 }
373 placeholder="field_id"
374 className="mt-1 font-mono text-xs"
375 />
376 <p className="text-[10px] text-gray-400 mt-0.5">
377 {__("Lowercase, no spaces", "yatra")}
378 </p>
379 </div>
380 )}
381 {newField.type !== "text_block" && (
382 <div>
383 <Label className="text-xs">
384 {__("Placeholder", "yatra")}
385 </Label>
386 <Input
387 value={newField.placeholder || ""}
388 onChange={(e) =>
389 setNewField((prev) => ({
390 ...prev,
391 placeholder: e.target.value,
392 }))
393 }
394 placeholder="Placeholder text"
395 className="mt-1"
396 />
397 </div>
398 )}
399 <div>
400 <Label className="text-xs">{__("Width", "yatra")}</Label>
401 <Select
402 value={newField.width || "full"}
403 onChange={(e) =>
404 setNewField((prev) => ({
405 ...prev,
406 width: e.target.value as FormFieldConfig["width"],
407 }))
408 }
409 className="mt-1"
410 >
411 {widthOptions.map((w) => (
412 <option key={w.value} value={w.value}>
413 {w.label}
414 </option>
415 ))}
416 </Select>
417 </div>
418 {formType === "traveler_form" && (
419 <div>
420 <Label className="text-xs">
421 {__("Applies to", "yatra")}
422 </Label>
423 <Select
424 value={newField.applies_to || "all"}
425 onChange={(e) =>
426 setNewField((prev) => ({
427 ...prev,
428 applies_to: e.target
429 .value as FormFieldConfig["applies_to"],
430 }))
431 }
432 className="mt-1"
433 >
434 {appliesToOptions.map((a) => (
435 <option key={a.value} value={a.value}>
436 {a.label}
437 </option>
438 ))}
439 </Select>
440 </div>
441 )}
442 </div>
443 {/* Content editor for text blocks (display-only) */}
444 {newField.type === "text_block" && (
445 <div className="mt-3">
446 <Label className="text-xs font-medium">
447 {__("Content", "yatra")}
448 </Label>
449 <textarea
450 value={newField.content || ""}
451 onChange={(e) =>
452 setNewField((prev) => ({
453 ...prev,
454 content: e.target.value,
455 }))
456 }
457 rows={4}
458 placeholder={__(
459 "Text shown to customers between fields. Basic HTML (bold, links, lists) is allowed.",
460 "yatra",
461 )}
462 className="mt-1 w-full rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 text-sm"
463 />
464 <p className="text-[10px] text-gray-400 mt-0.5">
465 {__(
466 "Display only — this is not an input and is not submitted by the customer.",
467 "yatra",
468 )}
469 </p>
470 </div>
471 )}
472
473 {/* Options editor for select fields */}
474 {newField.type === "select" && (
475 <div className="mt-3 p-3 bg-white dark:bg-gray-800 rounded-lg border border-blue-100 dark:border-blue-900">
476 <div className="flex items-center justify-between mb-2">
477 <Label className="text-xs font-medium">
478 {__("Dropdown Options", "yatra")}
479 </Label>
480 <Button
481 type="button"
482 variant="ghost"
483 size="sm"
484 onClick={() => {
485 const currentOptions = newField.options || [];
486 setNewField((prev) => ({
487 ...prev,
488 options: [
489 ...currentOptions,
490 { value: "", label: "" },
491 ],
492 }));
493 }}
494 className="h-6 text-xs"
495 >
496 <Plus className="w-3 h-3 mr-1" />
497 {__("Add Option", "yatra")}
498 </Button>
499 </div>
500 <div className="space-y-2">
501 {(newField.options || []).map((option, optIndex) => (
502 <div key={optIndex} className="flex items-center gap-2">
503 <Input
504 value={option.value}
505 onChange={(e) => {
506 const newOptions = [...(newField.options || [])];
507 newOptions[optIndex] = {
508 ...newOptions[optIndex],
509 value: e.target.value,
510 };
511 setNewField((prev) => ({
512 ...prev,
513 options: newOptions,
514 }));
515 }}
516 placeholder="Value (e.g., option1)"
517 className="text-xs flex-1"
518 />
519 <Input
520 value={option.label}
521 onChange={(e) => {
522 const newOptions = [...(newField.options || [])];
523 newOptions[optIndex] = {
524 ...newOptions[optIndex],
525 label: e.target.value,
526 };
527 setNewField((prev) => ({
528 ...prev,
529 options: newOptions,
530 }));
531 }}
532 placeholder="Label (e.g., Option 1)"
533 className="text-xs flex-1"
534 />
535 <button
536 type="button"
537 onClick={() => {
538 const newOptions = (newField.options || []).filter(
539 (_, i) => i !== optIndex,
540 );
541 setNewField((prev) => ({
542 ...prev,
543 options: newOptions,
544 }));
545 }}
546 className="p-1 text-gray-400 hover:text-red-500"
547 >
548 <Trash2 className="w-3 h-3" />
549 </button>
550 </div>
551 ))}
552 {(!newField.options || newField.options.length === 0) && (
553 <p className="text-xs text-gray-400 italic">
554 {__(
555 'Click "Add Option" to add dropdown choices.',
556 "yatra",
557 )}
558 </p>
559 )}
560 </div>
561 </div>
562 )}
563
564 <div className="flex items-center gap-4 mt-3">
565 {newField.type !== "text_block" && (
566 <label className="flex items-center gap-2 text-sm">
567 <input
568 type="checkbox"
569 checked={newField.required || false}
570 onChange={(e) =>
571 setNewField((prev) => ({
572 ...prev,
573 required: e.target.checked,
574 }))
575 }
576 className="w-4 h-4 rounded border-gray-300 text-blue-600"
577 />
578 {__("Required", "yatra")}
579 </label>
580 )}
581 {newField.type === "tel" && (
582 <label className="flex items-center gap-2 text-sm">
583 <input
584 type="checkbox"
585 checked={newField.show_country_code !== false}
586 onChange={(e) =>
587 setNewField((prev) => ({
588 ...prev,
589 show_country_code: e.target.checked,
590 }))
591 }
592 className="w-4 h-4 rounded border-gray-300 text-blue-600"
593 />
594 {__("Show country code", "yatra")}
595 </label>
596 )}
597 <div className="flex-1"></div>
598 <Button
599 variant="ghost"
600 size="sm"
601 onClick={() => setShowAddField(false)}
602 >
603 {__("Cancel", "yatra")}
604 </Button>
605 <Button
606 size="sm"
607 onClick={addNewField}
608 disabled={
609 newField.type === "text_block"
610 ? !newField.content || !newField.content.trim()
611 : !newField.id || !newField.label
612 }
613 >
614 {__("Add Field", "yatra")}
615 </Button>
616 </div>
617 </div>
618 )}
619
620 {/* Field List */}
621 {fields?.length === 0 ? (
622 <div className="text-center py-8 text-gray-500 dark:text-gray-400">
623 <p>
624 {__(
625 'No fields configured. Click "Add Field" to get started.',
626 "yatra",
627 )}
628 </p>
629 </div>
630 ) : (
631 <div className="space-y-2">
632 {fields?.map((field, index) => (
633 <div
634 key={field.id}
635 draggable
636 onDragStart={(e) => handleDragStart(e, field.id)}
637 onDragOver={(e) => handleDragOver(e, field.id)}
638 onDragLeave={handleDragLeave}
639 onDrop={(e) => handleDrop(e, field.id)}
640 onDragEnd={handleDragEnd}
641 className={`flex items-center gap-3 p-3 rounded-lg border transition-all cursor-grab active:cursor-grabbing ${
642 draggedFieldId === field.id
643 ? "opacity-50 border-dashed"
644 : ""
645 } ${
646 dragOverFieldId === field.id
647 ? "border-blue-500 bg-blue-50 dark:bg-blue-900/20"
648 : ""
649 } ${
650 field.enabled
651 ? "bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700"
652 : "bg-gray-50 dark:bg-gray-900 border-gray-100 dark:border-gray-800 opacity-60"
653 }`}
654 >
655 {/* Drag Handle & Order */}
656 <div className="flex items-center gap-1">
657 <GripVertical className="w-4 h-4 text-gray-400" />
658 <div className="flex flex-col gap-0.5">
659 <button
660 type="button"
661 onClick={() => moveField(field.id, "up")}
662 disabled={index === 0}
663 className="p-0.5 text-gray-400 hover:text-gray-600 disabled:opacity-30 disabled:cursor-not-allowed"
664 >
665 <ArrowUp className="w-3 h-3" />
666 </button>
667 <button
668 type="button"
669 onClick={() => moveField(field.id, "down")}
670 disabled={index === fields.length - 1}
671 className="p-0.5 text-gray-400 hover:text-gray-600 disabled:opacity-30 disabled:cursor-not-allowed"
672 >
673 <ArrowDown className="w-3 h-3" />
674 </button>
675 </div>
676 </div>
677
678 {/* Field Info */}
679 <div className="flex-1 min-w-0">
680 {editingField === field.id ? (
681 <div className="space-y-3">
682 <div className="grid grid-cols-4 gap-2">
683 {field.type !== "text_block" && (
684 <>
685 <Input
686 value={field.label}
687 onChange={(e) =>
688 updateField(field.id, {
689 label: e.target.value,
690 })
691 }
692 placeholder="Label"
693 className="text-sm"
694 />
695 <Input
696 value={field.placeholder}
697 onChange={(e) =>
698 updateField(field.id, {
699 placeholder: e.target.value,
700 })
701 }
702 placeholder="Placeholder"
703 className="text-sm"
704 />
705 </>
706 )}
707 <Select
708 value={field.type}
709 onChange={(e) =>
710 !field.locked &&
711 updateField(field.id, {
712 type: e.target.value as FormFieldConfig["type"],
713 })
714 }
715 disabled={field.locked}
716 title={
717 field.locked
718 ? "Locked fields cannot change type"
719 : undefined
720 }
721 className="text-sm"
722 >
723 {fieldTypes.map((type) => (
724 <option key={type.value} value={type.value}>
725 {type.label}
726 </option>
727 ))}
728 </Select>
729 <Select
730 value={field.width}
731 onChange={(e) =>
732 updateField(field.id, {
733 width: e.target
734 .value as FormFieldConfig["width"],
735 })
736 }
737 className="text-sm"
738 >
739 {widthOptions.map((w) => (
740 <option key={w.value} value={w.value}>
741 {w.label}
742 </option>
743 ))}
744 </Select>
745 {formType === "traveler_form" && (
746 <Select
747 value={field.applies_to || "all"}
748 onChange={(e) =>
749 updateField(field.id, {
750 applies_to: e.target
751 .value as FormFieldConfig["applies_to"],
752 })
753 }
754 className="text-sm"
755 title={__(
756 "Which travelers see this field",
757 "yatra",
758 )}
759 >
760 {appliesToOptions.map((a) => (
761 <option key={a.value} value={a.value}>
762 {a.label}
763 </option>
764 ))}
765 </Select>
766 )}
767 {field.type === "tel" && (
768 <label
769 className="flex items-center gap-2 text-sm whitespace-nowrap px-1"
770 title={__(
771 "Show the international country flag + dial code selector on this phone field",
772 "yatra",
773 )}
774 >
775 <input
776 type="checkbox"
777 checked={field.show_country_code !== false}
778 onChange={(e) =>
779 updateField(field.id, {
780 show_country_code: e.target.checked,
781 })
782 }
783 className="w-4 h-4 rounded border-gray-300 text-blue-600"
784 />
785 {__("Show country code", "yatra")}
786 </label>
787 )}
788 </div>
789
790 {/* Content editor for text blocks (display-only) */}
791 {field.type === "text_block" && (
792 <div>
793 <Label className="text-xs font-medium">
794 {__("Content", "yatra")}
795 </Label>
796 <textarea
797 value={field.content || ""}
798 onChange={(e) =>
799 updateField(field.id, {
800 content: e.target.value,
801 })
802 }
803 rows={4}
804 placeholder={__(
805 "Text shown to customers between fields. Basic HTML (bold, links, lists) is allowed.",
806 "yatra",
807 )}
808 className="mt-1 w-full rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 text-sm"
809 />
810 <p className="text-[10px] text-gray-400 mt-0.5">
811 {__(
812 "Display only — this is not an input and is not submitted by the customer.",
813 "yatra",
814 )}
815 </p>
816 </div>
817 )}
818
819 {/* Field ID - Below other fields (input fields only) */}
820 {field.type !== "text_block" && (
821 <div className="flex items-center gap-2 p-2 bg-gray-50 dark:bg-gray-900 rounded">
822 <Label className="text-xs text-gray-500 whitespace-nowrap">
823 {__("Field ID:", "yatra")}
824 </Label>
825 <Input
826 value={
827 fieldIdDraft !== null ? fieldIdDraft : field.id
828 }
829 onChange={(e) => {
830 if (!field.locked) {
831 // Local draft only — do NOT mutate field.id
832 // here, or the row remounts (focus loss) and
833 // the editor closes on every keystroke.
834 setFieldIdDraft(sanitizeId(e.target.value));
835 }
836 }}
837 onBlur={() => {
838 if (fieldIdDraft === null) return;
839 const next = fieldIdDraft;
840 setFieldIdDraft(null);
841 if (
842 !field.locked &&
843 next !== "" &&
844 next !== field.id
845 ) {
846 updateField(field.id, { id: next });
847 // Keep the editor open on the renamed field.
848 setEditingField((cur) =>
849 cur === field.id ? next : cur,
850 );
851 }
852 }}
853 onKeyDown={(e) => {
854 if (e.key === "Enter") {
855 e.preventDefault();
856 (e.currentTarget as HTMLInputElement).blur();
857 }
858 }}
859 placeholder="field_id"
860 className="text-sm font-mono flex-1 max-w-xs"
861 disabled={field.locked}
862 title={
863 field.locked
864 ? "Locked fields cannot change ID"
865 : "Field ID (lowercase, no spaces)"
866 }
867 />
868 {field.locked && (
869 <span className="text-xs text-amber-600">
870 {__("(locked)", "yatra")}
871 </span>
872 )}
873 </div>
874 )}
875
876 {/* Dropdown Options Editor */}
877 {field.type === "select" && (
878 <div className="p-3 bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700">
879 <div className="flex items-center justify-between mb-2">
880 <Label className="text-xs font-medium">
881 {__("Dropdown Options", "yatra")}
882 </Label>
883 <Button
884 type="button"
885 variant="ghost"
886 size="sm"
887 onClick={() => {
888 const newOptions = [
889 ...(field.options || []),
890 { value: "", label: "" },
891 ];
892 updateField(field.id, {
893 options: newOptions,
894 });
895 }}
896 className="h-6 text-xs"
897 >
898 <Plus className="w-3 h-3 mr-1" />
899 {__("Add Option", "yatra")}
900 </Button>
901 </div>
902 <div className="space-y-2">
903 {(field.options || []).map((option, optIndex) => (
904 <div
905 key={optIndex}
906 className="flex items-center gap-2"
907 >
908 <Input
909 value={option.value}
910 onChange={(e) => {
911 const newOptions = [
912 ...(field.options || []),
913 ];
914 newOptions[optIndex] = {
915 ...newOptions[optIndex],
916 value: e.target.value,
917 };
918 updateField(field.id, {
919 options: newOptions,
920 });
921 }}
922 placeholder="Value (e.g., spouse)"
923 className="text-xs flex-1"
924 />
925 <Input
926 value={option.label}
927 onChange={(e) => {
928 const newOptions = [
929 ...(field.options || []),
930 ];
931 newOptions[optIndex] = {
932 ...newOptions[optIndex],
933 label: e.target.value,
934 };
935 updateField(field.id, {
936 options: newOptions,
937 });
938 }}
939 placeholder="Label (e.g., Spouse/Partner)"
940 className="text-xs flex-1"
941 />
942 <button
943 type="button"
944 onClick={() => {
945 const newOptions = (
946 field.options || []
947 ).filter((_, i) => i !== optIndex);
948 updateField(field.id, {
949 options: newOptions,
950 });
951 }}
952 className="p-1 text-gray-400 hover:text-red-500"
953 >
954 <Trash2 className="w-3 h-3" />
955 </button>
956 </div>
957 ))}
958 {(!field.options ||
959 field.options.length === 0) && (
960 <p className="text-xs text-gray-400 italic">
961 {__(
962 'No options. Click "Add Option" to add dropdown choices.',
963 "yatra",
964 )}
965 </p>
966 )}
967 </div>
968 </div>
969 )}
970 </div>
971 ) : (
972 <div className="flex items-center gap-3 flex-wrap">
973 <span className="font-medium text-sm">
974 {field.type === "text_block"
975 ? __("Text Block", "yatra")
976 : field.label}
977 </span>
978 {field.type !== "text_block" && (
979 <code className="text-xs text-gray-500 dark:text-gray-400 px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 rounded font-mono">
980 {field.id}
981 </code>
982 )}
983 <span className="text-xs text-gray-400 dark:text-gray-500 px-2 py-0.5 bg-gray-100 dark:bg-gray-700 rounded">
984 {fieldTypes.find((t) => t.value === field.type)
985 ?.label || field.type}
986 </span>
987 <span className="text-xs text-gray-400 dark:text-gray-500">
988 {widthOptions.find((w) => w.value === field.width)
989 ?.label || "Full"}
990 </span>
991 {field.type === "text_block" && field.content && (
992 <span className="text-xs text-gray-500 dark:text-gray-400 italic truncate max-w-[16rem]">
993 "
994 {field.content.replace(/<[^>]*>/g, "").slice(0, 60)}
995 "
996 </span>
997 )}
998 {field.type === "select" &&
999 field.options &&
1000 field.options.length > 0 && (
1001 <span className="text-xs text-blue-500 dark:text-blue-400">
1002 ({field.options.length}{" "}
1003 {field.options.length === 1
1004 ? "option"
1005 : "options"}
1006 )
1007 </span>
1008 )}
1009 {field.required && (
1010 <span className="text-xs text-red-500 font-medium">
1011 {__("Required", "yatra")}
1012 </span>
1013 )}
1014 {formType === "traveler_form" &&
1015 field.applies_to === "lead" && (
1016 <span className="text-xs px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400 font-medium">
1017 {__("Lead only", "yatra")}
1018 </span>
1019 )}
1020 {field.locked && (
1021 <span
1022 className="inline-flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400 px-1.5 py-0.5 bg-amber-50 dark:bg-amber-900/20 rounded"
1023 title="This field is protected and cannot be deleted"
1024 >
1025 <Lock className="w-3 h-3" />
1026 Locked
1027 </span>
1028 )}
1029 </div>
1030 )}
1031 </div>
1032
1033 {/* Actions */}
1034 <div className="flex items-center gap-1">
1035 {field.type !== "text_block" && (
1036 <button
1037 type="button"
1038 onClick={() =>
1039 !field.locked && toggleFieldRequired(field.id)
1040 }
1041 disabled={field.locked}
1042 className={`p-1.5 rounded ${field.locked ? "cursor-not-allowed opacity-50" : ""} ${field.required ? "text-red-500 bg-red-50 dark:bg-red-900/20" : "text-gray-400 hover:text-gray-600"}`}
1043 title={
1044 field.locked
1045 ? "This field is required and cannot be changed"
1046 : field.required
1047 ? "Make optional"
1048 : "Make required"
1049 }
1050 >
1051 <Star
1052 className="w-4 h-4"
1053 fill={field.required ? "currentColor" : "none"}
1054 />
1055 </button>
1056 )}
1057 {/* Show enable/disable toggle only for non-locked fields */}
1058 {!field.locked && (
1059 <button
1060 type="button"
1061 onClick={() => toggleFieldEnabled(field.id)}
1062 className={`p-1.5 rounded ${field.enabled ? "text-green-500" : "text-gray-400"}`}
1063 title={field.enabled ? "Disable field" : "Enable field"}
1064 >
1065 {field.enabled ? (
1066 <Eye className="w-4 h-4" />
1067 ) : (
1068 <EyeOff className="w-4 h-4" />
1069 )}
1070 </button>
1071 )}
1072 <button
1073 type="button"
1074 onClick={() =>
1075 setEditingField(
1076 editingField === field.id ? null : field.id,
1077 )
1078 }
1079 className="p-1.5 rounded text-gray-400 hover:text-blue-500"
1080 title="Edit field"
1081 >
1082 <Edit2 className="w-4 h-4" />
1083 </button>
1084 {/* Show delete button only for non-locked fields */}
1085 {!field.locked && (
1086 <button
1087 type="button"
1088 onClick={() =>
1089 setDeleteConfirm({
1090 isOpen: true,
1091 fieldId: field.id,
1092 fieldLabel: field.label,
1093 })
1094 }
1095 className="p-1.5 rounded text-gray-400 hover:text-red-500"
1096 title="Delete field"
1097 >
1098 <Trash2 className="w-4 h-4" />
1099 </button>
1100 )}
1101 </div>
1102 </div>
1103 ))}
1104 </div>
1105 )}
1106 </CardContent>
1107 </Card>
1108
1109 {/* Delete Confirmation Dialog */}
1110 <ConfirmationDialog
1111 isOpen={deleteConfirm.isOpen}
1112 onClose={() =>
1113 setDeleteConfirm({ isOpen: false, fieldId: null, fieldLabel: "" })
1114 }
1115 onConfirm={() => {
1116 if (deleteConfirm.fieldId) {
1117 deleteField(deleteConfirm.fieldId);
1118 }
1119 }}
1120 title={__("Delete Field", "yatra")}
1121 message={`Are you sure you want to delete the field "${deleteConfirm.fieldLabel}"? This action cannot be undone.`}
1122 confirmText={__("Delete", "yatra")}
1123 cancelText={__("Cancel", "yatra")}
1124 variant="danger"
1125 />
1126 </>
1127 );
1128 };
1129
1130 export default BookingFormFieldsEditor;
1131