PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.16
Yatra – Travel Booking & Tour Operator Software v3.0.16
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.16, at resources/js/pages/ActivityForm.tsx

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