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 / ItemForm.tsx

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

506 lines 17.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Item Form Page (Item Subtype)
3 * Add/Edit Item form
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, X } from "lucide-react";
9 import { __ } from "../lib/i18n";
10 import { usePermissions } from "../hooks/usePermissions";
11 import { useToast } from "../components/ui/toast";
12 import { apiClient } from "../lib/api-client";
13 import { generateSlug } from "../lib/slug";
14 import { Button } from "../components/ui/button";
15 import { Input } from "../components/ui/input";
16 import { Select } from "../components/ui/select";
17 import { SearchableSelect } from "../components/ui/searchable-select";
18 import { PageHeader } from "../components/common/PageHeader";
19 import {
20 Card,
21 CardContent,
22 CardHeader,
23 CardTitle,
24 } from "../components/ui/card";
25 import { ConditionalRender } from "../components/ui/conditional-render";
26
27 interface ItemFormData {
28 name: string;
29 slug: string;
30 description: string;
31 type_id: string;
32 status: string;
33 }
34
35 const ItemForm: React.FC = () => {
36 const queryClient = useQueryClient();
37 const { can } = usePermissions();
38 const { showToast } = useToast();
39 const [formData, setFormData] = useState<ItemFormData>({
40 name: "",
41 slug: "",
42 description: "",
43 type_id: "",
44 status: "publish",
45 });
46 const [errors, setErrors] = useState<Record<string, string>>({});
47 const [isSubmitting, setIsSubmitting] = useState(false);
48 const [isSlugEditable, setIsSlugEditable] = useState(false);
49
50 const action = useMemo(() => {
51 const params = new URLSearchParams(window.location.search);
52 return params.get("action") || "create";
53 }, []);
54
55 const itemId = useMemo(() => {
56 const params = new URLSearchParams(window.location.search);
57 return params.get("id") ? parseInt(params.get("id") || "0") : null;
58 }, []);
59
60 const isEditMode = action === "edit" && itemId !== null;
61
62 // Fetch item types - only published ones are usable
63 const { data: typesData } = useQuery({
64 queryKey: ["item-types-published"],
65 queryFn: async () => {
66 try {
67 const response = await apiClient.get("/item-types", {
68 params: {
69 per_page: 100,
70 status: "publish", // Only get published item types
71 },
72 });
73 return response.data || [];
74 } catch (error: any) {
75 showToast(
76 error?.message || __("Failed to load item types", "yatra"),
77 "error",
78 );
79 return [];
80 }
81 },
82 enabled: can("yatra_view_trips"),
83 });
84
85 const { data: itemData, isLoading: isLoadingItem } = useQuery({
86 queryKey: ["item", itemId],
87 queryFn: async () => {
88 if (!itemId) return null;
89 try {
90 const response = await apiClient.get(`/items/${itemId}`);
91 return response;
92 } catch (error: any) {
93 showToast(
94 error?.message || __("Failed to load item", "yatra"),
95 "error",
96 );
97 throw error;
98 }
99 },
100 enabled: isEditMode && can("yatra_view_trips"),
101 });
102
103 useEffect(() => {
104 if (itemData && isEditMode) {
105 setFormData({
106 name: itemData.name || "",
107 slug: itemData.slug || "",
108 description: itemData.description || "",
109 type_id: itemData.type_id?.toString() || "",
110 status: itemData.status || "publish",
111 });
112 }
113 }, [itemData, isEditMode]);
114
115 const handleNameChange = (value: string) => {
116 // Auto-generate slug from name only in ADD mode (not in EDIT mode)
117 // In EDIT mode, slug only changes if user explicitly edits it
118 if (!isEditMode && !isSlugEditable) {
119 const newSlug = generateSlug(value);
120 setFormData((prev) => ({
121 ...prev,
122 name: value,
123 slug: newSlug,
124 }));
125 } else {
126 setFormData((prev) => ({
127 ...prev,
128 name: value,
129 }));
130 }
131 if (errors.name) {
132 setErrors((prev) => ({ ...prev, name: "" }));
133 }
134 };
135
136 const handleSlugChange = (value: string) => {
137 // Only allow manual slug editing if edit mode is enabled
138 if (isSlugEditable) {
139 setFormData((prev) => ({ ...prev, slug: value }));
140 if (errors.slug) {
141 setErrors((prev) => ({ ...prev, slug: "" }));
142 }
143 }
144 };
145
146 const handleToggleSlugEdit = () => {
147 if (isSlugEditable) {
148 // If disabling edit, regenerate slug from name
149 const newSlug = generateSlug(formData.name);
150 setFormData((prev) => ({ ...prev, slug: newSlug }));
151 }
152 setIsSlugEditable(!isSlugEditable);
153 };
154
155 const handleFieldChange = (field: keyof ItemFormData, value: string) => {
156 setFormData((prev) => ({ ...prev, [field]: value }));
157 if (errors[field]) {
158 setErrors((prev) => ({ ...prev, [field]: "" }));
159 }
160 };
161
162 const validateForm = (): boolean => {
163 const newErrors: Record<string, string> = {};
164
165 if (!formData.name.trim()) {
166 newErrors.name = __("Name is required", "yatra");
167 }
168
169 if (!formData.slug.trim()) {
170 newErrors.slug = __("Slug is required", "yatra");
171 } else if (!/^[\p{L}\p{N}-]+$/u.test(formData.slug)) {
172 newErrors.slug = __(
173 "Slug can only contain letters, numbers, and hyphens",
174 "yatra",
175 );
176 }
177
178 if (!formData.type_id) {
179 newErrors.type_id = __("Item type is required", "yatra");
180 }
181
182 setErrors(newErrors);
183 return Object.keys(newErrors).length === 0;
184 };
185
186 const saveMutation = useMutation({
187 mutationFn: async (data: ItemFormData) => {
188 const payload: any = {
189 name: data.name.trim(),
190 slug: data.slug.trim(),
191 description: data.description.trim(),
192 type_id: parseInt(data.type_id),
193 status: data.status,
194 };
195
196 // If slug was manually edited, add flag to preserve it
197 if (isEditMode && isSlugEditable) {
198 payload.preserve_slug = true;
199 }
200
201 if (isEditMode && itemId) {
202 return await apiClient.put(`/items/${itemId}`, payload);
203 } else {
204 return await apiClient.post("/items", payload);
205 }
206 },
207 onSuccess: (response) => {
208 queryClient.invalidateQueries({ queryKey: ["items"] });
209 queryClient.invalidateQueries({ queryKey: ["item", itemId] });
210 showToast(
211 isEditMode
212 ? __("Item updated successfully", "yatra")
213 : __("Item created successfully", "yatra"),
214 "success",
215 );
216
217 // Handle redirect based on backend response
218 if (response?.redirect) {
219 setTimeout(() => {
220 window.location.href = response.redirect_to;
221 }, 1000);
222 } else if (response?.stay_on_page) {
223 // Stay on current page for updates
224 setIsSubmitting(false);
225 } else {
226 // Default fallback - redirect to items list
227 setTimeout(() => {
228 window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=itinerary&tab=items`;
229 }, 1000);
230 }
231 },
232 onError: (error: any) => {
233 const errorMessage =
234 error?.message ||
235 __("An error occurred while saving the item", "yatra");
236 showToast(errorMessage, "error");
237 setIsSubmitting(false);
238 },
239 });
240
241 const handleSubmit = async (e: React.FormEvent) => {
242 e.preventDefault();
243
244 if (!validateForm()) {
245 showToast(__("Please fix the form errors", "yatra"), "warning");
246 return;
247 }
248
249 setIsSubmitting(true);
250 setErrors({});
251 saveMutation.mutate(formData);
252 };
253
254 const handleCancel = () => {
255 window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=itinerary&tab=items`;
256 };
257
258 if (isEditMode && isLoadingItem) {
259 return (
260 <div className="flex items-center justify-center p-8">
261 <Loader2 className="w-6 h-6 animate-spin text-gray-400" />
262 <span className="ml-2 text-gray-600 dark:text-gray-400">
263 {__("Loading...", "yatra")}
264 </span>
265 </div>
266 );
267 }
268
269 const types = typesData || [];
270
271 return (
272 <div className="space-y-3">
273 <PageHeader
274 title={
275 isEditMode ? __("Edit Item", "yatra") : __("Add New Item", "yatra")
276 }
277 description={
278 isEditMode
279 ? __("Update item information", "yatra")
280 : __(
281 "Create a specific item under an item type. Examples: Hiking (under Activity), Lunch (under Meal).",
282 "yatra",
283 )
284 }
285 actions={
286 <Button
287 variant="outline"
288 onClick={handleCancel}
289 className="flex items-center gap-2"
290 >
291 <ArrowLeft className="w-4 h-4" />
292 {__("Back", "yatra")}
293 </Button>
294 }
295 />
296
297 <ConditionalRender capability="yatra_edit_trips">
298 <form onSubmit={handleSubmit}>
299 <div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
300 <div className="lg:col-span-2 space-y-3">
301 <Card>
302 <CardHeader className="pb-2">
303 <CardTitle className="text-base">
304 {__("Basic Information", "yatra")}
305 </CardTitle>
306 </CardHeader>
307 <CardContent className="space-y-3">
308 <div>
309 <label
310 htmlFor="type_id"
311 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
312 >
313 {__("Item Type", "yatra")}{" "}
314 <span className="text-red-500">*</span>
315 </label>
316 <SearchableSelect
317 value={formData.type_id}
318 onChange={(value) => handleFieldChange("type_id", value)}
319 options={types.map((type: any) => ({
320 value: type.id.toString(),
321 label: type.name,
322 icon: type.icon,
323 }))}
324 placeholder={__("Select a type...", "yatra")}
325 searchPlaceholder={__("Search item types...", "yatra")}
326 className={errors.type_id ? "border-red-500" : ""}
327 error={!!errors.type_id}
328 />
329 {errors.type_id && (
330 <p className="mt-1 text-sm text-red-500">
331 {errors.type_id}
332 </p>
333 )}
334 </div>
335
336 <div>
337 <label
338 htmlFor="name"
339 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
340 >
341 {__("Item Name", "yatra")}{" "}
342 <span className="text-red-500">*</span>
343 </label>
344 <Input
345 id="name"
346 type="text"
347 value={formData.name}
348 onChange={(e) => handleNameChange(e.target.value)}
349 placeholder={__("e.g., Hiking", "yatra")}
350 className={errors.name ? "border-red-500" : ""}
351 required
352 />
353 {errors.name && (
354 <p className="mt-1 text-sm text-red-500">{errors.name}</p>
355 )}
356 </div>
357
358 <div>
359 <label
360 htmlFor="slug"
361 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
362 >
363 {__("Slug", "yatra")}{" "}
364 <span className="text-red-500">*</span>
365 </label>
366 <div className="relative">
367 <Input
368 id="slug"
369 type="text"
370 value={formData.slug}
371 onChange={(e) => handleSlugChange(e.target.value)}
372 placeholder={__("item-slug", "yatra")}
373 className={`pr-10 ${errors.slug ? "border-red-500" : ""} ${!isSlugEditable ? "bg-gray-50 dark:bg-gray-800 cursor-not-allowed" : ""}`}
374 disabled={!isSlugEditable}
375 required
376 />
377 <button
378 type="button"
379 onClick={handleToggleSlugEdit}
380 className="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors rounded"
381 aria-label={
382 isSlugEditable
383 ? __("Cancel editing slug", "yatra")
384 : __("Edit slug", "yatra")
385 }
386 >
387 {isSlugEditable ? (
388 <X className="w-4 h-4" />
389 ) : (
390 <Edit2 className="w-4 h-4" />
391 )}
392 </button>
393 </div>
394 {errors.slug && (
395 <p className="mt-1 text-sm text-red-500">{errors.slug}</p>
396 )}
397 <p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
398 {isSlugEditable
399 ? __(
400 "Manually editing slug. Click X to cancel and regenerate from name.",
401 "yatra",
402 )
403 : __(
404 "Auto-generated from name. Click edit icon to customize.",
405 "yatra",
406 )}
407 </p>
408 </div>
409
410 <div>
411 <label
412 htmlFor="description"
413 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
414 >
415 {__("Description", "yatra")}
416 </label>
417 <textarea
418 id="description"
419 value={formData.description}
420 onChange={(e) =>
421 handleFieldChange("description", e.target.value)
422 }
423 placeholder={__("Describe this item...", "yatra")}
424 rows={4}
425 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"
426 />
427 </div>
428 </CardContent>
429 </Card>
430 </div>
431
432 <div className="space-y-3">
433 <Card>
434 <CardHeader className="pb-2">
435 <CardTitle className="text-base">
436 {__("Status", "yatra")}
437 </CardTitle>
438 </CardHeader>
439 <CardContent className="space-y-3">
440 <div>
441 <label
442 htmlFor="status"
443 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
444 >
445 {__("Item Status", "yatra")}
446 </label>
447 <Select
448 id="status"
449 value={formData.status}
450 onChange={(e) =>
451 handleFieldChange("status", e.target.value)
452 }
453 >
454 <option value="publish">{__("Publish", "yatra")}</option>
455 <option value="draft">{__("Draft", "yatra")}</option>
456 <option value="trash">{__("Trash", "yatra")}</option>
457 </Select>
458 </div>
459 </CardContent>
460 </Card>
461
462 <Card>
463 <CardContent className="p-3">
464 <div className="space-y-2">
465 <div className="flex gap-2">
466 <Button
467 type="submit"
468 disabled={isSubmitting}
469 className="flex-1 flex items-center justify-center gap-2"
470 >
471 {isSubmitting ? (
472 <>
473 <Loader2 className="w-4 h-4 animate-spin" />
474 {__("Saving...", "yatra")}
475 </>
476 ) : (
477 <>
478 <Save className="w-4 h-4" />
479 {isEditMode
480 ? __("Update Item", "yatra")
481 : __("Create Item", "yatra")}
482 </>
483 )}
484 </Button>
485 <Button
486 type="button"
487 variant="outline"
488 onClick={handleCancel}
489 disabled={isSubmitting}
490 >
491 {__("Cancel", "yatra")}
492 </Button>
493 </div>
494 </div>
495 </CardContent>
496 </Card>
497 </div>
498 </div>
499 </form>
500 </ConditionalRender>
501 </div>
502 );
503 };
504
505 export default ItemForm;
506