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

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

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