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

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

452 lines 15.6 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 { ChevronDown, ChevronUp, Plus, RotateCcw } from "lucide-react";
3 import { __, sprintf } from "../../lib/i18n";
4 import { Modal } from "../ui/modal";
5 import { Button } from "../ui/button";
6 import { Input } from "../ui/input";
7 import { Label } from "../ui/label";
8 import { Select } from "../ui/select";
9 import { MultiSelect } from "../ui/multi-select";
10 import type {
11 BookingFormType,
12 FormCondition,
13 FormConditionTargets,
14 FormFieldConfig,
15 FormSectionConfig,
16 } from "./booking-form-types";
17 import { BookingFormFieldsEditor } from "./BookingFormFieldsEditor";
18
19 /**
20 * Settings → Booking Form → "Conditions" popup (Pro — Dynamic Form Field).
21 *
22 * A condition is a complete alternative version of one form section — its own
23 * title, description and field list — used on the trips it names. Each one
24 * starts as a copy of the global section and is edited with the same controls
25 * as the global builder. Conditions are part of `booking_form_config` and are
26 * saved with the page's Save Settings button; this popup only edits a draft.
27 */
28
29 // Trip targeting (picker options, encode/decode, labels) is shared with the
30 // email template overrides — see hooks/useTripTargets.
31 import {
32 decodeTargets,
33 describeTargets,
34 encodeTargets,
35 hasTargets,
36 useTripTargets,
37 } from "../../hooks/useTripTargets";
38 export { describeTargets, hasTargets, useTripTargets as useConditionTargets };
39
40 // ---- helpers ---------------------------------------------------------------
41
42 const clone = <T,>(v: T): T => JSON.parse(JSON.stringify(v));
43
44 const reorder = (fields: FormFieldConfig[]): FormFieldConfig[] =>
45 fields.map((f, i) => ({ ...f, order: i + 1 }));
46
47 const newConditionFrom = (section: FormSectionConfig): FormCondition => ({
48 id: `condition_${Date.now().toString(36)}`,
49 targets: {},
50 title: section.title || "",
51 description: section.description || "",
52 fields: clone(section.fields || []),
53 });
54
55 // ---- component -------------------------------------------------------------
56
57 interface Option {
58 value: string;
59 label: string;
60 }
61
62 interface BookingFormConditionsModalProps {
63 isOpen: boolean;
64 onClose: () => void;
65 formType: BookingFormType;
66 formLabel: string;
67 /** The global section this popup's conditions are versions of. */
68 section: FormSectionConfig;
69 onSave: (conditions: FormCondition[]) => void;
70 fieldTypes: Option[];
71 widthOptions: Option[];
72 appliesToOptions: Option[];
73 }
74
75 export const BookingFormConditionsModal: React.FC<
76 BookingFormConditionsModalProps
77 > = ({
78 isOpen,
79 onClose,
80 formType,
81 formLabel,
82 section,
83 onSave,
84 fieldTypes,
85 widthOptions,
86 appliesToOptions,
87 }) => {
88 const [draft, setDraft] = useState<FormCondition[]>([]);
89 const [collapsed, setCollapsed] = useState<Set<number>>(new Set());
90 const [addFromGlobal, setAddFromGlobal] = useState<number | null>(null);
91 const [error, setError] = useState<string | null>(null);
92 const { options: targetOptions } = useTripTargets(isOpen);
93
94 // Fresh draft every time the popup opens.
95 useEffect(() => {
96 if (!isOpen) return;
97 setDraft(clone(section.conditions || []));
98 setCollapsed(new Set());
99 setAddFromGlobal(null);
100 setError(null);
101 }, [isOpen, section.conditions]);
102
103 const globalFields = section.fields || [];
104 const isTraveler = formType === "traveler_form";
105
106 const update = (ci: number, patch: Partial<FormCondition>) =>
107 setDraft((d) => d.map((c, i) => (i === ci ? { ...c, ...patch } : c)));
108
109 const moveCondition = (ci: number, delta: number) =>
110 setDraft((d) => {
111 const next = [...d];
112 const to = ci + delta;
113 if (to < 0 || to >= next.length) return next;
114 [next[ci], next[to]] = [next[to], next[ci]];
115 return next;
116 });
117
118 const toggleCollapsed = (ci: number) =>
119 setCollapsed((s) => {
120 const n = new Set(s);
121 if (n.has(ci)) n.delete(ci);
122 else n.add(ci);
123 return n;
124 });
125
126 const addCondition = () => {
127 setDraft((d) => [...d, newConditionFrom(section)]);
128 setCollapsed(new Set(draft.map((_, i) => i)));
129 };
130
131 const done = () => {
132 const bad = draft.findIndex((c) => !hasTargets(c.targets));
133 if (bad >= 0) {
134 setError(
135 sprintf(
136 __(
137 "Condition %d needs at least one trip, category or trip type.",
138 "yatra",
139 ),
140 bad + 1,
141 ),
142 );
143 setCollapsed((s) => {
144 const n = new Set(s);
145 n.delete(bad);
146 return n;
147 });
148 return;
149 }
150 onSave(draft);
151 onClose();
152 };
153
154 const typeLabel = (t: string) =>
155 fieldTypes.find((x) => x.value === t)?.label || t;
156
157 // ---- renderers -----------------------------------------------------------
158
159 const renderCondition = (c: FormCondition, ci: number) => {
160 const isCollapsed = collapsed.has(ci);
161 const missingFromGlobal = globalFields.filter(
162 (g) => !c.fields.some((f) => f.id === g.id),
163 );
164 const enabledCount = c.fields.filter((f) => f.enabled !== false).length;
165 const where = describeTargets(c.targets, targetOptions);
166 return (
167 <div
168 key={c.id}
169 className="rounded-lg border border-gray-200 dark:border-gray-700 border-l-4 border-l-purple-500 overflow-hidden"
170 data-testid={`condition-${ci}`}
171 >
172 <div className="flex flex-wrap items-center gap-2 px-3 py-2 bg-gray-50 dark:bg-gray-800/60 border-b border-gray-200 dark:border-gray-700">
173 <span className="text-[11px] font-mono font-semibold px-1.5 py-0.5 rounded bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300">
174 {sprintf(__("COND %d", "yatra"), ci + 1)}
175 </span>
176 <span className="text-sm font-semibold">
177 {where || __("Choose where this applies", "yatra")}
178 </span>
179 <span className="text-xs text-gray-500">
180 {sprintf(
181 __("→ “%1$s” · %2$d fields", "yatra"),
182 c.title,
183 enabledCount,
184 )}
185 </span>
186 <span className="flex-1" />
187 <button
188 type="button"
189 className="p-1 text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 disabled:opacity-30"
190 disabled={ci === 0}
191 onClick={() => moveCondition(ci, -1)}
192 title={__("Higher priority", "yatra")}
193 >
194 <ChevronUp className="w-4 h-4" />
195 </button>
196 <button
197 type="button"
198 className="p-1 text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 disabled:opacity-30"
199 disabled={ci === draft.length - 1}
200 onClick={() => moveCondition(ci, 1)}
201 title={__("Lower priority", "yatra")}
202 >
203 <ChevronDown className="w-4 h-4" />
204 </button>
205 <Button
206 type="button"
207 variant="ghost"
208 size="sm"
209 onClick={() => {
210 const fresh = newConditionFrom(section);
211 update(ci, {
212 title: fresh.title,
213 description: fresh.description,
214 fields: fresh.fields,
215 });
216 }}
217 title={__(
218 "Replace this condition's form with a fresh copy of the global form",
219 "yatra",
220 )}
221 >
222 <RotateCcw className="w-3 h-3 mr-1" />
223 {__("Reset to global form", "yatra")}
224 </Button>
225 <Button
226 type="button"
227 variant="ghost"
228 size="sm"
229 className="text-red-600"
230 onClick={() => setDraft((d) => d.filter((_, i) => i !== ci))}
231 >
232 {__("Remove", "yatra")}
233 </Button>
234 <Button
235 type="button"
236 variant="ghost"
237 size="sm"
238 onClick={() => toggleCollapsed(ci)}
239 >
240 {isCollapsed ? __("Expand", "yatra") : __("Collapse", "yatra")}
241 </Button>
242 </div>
243
244 {!isCollapsed && (
245 <div className="p-3 space-y-4">
246 <div>
247 <Label className="text-xs">
248 {__("Use this form on", "yatra")}
249 <span className="ml-2 font-normal text-gray-400">
250 {__(
251 "trips, categories or trip types — any match applies",
252 "yatra",
253 )}
254 </span>
255 </Label>
256 <MultiSelect
257 value={encodeTargets(c.targets)}
258 onChange={(vals) =>
259 update(ci, { targets: decodeTargets(vals) })
260 }
261 options={targetOptions}
262 placeholder={__(
263 "Search trips, categories, trip types…",
264 "yatra",
265 )}
266 className="mt-1"
267 />
268 {!hasTargets(c.targets) && (
269 <p className="text-xs text-red-600 mt-1">
270 {__(
271 "Pick at least one trip, category or trip type.",
272 "yatra",
273 )}
274 </p>
275 )}
276 </div>
277
278 <div className="rounded-lg border border-gray-200 dark:border-gray-700">
279 <div className="px-3 py-2 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
280 <span className="text-sm font-semibold">
281 {__("Form Section Settings", "yatra")}
282 </span>
283 <span className="text-xs text-gray-400">
284 {__("for these trips", "yatra")}
285 </span>
286 </div>
287 <div className="p-3 grid grid-cols-1 md:grid-cols-2 gap-3">
288 <div>
289 <Label className="text-xs">
290 {__("Section Title", "yatra")}
291 </Label>
292 <Input
293 value={c.title}
294 onChange={(e) => update(ci, { title: e.target.value })}
295 className="mt-1 text-sm"
296 data-testid={`condition-${ci}-title`}
297 />
298 </div>
299 <div>
300 <Label className="text-xs">
301 {__("Section Description", "yatra")}
302 </Label>
303 <Input
304 value={c.description}
305 onChange={(e) =>
306 update(ci, { description: e.target.value })
307 }
308 className="mt-1 text-sm"
309 />
310 </div>
311 </div>
312 </div>
313
314 {/* Same builder as the global list — drag to reorder, arrows,
315 inline editor, Add New Field — plus "Add from global form". */}
316 <BookingFormFieldsEditor
317 formType={formType}
318 fields={c.fields}
319 onChange={(fields) => update(ci, { fields })}
320 fieldTypes={fieldTypes}
321 widthOptions={widthOptions}
322 appliesToOptions={appliesToOptions}
323 headerExtra={
324 addFromGlobal === ci ? (
325 <Select
326 autoFocus
327 className="text-sm h-9 w-56"
328 value=""
329 onChange={(e) => {
330 const g = globalFields.find(
331 (f) => f.id === e.target.value,
332 );
333 if (g) {
334 update(ci, {
335 fields: reorder([...c.fields, clone(g)]),
336 });
337 }
338 setAddFromGlobal(null);
339 }}
340 onBlur={() => setAddFromGlobal(null)}
341 data-testid="add-from-global-select"
342 >
343 <option value="">
344 {__("Pick a global field…", "yatra")}
345 </option>
346 {missingFromGlobal.map((g) => (
347 <option key={g.id} value={g.id}>
348 {g.type === "text_block"
349 ? __("Text Block", "yatra")
350 : g.label}{" "}
351 ({typeLabel(g.type)})
352 </option>
353 ))}
354 </Select>
355 ) : (
356 <Button
357 type="button"
358 size="sm"
359 variant="outline"
360 disabled={missingFromGlobal.length === 0}
361 onClick={() => setAddFromGlobal(ci)}
362 title={
363 missingFromGlobal.length === 0
364 ? __(
365 "Every global field is already in this condition",
366 "yatra",
367 )
368 : undefined
369 }
370 data-testid="add-from-global"
371 >
372 {__("Add from global form", "yatra")}
373 </Button>
374 )
375 }
376 />
377 </div>
378 )}
379 </div>
380 );
381 };
382
383 return (
384 <Modal
385 isOpen={isOpen}
386 onClose={onClose}
387 size="xl"
388 title={sprintf(__("Conditions — %s", "yatra"), formLabel)}
389 description={__(
390 "Give some trips their own version of this form. Each condition is a full copy of the form builder — change the section title, add or remove fields, reorder, edit labels, required, widths and options — and applies only to the trips it names.",
391 "yatra",
392 )}
393 footer={
394 <div className="flex flex-wrap items-center justify-between gap-3 w-full">
395 <span className="text-xs text-gray-500">
396 {__(
397 "Conditions are saved with Save Settings, like every other form change.",
398 "yatra",
399 )}
400 </span>
401 <div className="flex items-center gap-2">
402 {error && <span className="text-xs text-red-600">{error}</span>}
403 <Button type="button" variant="outline" onClick={onClose}>
404 {__("Cancel", "yatra")}
405 </Button>
406 <Button type="button" onClick={done} data-testid="conditions-done">
407 {__("Done", "yatra")}
408 </Button>
409 </div>
410 </div>
411 }
412 >
413 <div className="space-y-3" data-testid="conditions-modal">
414 {draft.length === 0 && (
415 <p className="text-sm text-gray-500">
416 {__(
417 "No conditions yet — every trip uses the global form. Add one to give some trips their own version.",
418 "yatra",
419 )}
420 </p>
421 )}
422 {draft.map(renderCondition)}
423 <button
424 type="button"
425 className="w-full flex items-center justify-center gap-2 p-3 rounded-lg border border-dashed border-gray-300 dark:border-gray-600 text-sm text-gray-500 hover:border-purple-500 hover:text-purple-700 hover:bg-purple-50 dark:hover:bg-purple-900/10"
426 onClick={addCondition}
427 data-testid="add-condition"
428 >
429 <Plus className="w-4 h-4" />
430 {__("Add condition", "yatra")}
431 <span className="text-xs text-gray-400">
432 {__("— starts as a copy of the global form", "yatra")}
433 </span>
434 </button>
435 <div className="p-3 rounded-lg bg-gray-50 dark:bg-gray-800/60 text-sm">
436 <span className="font-semibold">
437 {__("All other trips", "yatra")}
438 </span>{" "}
439 <span className="text-gray-500">
440 {__(
441 "Trips that match no condition use the global form behind this popup — the behaviour before conditions existed.",
442 "yatra",
443 )}
444 </span>
445 </div>
446 </div>
447 </Modal>
448 );
449 };
450
451 export default BookingFormConditionsModal;
452