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

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

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