PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.2.6
Yatra – Travel Booking & Tour Operator Software v3.0.2.6
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 / AttributeForm.tsx

AttributeForm.tsx in Yatra – Travel Booking & Tour Operator Software 3.0.2.6, at resources/js/pages/AttributeForm.tsx

1,166 lines 41.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Attribute Form Page
3 * Add/Edit Attribute form with clean, minimal SaaS-style design matching ActivityForm
4 */
5
6 import React, { useState, useEffect, useMemo } from "react";
7 import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
8 import { ArrowLeft, Save, Loader2, Edit2, Plus, Trash2 } from "lucide-react";
9 import { __ } from "../lib/i18n";
10 import { useToast } from "../components/ui/toast";
11 import { generateSlug } from "../lib/slug";
12 import { apiClient } from "../lib/api-client";
13 import { ajaxService } from "../lib/api-client";
14 import { Button } from "../components/ui/button";
15 import { Input } from "../components/ui/input";
16 import { Select } from "../components/ui/select";
17 import { PageHeader } from "../components/common/PageHeader";
18 import {
19 Card,
20 CardContent,
21 CardHeader,
22 CardTitle,
23 } from "../components/ui/card";
24 import { ConditionalRender } from "../components/ui/conditional-render";
25 import { Switch } from "../components/ui/switch";
26 import { IconPicker } from "../components/ui/icon-picker";
27
28 type FieldOptionRow = { label: string; value: string };
29
30 function parseFieldOptionRows(raw: unknown): FieldOptionRow[] {
31 if (Array.isArray(raw)) {
32 return raw.map((o: { label?: string; value?: string }) => ({
33 label: String(o?.label ?? ""),
34 value: String(o?.value ?? ""),
35 }));
36 }
37 if (typeof raw === "string" && raw.trim()) {
38 try {
39 const p = JSON.parse(raw) as unknown;
40 if (Array.isArray(p)) {
41 return p.map((o: { label?: string; value?: string }) => ({
42 label: String(o?.label ?? ""),
43 value: String(o?.value ?? ""),
44 }));
45 }
46 } catch {
47 return [];
48 }
49 }
50 return [];
51 }
52
53 /** REST may return the entity at the root or nested under `data`. */
54 function unwrapAttributePayload(raw: unknown): Record<string, unknown> {
55 if (raw == null || typeof raw !== "object") {
56 return {};
57 }
58 const o = raw as Record<string, unknown>;
59 if ("id" in o) {
60 return o;
61 }
62 const inner = o.data;
63 if (
64 inner &&
65 typeof inner === "object" &&
66 !Array.isArray(inner) &&
67 "id" in (inner as object)
68 ) {
69 return inner as Record<string, unknown>;
70 }
71 return {};
72 }
73
74 type AttributeStatusUi = "publish" | "draft" | "trash";
75
76 function normalizeAttributeStatusForForm(raw: unknown): AttributeStatusUi {
77 const s = typeof raw === "string" ? raw.trim().toLowerCase() : "";
78 if (s === "publish" || s === "draft" || s === "trash") {
79 return s;
80 }
81 return "publish";
82 }
83
84 interface AttributeFormData {
85 name: string;
86 slug: string;
87 description: string;
88 icon: {
89 type: "icon" | "image";
90 value: string;
91 } | null;
92 field_type: string;
93 field_option_rows: FieldOptionRow[];
94 default_value: string;
95 placeholder: string;
96 required: boolean;
97 validation_rules: string;
98 display_order: number;
99 show_on_frontend: boolean;
100 show_in_filters: boolean;
101 filter_type: string;
102 searchable: boolean;
103 status: string;
104 }
105
106 const AttributeForm: React.FC = () => {
107 const queryClient = useQueryClient();
108 const { showToast } = useToast();
109 const [formData, setFormData] = useState<AttributeFormData>({
110 name: "",
111 slug: "",
112 description: "",
113 icon: null,
114 field_type: "text_field",
115 field_option_rows: [],
116 default_value: "",
117 placeholder: "",
118 required: false,
119 validation_rules: "",
120 display_order: 0,
121 show_on_frontend: true,
122 show_in_filters: false,
123 filter_type: "dropdown",
124 searchable: false,
125 status: "publish",
126 });
127 const [errors, setErrors] = useState<Record<string, string>>({});
128 const [isSubmitting, setIsSubmitting] = useState(false);
129 const [isSlugEditable, setIsSlugEditable] = useState(false);
130
131 // Get action and id from URL
132 const action = useMemo(() => {
133 const params = new URLSearchParams(window.location.search);
134 return params.get("action") || "create";
135 }, []);
136
137 const attributeId = useMemo(() => {
138 const params = new URLSearchParams(window.location.search);
139 return params.get("id");
140 }, []);
141
142 const isEditMode = action === "edit" && attributeId;
143
144 const fieldTypeOptions = [
145 { value: "text_field", label: "Text Field" },
146 { value: "textarea", label: "Textarea" },
147 { value: "number", label: "Number" },
148 { value: "email", label: "Email" },
149 { value: "url", label: "URL" },
150 { value: "date", label: "Date" },
151 { value: "time", label: "Time" },
152 { value: "color", label: "Color" },
153 { value: "select", label: "Select Dropdown" },
154 { value: "radio", label: "Radio Buttons" },
155 { value: "checkbox", label: "Checkbox" },
156 { value: "file", label: "File Upload" },
157 ];
158
159 // Handle field changes
160 const handleFieldChange = (field: keyof AttributeFormData, value: any) => {
161 if (field === "field_type") {
162 setFormData((prev) => {
163 const nextNeeds =
164 value === "select" || value === "radio" || value === "checkbox";
165 const wasNeeds =
166 prev.field_type === "select" ||
167 prev.field_type === "radio" ||
168 prev.field_type === "checkbox";
169 let rows = prev.field_option_rows;
170 if (nextNeeds && !wasNeeds && rows.length === 0) {
171 rows = [{ label: "", value: "" }];
172 }
173 return { ...prev, field_type: value, field_option_rows: rows };
174 });
175 if (errors.field_type) {
176 setErrors((prev) => ({ ...prev, field_type: "" }));
177 }
178 if (errors.field_options) {
179 setErrors((prev) => ({ ...prev, field_options: "" }));
180 }
181 return;
182 }
183
184 setFormData((prev) => ({ ...prev, [field]: value }));
185
186 // Auto-generate slug from name only in ADD mode (not in EDIT mode)
187 // In EDIT mode, slug only changes if user explicitly edits it
188 if (field === "name" && !isEditMode && !isSlugEditable) {
189 setFormData((prev) => ({
190 ...prev,
191 name: value,
192 slug: generateSlug(value),
193 }));
194 }
195
196 // Clear error for this field
197 if (errors[field]) {
198 setErrors((prev) => ({ ...prev, [field]: "" }));
199 }
200 };
201
202 const updateOptionRow = (
203 index: number,
204 key: "label" | "value",
205 val: string,
206 ) => {
207 setFormData((prev) => {
208 const next = [...prev.field_option_rows];
209 next[index] = { ...next[index], [key]: val };
210 return { ...prev, field_option_rows: next };
211 });
212 if (errors.field_options) {
213 setErrors((prev) => ({ ...prev, field_options: "" }));
214 }
215 };
216
217 const addOptionRow = () => {
218 setFormData((prev) => ({
219 ...prev,
220 field_option_rows: [...prev.field_option_rows, { label: "", value: "" }],
221 }));
222 };
223
224 const removeOptionRow = (index: number) => {
225 setFormData((prev) => ({
226 ...prev,
227 field_option_rows: prev.field_option_rows.filter((_, i) => i !== index),
228 }));
229 };
230
231 // Generate unique slug with numeric suffix if needed
232 const generateUniqueSlug = async (baseSlug: string): Promise<string> => {
233 try {
234 // Check if base slug exists
235 const response = await apiClient.get(
236 `/attributes/check-slug?slug=${encodeURIComponent(baseSlug)}${isEditMode && attributeId ? `&exclude_id=${attributeId}` : ""}`,
237 );
238
239 if (response.data.exists && response.data.suggested_slug) {
240 return response.data.suggested_slug;
241 }
242
243 return baseSlug;
244 } catch (error) {
245 // If API fails, fallback to client-side generation
246 let slug = baseSlug;
247 let counter = 1;
248
249 while (counter <= 100) {
250 // Prevent infinite loop
251 const testSlug = counter === 1 ? baseSlug : `${baseSlug}-${counter}`;
252
253 // For now, just return the testSlug (in a real implementation, you'd check against existing slugs)
254 // This is a fallback when the API is unavailable
255 if (counter === 1) {
256 return testSlug; // Return base slug on first attempt
257 }
258
259 slug = testSlug;
260 counter++;
261 }
262
263 return slug;
264 }
265 };
266
267 // Toggle slug editability
268 const toggleSlugEdit = () => {
269 if (isSlugEditable) {
270 // If disabling edit, regenerate slug from name
271 const newSlug = generateSlug(formData.name);
272 setFormData((prev) => ({ ...prev, slug: newSlug }));
273 }
274 setIsSlugEditable(!isSlugEditable);
275 };
276
277 // Validate form
278 const validateForm = (): boolean => {
279 const newErrors: Record<string, string> = {};
280
281 if (!formData.name.trim()) {
282 newErrors.name = __("Name is required", "yatra");
283 }
284
285 if (!formData.field_type) {
286 newErrors.field_type = __("Field type is required", "yatra");
287 }
288
289 // Validate field options for select/radio/checkbox (repeater rows)
290 if (
291 formData.field_type === "select" ||
292 formData.field_type === "radio" ||
293 formData.field_type === "checkbox"
294 ) {
295 const built = formData.field_option_rows
296 .map((r) => {
297 const label = r.label.trim();
298 const value = r.value.trim() || (label ? generateSlug(label) : "");
299 return { label, value };
300 })
301 .filter((r) => r.label !== "" && r.value !== "");
302
303 if (built.length === 0) {
304 newErrors.field_options = __(
305 "Add at least one option with a label (value can be left blank to auto-generate from the label).",
306 "yatra",
307 );
308 }
309 }
310
311 setErrors(newErrors);
312 return Object.keys(newErrors).length === 0;
313 };
314
315 // Load attribute data for editing
316 const {
317 data: attribute,
318 isLoading: isLoadingAttribute,
319 error: attributeError,
320 } = useQuery({
321 queryKey: ["attribute", attributeId],
322 queryFn: async () => {
323 if (!attributeId) return null;
324 const response = await apiClient.get(`/attributes/${attributeId}`);
325 return response;
326 },
327 enabled: Boolean(isEditMode),
328 retry: 2,
329 retryDelay: 1000,
330 });
331
332 // Direct database query to bypass caching issues (fallback only)
333 const fetchDirectDatabaseValues = async (attributeId: number) => {
334 try {
335 const data = await ajaxService.post("yatra_get_attribute_direct", {
336 attribute_id: attributeId,
337 nonce: window.yatraAdmin?.nonce || "",
338 });
339 if (data?.success && data?.data) {
340 return data.data;
341 }
342 } catch (error) {
343 console.warn("Direct database query failed", { error, attributeId });
344 }
345 return null;
346 };
347
348 useEffect(() => {
349 if (!attribute || !attributeId) {
350 return;
351 }
352 const fromRest = unwrapAttributePayload(attribute);
353
354 fetchDirectDatabaseValues(Number(attributeId) || 0).then((directData) => {
355 const direct =
356 directData && typeof directData === "object"
357 ? (directData as Record<string, unknown>)
358 : {};
359 const finalAttribute = { ...fromRest, ...direct };
360
361 const convertedData: AttributeFormData = {
362 name: String(finalAttribute.name ?? ""),
363 slug: String(finalAttribute.slug ?? ""),
364 description: String(finalAttribute.description ?? ""),
365 icon: (() => {
366 const ic = finalAttribute.icon;
367 if (!ic || typeof ic !== "object" || Array.isArray(ic)) {
368 return null;
369 }
370 const io = ic as { type?: string; value?: string };
371 return {
372 type: io.type === "image" ? "image" : "icon",
373 value: String(io.value ?? ""),
374 };
375 })(),
376 field_type: String(finalAttribute.field_type ?? "text_field"),
377 field_option_rows: (() => {
378 const rows = parseFieldOptionRows(finalAttribute.field_options);
379 return rows.length > 0
380 ? rows
381 : ["select", "radio", "checkbox"].includes(
382 String(finalAttribute.field_type ?? ""),
383 )
384 ? [{ label: "", value: "" }]
385 : [];
386 })(),
387 default_value: String(finalAttribute.default_value ?? ""),
388 placeholder: String(finalAttribute.placeholder ?? ""),
389 required:
390 finalAttribute.required === "1" ||
391 finalAttribute.required === 1 ||
392 finalAttribute.required === true,
393 validation_rules: String(finalAttribute.validation_rules ?? ""),
394 display_order: Number(finalAttribute.display_order) || 0,
395 show_on_frontend:
396 finalAttribute.show_on_frontend === "1" ||
397 finalAttribute.show_on_frontend === 1 ||
398 finalAttribute.show_on_frontend === true,
399 show_in_filters:
400 finalAttribute.show_in_filters === "1" ||
401 finalAttribute.show_in_filters === 1 ||
402 finalAttribute.show_in_filters === true,
403 filter_type: String(finalAttribute.filter_type ?? "dropdown"),
404 searchable:
405 finalAttribute.searchable === "1" ||
406 finalAttribute.searchable === 1 ||
407 finalAttribute.searchable === true,
408 status: normalizeAttributeStatusForForm(finalAttribute.status),
409 };
410
411 setFormData(convertedData);
412 });
413 }, [attribute, attributeId]);
414
415 // Handle attribute loading error
416 useEffect(() => {
417 if (attributeError) {
418 showToast(__("Failed to load attribute data", "yatra"), "error");
419 }
420 }, [attributeError, showToast]);
421
422 // Create/Update mutation
423 const saveMutation = useMutation({
424 mutationFn: async (data: AttributeFormData) => {
425 let slug = data.slug.trim();
426
427 // For new attributes, ensure slug is unique
428 if (!isEditMode) {
429 slug = await generateUniqueSlug(slug);
430 }
431
432 const needsOptionRows =
433 data.field_type === "select" ||
434 data.field_type === "radio" ||
435 data.field_type === "checkbox";
436
437 const fieldOptionsPayload = needsOptionRows
438 ? data.field_option_rows
439 .map((r) => {
440 const label = r.label.trim();
441 const value =
442 r.value.trim() || (label ? generateSlug(label) : "");
443 return { label, value };
444 })
445 .filter((r) => r.label !== "" && r.value !== "")
446 : [];
447
448 const payload: any = {
449 name: data.name.trim(),
450 slug: slug,
451 description: data.description.trim(),
452 icon: data.icon,
453 field_type: data.field_type,
454 default_value: data.default_value.trim(),
455 placeholder: data.placeholder.trim(),
456 required: Boolean(data.required),
457 validation_rules: data.validation_rules.trim(),
458 display_order: Number(data.display_order),
459 show_on_frontend: Boolean(data.show_on_frontend),
460 show_in_filters: Boolean(data.show_in_filters),
461 filter_type: data.filter_type,
462 searchable: Boolean(data.searchable),
463 status: normalizeAttributeStatusForForm(data.status),
464 };
465
466 if (needsOptionRows) {
467 payload.field_options = fieldOptionsPayload;
468 }
469
470 // Debug: Log the payload being sent
471
472 // If slug was manually edited, add flag to preserve it
473 if (isEditMode && isSlugEditable) {
474 payload.preserve_slug = true;
475 }
476
477 try {
478 if (isEditMode && attributeId) {
479 const response = await apiClient.put(
480 `/attributes/${attributeId}`,
481 payload,
482 );
483
484 return response;
485 } else {
486 const response = await apiClient.post("/attributes", payload);
487
488 return response;
489 }
490 } catch (error: any) {
491 // Handle specific API errors
492 throw error;
493 }
494 },
495 onSuccess: (response) => {
496 setIsSubmitting(false);
497 queryClient.invalidateQueries({ queryKey: ["attributes"] });
498 queryClient.invalidateQueries({ queryKey: ["attributes-stats"] });
499 if (attributeId) {
500 queryClient.invalidateQueries({ queryKey: ["attribute", attributeId] });
501 }
502 showToast(
503 isEditMode
504 ? __("Attribute updated successfully", "yatra")
505 : __("Attribute created successfully", "yatra"),
506 "success",
507 );
508
509 // Redirect logic
510 if (!isEditMode) {
511 // Redirect to edit page after creation
512
513 // Try different possible response structures
514 const newId =
515 response?.data?.id || response?.id || response?.data?.data?.id;
516
517 if (newId) {
518 setTimeout(() => {
519 window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=trips&tab=attributes&action=edit&id=${newId}`;
520 }, 1000);
521 } else {
522 console.error(
523 "AttributeForm - Could not extract ID from response for redirect",
524 );
525 }
526 }
527 },
528 onError: (error: any) => {
529 const errorMessage =
530 error?.message ||
531 __("An error occurred while saving the attribute", "yatra");
532 showToast(errorMessage, "error");
533 setIsSubmitting(false);
534 },
535 });
536
537 const handleSubmit = async (e: React.FormEvent) => {
538 e.preventDefault();
539
540 if (!validateForm()) {
541 return;
542 }
543
544 setIsSubmitting(true);
545 saveMutation.mutate(formData);
546 };
547
548 const handleCancel = () => {
549 window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=trips&tab=attributes`;
550 };
551
552 // Show loading skeleton
553 if (isLoadingAttribute) {
554 return (
555 <div className="space-y-3">
556 <div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-48 mb-2 animate-pulse"></div>
557 <div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
558 <div className="lg:col-span-2 space-y-3">
559 <Card>
560 <CardContent className="p-6 space-y-4">
561 <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-16 mb-2 animate-pulse"></div>
562 <div className="h-10 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
563 <div className="h-10 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
564 <div className="h-24 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
565 </CardContent>
566 </Card>
567 <Card>
568 <CardContent className="p-6 space-y-4">
569 <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-20 mb-2 animate-pulse"></div>
570 <div className="grid grid-cols-2 gap-4">
571 <div className="h-10 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
572 <div className="h-10 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
573 </div>
574 <div className="h-40 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
575 </CardContent>
576 </Card>
577 </div>
578 <div className="space-y-3">
579 <Card>
580 <CardContent className="p-6 space-y-4">
581 <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-12 mb-2 animate-pulse"></div>
582 <div className="h-10 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
583 </CardContent>
584 </Card>
585 <Card>
586 <CardContent className="p-6 space-y-4">
587 <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-16 mb-2 animate-pulse"></div>
588 <div className="space-y-3">
589 <div className="h-8 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
590 <div className="h-8 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
591 <div className="h-8 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
592 <div className="h-8 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
593 </div>
594 </CardContent>
595 </Card>
596 </div>
597 </div>
598 </div>
599 );
600 }
601
602 // Show error state if attribute loading failed
603 if (attributeError && isEditMode) {
604 return (
605 <div className="space-y-3">
606 <PageHeader
607 title={__("Error Loading Attribute", "yatra")}
608 description={__(
609 "Unable to load attribute data. Please try again.",
610 "yatra",
611 )}
612 actions={
613 <Button
614 variant="outline"
615 onClick={handleCancel}
616 className="flex items-center gap-2"
617 >
618 <ArrowLeft className="w-4 h-4" />
619 {__("Back", "yatra")}
620 </Button>
621 }
622 />
623 <Card>
624 <CardContent className="p-6 text-center">
625 <div className="text-red-500 mb-4">
626 {__(
627 "Failed to load attribute data. The attribute may not exist or there might be a server error.",
628 "yatra",
629 )}
630 </div>
631 <Button variant="outline" onClick={() => window.location.reload()}>
632 {__("Try Again", "yatra")}
633 </Button>
634 </CardContent>
635 </Card>
636 </div>
637 );
638 }
639
640 return (
641 <div className="space-y-3">
642 <PageHeader
643 title={
644 isEditMode
645 ? __("Edit Attribute", "yatra")
646 : __("Add New Attribute", "yatra")
647 }
648 description={
649 isEditMode
650 ? __("Update attribute information", "yatra")
651 : __("Create a new trip attribute", "yatra")
652 }
653 actions={
654 <Button
655 variant="outline"
656 onClick={handleCancel}
657 className="flex items-center gap-2"
658 >
659 <ArrowLeft className="w-4 h-4" />
660 {__("Back", "yatra")}
661 </Button>
662 }
663 />
664
665 <ConditionalRender capability="yatra_edit_trips">
666 <form onSubmit={handleSubmit}>
667 <div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
668 {/* Main Form Fields */}
669 <div className="lg:col-span-2 space-y-3">
670 {/* Basic Information */}
671 <Card>
672 <CardHeader className="pb-2">
673 <CardTitle className="text-base">
674 {__("Basic Information", "yatra")}
675 </CardTitle>
676 </CardHeader>
677 <CardContent className="space-y-3">
678 {/* Name */}
679 <div>
680 <label
681 htmlFor="name"
682 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
683 >
684 {__("Name", "yatra")}{" "}
685 <span className="text-red-500">*</span>
686 </label>
687 <Input
688 id="name"
689 value={formData.name}
690 onChange={(e) =>
691 handleFieldChange("name", e.target.value)
692 }
693 placeholder={__("Enter attribute name", "yatra")}
694 className={errors.name ? "border-red-500" : ""}
695 />
696 {errors.name && (
697 <p className="mt-1 text-sm text-red-500">{errors.name}</p>
698 )}
699 </div>
700
701 {/* Slug */}
702 <div>
703 <label
704 htmlFor="slug"
705 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
706 >
707 {__("Slug", "yatra")}
708 </label>
709 <div className="flex gap-2">
710 <Input
711 id="slug"
712 value={formData.slug}
713 onChange={(e) =>
714 handleFieldChange("slug", e.target.value)
715 }
716 placeholder={__("attribute-slug", "yatra")}
717 disabled={!isSlugEditable}
718 className={`flex-1 ${errors.slug ? "border-red-500" : ""}`}
719 />
720 <Button
721 type="button"
722 variant="outline"
723 size="sm"
724 onClick={toggleSlugEdit}
725 className="px-3 py-2"
726 >
727 {isSlugEditable ? (
728 <Save className="w-4 h-4" />
729 ) : (
730 <Edit2 className="w-4 h-4" />
731 )}
732 </Button>
733 </div>
734 {isSlugEditable ? (
735 <p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
736 {__(
737 "Click the save icon to preserve your custom slug.",
738 "yatra",
739 )}
740 </p>
741 ) : (
742 <p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
743 {__(
744 "Auto-generated from name. Click edit icon to customize.",
745 "yatra",
746 )}
747 </p>
748 )}
749 </div>
750
751 {/* Description */}
752 <div>
753 <label
754 htmlFor="description"
755 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
756 >
757 {__("Description", "yatra")}
758 </label>
759 <textarea
760 id="description"
761 value={formData.description}
762 onChange={(e) =>
763 handleFieldChange("description", e.target.value)
764 }
765 placeholder={__("Enter attribute description", "yatra")}
766 rows={6}
767 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"
768 />
769 </div>
770 </CardContent>
771 </Card>
772
773 {/* Field Configuration */}
774 <Card>
775 <CardHeader className="pb-2">
776 <CardTitle className="text-base">
777 {__("Field Configuration", "yatra")}
778 </CardTitle>
779 </CardHeader>
780 <CardContent className="space-y-3">
781 <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
782 {/* Field Type */}
783 <div>
784 <label
785 htmlFor="field_type"
786 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
787 >
788 {__("Field Type", "yatra")}{" "}
789 <span className="text-red-500">*</span>
790 </label>
791 <Select
792 id="field_type"
793 value={formData.field_type}
794 onChange={(e) =>
795 handleFieldChange("field_type", e.target.value)
796 }
797 className={errors.field_type ? "border-red-500" : ""}
798 >
799 {fieldTypeOptions.map((option) => (
800 <option key={option.value} value={option.value}>
801 {option.label}
802 </option>
803 ))}
804 </Select>
805 {errors.field_type && (
806 <p className="mt-1 text-sm text-red-500">
807 {errors.field_type}
808 </p>
809 )}
810 </div>
811
812 {/* Display Order */}
813 <div>
814 <label
815 htmlFor="display_order"
816 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
817 >
818 {__("Display Order", "yatra")}
819 </label>
820 <Input
821 id="display_order"
822 type="number"
823 value={formData.display_order}
824 onChange={(e) =>
825 handleFieldChange(
826 "display_order",
827 parseInt(e.target.value) || 0,
828 )
829 }
830 placeholder="0"
831 />
832 </div>
833 </div>
834
835 {/* Field Options for select/radio/checkbox — repeater (label / value) */}
836 {(formData.field_type === "select" ||
837 formData.field_type === "radio" ||
838 formData.field_type === "checkbox") && (
839 <div>
840 <div className="flex items-center justify-between mb-1.5">
841 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
842 {__("Field Options", "yatra")}{" "}
843 <span className="text-red-500">*</span>
844 </label>
845 <Button
846 type="button"
847 variant="outline"
848 size="sm"
849 className="gap-1 h-8"
850 onClick={addOptionRow}
851 >
852 <Plus className="w-3.5 h-3.5" />
853 {__("Add option", "yatra")}
854 </Button>
855 </div>
856 <p className="text-xs text-gray-500 dark:text-gray-400 mb-2">
857 {__(
858 "Each row is one choice. Leave value empty to auto-generate a slug from the label.",
859 "yatra",
860 )}
861 </p>
862 <div
863 className={`space-y-2 rounded-md border p-3 ${errors.field_options ? "border-red-500" : "border-gray-200 dark:border-gray-600"}`}
864 >
865 {(formData.field_option_rows.length > 0
866 ? formData.field_option_rows
867 : [{ label: "", value: "" }]
868 ).map((row, index) => (
869 <div
870 key={index}
871 className="flex flex-col sm:flex-row gap-2 sm:items-end"
872 >
873 <div className="flex-1 min-w-0">
874 <label className="text-xs text-gray-500 dark:text-gray-400 block mb-1">
875 {__("Label", "yatra")}
876 </label>
877 <Input
878 value={row.label}
879 onChange={(e) => {
880 if (formData.field_option_rows.length === 0) {
881 setFormData((prev) => ({
882 ...prev,
883 field_option_rows: [
884 {
885 label: e.target.value,
886 value: "",
887 },
888 ],
889 }));
890 } else {
891 updateOptionRow(
892 index,
893 "label",
894 e.target.value,
895 );
896 }
897 }}
898 placeholder={__("Display name", "yatra")}
899 />
900 </div>
901 <div className="flex-1 min-w-0">
902 <label className="text-xs text-gray-500 dark:text-gray-400 block mb-1">
903 {__("Value", "yatra")}
904 </label>
905 <Input
906 value={row.value}
907 onChange={(e) => {
908 if (formData.field_option_rows.length === 0) {
909 setFormData((prev) => ({
910 ...prev,
911 field_option_rows: [
912 {
913 label: "",
914 value: e.target.value,
915 },
916 ],
917 }));
918 } else {
919 updateOptionRow(
920 index,
921 "value",
922 e.target.value,
923 );
924 }
925 }}
926 placeholder={__(
927 "Stored value (optional)",
928 "yatra",
929 )}
930 />
931 </div>
932 <Button
933 type="button"
934 variant="ghost"
935 size="sm"
936 className="text-red-600 hover:text-red-700 shrink-0"
937 onClick={() => removeOptionRow(index)}
938 disabled={formData.field_option_rows.length <= 1}
939 aria-label={__("Remove option", "yatra")}
940 >
941 <Trash2 className="w-4 h-4" />
942 </Button>
943 </div>
944 ))}
945 </div>
946 {errors.field_options && (
947 <p className="mt-1 text-sm text-red-500">
948 {errors.field_options}
949 </p>
950 )}
951 </div>
952 )}
953
954 {/* Default Value */}
955 <div>
956 <label
957 htmlFor="default_value"
958 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
959 >
960 {__("Default Value", "yatra")}
961 </label>
962 <Input
963 id="default_value"
964 value={formData.default_value}
965 onChange={(e) =>
966 handleFieldChange("default_value", e.target.value)
967 }
968 placeholder={__("Default value for the field", "yatra")}
969 />
970 </div>
971
972 {/* Placeholder */}
973 <div>
974 <label
975 htmlFor="placeholder"
976 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
977 >
978 {__("Placeholder", "yatra")}
979 </label>
980 <Input
981 id="placeholder"
982 value={formData.placeholder}
983 onChange={(e) =>
984 handleFieldChange("placeholder", e.target.value)
985 }
986 placeholder={__(
987 "Placeholder text for input field",
988 "yatra",
989 )}
990 />
991 </div>
992 </CardContent>
993 </Card>
994 </div>
995
996 {/* Sidebar */}
997 <div className="space-y-3">
998 {/* Status */}
999 <Card>
1000 <CardHeader className="pb-2">
1001 <CardTitle className="text-base">
1002 {__("Status", "yatra")}
1003 </CardTitle>
1004 </CardHeader>
1005 <CardContent className="space-y-3">
1006 <div>
1007 <label
1008 htmlFor="status"
1009 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
1010 >
1011 {__("Status", "yatra")}
1012 </label>
1013 <Select
1014 id="status"
1015 value={formData.status}
1016 onChange={(e) =>
1017 handleFieldChange("status", e.target.value)
1018 }
1019 >
1020 <option value="draft">{__("Draft", "yatra")}</option>
1021 <option value="publish">{__("Publish", "yatra")}</option>
1022 <option value="trash">{__("Trash", "yatra")}</option>
1023 </Select>
1024 </div>
1025 </CardContent>
1026 </Card>
1027
1028 {/* Field Options */}
1029 <Card>
1030 <CardHeader className="pb-2">
1031 <CardTitle className="text-base">
1032 {__("Field Options", "yatra")}
1033 </CardTitle>
1034 </CardHeader>
1035 <CardContent className="space-y-3">
1036 <div className="flex items-center justify-between">
1037 <label
1038 htmlFor="show_on_frontend"
1039 className="text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer"
1040 >
1041 {__("Show on Frontend", "yatra")}
1042 </label>
1043 <Switch
1044 checked={formData.show_on_frontend}
1045 onCheckedChange={(checked) =>
1046 handleFieldChange("show_on_frontend", checked)
1047 }
1048 />
1049 </div>
1050
1051 <div className="flex items-center justify-between">
1052 <label
1053 htmlFor="show_in_filters"
1054 className="text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer"
1055 >
1056 {__("Show in Filters", "yatra")}
1057 </label>
1058 <Switch
1059 checked={formData.show_in_filters}
1060 onCheckedChange={(checked) =>
1061 handleFieldChange("show_in_filters", checked)
1062 }
1063 />
1064 </div>
1065
1066 <div className="flex items-center justify-between">
1067 <label
1068 htmlFor="searchable"
1069 className="text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer"
1070 >
1071 {__("Searchable", "yatra")}
1072 </label>
1073 <Switch
1074 checked={formData.searchable}
1075 onCheckedChange={(checked) =>
1076 handleFieldChange("searchable", checked)
1077 }
1078 />
1079 </div>
1080
1081 <div className="flex items-center justify-between">
1082 <label
1083 htmlFor="required"
1084 className="text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer"
1085 >
1086 {__("Required Field", "yatra")}
1087 </label>
1088 <Switch
1089 checked={formData.required}
1090 onCheckedChange={(checked) =>
1091 handleFieldChange("required", checked)
1092 }
1093 />
1094 </div>
1095 </CardContent>
1096 </Card>
1097
1098 {/* Icon/Image Picker */}
1099 <Card>
1100 <CardHeader className="pb-2">
1101 <CardTitle className="text-base">
1102 {__("Attribute Icon", "yatra")}
1103 </CardTitle>
1104 </CardHeader>
1105 <CardContent>
1106 <IconPicker
1107 value={formData.icon}
1108 onChange={(value) => handleFieldChange("icon", value)}
1109 label={__("Icon or Image", "yatra")}
1110 helpText={__(
1111 "Select an icon from the library or upload a custom image for this attribute.",
1112 "yatra",
1113 )}
1114 allowImageUpload={true}
1115 allowIconSelection={true}
1116 size="md"
1117 />
1118 </CardContent>
1119 </Card>
1120
1121 {/* Submit Actions */}
1122 <Card>
1123 <CardContent className="p-3">
1124 <div className="space-y-2">
1125 <div className="flex gap-2">
1126 <Button
1127 type="submit"
1128 disabled={isSubmitting}
1129 className="flex-1 flex items-center justify-center gap-2"
1130 >
1131 {isSubmitting ? (
1132 <>
1133 <Loader2 className="w-4 h-4 animate-spin" />
1134 {__("Saving...", "yatra")}
1135 </>
1136 ) : (
1137 <>
1138 <Save className="w-4 h-4" />
1139 {isEditMode
1140 ? __("Update Attribute", "yatra")
1141 : __("Create Attribute", "yatra")}
1142 </>
1143 )}
1144 </Button>
1145 <Button
1146 type="button"
1147 variant="outline"
1148 onClick={handleCancel}
1149 disabled={isSubmitting}
1150 >
1151 {__("Cancel", "yatra")}
1152 </Button>
1153 </div>
1154 </div>
1155 </CardContent>
1156 </Card>
1157 </div>
1158 </div>
1159 </form>
1160 </ConditionalRender>
1161 </div>
1162 );
1163 };
1164
1165 export default AttributeForm;
1166