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

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

539 lines 19.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Difficulty Level Form Page
3 * Add/Edit trip difficulty level
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 { 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 { IconPicker, IconPickerValue } from "../components/ui/icon-picker";
26 import { RichTextEditor } from "../components/ui/rich-text-editor";
27
28 interface DifficultyLevelFormData {
29 name: string;
30 slug: string;
31 description: string;
32 icon: IconPickerValue | null;
33 sorting: number | "";
34 status: string;
35 }
36
37 const DifficultyLevelForm: React.FC = () => {
38 const queryClient = useQueryClient();
39 const { can } = usePermissions();
40 const { showToast } = useToast();
41 const [formData, setFormData] = useState<DifficultyLevelFormData>({
42 name: "",
43 slug: "",
44 description: "",
45 icon: null,
46 sorting: "",
47 status: "publish",
48 });
49 const [errors, setErrors] = useState<Record<string, string>>({});
50 const [isSubmitting, setIsSubmitting] = useState(false);
51 const [isSlugEditable, setIsSlugEditable] = useState(false);
52
53 const action = useMemo(() => {
54 const params = new URLSearchParams(window.location.search);
55 return params.get("action") || "create";
56 }, []);
57
58 const levelId = useMemo(() => {
59 const params = new URLSearchParams(window.location.search);
60 return params.get("id") ? parseInt(params.get("id") || "0", 10) : null;
61 }, []);
62
63 const isEditMode = action === "edit" && levelId !== null;
64
65 const { data: levelData, isLoading: isLoadingLevel } = useQuery({
66 queryKey: ["difficulty-level", levelId],
67 queryFn: async () => {
68 if (!levelId) return null;
69 try {
70 const response = await apiClient.get(`/difficulty-levels/${levelId}`);
71 return response;
72 } catch (error: any) {
73 showToast(
74 error?.message || __("Failed to load difficulty level", "yatra"),
75 "error",
76 );
77 throw error;
78 }
79 },
80 enabled: isEditMode && can("yatra_view_trips"),
81 });
82
83 useEffect(() => {
84 if (levelData && isEditMode) {
85 setFormData({
86 name: levelData.name || "",
87 slug: levelData.slug || "",
88 description: levelData.description || "",
89 icon: (levelData.icon as IconPickerValue) || null,
90 sorting: typeof levelData.sorting === "number" ? levelData.sorting : "",
91 status: levelData.status || "publish",
92 });
93 }
94 }, [levelData, isEditMode]);
95
96 const handleNameChange = (value: string) => {
97 // Only auto-generate slug on create; in edit mode, keep existing slug
98 if (!isEditMode && !isSlugEditable) {
99 const newSlug = generateSlug(value);
100 setFormData((prev) => ({
101 ...prev,
102 name: value,
103 slug: newSlug,
104 }));
105 } else {
106 setFormData((prev) => ({ ...prev, name: value }));
107 }
108 if (errors.name) {
109 setErrors((prev) => ({ ...prev, name: "" }));
110 }
111 };
112
113 const handleSlugChange = (value: string) => {
114 if (isSlugEditable) {
115 setFormData((prev) => ({ ...prev, slug: value }));
116 if (errors.slug) {
117 setErrors((prev) => ({ ...prev, slug: "" }));
118 }
119 }
120 };
121
122 const handleToggleSlugEdit = () => {
123 if (isSlugEditable) {
124 const newSlug = generateSlug(formData.name);
125 setFormData((prev) => ({ ...prev, slug: newSlug }));
126 }
127 setIsSlugEditable(!isSlugEditable);
128 };
129
130 const handleFieldChange = (
131 field: keyof DifficultyLevelFormData,
132 value: any,
133 ) => {
134 setFormData((prev) => ({ ...prev, [field]: value }));
135 if (errors[field]) {
136 setErrors((prev) => ({ ...prev, [field]: "" }));
137 }
138 };
139
140 const validateForm = (): boolean => {
141 const newErrors: Record<string, string> = {};
142
143 if (!formData.name.trim()) {
144 newErrors.name = __("Name is required", "yatra");
145 }
146
147 if (!formData.slug.trim()) {
148 newErrors.slug = __("Slug is required", "yatra");
149 } else if (!/^[\p{L}\p{N}-]+$/u.test(formData.slug)) {
150 newErrors.slug = __(
151 "Slug can only contain letters, numbers, and hyphens",
152 "yatra",
153 );
154 }
155
156 if (formData.sorting !== "" && Number(formData.sorting) < 0) {
157 newErrors.sorting = __("Order must be a positive number", "yatra");
158 }
159
160 setErrors(newErrors);
161 return Object.keys(newErrors).length === 0;
162 };
163
164 const saveMutation = useMutation({
165 mutationFn: async (data: DifficultyLevelFormData) => {
166 const payload: any = {
167 name: data.name.trim(),
168 slug: data.slug.trim(),
169 description: data.description,
170 icon: data.icon,
171 sorting: data.sorting === "" ? null : Number(data.sorting),
172 status: data.status,
173 };
174
175 if (isEditMode && isSlugEditable) {
176 payload.preserve_slug = true;
177 }
178
179 if (isEditMode && levelId) {
180 return await apiClient.put(`/difficulty-levels/${levelId}`, payload);
181 }
182 return await apiClient.post("/difficulty-levels", payload);
183 },
184 onSuccess: (response) => {
185 queryClient.invalidateQueries({ queryKey: ["difficulty-levels"] });
186 queryClient.invalidateQueries({
187 queryKey: ["difficulty-level", levelId],
188 });
189 showToast(
190 isEditMode
191 ? __("Difficulty level updated successfully", "yatra")
192 : __("Difficulty level created successfully", "yatra"),
193 "success",
194 );
195 setIsSubmitting(false);
196
197 if (!isEditMode) {
198 const newId = response?.id;
199 if (newId) {
200 window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=trips&tab=difficulty-levels&action=edit&id=${newId}`;
201 } else {
202 window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=trips&tab=difficulty-levels`;
203 }
204 }
205 },
206 onError: (error: any) => {
207 const errorMessage =
208 error?.message ||
209 __("An error occurred while saving the difficulty level", "yatra");
210 showToast(errorMessage, "error");
211 setIsSubmitting(false);
212 },
213 });
214
215 const handleSubmit = (e: React.FormEvent) => {
216 e.preventDefault();
217
218 if (!validateForm()) {
219 showToast(__("Please fix the form errors", "yatra"), "warning");
220 return;
221 }
222
223 setIsSubmitting(true);
224 setErrors({});
225 saveMutation.mutate(formData);
226 };
227
228 const handleCancel = () => {
229 window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=trips&tab=difficulty-levels`;
230 };
231
232 if (isEditMode && isLoadingLevel) {
233 return (
234 <div className="space-y-3">
235 {/* Header Skeleton */}
236 <div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-6">
237 <div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-1/4 mb-2 animate-pulse"></div>
238 <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-1/2 animate-pulse"></div>
239 </div>
240
241 {/* Form Skeleton */}
242 <div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
243 {/* Main Fields */}
244 <div className="lg:col-span-2 space-y-3">
245 <Card>
246 <CardContent className="p-6 space-y-4">
247 {[1, 2, 3].map((_, idx) => (
248 <div key={idx}>
249 <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-24 mb-2 animate-pulse"></div>
250 <div className="h-10 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
251 </div>
252 ))}
253 <div>
254 <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-32 mb-2 animate-pulse"></div>
255 <div className="h-32 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
256 </div>
257 </CardContent>
258 </Card>
259 </div>
260
261 {/* Sidebar */}
262 <div className="space-y-3">
263 {[1, 2, 3].map((_, idx) => (
264 <Card key={idx}>
265 <CardContent className="p-6 space-y-4">
266 <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-24 mb-2 animate-pulse"></div>
267 <div className="h-10 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
268 </CardContent>
269 </Card>
270 ))}
271 </div>
272 </div>
273 </div>
274 );
275 }
276
277 return (
278 <div className="space-y-3">
279 <PageHeader
280 title={
281 isEditMode
282 ? __("Edit Difficulty Level", "yatra")
283 : __("Add Difficulty Level", "yatra")
284 }
285 description={
286 isEditMode
287 ? __("Update difficulty level information", "yatra")
288 : __("Create a new trip difficulty level", "yatra")
289 }
290 actions={
291 <Button
292 variant="outline"
293 onClick={handleCancel}
294 className="flex items-center gap-2"
295 >
296 <ArrowLeft className="w-4 h-4" />
297 {__("Back", "yatra")}
298 </Button>
299 }
300 />
301
302 <ConditionalRender capability="yatra_edit_trips">
303 <form onSubmit={handleSubmit}>
304 <div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
305 <div className="lg:col-span-2 space-y-3">
306 <Card>
307 <CardHeader className="pb-2">
308 <CardTitle className="text-base">
309 {__("Basic Information", "yatra")}
310 </CardTitle>
311 </CardHeader>
312 <CardContent className="space-y-3">
313 <div>
314 <label
315 htmlFor="name"
316 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
317 >
318 {__("Name", "yatra")}{" "}
319 <span className="text-red-500">*</span>
320 </label>
321 <Input
322 id="name"
323 type="text"
324 value={formData.name}
325 onChange={(e) => handleNameChange(e.target.value)}
326 placeholder={__("Enter difficulty level name", "yatra")}
327 className={errors.name ? "border-red-500" : ""}
328 required
329 />
330 {errors.name && (
331 <p className="mt-1 text-sm text-red-500">{errors.name}</p>
332 )}
333 </div>
334
335 <div>
336 <label
337 htmlFor="slug"
338 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
339 >
340 {__("Slug", "yatra")}{" "}
341 <span className="text-red-500">*</span>
342 </label>
343 <div className="relative">
344 <Input
345 id="slug"
346 type="text"
347 value={formData.slug}
348 onChange={(e) => handleSlugChange(e.target.value)}
349 placeholder={__("difficulty-slug", "yatra")}
350 className={`pr-10 ${errors.slug ? "border-red-500" : ""} ${!isSlugEditable ? "bg-gray-50 dark:bg-gray-800 cursor-not-allowed" : ""}`}
351 disabled={!isSlugEditable}
352 required
353 />
354 <button
355 type="button"
356 onClick={handleToggleSlugEdit}
357 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"
358 aria-label={
359 isSlugEditable
360 ? __("Cancel editing slug", "yatra")
361 : __("Edit slug", "yatra")
362 }
363 >
364 {isSlugEditable ? (
365 <X className="w-4 h-4" />
366 ) : (
367 <Edit2 className="w-4 h-4" />
368 )}
369 </button>
370 </div>
371 {errors.slug && (
372 <p className="mt-1 text-sm text-red-500">{errors.slug}</p>
373 )}
374 <p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
375 {isSlugEditable
376 ? __(
377 "Manually editing slug. Click X to cancel and regenerate from name.",
378 "yatra",
379 )
380 : __(
381 "Auto-generated from name. Click edit icon to customize.",
382 "yatra",
383 )}
384 </p>
385 </div>
386
387 <RichTextEditor
388 label={__("Description", "yatra")}
389 value={formData.description || ""}
390 onChange={(value) =>
391 handleFieldChange("description", value)
392 }
393 placeholder={__(
394 "Describe this difficulty level (supports formatting, lists, links...)",
395 "yatra",
396 )}
397 helperText={__(
398 "Use formatting, bullet lists, and links to explain this difficulty level. HTML is supported.",
399 "yatra",
400 )}
401 minHeight={260}
402 maxHeight={600}
403 />
404 </CardContent>
405 </Card>
406 </div>
407
408 <div className="space-y-3">
409 <Card>
410 <CardHeader className="pb-2">
411 <CardTitle className="text-base">
412 {__("Ordering & Status", "yatra")}
413 </CardTitle>
414 </CardHeader>
415 <CardContent className="space-y-3">
416 <div>
417 <label
418 htmlFor="sorting"
419 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
420 >
421 {__("Display Order", "yatra")}
422 </label>
423 <Input
424 id="sorting"
425 type="number"
426 min={0}
427 value={formData.sorting}
428 onChange={(e) =>
429 handleFieldChange(
430 "sorting",
431 e.target.value === "" ? "" : Number(e.target.value),
432 )
433 }
434 placeholder={__("Auto", "yatra")}
435 className={errors.sorting ? "border-red-500" : ""}
436 />
437 {errors.sorting && (
438 <p className="mt-1 text-sm text-red-500">
439 {errors.sorting}
440 </p>
441 )}
442 <p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
443 {__(
444 "Lower numbers appear first. Leave blank to auto-assign.",
445 "yatra",
446 )}
447 </p>
448 </div>
449
450 <div>
451 <label
452 htmlFor="status"
453 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
454 >
455 {__("Status", "yatra")}
456 </label>
457 <Select
458 id="status"
459 value={formData.status}
460 onChange={(e) =>
461 handleFieldChange("status", e.target.value)
462 }
463 className="w-full"
464 >
465 <option value="draft">{__("Draft", "yatra")}</option>
466 <option value="publish">{__("Publish", "yatra")}</option>
467 <option value="trash">{__("Trash", "yatra")}</option>
468 </Select>
469 </div>
470 </CardContent>
471 </Card>
472
473 <Card>
474 <CardHeader className="pb-2">
475 <CardTitle className="text-base">
476 {__("Difficulty Level Icon or Image", "yatra")}
477 </CardTitle>
478 </CardHeader>
479 <CardContent>
480 <IconPicker
481 value={formData.icon}
482 onChange={(value) => handleFieldChange("icon", value)}
483 label={__("Select Icon or Upload Image", "yatra")}
484 helpText={__(
485 "Choose a library icon or upload a custom image for this difficulty level.",
486 "yatra",
487 )}
488 allowImageUpload
489 allowIconSelection
490 size="md"
491 />
492 </CardContent>
493 </Card>
494
495 <Card>
496 <CardContent className="p-3">
497 <div className="space-y-2">
498 <div className="flex gap-2">
499 <Button
500 type="submit"
501 disabled={isSubmitting}
502 className="flex-1 flex items-center justify-center gap-2"
503 >
504 {isSubmitting ? (
505 <>
506 <Loader2 className="w-4 h-4 animate-spin" />
507 {__("Saving...", "yatra")}
508 </>
509 ) : (
510 <>
511 <Save className="w-4 h-4" />
512 {isEditMode
513 ? __("Update Difficulty Level", "yatra")
514 : __("Create Difficulty Level", "yatra")}
515 </>
516 )}
517 </Button>
518 <Button
519 type="button"
520 variant="outline"
521 onClick={handleCancel}
522 disabled={isSubmitting}
523 >
524 {__("Cancel", "yatra")}
525 </Button>
526 </div>
527 </div>
528 </CardContent>
529 </Card>
530 </div>
531 </div>
532 </form>
533 </ConditionalRender>
534 </div>
535 );
536 };
537
538 export default DifficultyLevelForm;
539