PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.2.8
Yatra – Travel Booking & Tour Operator Software v3.0.2.8
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 / trip-form / TripAttributesSection.tsx

TripAttributesSection.tsx in Yatra – Travel Booking & Tour Operator Software 3.0.2.8, at resources/js/components/trip-form/TripAttributesSection.tsx

691 lines 24.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Trip Attributes Section Component
3 * Handles attribute selection and value assignment for trips
4 */
5
6 import React, { useState, useEffect, useRef } from "react";
7 import { useQuery } from "@tanstack/react-query";
8 import { Plus, X, Tag, ChevronDown, ChevronUp } from "lucide-react";
9 import { __ } from "../../lib/i18n";
10 import { apiClient } from "../../lib/api-client";
11 import { Card, CardContent } from "../../components/ui/card";
12 import { Badge } from "../../components/ui/badge";
13 import { Input } from "../../components/ui/input";
14 import { Select } from "../../components/ui/select";
15 import { Button } from "../../components/ui/button";
16 import { TimePicker } from "../../components/ui/time-picker";
17
18 interface Attribute {
19 id: number;
20 name: string;
21 slug: string;
22 field_type: string;
23 field_options: string;
24 default_value: string;
25 placeholder: string;
26 required: boolean;
27 description?: string;
28 validation_rules: string;
29 display_order: number;
30 show_on_frontend: boolean;
31 show_in_filters: boolean;
32 filter_type: string;
33 searchable: boolean;
34 status: string;
35 }
36
37 interface TripAttributesSectionProps {
38 formData: any;
39 handleFieldChange: (field: "attributes", value: any) => void;
40 tripId?: number;
41 isEditMode?: boolean;
42 tripAttributesData?: any; // Add this prop
43 /** False while parent is loading GET /trips/:id/attributes in edit mode; avoids init effect locking empty before data arrives */
44 tripAttributesReady?: boolean;
45 }
46
47 function parseAttributeFieldOptions(
48 field_options: string | unknown,
49 ): Array<{ label: string; value: string }> {
50 if (Array.isArray(field_options)) {
51 return field_options
52 .filter((o) => o && typeof o === "object")
53 .map((o: { label?: string; value?: string }) => ({
54 label: String(o?.label ?? ""),
55 value: String(o?.value ?? ""),
56 }))
57 .filter((o) => o.label !== "" || o.value !== "");
58 }
59 if (typeof field_options === "string" && field_options.trim()) {
60 try {
61 const p = JSON.parse(field_options) as unknown;
62 if (Array.isArray(p)) {
63 return p
64 .filter((o) => o && typeof o === "object")
65 .map((o: { label?: string; value?: string }) => ({
66 label: String(o?.label ?? ""),
67 value: String(o?.value ?? ""),
68 }))
69 .filter((o) => o.label !== "" || o.value !== "");
70 }
71 } catch {
72 return [];
73 }
74 }
75 return [];
76 }
77
78 function normalizeAttributeStoredValue(
79 fieldType: string,
80 raw: unknown,
81 ): string | string[] {
82 if (fieldType === "checkbox") {
83 if (Array.isArray(raw)) {
84 return raw.map((v) => String(v));
85 }
86 if (typeof raw === "string") {
87 const t = raw.trim();
88 if (t.startsWith("[")) {
89 try {
90 const p = JSON.parse(t) as unknown;
91 if (Array.isArray(p)) {
92 return p.map((v) => String(v));
93 }
94 } catch {
95 /* fall through */
96 }
97 }
98 return t ? [t] : [];
99 }
100 if (typeof raw === "boolean") {
101 return raw ? ["1"] : [];
102 }
103 return [];
104 }
105 if (raw === null || raw === undefined) {
106 return "";
107 }
108 return typeof raw === "string" ? raw : String(raw);
109 }
110
111 const TripAttributesSection: React.FC<TripAttributesSectionProps> = ({
112 formData,
113 handleFieldChange,
114 tripId,
115 isEditMode = false,
116 tripAttributesData = {}, // Add this prop
117 tripAttributesReady = true,
118 }) => {
119 const [selectedAttributes, setSelectedAttributes] = useState<number[]>([]);
120 const [attributeValues, setAttributeValues] = useState<Record<number, any>>(
121 {},
122 );
123 const [showAttributeDropdown, setShowAttributeDropdown] = useState(false);
124 const isInitializing = useRef(true);
125
126 // Fetch available attributes
127 const { data: attributesData, isLoading: isLoadingAttributes } = useQuery({
128 queryKey: ["attributes"],
129 queryFn: async () => {
130 const response = await apiClient.get("/attributes?status=publish");
131 const rawData = response?.data ?? [];
132 const list = Array.isArray(rawData)
133 ? rawData
134 : Array.isArray(rawData?.data)
135 ? rawData.data
136 : [];
137
138 // Normalize attribute shape (ids sometimes arrive as strings)
139 return list.map((item: any) => ({
140 ...item,
141 id: Number(item?.id) || 0,
142 }));
143 },
144 });
145
146 // Initialize from formData when component mounts or when trip attributes are loaded
147 useEffect(() => {
148 // Only run initialization logic, not when user is typing or deleting
149 if (!isInitializing.current) return;
150
151 // Edit mode: wait for GET /trips/:id/attributes — otherwise we mark initialized empty and never hydrate when data arrives
152 if (isEditMode && tripId && !tripAttributesReady) {
153 return;
154 }
155
156 // In edit mode, prioritize trip attributes from prop (from main form)
157 if (
158 isEditMode &&
159 tripAttributesData &&
160 Object.keys(tripAttributesData).length > 0
161 ) {
162 const attributeIds = Object.keys(tripAttributesData).map((id) =>
163 Number(id),
164 );
165 setSelectedAttributes(attributeIds);
166 setAttributeValues(tripAttributesData);
167 // Set initialization to false only after successful initialization
168 isInitializing.current = false;
169 }
170 // Fallback to formData attributes (only for initial load)
171 else if (
172 formData.attributes &&
173 Object.keys(formData.attributes).length > 0 &&
174 isInitializing.current
175 ) {
176 const attributeIds = Object.keys(formData.attributes).map((id) =>
177 Number(id),
178 );
179 setSelectedAttributes(attributeIds);
180 setAttributeValues(formData.attributes);
181 isInitializing.current = false;
182 } else {
183 isInitializing.current = false;
184 }
185 }, [
186 tripAttributesData,
187 formData.attributes,
188 isEditMode,
189 tripId,
190 tripAttributesReady,
191 ]);
192
193 // Add attribute to selected list
194 const handleAddAttribute = (attributeId: number) => {
195 if (!selectedAttributes.includes(attributeId)) {
196 const newSelectedAttributes = [...selectedAttributes, attributeId];
197 setSelectedAttributes(newSelectedAttributes);
198
199 // Initialize with default value if not exists
200 if (!attributeValues[attributeId]) {
201 const newAttributeValues = { ...attributeValues, [attributeId]: "" };
202 setAttributeValues(newAttributeValues);
203 handleFieldChange("attributes", newAttributeValues);
204 }
205
206 setShowAttributeDropdown(false);
207 }
208 };
209
210 // Remove attribute from selected list
211 const handleRemoveAttribute = (attributeId: number) => {
212 const newSelectedAttributes = selectedAttributes.filter(
213 (id) => id !== attributeId,
214 );
215 const newAttributeValues = { ...attributeValues };
216 delete newAttributeValues[attributeId];
217
218 setSelectedAttributes(newSelectedAttributes);
219 setAttributeValues(newAttributeValues);
220 handleFieldChange("attributes", newAttributeValues);
221 };
222
223 // Handle attribute value change
224 const handleAttributeValueChange = (attributeId: number, value: any) => {
225 const newAttributeValues = { ...attributeValues, [attributeId]: value };
226 setAttributeValues(newAttributeValues);
227 handleFieldChange("attributes", newAttributeValues);
228 };
229
230 // Render attribute input based on field type
231 const renderAttributeInput = (attribute: Attribute) => {
232 const raw = attributeValues[attribute.id];
233 const value = normalizeAttributeStoredValue(attribute.field_type, raw);
234
235 switch (attribute.field_type) {
236 case "text_field":
237 case "email":
238 case "url":
239 return (
240 <Input
241 type={
242 attribute.field_type === "email"
243 ? "email"
244 : attribute.field_type === "url"
245 ? "url"
246 : "text"
247 }
248 value={value}
249 onChange={(e) =>
250 handleAttributeValueChange(attribute.id, e.target.value)
251 }
252 placeholder={attribute.placeholder || __("Enter value", "yatra")}
253 className="mt-2"
254 />
255 );
256
257 case "number":
258 return (
259 <Input
260 type="number"
261 value={value}
262 onChange={(e) =>
263 handleAttributeValueChange(attribute.id, e.target.value)
264 }
265 placeholder={attribute.placeholder || __("Enter number", "yatra")}
266 className="mt-2"
267 />
268 );
269
270 case "textarea":
271 return (
272 <textarea
273 value={value}
274 onChange={(e) =>
275 handleAttributeValueChange(attribute.id, e.target.value)
276 }
277 placeholder={attribute.placeholder || __("Enter text", "yatra")}
278 rows={3}
279 className="flex w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm ring-offset-white placeholder:text-gray-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:ring-offset-gray-900 dark:placeholder:text-gray-400 dark:focus-visible:ring-blue-400 resize-none mt-2"
280 />
281 );
282
283 case "select": {
284 const options = parseAttributeFieldOptions(attribute.field_options);
285 const strVal = typeof value === "string" ? value : "";
286 if (options.length === 0) {
287 return (
288 <p className="mt-2 text-sm text-amber-600 dark:text-amber-500">
289 {__(
290 "This attribute has no options configured. Edit the attribute to add choices.",
291 "yatra",
292 )}
293 </p>
294 );
295 }
296 return (
297 <Select
298 value={strVal}
299 onChange={(e) =>
300 handleAttributeValueChange(attribute.id, e.target.value)
301 }
302 className="mt-2"
303 >
304 <option value="">{__("Select an option", "yatra")}</option>
305 {options.map((option, index) => (
306 <option key={`${option.value}-${index}`} value={option.value}>
307 {option.label || option.value}
308 </option>
309 ))}
310 </Select>
311 );
312 }
313
314 case "radio": {
315 const options = parseAttributeFieldOptions(attribute.field_options);
316 const strVal = typeof value === "string" ? value : "";
317 if (options.length === 0) {
318 return (
319 <p className="mt-2 text-sm text-amber-600 dark:text-amber-500">
320 {__(
321 "This attribute has no options configured. Edit the attribute to add choices.",
322 "yatra",
323 )}
324 </p>
325 );
326 }
327 return (
328 <div
329 className="mt-2 space-y-2"
330 role="radiogroup"
331 aria-label={attribute.name}
332 >
333 {options.map((option, index) => (
334 <label
335 key={`${option.value}-${index}`}
336 className="flex items-center gap-2 cursor-pointer text-sm text-gray-700 dark:text-gray-300"
337 >
338 <input
339 type="radio"
340 name={`yatra-attr-${attribute.id}`}
341 value={option.value}
342 checked={String(strVal) === String(option.value)}
343 onChange={() =>
344 handleAttributeValueChange(attribute.id, option.value)
345 }
346 className="border-gray-300 text-blue-600 focus:ring-blue-500"
347 />
348 <span>{option.label || option.value}</span>
349 </label>
350 ))}
351 </div>
352 );
353 }
354
355 case "checkbox": {
356 const options = parseAttributeFieldOptions(attribute.field_options);
357 const selected = Array.isArray(value) ? value.map(String) : [];
358 if (options.length === 0) {
359 return (
360 <p className="mt-2 text-sm text-amber-600 dark:text-amber-500">
361 {__(
362 "This attribute has no options configured. Edit the attribute to add choices.",
363 "yatra",
364 )}
365 </p>
366 );
367 }
368 const toggle = (optValue: string, checked: boolean) => {
369 const next = checked
370 ? [...selected.filter((v) => v !== optValue), optValue]
371 : selected.filter((v) => v !== optValue);
372 handleAttributeValueChange(attribute.id, next);
373 };
374 return (
375 <div className="mt-2 space-y-2">
376 {options.map((option, index) => (
377 <label
378 key={`${option.value}-${index}`}
379 className="flex items-center gap-2 cursor-pointer text-sm text-gray-700 dark:text-gray-300"
380 >
381 <input
382 type="checkbox"
383 checked={selected.includes(String(option.value))}
384 onChange={(e) =>
385 toggle(String(option.value), e.target.checked)
386 }
387 className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
388 />
389 <span>{option.label || option.value}</span>
390 </label>
391 ))}
392 </div>
393 );
394 }
395
396 case "file":
397 return (
398 <Input
399 type="text"
400 value={typeof value === "string" ? value : ""}
401 onChange={(e) =>
402 handleAttributeValueChange(attribute.id, e.target.value)
403 }
404 placeholder={
405 attribute.placeholder ||
406 __("File URL or attachment path", "yatra")
407 }
408 className="mt-2"
409 />
410 );
411
412 case "date":
413 return (
414 <Input
415 type="date"
416 value={value}
417 onChange={(e) =>
418 handleAttributeValueChange(attribute.id, e.target.value)
419 }
420 className="mt-2"
421 />
422 );
423
424 case "time":
425 return (
426 <TimePicker
427 value={Array.isArray(value) ? (value[0] ?? "") : value}
428 onChange={(newValue) =>
429 handleAttributeValueChange(attribute.id, newValue)
430 }
431 placeholder="Select time"
432 className="mt-2 w-full"
433 />
434 );
435
436 case "color":
437 return (
438 <Input
439 type="color"
440 value={value}
441 onChange={(e) =>
442 handleAttributeValueChange(attribute.id, e.target.value)
443 }
444 className="mt-2 h-10 w-20"
445 />
446 );
447
448 default:
449 return (
450 <Input
451 type="text"
452 value={value}
453 onChange={(e) =>
454 handleAttributeValueChange(attribute.id, e.target.value)
455 }
456 placeholder={attribute.placeholder || __("Enter value", "yatra")}
457 className="mt-2"
458 />
459 );
460 }
461 };
462
463 const availableAttributes =
464 attributesData?.filter(
465 (attr: Attribute) =>
466 attr.status === "publish" && !selectedAttributes.includes(attr.id),
467 ) || [];
468
469 if (isEditMode && tripId && !tripAttributesReady) {
470 return (
471 <div className="space-y-4" aria-busy="true">
472 <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-1/3 animate-pulse" />
473 <div className="space-y-3">
474 {[1, 2, 3].map((i) => (
475 <div
476 key={i}
477 className="h-16 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"
478 />
479 ))}
480 </div>
481 </div>
482 );
483 }
484
485 if (isLoadingAttributes) {
486 return (
487 <div className="space-y-4">
488 <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-1/3 animate-pulse"></div>
489 <div className="space-y-3">
490 {[1, 2, 3].map((i) => (
491 <div
492 key={i}
493 className="h-16 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"
494 ></div>
495 ))}
496 </div>
497 </div>
498 );
499 }
500
501 return (
502 <div className="space-y-4">
503 {/* Show message when no attributes exist at all */}
504 {!attributesData || attributesData.length === 0 ? (
505 <Card>
506 <CardContent className="p-6 text-center">
507 <div className="mb-4">
508 <Tag className="w-12 h-12 text-gray-400 mx-auto" />
509 </div>
510 <h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
511 {__("No Attributes Found", "yatra")}
512 </h3>
513 <p className="text-gray-600 dark:text-gray-400 mb-4">
514 {__(
515 "Attributes allow you to add custom fields to your trips that are not included in the main plugin features. You can create any type of custom attribute you need.",
516 "yatra",
517 )}
518 </p>
519 <a
520 href="/wp-admin/admin.php?page=yatra&subpage=trips&tab=attributes&action=create"
521 target="_blank"
522 rel="noopener noreferrer"
523 className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors"
524 >
525 <Plus className="w-4 h-4" />
526 {__("Create Your First Attribute", "yatra")}
527 </a>
528 </CardContent>
529 </Card>
530 ) : (
531 <>
532 {/* Add Attribute Button/Card */}
533 <Card>
534 <CardContent className="p-4">
535 <Button
536 type="button"
537 onClick={() => setShowAttributeDropdown(!showAttributeDropdown)}
538 className="w-full justify-between h-auto py-3 px-4 border-2 border-dashed border-gray-300 dark:border-gray-600 hover:border-blue-500 dark:hover:border-blue-400 transition-colors"
539 variant="outline"
540 >
541 <div className="flex items-center gap-2">
542 <Plus className="w-4 h-4" />
543 <span>{__("Add Attribute", "yatra")}</span>
544 </div>
545 {showAttributeDropdown ? (
546 <ChevronUp className="w-4 h-4" />
547 ) : (
548 <ChevronDown className="w-4 h-4" />
549 )}
550 </Button>
551
552 {/* Attribute Dropdown */}
553 {showAttributeDropdown && availableAttributes.length > 0 && (
554 <div className="mt-3 border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 shadow-lg">
555 <div className="max-h-60 overflow-y-auto">
556 {availableAttributes.map((attribute: Attribute) => (
557 <button
558 key={attribute.id}
559 type="button"
560 onClick={() => handleAddAttribute(attribute.id)}
561 className="w-full text-left px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700 border-b border-gray-100 dark:border-gray-700 last:border-b-0 transition-colors"
562 >
563 <div className="flex items-center justify-between">
564 <div>
565 <div className="font-medium text-gray-900 dark:text-white">
566 {attribute.name}
567 </div>
568 {attribute.description && (
569 <div className="text-sm text-gray-500 dark:text-gray-400 mt-1">
570 {attribute.description}
571 </div>
572 )}
573 </div>
574 <div className="flex items-center gap-2">
575 <Badge variant="outline" className="text-xs">
576 {attribute.field_type.replace("_", " ")}
577 </Badge>
578 {attribute.required && (
579 <Badge variant="error" className="text-xs">
580 {__("Required", "yatra")}
581 </Badge>
582 )}
583 </div>
584 </div>
585 </button>
586 ))}
587 </div>
588 </div>
589 )}
590
591 {showAttributeDropdown && availableAttributes.length === 0 && (
592 <div className="mt-3 p-4 text-center bg-gray-50 dark:bg-gray-800 rounded-lg">
593 <p className="text-sm text-gray-500 dark:text-gray-400 mb-3">
594 {__("No attributes available to add", "yatra")}
595 </p>
596 <a
597 href="/wp-admin/admin.php?page=yatra&subpage=trips&tab=attributes&action=create"
598 target="_blank"
599 rel="noopener noreferrer"
600 className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors"
601 >
602 <Plus className="w-4 h-4" />
603 {__("Create New Attribute", "yatra")}
604 </a>
605 </div>
606 )}
607 </CardContent>
608 </Card>
609
610 {/* Selected Attributes */}
611 {selectedAttributes.length > 0 && (
612 <div className="space-y-4">
613 {selectedAttributes.map((attributeId) => {
614 const attribute = attributesData?.find(
615 (attr: Attribute) => attr.id === attributeId,
616 );
617
618 if (!attribute) {
619 return (
620 <Card
621 key={attributeId}
622 className="border-orange-200 bg-orange-50 dark:bg-orange-900/20"
623 >
624 <CardContent className="p-4">
625 <div className="flex items-center justify-between">
626 <div>
627 <h4 className="font-medium text-orange-900 dark:text-orange-100">
628 Attribute ID {attributeId} (Not Found)
629 </h4>
630 <p className="text-sm text-orange-700 dark:text-orange-300">
631 Value:{" "}
632 {JSON.stringify(attributeValues[attributeId])}
633 </p>
634 </div>
635 <Button
636 type="button"
637 variant="ghost"
638 size="sm"
639 onClick={() => handleRemoveAttribute(attributeId)}
640 className="text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20"
641 >
642 <X className="w-4 h-4" />
643 </Button>
644 </div>
645 </CardContent>
646 </Card>
647 );
648 }
649
650 return (
651 <Card key={attribute.id}>
652 <CardContent className="p-4">
653 <div className="flex items-center justify-between mb-3">
654 <div className="flex items-center gap-2">
655 <Tag className="w-4 h-4 text-blue-500" />
656 <h4 className="font-medium text-gray-900 dark:text-white">
657 {attribute.name}
658 </h4>
659 </div>
660 <Button
661 type="button"
662 variant="ghost"
663 size="sm"
664 onClick={() => handleRemoveAttribute(attribute.id)}
665 className="text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20"
666 >
667 <X className="w-4 h-4" />
668 </Button>
669 </div>
670
671 {attribute.description && (
672 <p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
673 {attribute.description}
674 </p>
675 )}
676
677 {renderAttributeInput(attribute)}
678 </CardContent>
679 </Card>
680 );
681 })}
682 </div>
683 )}
684 </>
685 )}
686 </div>
687 );
688 };
689
690 export default TripAttributesSection;
691