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

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

841 lines 30.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Category Form Page
3 * Add/Edit Trip Category with parent/subcategory support
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 /** DB / REST may send 0|1 as number or string; never use Boolean() on string "0". */
36 function parseTripCategoryIsFeatured(raw: unknown): boolean {
37 if (raw === true || raw === 1) {
38 return true;
39 }
40 if (raw === false || raw === 0 || raw === null || raw === undefined) {
41 return false;
42 }
43 if (typeof raw === "string") {
44 const t = raw.trim();
45 return t === "1" || t.toLowerCase() === "true";
46 }
47 return Number(raw) === 1;
48 }
49
50 interface CategoryFormData {
51 name: string;
52 slug: string;
53 description: string;
54 icon: IconPickerValue | null;
55 parent_id: number | "";
56 status: string;
57 /** Trip category card "Featured" badge on shortcode / Trip Category block */
58 is_featured: boolean;
59 seo_title: string;
60 seo_description: string;
61 seo_keywords: string;
62 landing_page_id: number | null;
63 }
64
65 interface CategoryOption {
66 id: number;
67 name: string;
68 parent_id: number | null;
69 }
70
71 const CategoryForm: React.FC = () => {
72 const queryClient = useQueryClient();
73 const { can } = usePermissions();
74 const { showToast } = useToast();
75 const [formData, setFormData] = useState<CategoryFormData>({
76 name: "",
77 slug: "",
78 description: "",
79 icon: null,
80 parent_id: "",
81 status: "publish",
82 is_featured: false,
83 seo_title: "",
84 seo_description: "",
85 seo_keywords: "",
86 landing_page_id: null,
87 });
88 const [errors, setErrors] = useState<Record<string, string>>({});
89 const [isSubmitting, setIsSubmitting] = useState(false);
90 const [isSlugEditable, setIsSlugEditable] = useState(false);
91
92 const action = useMemo(() => {
93 const params = new URLSearchParams(window.location.search);
94 return params.get("action") || "create";
95 }, []);
96
97 const categoryId = useMemo(() => {
98 const params = new URLSearchParams(window.location.search);
99 return params.get("id") ? parseInt(params.get("id") || "0", 10) : null;
100 }, []);
101
102 const isEditMode = action === "edit" && categoryId !== null;
103
104 // Fetch category data if editing
105 const { data: categoryData, isLoading: isLoadingCategory } = useQuery({
106 queryKey: ["trip-category", categoryId],
107 queryFn: async () => {
108 if (!categoryId) return null;
109 try {
110 const response = await apiClient.get(`/trip-categories/${categoryId}`);
111 return response;
112 } catch (error: any) {
113 showToast(
114 error?.message || __("Failed to load category", "yatra"),
115 "error",
116 );
117 throw error;
118 }
119 },
120 enabled: isEditMode && can("yatra_view_trips"),
121 });
122
123 // Fetch parent category options (top-level categories)
124 const { data: parentCategoriesData } = useQuery({
125 queryKey: ["trip-categories", "parent-options"],
126 queryFn: async () => {
127 try {
128 const response = await apiClient.get("/trip-categories", {
129 params: {
130 per_page: 100,
131 parent_id: null,
132 hierarchical: false,
133 orderby: "name",
134 order: "ASC",
135 },
136 });
137 const payload = response?.data || response;
138 if (!payload) {
139 return [];
140 }
141 if (Array.isArray(payload)) {
142 return payload;
143 }
144 return payload.data || [];
145 } catch (error) {
146 return [];
147 }
148 },
149 enabled: can("yatra_view_trips"),
150 });
151
152 // Fetch settings for permalink handling
153 const { data: settings } = useQuery({
154 queryKey: ["settings"],
155 queryFn: async () => {
156 try {
157 const response = await fetchSettings();
158 return response?.data || response;
159 } catch (error) {
160 return {};
161 }
162 },
163 });
164
165 const parentOptions: CategoryOption[] = useMemo(() => {
166 if (!parentCategoriesData || !Array.isArray(parentCategoriesData)) {
167 return [];
168 }
169 return parentCategoriesData.filter((cat: any) => {
170 if (!cat || typeof cat !== "object") return false;
171 // Only allow top-level categories (no parent)
172 if (cat.parent_id) return false;
173 // Prevent selecting itself as parent when editing
174 if (isEditMode && categoryId && cat.id === categoryId) return false;
175 return true;
176 });
177 }, [parentCategoriesData, isEditMode, categoryId]);
178
179 // Load category data into form when editing
180 useEffect(() => {
181 if (categoryData && isEditMode) {
182 setFormData({
183 name: categoryData.name || "",
184 slug: categoryData.slug || "",
185 description: categoryData.description || "",
186 icon: (categoryData.icon as IconPickerValue) || null,
187 parent_id: categoryData.parent_id ?? "",
188 status: categoryData.status || "publish",
189 is_featured: parseTripCategoryIsFeatured(
190 (categoryData as { is_featured?: unknown }).is_featured,
191 ),
192 seo_title: categoryData.metadata?.seo_title || "",
193 seo_description: categoryData.metadata?.seo_description || "",
194 seo_keywords: categoryData.metadata?.seo_keywords || "",
195 landing_page_id: parseLandingPageIdFromMetadata(
196 categoryData.metadata as { landing_page_id?: unknown },
197 ),
198 });
199 }
200 }, [categoryData, isEditMode]);
201
202 const handleNameChange = (value: string) => {
203 // Only auto-generate slug on create; in edit mode, keep existing slug
204 if (!isEditMode && !isSlugEditable) {
205 const newSlug = generateSlug(value);
206 setFormData((prev) => ({
207 ...prev,
208 name: value,
209 slug: newSlug,
210 }));
211 } else {
212 setFormData((prev) => ({ ...prev, name: value }));
213 }
214 if (errors.name) {
215 setErrors((prev) => ({ ...prev, name: "" }));
216 }
217 };
218
219 const handleSlugChange = (value: string) => {
220 if (isSlugEditable) {
221 setFormData((prev) => ({ ...prev, slug: value }));
222 if (errors.slug) {
223 setErrors((prev) => ({ ...prev, slug: "" }));
224 }
225 }
226 };
227
228 const handleToggleSlugEdit = () => {
229 if (isSlugEditable) {
230 const newSlug = generateSlug(formData.name);
231 setFormData((prev) => ({ ...prev, slug: newSlug }));
232 }
233 setIsSlugEditable(!isSlugEditable);
234 };
235
236 const handleFieldChange = (field: keyof CategoryFormData, value: any) => {
237 setFormData((prev) => ({ ...prev, [field]: value }));
238 if (errors[field]) {
239 setErrors((prev) => ({ ...prev, [field]: "" }));
240 }
241 };
242
243 const validateForm = (): boolean => {
244 const newErrors: Record<string, string> = {};
245
246 if (!formData.name.trim()) {
247 newErrors.name = __("Name is required", "yatra");
248 }
249
250 if (!formData.slug.trim()) {
251 newErrors.slug = __("Slug is required", "yatra");
252 } else if (!/^[\p{L}\p{N}-]+$/u.test(formData.slug)) {
253 newErrors.slug = __(
254 "Slug can only contain letters, numbers, and hyphens",
255 "yatra",
256 );
257 }
258
259 if (
260 formData.parent_id &&
261 categoryId &&
262 Number(formData.parent_id) === categoryId
263 ) {
264 newErrors.parent_id = __("Category cannot be its own parent", "yatra");
265 }
266
267 setErrors(newErrors);
268 return Object.keys(newErrors).length === 0;
269 };
270
271 const saveMutation = useMutation({
272 mutationFn: async (data: CategoryFormData) => {
273 const payload: any = {
274 name: data.name.trim(),
275 slug: data.slug.trim(),
276 description: data.description.trim(),
277 icon: data.icon,
278 parent_id: data.parent_id === "" ? null : Number(data.parent_id),
279 status: data.status,
280 is_featured: data.is_featured ? 1 : 0,
281 seo_title: data.seo_title.trim(),
282 seo_description: data.seo_description.trim(),
283 seo_keywords: data.seo_keywords.trim(),
284 landing_page_id: data.landing_page_id ?? null,
285 };
286
287 if (isEditMode && isSlugEditable) {
288 payload.preserve_slug = true;
289 }
290
291 if (isEditMode && categoryId) {
292 return await apiClient.put(`/trip-categories/${categoryId}`, payload);
293 }
294 return await apiClient.post("/trip-categories", payload);
295 },
296 onSuccess: (response) => {
297 queryClient.invalidateQueries({ queryKey: ["trip-categories"] });
298 queryClient.invalidateQueries({
299 queryKey: ["trip-category", categoryId],
300 });
301 showToast(
302 isEditMode
303 ? __("Category updated successfully", "yatra")
304 : __("Category created successfully", "yatra"),
305 "success",
306 );
307 setIsSubmitting(false);
308
309 if (!isEditMode) {
310 const newId = response?.id;
311 if (newId) {
312 window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=trips&tab=categories&action=edit&id=${newId}`;
313 } else {
314 window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=trips&tab=categories`;
315 }
316 }
317 },
318 onError: (error: any) => {
319 const errorMessage =
320 error?.message ||
321 __("An error occurred while saving the category", "yatra");
322 showToast(errorMessage, "error");
323 setIsSubmitting(false);
324 },
325 });
326
327 const handleSubmit = (e: React.FormEvent) => {
328 e.preventDefault();
329
330 if (!validateForm()) {
331 showToast(__("Please fix the form errors", "yatra"), "warning");
332 return;
333 }
334
335 setIsSubmitting(true);
336 setErrors({});
337 saveMutation.mutate(formData);
338 };
339
340 const handleCancel = () => {
341 window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=trips&tab=categories`;
342 };
343
344 if (isEditMode && isLoadingCategory) {
345 return (
346 <div className="space-y-3">
347 {/* Header Skeleton */}
348 <div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-6">
349 <div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-1/4 mb-2 animate-pulse"></div>
350 <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-1/2 animate-pulse"></div>
351 </div>
352
353 {/* Form Skeleton */}
354 <div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
355 {/* Main Fields */}
356 <div className="lg:col-span-2 space-y-3">
357 <Card>
358 <CardContent className="p-6 space-y-4">
359 {/* Name field */}
360 <div>
361 <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-20 mb-2 animate-pulse"></div>
362 <div className="h-10 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
363 </div>
364 {/* Slug field */}
365 <div>
366 <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-16 mb-2 animate-pulse"></div>
367 <div className="h-10 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
368 </div>
369 {/* Description field */}
370 <div>
371 <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-24 mb-2 animate-pulse"></div>
372 <div className="h-32 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
373 </div>
374 {/* Icon field */}
375 <div>
376 <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-20 mb-2 animate-pulse"></div>
377 <div className="h-32 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
378 </div>
379 </CardContent>
380 </Card>
381 </div>
382
383 {/* Sidebar */}
384 <div className="space-y-3">
385 <Card>
386 <CardContent className="p-6 space-y-4">
387 <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-24 mb-2 animate-pulse"></div>
388 <div className="h-10 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
389 </CardContent>
390 </Card>
391 <Card>
392 <CardContent className="p-6 space-y-4">
393 <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-16 mb-2 animate-pulse"></div>
394 <div className="h-10 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
395 </CardContent>
396 </Card>
397 </div>
398 </div>
399 </div>
400 );
401 }
402
403 return (
404 <div className="space-y-3">
405 <PageHeader
406 title={
407 isEditMode
408 ? __("Edit Category", "yatra")
409 : __("Add New Category", "yatra")
410 }
411 description={
412 isEditMode
413 ? __("Update category information", "yatra")
414 : __("Create a new trip category", "yatra")
415 }
416 actions={
417 <div className="flex items-center gap-2">
418 <Button
419 variant="outline"
420 onClick={handleCancel}
421 className="flex items-center gap-2"
422 >
423 <ArrowLeft className="w-4 h-4" />
424 {__("Back", "yatra")}
425 </Button>
426 {formData.slug && (
427 <Button
428 variant="outline"
429 onClick={async () => {
430 if (
431 formData.landing_page_id &&
432 window.yatraAdmin?.customLandingPagesModuleEnabled
433 ) {
434 const link = await fetchPublishedPagePermalink(
435 formData.landing_page_id,
436 );
437 if (link) {
438 window.open(link, "_blank", "noopener,noreferrer");
439 return;
440 }
441 }
442 const targetUrl = buildYatraSinglePreviewUrl({
443 entity: "category",
444 slug: formData.slug || "",
445 bases: settings as Record<string, unknown> | null,
446 });
447 window.open(targetUrl, "_blank", "noopener,noreferrer");
448 }}
449 className="flex items-center gap-2"
450 title={__("Preview category in new tab", "yatra")}
451 >
452 <Eye className="w-4 h-4" />
453 {__("Preview", "yatra")}
454 </Button>
455 )}
456 </div>
457 }
458 />
459
460 <ConditionalRender capability="yatra_edit_trips">
461 <form onSubmit={handleSubmit}>
462 <div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
463 <div className="lg:col-span-2 space-y-3">
464 <Card>
465 <CardHeader className="pb-2">
466 <CardTitle className="text-base">
467 {__("Basic Information", "yatra")}
468 </CardTitle>
469 </CardHeader>
470 <CardContent className="space-y-3">
471 <div>
472 <label
473 htmlFor="name"
474 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
475 >
476 {__("Name", "yatra")}{" "}
477 <span className="text-red-500">*</span>
478 </label>
479 <Input
480 id="name"
481 type="text"
482 value={formData.name}
483 onChange={(e) => handleNameChange(e.target.value)}
484 placeholder={__("Enter category name", "yatra")}
485 className={errors.name ? "border-red-500" : ""}
486 required
487 />
488 {errors.name && (
489 <p className="mt-1 text-sm text-red-500">{errors.name}</p>
490 )}
491 </div>
492
493 <div>
494 <label
495 htmlFor="slug"
496 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
497 >
498 {__("Slug", "yatra")}{" "}
499 <span className="text-red-500">*</span>
500 </label>
501 <div className="relative">
502 <Input
503 id="slug"
504 type="text"
505 value={formData.slug}
506 onChange={(e) => handleSlugChange(e.target.value)}
507 placeholder={__("category-slug", "yatra")}
508 className={`pr-10 ${errors.slug ? "border-red-500" : ""} ${!isSlugEditable ? "bg-gray-50 dark:bg-gray-800 cursor-not-allowed" : ""}`}
509 disabled={!isSlugEditable}
510 required
511 />
512 <button
513 type="button"
514 onClick={handleToggleSlugEdit}
515 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"
516 aria-label={
517 isSlugEditable
518 ? __("Cancel editing slug", "yatra")
519 : __("Edit slug", "yatra")
520 }
521 >
522 {isSlugEditable ? (
523 <X className="w-4 h-4" />
524 ) : (
525 <Edit2 className="w-4 h-4" />
526 )}
527 </button>
528 </div>
529 {errors.slug && (
530 <p className="mt-1 text-sm text-red-500">{errors.slug}</p>
531 )}
532 <p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
533 {isSlugEditable
534 ? __(
535 "Manually editing slug. Click X to cancel and regenerate from name.",
536 "yatra",
537 )
538 : __(
539 "Auto-generated from name. Click edit icon to customize.",
540 "yatra",
541 )}
542 </p>
543 </div>
544
545 {/* Description */}
546 <div className="mt-4">
547 <RichTextEditor
548 label={__("Description", "yatra")}
549 value={formData.description || ""}
550 onChange={(value) =>
551 handleFieldChange("description", value)
552 }
553 placeholder={__("Enter category description...", "yatra")}
554 helperText={__(
555 "Describe what this category is for and what types of trips it contains.",
556 "yatra",
557 )}
558 minHeight={120}
559 maxHeight={360}
560 />
561 </div>
562 </CardContent>
563 </Card>
564
565 <ClassificationLandingPageField
566 selectId="yatra-category-landing-page"
567 value={formData.landing_page_id}
568 onChange={(id) =>
569 setFormData((prev) => ({ ...prev, landing_page_id: id }))
570 }
571 />
572 </div>
573
574 <div className="space-y-3">
575 <Card>
576 <CardHeader className="pb-2">
577 <CardTitle className="text-base">
578 {__("Hierarchy", "yatra")}
579 </CardTitle>
580 </CardHeader>
581 <CardContent className="space-y-3">
582 <div>
583 <label
584 htmlFor="parent_id"
585 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
586 >
587 {__("Parent Category", "yatra")}
588 </label>
589 <Select
590 id="parent_id"
591 value={
592 formData.parent_id === ""
593 ? ""
594 : String(formData.parent_id)
595 }
596 onChange={(e) =>
597 handleFieldChange(
598 "parent_id",
599 e.target.value === "" ? "" : Number(e.target.value),
600 )
601 }
602 className="w-full h-10"
603 >
604 <option value="">
605 {__("None (Top Level)", "yatra")}
606 </option>
607 {parentOptions.map((cat) => (
608 <option key={cat.id} value={cat.id}>
609 {cat.name}
610 </option>
611 ))}
612 </Select>
613 {errors.parent_id && (
614 <p className="mt-1 text-sm text-red-500">
615 {errors.parent_id}
616 </p>
617 )}
618 <p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
619 {__(
620 "Assign a parent category to create subcategories.",
621 "yatra",
622 )}
623 </p>
624 </div>
625 </CardContent>
626 </Card>
627
628 <Card>
629 <CardHeader className="pb-2">
630 <CardTitle className="text-base">
631 {__("Status", "yatra")}
632 </CardTitle>
633 </CardHeader>
634 <CardContent>
635 <Select
636 id="status"
637 value={formData.status}
638 onChange={(e) =>
639 handleFieldChange("status", e.target.value)
640 }
641 className="w-full h-10"
642 >
643 <option value="draft">{__("Draft", "yatra")}</option>
644 <option value="publish">{__("Publish", "yatra")}</option>
645 <option value="trash">{__("Trash", "yatra")}</option>
646 </Select>
647 </CardContent>
648 </Card>
649
650 <Card>
651 <CardHeader className="pb-2">
652 <CardTitle className="text-base">
653 {__("Visibility", "yatra")}
654 </CardTitle>
655 </CardHeader>
656 <CardContent>
657 <label className="flex items-start gap-3 cursor-pointer">
658 <input
659 type="checkbox"
660 className="mt-1 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
661 checked={formData.is_featured}
662 onChange={(e) =>
663 handleFieldChange("is_featured", e.target.checked)
664 }
665 />
666 <span>
667 <span className="block text-sm font-medium text-gray-900 dark:text-gray-100">
668 {__("Featured category", "yatra")}
669 </span>
670 <span className="block text-xs text-gray-500 dark:text-gray-400 mt-0.5">
671 {__(
672 "Shows a “Featured” badge on Trip Category blocks and the [yatra_trip_category] shortcode cards.",
673 "yatra",
674 )}
675 </span>
676 </span>
677 </label>
678 </CardContent>
679 </Card>
680
681 {/* SEO Settings */}
682 <Card>
683 <CardHeader className="pb-2">
684 <CardTitle className="text-base">
685 {__("SEO Settings", "yatra")}
686 </CardTitle>
687 </CardHeader>
688 <CardContent className="space-y-3">
689 <div>
690 <label
691 htmlFor="seo_title"
692 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
693 >
694 {__("Meta Title", "yatra")}
695 </label>
696 <Input
697 id="seo_title"
698 type="text"
699 value={formData.seo_title}
700 onChange={(e) =>
701 handleFieldChange("seo_title", e.target.value)
702 }
703 placeholder={__(
704 "e.g., {name} Trip Categories | Your Travel Agency",
705 "yatra",
706 )}
707 className="w-full"
708 />
709 <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
710 {__(
711 "Custom title for search engines. Use {name} as placeholder.",
712 "yatra",
713 )}
714 </p>
715 </div>
716
717 <div>
718 <label
719 htmlFor="seo_description"
720 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
721 >
722 {__("Meta Description", "yatra")}
723 </label>
724 <textarea
725 id="seo_description"
726 value={formData.seo_description}
727 onChange={(e) =>
728 handleFieldChange("seo_description", e.target.value)
729 }
730 placeholder={__(
731 "e.g., Browse {name} trip categories and find your perfect adventure. Book now!",
732 "yatra",
733 )}
734 rows={3}
735 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"
736 />
737 <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
738 {__(
739 "Description for search engines (150-160 characters). Use {name} as placeholder.",
740 "yatra",
741 )}
742 </p>
743 </div>
744
745 <div>
746 <label
747 htmlFor="seo_keywords"
748 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
749 >
750 {__("Meta Keywords", "yatra")}
751 </label>
752 <Input
753 id="seo_keywords"
754 type="text"
755 value={formData.seo_keywords}
756 onChange={(e) =>
757 handleFieldChange("seo_keywords", e.target.value)
758 }
759 placeholder={__(
760 "e.g., categories, trips, {name}, travel, adventure",
761 "yatra",
762 )}
763 className="w-full"
764 />
765 <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
766 {__(
767 "Comma-separated keywords. Use {name} as placeholder.",
768 "yatra",
769 )}
770 </p>
771 </div>
772 </CardContent>
773 </Card>
774
775 <Card>
776 <CardHeader className="pb-2">
777 <CardTitle className="text-base">
778 {__("Category Icon or Image", "yatra")}
779 </CardTitle>
780 </CardHeader>
781 <CardContent>
782 <IconPicker
783 value={formData.icon}
784 onChange={(value) => handleFieldChange("icon", value)}
785 label={__("Select Icon or Upload Image", "yatra")}
786 helpText={__(
787 "Choose a library icon or upload a custom image for this category.",
788 "yatra",
789 )}
790 allowImageUpload={true}
791 allowIconSelection={true}
792 size="md"
793 />
794 </CardContent>
795 </Card>
796
797 <Card>
798 <CardContent className="p-3">
799 <div className="space-y-2">
800 <div className="flex gap-2">
801 <Button
802 type="submit"
803 disabled={isSubmitting}
804 className="flex-1 flex items-center justify-center gap-2"
805 >
806 {isSubmitting ? (
807 <>
808 <Loader2 className="w-4 h-4 animate-spin" />
809 {__("Saving...", "yatra")}
810 </>
811 ) : (
812 <>
813 <Save className="w-4 h-4" />
814 {isEditMode
815 ? __("Update Category", "yatra")
816 : __("Create Category", "yatra")}
817 </>
818 )}
819 </Button>
820 <Button
821 type="button"
822 variant="outline"
823 onClick={handleCancel}
824 disabled={isSubmitting}
825 >
826 {__("Cancel", "yatra")}
827 </Button>
828 </div>
829 </div>
830 </CardContent>
831 </Card>
832 </div>
833 </div>
834 </form>
835 </ConditionalRender>
836 </div>
837 );
838 };
839
840 export default CategoryForm;
841