PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.2.7
Yatra – Travel Booking & Tour Operator Software v3.0.2.7
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 / ActivityForm.tsx

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

666 lines 23.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Activity Form Page
3 * Add/Edit Activity form with clean, minimal SaaS-style design
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, Eye } from "lucide-react";
9 import { __ } from "../lib/i18n";
10 import { usePermissions } from "../hooks/usePermissions";
11 import { useToast } from "../components/ui/toast";
12 import { fetchSettings } from "../api/settings-api";
13 import { apiClient } from "../lib/api-client";
14 import { generateSlug } from "../lib/slug";
15 import { buildYatraSinglePreviewUrl } from "../lib/frontend-permalink-urls";
16 import { Button } from "../components/ui/button";
17 import { Input } from "../components/ui/input";
18 import { Select } from "../components/ui/select";
19 import { PageHeader } from "../components/common/PageHeader";
20 import {
21 Card,
22 CardContent,
23 CardHeader,
24 CardTitle,
25 } from "../components/ui/card";
26 import { ConditionalRender } from "../components/ui/conditional-render";
27 import { IconPicker, IconPickerValue } from "../components/ui/icon-picker";
28 import { RichTextEditor } from "../components/ui/rich-text-editor";
29
30 interface ActivityFormData {
31 name: string;
32 slug: string;
33 description: string;
34 icon: {
35 type: "icon" | "image";
36 value: string;
37 } | null;
38 status: string;
39 seo_title: string;
40 seo_description: string;
41 seo_keywords: string;
42 }
43
44 const ActivityForm: React.FC = () => {
45 const queryClient = useQueryClient();
46 const { can } = usePermissions();
47 const { showToast } = useToast();
48 const [formData, setFormData] = useState<ActivityFormData>({
49 name: "",
50 slug: "",
51 description: "",
52 icon: null,
53 status: "publish",
54 seo_title: "",
55 seo_description: "",
56 seo_keywords: "",
57 });
58 const [errors, setErrors] = useState<Record<string, string>>({});
59 const [isSubmitting, setIsSubmitting] = useState(false);
60 const [isSlugEditable, setIsSlugEditable] = useState(false);
61
62 // Get action and id from URL
63 const action = useMemo(() => {
64 const params = new URLSearchParams(window.location.search);
65 return params.get("action") || "create";
66 }, []);
67
68 const activityId = useMemo(() => {
69 const params = new URLSearchParams(window.location.search);
70 return params.get("id") ? parseInt(params.get("id") || "0") : null;
71 }, []);
72
73 const isEditMode = action === "edit" && activityId !== null;
74
75 // Fetch activity data if editing
76 const { data: activityData, isLoading: isLoadingActivity } = useQuery({
77 queryKey: ["activity", activityId],
78 queryFn: async () => {
79 if (!activityId) return null;
80 try {
81 const response = await apiClient.get(`/activities/${activityId}`);
82 return response;
83 } catch (error: any) {
84 showToast(
85 error?.message || __("Failed to load activity", "yatra"),
86 "error",
87 );
88 throw error;
89 }
90 },
91 enabled: isEditMode && can("yatra_view_trips"),
92 });
93
94 // Fetch settings for permalink handling
95 const { data: settings } = useQuery({
96 queryKey: ["settings"],
97 queryFn: async () => {
98 try {
99 const response = await fetchSettings();
100 return response?.data || response;
101 } catch (error) {
102 return {};
103 }
104 },
105 });
106
107 // Load activity data into form when editing
108 useEffect(() => {
109 if (activityData && isEditMode) {
110 setFormData({
111 name: activityData.name || "",
112 slug: activityData.slug || "",
113 description: activityData.description || "",
114 icon: (activityData.icon as IconPickerValue) || null,
115 status: activityData.status || "draft",
116 seo_title: activityData.metadata?.seo_title || "",
117 seo_description: activityData.metadata?.seo_description || "",
118 seo_keywords: activityData.metadata?.seo_keywords || "",
119 });
120 }
121 }, [activityData, isEditMode]);
122
123 const handleNameChange = (value: string) => {
124 // Auto-generate slug from name only in ADD mode (not in EDIT mode)
125 // In EDIT mode, slug only changes if user explicitly edits it
126 if (!isEditMode && !isSlugEditable) {
127 const newSlug = generateSlug(value);
128 setFormData((prev) => ({
129 ...prev,
130 name: value,
131 slug: newSlug,
132 }));
133 } else {
134 setFormData((prev) => ({
135 ...prev,
136 name: value,
137 }));
138 }
139 if (errors.name) {
140 setErrors((prev) => ({ ...prev, name: "" }));
141 }
142 };
143
144 const handleSlugChange = (value: string) => {
145 // Only allow manual slug editing if edit mode is enabled
146 if (isSlugEditable) {
147 setFormData((prev) => ({ ...prev, slug: value }));
148 if (errors.slug) {
149 setErrors((prev) => ({ ...prev, slug: "" }));
150 }
151 }
152 };
153
154 const handleToggleSlugEdit = () => {
155 if (isSlugEditable) {
156 // If disabling edit, regenerate slug from name
157 const newSlug = generateSlug(formData.name);
158 setFormData((prev) => ({ ...prev, slug: newSlug }));
159 }
160 setIsSlugEditable(!isSlugEditable);
161 };
162
163 const handleFieldChange = (
164 field: keyof ActivityFormData,
165 value: string | IconPickerValue | null,
166 ) => {
167 setFormData((prev) => ({ ...prev, [field]: value }));
168 if (errors[field]) {
169 setErrors((prev) => ({ ...prev, [field]: "" }));
170 }
171 };
172
173 const validateForm = (): boolean => {
174 const newErrors: Record<string, string> = {};
175
176 if (!formData.name.trim()) {
177 newErrors.name = __("Name is required", "yatra");
178 }
179
180 if (!formData.slug.trim()) {
181 newErrors.slug = __("Slug is required", "yatra");
182 } else if (!/^[a-z0-9-]+$/.test(formData.slug)) {
183 newErrors.slug = __(
184 "Slug can only contain lowercase letters, numbers, and hyphens",
185 "yatra",
186 );
187 }
188
189 setErrors(newErrors);
190 return Object.keys(newErrors).length === 0;
191 };
192
193 // Create/Update mutation
194 const saveMutation = useMutation({
195 mutationFn: async (data: ActivityFormData) => {
196 const payload: any = {
197 name: data.name.trim(),
198 slug: data.slug.trim(),
199 description: data.description.trim(),
200 icon: data.icon,
201 status: data.status,
202 seo_title: data.seo_title.trim(),
203 seo_description: data.seo_description.trim(),
204 seo_keywords: data.seo_keywords.trim(),
205 };
206
207 // If slug was manually edited, add flag to preserve it
208 if (isEditMode && isSlugEditable) {
209 payload.preserve_slug = true;
210 }
211
212 if (isEditMode && activityId) {
213 return await apiClient.put(`/activities/${activityId}`, payload);
214 } else {
215 return await apiClient.post("/activities", payload);
216 }
217 },
218 onSuccess: (response) => {
219 queryClient.invalidateQueries({ queryKey: ["activities"] });
220 queryClient.invalidateQueries({ queryKey: ["activity", activityId] });
221 showToast(
222 isEditMode
223 ? __("Activity updated successfully", "yatra")
224 : __("Activity created successfully", "yatra"),
225 "success",
226 );
227 if (!isEditMode && response?.id) {
228 const editUrl = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=trips&tab=activities&action=edit&id=${response.id}`;
229 setTimeout(() => {
230 window.location.href = editUrl;
231 }, 800);
232 } else {
233 setIsSubmitting(false);
234 }
235 },
236 onError: (error: any) => {
237 const errorMessage =
238 error?.message ||
239 __("An error occurred while saving the activity", "yatra");
240 showToast(errorMessage, "error");
241 setIsSubmitting(false);
242 },
243 });
244
245 const handleSubmit = async (e: React.FormEvent) => {
246 e.preventDefault();
247
248 if (!validateForm()) {
249 showToast(__("Please fix the form errors", "yatra"), "warning");
250 return;
251 }
252
253 setIsSubmitting(true);
254 setErrors({});
255 saveMutation.mutate(formData);
256 };
257
258 const handleCancel = () => {
259 window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=trips&tab=activities`;
260 };
261
262 if (isEditMode && isLoadingActivity) {
263 return (
264 <div className="space-y-3">
265 {/* Header Skeleton */}
266 <div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-6">
267 <div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-1/4 mb-2 animate-pulse"></div>
268 <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-1/2 animate-pulse"></div>
269 </div>
270
271 {/* Form Skeleton */}
272 <div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
273 {/* Main Fields */}
274 <div className="lg:col-span-2 space-y-3">
275 <Card>
276 <CardContent className="p-6 space-y-4">
277 {/* Name field */}
278 <div>
279 <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-20 mb-2 animate-pulse"></div>
280 <div className="h-10 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
281 </div>
282 {/* Slug field */}
283 <div>
284 <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-16 mb-2 animate-pulse"></div>
285 <div className="h-10 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
286 </div>
287 {/* Description field */}
288 <div>
289 <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-24 mb-2 animate-pulse"></div>
290 <div className="h-32 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
291 </div>
292 </CardContent>
293 </Card>
294 </div>
295
296 {/* Sidebar */}
297 <div className="space-y-3">
298 <Card>
299 <CardContent className="p-6 space-y-4">
300 <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-16 mb-2 animate-pulse"></div>
301 <div className="h-10 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
302 </CardContent>
303 </Card>
304 <Card>
305 <CardContent className="p-6 space-y-4">
306 <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-20 mb-2 animate-pulse"></div>
307 <div className="h-40 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
308 </CardContent>
309 </Card>
310 </div>
311 </div>
312 </div>
313 );
314 }
315
316 return (
317 <div className="space-y-3">
318 <PageHeader
319 title={
320 isEditMode
321 ? __("Edit Activity", "yatra")
322 : __("Add New Activity", "yatra")
323 }
324 description={
325 isEditMode
326 ? __("Update activity information", "yatra")
327 : __("Create a new travel activity", "yatra")
328 }
329 actions={
330 <div className="flex items-center gap-2">
331 <Button
332 variant="outline"
333 onClick={handleCancel}
334 className="flex items-center gap-2"
335 >
336 <ArrowLeft className="w-4 h-4" />
337 {__("Back", "yatra")}
338 </Button>
339 {formData.slug && (
340 <Button
341 variant="outline"
342 onClick={() => {
343 const targetUrl = buildYatraSinglePreviewUrl({
344 entity: "activity",
345 slug: formData.slug || "",
346 bases: settings as Record<string, unknown> | null,
347 });
348 window.open(targetUrl, "_blank", "noopener,noreferrer");
349 }}
350 className="flex items-center gap-2"
351 title={__("Preview activity in new tab", "yatra")}
352 >
353 <Eye className="w-4 h-4" />
354 {__("Preview", "yatra")}
355 </Button>
356 )}
357 </div>
358 }
359 />
360
361 <ConditionalRender capability="yatra_edit_trips">
362 <form onSubmit={handleSubmit}>
363 <div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
364 {/* Main Form Fields */}
365 <div className="lg:col-span-2 space-y-3">
366 {/* Basic Information */}
367 <Card>
368 <CardHeader className="pb-2">
369 <CardTitle className="text-base">
370 {__("Basic Information", "yatra")}
371 </CardTitle>
372 </CardHeader>
373 <CardContent className="space-y-3">
374 {/* Name */}
375 <div>
376 <label
377 htmlFor="name"
378 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
379 >
380 {__("Name", "yatra")}{" "}
381 <span className="text-red-500">*</span>
382 </label>
383 <Input
384 id="name"
385 type="text"
386 value={formData.name}
387 onChange={(e) => handleNameChange(e.target.value)}
388 placeholder={__("Enter activity name", "yatra")}
389 className={errors.name ? "border-red-500" : ""}
390 required
391 />
392 {errors.name && (
393 <p className="mt-1 text-sm text-red-500">{errors.name}</p>
394 )}
395 </div>
396
397 {/* Slug */}
398 <div>
399 <label
400 htmlFor="slug"
401 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
402 >
403 {__("Slug", "yatra")}{" "}
404 <span className="text-red-500">*</span>
405 </label>
406 <div className="relative">
407 <Input
408 id="slug"
409 type="text"
410 value={formData.slug}
411 onChange={(e) => handleSlugChange(e.target.value)}
412 placeholder={__("activity-slug", "yatra")}
413 className={`pr-10 ${errors.slug ? "border-red-500" : ""} ${!isSlugEditable ? "bg-gray-50 dark:bg-gray-800 cursor-not-allowed" : ""}`}
414 disabled={!isSlugEditable}
415 required
416 />
417 <button
418 type="button"
419 onClick={handleToggleSlugEdit}
420 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"
421 aria-label={
422 isSlugEditable
423 ? __("Cancel editing slug", "yatra")
424 : __("Edit slug", "yatra")
425 }
426 >
427 {isSlugEditable ? (
428 <X className="w-4 h-4" />
429 ) : (
430 <Edit2 className="w-4 h-4" />
431 )}
432 </button>
433 </div>
434 {errors.slug && (
435 <p className="mt-1 text-sm text-red-500">{errors.slug}</p>
436 )}
437 <p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
438 {isSlugEditable
439 ? __(
440 "Manually editing slug. Click X to cancel and regenerate from name.",
441 "yatra",
442 )
443 : __(
444 "Auto-generated from name. Click edit icon to customize.",
445 "yatra",
446 )}
447 </p>
448 </div>
449
450 {/* Description */}
451 <RichTextEditor
452 label={__("Description", "yatra")}
453 value={formData.description || ""}
454 onChange={(value) =>
455 handleFieldChange("description", value)
456 }
457 placeholder={__(
458 "Write a rich description (supports formatting, lists, links...)",
459 "yatra",
460 )}
461 helperText={__(
462 "Use formatting, bullet lists, and links to create a compelling description. HTML is supported.",
463 "yatra",
464 )}
465 minHeight={360}
466 maxHeight={720}
467 />
468 </CardContent>
469 </Card>
470 </div>
471
472 {/* Sidebar */}
473 <div className="space-y-3">
474 {/* Status */}
475 <Card>
476 <CardHeader className="pb-2">
477 <CardTitle className="text-base">
478 {__("Status", "yatra")}
479 </CardTitle>
480 </CardHeader>
481 <CardContent className="space-y-3">
482 <div>
483 <label
484 htmlFor="status"
485 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
486 >
487 {__("Status", "yatra")}
488 </label>
489 <Select
490 id="status"
491 value={formData.status}
492 onChange={(e) =>
493 handleFieldChange("status", e.target.value)
494 }
495 >
496 <option value="draft">{__("Draft", "yatra")}</option>
497 <option value="publish">{__("Publish", "yatra")}</option>
498 <option value="trash">{__("Trash", "yatra")}</option>
499 </Select>
500 </div>
501 </CardContent>
502 </Card>
503
504 {/* SEO Settings */}
505 <Card>
506 <CardHeader className="pb-2">
507 <CardTitle className="text-base">
508 {__("SEO Settings", "yatra")}
509 </CardTitle>
510 </CardHeader>
511 <CardContent className="space-y-3">
512 <div>
513 <label
514 htmlFor="seo_title"
515 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
516 >
517 {__("Meta Title", "yatra")}
518 </label>
519 <Input
520 id="seo_title"
521 type="text"
522 value={formData.seo_title}
523 onChange={(e) =>
524 handleFieldChange("seo_title", e.target.value)
525 }
526 placeholder={__(
527 "e.g., {name} Activities | Your Travel Agency",
528 "yatra",
529 )}
530 className="w-full"
531 />
532 <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
533 {__(
534 "Custom title for search engines. Use {name} as placeholder.",
535 "yatra",
536 )}
537 </p>
538 </div>
539
540 <div>
541 <label
542 htmlFor="seo_description"
543 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
544 >
545 {__("Meta Description", "yatra")}
546 </label>
547 <textarea
548 id="seo_description"
549 value={formData.seo_description}
550 onChange={(e) =>
551 handleFieldChange("seo_description", e.target.value)
552 }
553 placeholder={__(
554 "e.g., Discover amazing {name} activities and experiences. Book your adventure today!",
555 "yatra",
556 )}
557 rows={3}
558 className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
559 />
560 <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
561 {__(
562 "Description for search engines (150-160 characters). Use {name} as placeholder.",
563 "yatra",
564 )}
565 </p>
566 </div>
567
568 <div>
569 <label
570 htmlFor="seo_keywords"
571 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
572 >
573 {__("Meta Keywords", "yatra")}
574 </label>
575 <Input
576 id="seo_keywords"
577 type="text"
578 value={formData.seo_keywords}
579 onChange={(e) =>
580 handleFieldChange("seo_keywords", e.target.value)
581 }
582 placeholder={__(
583 "e.g., activities, adventure, {name}, travel, experiences",
584 "yatra",
585 )}
586 className="w-full"
587 />
588 <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
589 {__(
590 "Comma-separated keywords. Use {name} as placeholder.",
591 "yatra",
592 )}
593 </p>
594 </div>
595 </CardContent>
596 </Card>
597
598 {/* Icon/Image Picker */}
599 <Card>
600 <CardHeader className="pb-2">
601 <CardTitle className="text-base">
602 {__("Activity Icon or Image", "yatra")}
603 </CardTitle>
604 </CardHeader>
605 <CardContent>
606 <IconPicker
607 value={formData.icon}
608 onChange={(value) => handleFieldChange("icon", value)}
609 label={__("Select Icon or Upload Image", "yatra")}
610 helpText={__(
611 "Choose a library icon or upload a custom image to visually represent this activity.",
612 "yatra",
613 )}
614 allowImageUpload={true}
615 allowIconSelection={true}
616 size="md"
617 />
618 </CardContent>
619 </Card>
620
621 {/* Submit Actions */}
622 <Card>
623 <CardContent className="p-3">
624 <div className="space-y-2">
625 <div className="flex gap-2">
626 <Button
627 type="submit"
628 disabled={isSubmitting}
629 className="flex-1 flex items-center justify-center gap-2"
630 >
631 {isSubmitting ? (
632 <>
633 <Loader2 className="w-4 h-4 animate-spin" />
634 {__("Saving...", "yatra")}
635 </>
636 ) : (
637 <>
638 <Save className="w-4 h-4" />
639 {isEditMode
640 ? __("Update Activity", "yatra")
641 : __("Create Itinerary Activity", "yatra")}
642 </>
643 )}
644 </Button>
645 <Button
646 type="button"
647 variant="outline"
648 onClick={handleCancel}
649 disabled={isSubmitting}
650 >
651 {__("Cancel", "yatra")}
652 </Button>
653 </div>
654 </div>
655 </CardContent>
656 </Card>
657 </div>
658 </div>
659 </form>
660 </ConditionalRender>
661 </div>
662 );
663 };
664
665 export default ActivityForm;
666