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

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

703 lines 25.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Destination Form Page
3 * Add/Edit Destination 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 DestinationFormData {
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 DestinationForm: React.FC = () => {
51 const queryClient = useQueryClient();
52 const { can } = usePermissions();
53 const { showToast } = useToast();
54 const [formData, setFormData] = useState<DestinationFormData>({
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 destinationId = 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" && destinationId !== null;
81
82 // Fetch destination data if editing
83 const { data: destinationData, isLoading: isLoadingDestination } = useQuery({
84 queryKey: ["destination", destinationId],
85 queryFn: async () => {
86 if (!destinationId) return null;
87 try {
88 const response = await apiClient.get(`/destinations/${destinationId}`);
89 return response;
90 } catch (error: any) {
91 showToast(
92 error?.message || __("Failed to load destination", "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 destination data into form when editing
115 useEffect(() => {
116 if (destinationData && isEditMode) {
117 setFormData({
118 name: destinationData.name || "",
119 slug: destinationData.slug || "",
120 description: destinationData.description || "",
121 icon: (destinationData.icon as IconPickerValue) || null,
122 status: destinationData.status || "draft",
123 seo_title: destinationData.metadata?.seo_title || "",
124 seo_description: destinationData.metadata?.seo_description || "",
125 seo_keywords: destinationData.metadata?.seo_keywords || "",
126 landing_page_id: parseLandingPageIdFromMetadata(
127 destinationData.metadata as { landing_page_id?: unknown },
128 ),
129 });
130 }
131 }, [destinationData, 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 DestinationFormData,
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: DestinationFormData) => {
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 && destinationId) {
224 return await apiClient.put(`/destinations/${destinationId}`, payload);
225 } else {
226 return await apiClient.post("/destinations", payload);
227 }
228 },
229 onSuccess: (response) => {
230 queryClient.invalidateQueries({ queryKey: ["destinations"] });
231 queryClient.invalidateQueries({
232 queryKey: ["destination", destinationId],
233 });
234 showToast(
235 isEditMode
236 ? __("Destination updated successfully", "yatra")
237 : __("Destination created successfully", "yatra"),
238 "success",
239 );
240 setIsSubmitting(false);
241
242 if (!isEditMode) {
243 const newId = response?.id;
244 if (newId) {
245 window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=trips&tab=destinations&action=edit&id=${newId}`;
246 } else {
247 // Fallback to list if ID missing
248 window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=trips&tab=destinations`;
249 }
250 }
251 },
252 onError: (error: any) => {
253 const errorMessage =
254 error?.message ||
255 __("An error occurred while saving the destination", "yatra");
256 showToast(errorMessage, "error");
257 setIsSubmitting(false);
258 },
259 });
260
261 const handleSubmit = async (e: React.FormEvent) => {
262 e.preventDefault();
263
264 if (!validateForm()) {
265 showToast(__("Please fix the form errors", "yatra"), "warning");
266 return;
267 }
268
269 setIsSubmitting(true);
270 setErrors({});
271 saveMutation.mutate(formData);
272 };
273
274 const handleCancel = () => {
275 window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=trips&tab=destinations`;
276 };
277
278 if (isEditMode && isLoadingDestination) {
279 return (
280 <div className="space-y-3">
281 {/* Header Skeleton */}
282 <div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-6">
283 <div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-1/4 mb-2 animate-pulse"></div>
284 <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-1/2 animate-pulse"></div>
285 </div>
286
287 {/* Form Skeleton */}
288 <div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
289 {/* Main Fields */}
290 <div className="lg:col-span-2 space-y-3">
291 <Card>
292 <CardContent className="p-6 space-y-4">
293 {/* Name field */}
294 <div>
295 <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-20 mb-2 animate-pulse"></div>
296 <div className="h-10 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
297 </div>
298 {/* Slug field */}
299 <div>
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 </div>
303 {/* Description field */}
304 <div>
305 <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-24 mb-2 animate-pulse"></div>
306 <div className="h-32 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
307 </div>
308 </CardContent>
309 </Card>
310 </div>
311
312 {/* Sidebar */}
313 <div className="space-y-3">
314 <Card>
315 <CardContent className="p-6 space-y-4">
316 <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-16 mb-2 animate-pulse"></div>
317 <div className="h-10 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
318 </CardContent>
319 </Card>
320 <Card>
321 <CardContent className="p-6 space-y-4">
322 <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-20 mb-2 animate-pulse"></div>
323 <div className="h-40 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
324 </CardContent>
325 </Card>
326 </div>
327 </div>
328 </div>
329 );
330 }
331
332 return (
333 <div className="space-y-3">
334 <PageHeader
335 title={
336 isEditMode
337 ? __("Edit Destination", "yatra")
338 : __("Add New Destination", "yatra")
339 }
340 description={
341 isEditMode
342 ? __("Update destination information", "yatra")
343 : __("Create a new travel destination", "yatra")
344 }
345 actions={
346 <div className="flex items-center gap-2">
347 <Button
348 variant="outline"
349 onClick={handleCancel}
350 className="flex items-center gap-2"
351 >
352 <ArrowLeft className="w-4 h-4" />
353 {__("Back", "yatra")}
354 </Button>
355 {formData.slug && (
356 <Button
357 variant="outline"
358 onClick={async () => {
359 if (
360 formData.landing_page_id &&
361 window.yatraAdmin?.customLandingPagesModuleEnabled
362 ) {
363 const link = await fetchPublishedPagePermalink(
364 formData.landing_page_id,
365 );
366 if (link) {
367 window.open(link, "_blank", "noopener,noreferrer");
368 return;
369 }
370 }
371 const targetUrl = buildYatraSinglePreviewUrl({
372 entity: "destination",
373 slug: formData.slug || "",
374 bases: settings as Record<string, unknown> | null,
375 });
376 window.open(targetUrl, "_blank", "noopener,noreferrer");
377 }}
378 className="flex items-center gap-2"
379 title={__("Preview destination in new tab", "yatra")}
380 >
381 <Eye className="w-4 h-4" />
382 {__("Preview", "yatra")}
383 </Button>
384 )}
385 </div>
386 }
387 />
388
389 <ConditionalRender capability="yatra_edit_trips">
390 <form onSubmit={handleSubmit}>
391 <div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
392 {/* Main Form Fields */}
393 <div className="lg:col-span-2 space-y-3">
394 {/* Basic Information */}
395 <Card>
396 <CardHeader className="pb-2">
397 <CardTitle className="text-base">
398 {__("Basic Information", "yatra")}
399 </CardTitle>
400 </CardHeader>
401 <CardContent className="space-y-3">
402 {/* Name */}
403 <div>
404 <label
405 htmlFor="name"
406 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
407 >
408 {__("Name", "yatra")}{" "}
409 <span className="text-red-500">*</span>
410 </label>
411 <Input
412 id="name"
413 type="text"
414 value={formData.name}
415 onChange={(e) => handleNameChange(e.target.value)}
416 placeholder={__("Enter destination name", "yatra")}
417 className={errors.name ? "border-red-500" : ""}
418 required
419 />
420 {errors.name && (
421 <p className="mt-1 text-sm text-red-500">{errors.name}</p>
422 )}
423 </div>
424
425 {/* Slug */}
426 <div>
427 <label
428 htmlFor="slug"
429 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
430 >
431 {__("Slug", "yatra")}{" "}
432 <span className="text-red-500">*</span>
433 </label>
434 <div className="relative">
435 <Input
436 id="slug"
437 type="text"
438 value={formData.slug}
439 onChange={(e) => handleSlugChange(e.target.value)}
440 placeholder={__("destination-slug", "yatra")}
441 className={`pr-10 ${errors.slug ? "border-red-500" : ""} ${!isSlugEditable ? "bg-gray-50 dark:bg-gray-800 cursor-not-allowed" : ""}`}
442 disabled={!isSlugEditable}
443 required
444 />
445 <button
446 type="button"
447 onClick={handleToggleSlugEdit}
448 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"
449 aria-label={
450 isSlugEditable
451 ? __("Cancel editing slug", "yatra")
452 : __("Edit slug", "yatra")
453 }
454 >
455 {isSlugEditable ? (
456 <X className="w-4 h-4" />
457 ) : (
458 <Edit2 className="w-4 h-4" />
459 )}
460 </button>
461 </div>
462 {errors.slug && (
463 <p className="mt-1 text-sm text-red-500">{errors.slug}</p>
464 )}
465 <p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
466 {isSlugEditable
467 ? __(
468 "Manually editing slug. Click X to cancel and regenerate from name.",
469 "yatra",
470 )
471 : __(
472 "Auto-generated from name. Click edit icon to customize.",
473 "yatra",
474 )}
475 </p>
476 </div>
477
478 {/* Description */}
479 <RichTextEditor
480 label={__("Description", "yatra")}
481 value={formData.description || ""}
482 onChange={(value) =>
483 handleFieldChange("description", value)
484 }
485 placeholder={__(
486 "Write a rich description (supports formatting, lists, links...)",
487 "yatra",
488 )}
489 helperText={__(
490 "Use formatting, bullet lists, and links to create a compelling description. HTML is supported.",
491 "yatra",
492 )}
493 minHeight={360}
494 maxHeight={720}
495 />
496 </CardContent>
497 </Card>
498
499 <ClassificationLandingPageField
500 selectId="yatra-destination-landing-page"
501 value={formData.landing_page_id}
502 onChange={(id) =>
503 setFormData((prev) => ({ ...prev, landing_page_id: id }))
504 }
505 />
506 </div>
507
508 {/* Sidebar */}
509 <div className="space-y-3">
510 {/* Status */}
511 <Card>
512 <CardHeader className="pb-2">
513 <CardTitle className="text-base">
514 {__("Status", "yatra")}
515 </CardTitle>
516 </CardHeader>
517 <CardContent className="space-y-3">
518 <div>
519 <label
520 htmlFor="status"
521 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
522 >
523 {__("Status", "yatra")}
524 </label>
525 <Select
526 id="status"
527 value={formData.status}
528 onChange={(e) =>
529 handleFieldChange("status", e.target.value)
530 }
531 className="w-full h-10"
532 >
533 <option value="draft">{__("Draft", "yatra")}</option>
534 <option value="publish">{__("Publish", "yatra")}</option>
535 <option value="trash">{__("Trash", "yatra")}</option>
536 </Select>
537 </div>
538 </CardContent>
539 </Card>
540
541 {/* SEO Settings */}
542 <Card>
543 <CardHeader className="pb-2">
544 <CardTitle className="text-base">
545 {__("SEO Settings", "yatra")}
546 </CardTitle>
547 </CardHeader>
548 <CardContent className="space-y-3">
549 <div>
550 <label
551 htmlFor="seo_title"
552 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
553 >
554 {__("Meta Title", "yatra")}
555 </label>
556 <Input
557 id="seo_title"
558 type="text"
559 value={formData.seo_title}
560 onChange={(e) =>
561 handleFieldChange("seo_title", e.target.value)
562 }
563 placeholder={__(
564 "e.g., {name} Destinations | Your Travel Agency",
565 "yatra",
566 )}
567 className="w-full"
568 />
569 <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
570 {__(
571 "Custom title for search engines. Use {name} as placeholder.",
572 "yatra",
573 )}
574 </p>
575 </div>
576
577 <div>
578 <label
579 htmlFor="seo_description"
580 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
581 >
582 {__("Meta Description", "yatra")}
583 </label>
584 <textarea
585 id="seo_description"
586 value={formData.seo_description}
587 onChange={(e) =>
588 handleFieldChange("seo_description", e.target.value)
589 }
590 placeholder={__(
591 "e.g., Discover amazing destinations in {name}. Plan your perfect trip today!",
592 "yatra",
593 )}
594 rows={3}
595 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"
596 />
597 <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
598 {__(
599 "Description for search engines (150-160 characters). Use {name} as placeholder.",
600 "yatra",
601 )}
602 </p>
603 </div>
604
605 <div>
606 <label
607 htmlFor="seo_keywords"
608 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
609 >
610 {__("Meta Keywords", "yatra")}
611 </label>
612 <Input
613 id="seo_keywords"
614 type="text"
615 value={formData.seo_keywords}
616 onChange={(e) =>
617 handleFieldChange("seo_keywords", e.target.value)
618 }
619 placeholder={__(
620 "e.g., destinations, travel, {name}, tourism, trips",
621 "yatra",
622 )}
623 className="w-full"
624 />
625 <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
626 {__(
627 "Comma-separated keywords. Use {name} as placeholder.",
628 "yatra",
629 )}
630 </p>
631 </div>
632 </CardContent>
633 </Card>
634
635 {/* Icon/Image Picker */}
636 <Card>
637 <CardHeader className="pb-2">
638 <CardTitle className="text-base">
639 {__("Destination Icon or Image", "yatra")}
640 </CardTitle>
641 </CardHeader>
642 <CardContent>
643 <IconPicker
644 value={formData.icon}
645 onChange={(value) => handleFieldChange("icon", value)}
646 label={__("Select Icon or Upload Image", "yatra")}
647 helpText={__(
648 "Choose a library icon or upload a custom image to visually represent this destination.",
649 "yatra",
650 )}
651 allowImageUpload={true}
652 allowIconSelection={true}
653 size="md"
654 />
655 </CardContent>
656 </Card>
657
658 {/* Submit Actions */}
659 <Card>
660 <CardContent className="p-3">
661 <div className="space-y-2">
662 <div className="flex gap-2">
663 <Button
664 type="submit"
665 disabled={isSubmitting}
666 className="flex-1 flex items-center justify-center gap-2"
667 >
668 {isSubmitting ? (
669 <>
670 <Loader2 className="w-4 h-4 animate-spin" />
671 {__("Saving...", "yatra")}
672 </>
673 ) : (
674 <>
675 <Save className="w-4 h-4" />
676 {isEditMode
677 ? __("Update Destination", "yatra")
678 : __("Create Destination", "yatra")}
679 </>
680 )}
681 </Button>
682 <Button
683 type="button"
684 variant="outline"
685 onClick={handleCancel}
686 disabled={isSubmitting}
687 >
688 {__("Cancel", "yatra")}
689 </Button>
690 </div>
691 </div>
692 </CardContent>
693 </Card>
694 </div>
695 </div>
696 </form>
697 </ConditionalRender>
698 </div>
699 );
700 };
701
702 export default DestinationForm;
703