PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.9
Yatra – Travel Booking & Tour Operator Software v3.0.9
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 / TravelerCategoryForm.tsx

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

914 lines 34.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Traveler Category Form Page
3 * Add/Edit Traveler Category 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, AlertCircle } from "lucide-react";
9 import { __ } from "../lib/i18n";
10 import { usePermissions } from "../hooks/usePermissions";
11 import { useToast } from "../components/ui/toast";
12 import { apiClient } from "../lib/api-client";
13 import { generateSlug } from "../lib/slug";
14 import { Button } from "../components/ui/button";
15 import { Input } from "../components/ui/input";
16 import { Select } from "../components/ui/select";
17 import { PageHeader } from "../components/common/PageHeader";
18 import {
19 Card,
20 CardContent,
21 CardHeader,
22 CardTitle,
23 } from "../components/ui/card";
24 import { ConditionalRender } from "../components/ui/conditional-render";
25 import { IconPicker, IconPickerValue } from "../components/ui/icon-picker";
26 import { Skeleton } from "../components/ui/skeleton";
27 import { HelpText } from "../components/ui/help-text";
28
29 interface TravelerCategoryFormData {
30 label: string;
31 slug: string;
32 description: string;
33 age_min: string;
34 age_max: string;
35 icon: {
36 type: "icon" | "image";
37 value: string;
38 } | null;
39 status: "draft" | "publish" | "trash";
40 pricing_mode: "per_person" | "per_group";
41 min_pax: string;
42 max_pax: string;
43 group_overflow: "block" | "per_block";
44 }
45
46 const TravelerCategoryForm: React.FC = () => {
47 const queryClient = useQueryClient();
48 const { can } = usePermissions();
49 const { showToast } = useToast();
50 const [formData, setFormData] = useState<TravelerCategoryFormData>({
51 label: "",
52 slug: "",
53 description: "",
54 age_min: "",
55 age_max: "",
56 icon: null,
57 status: "publish",
58 pricing_mode: "per_person",
59 min_pax: "",
60 max_pax: "",
61 group_overflow: "block",
62 });
63 const [errors, setErrors] = useState<Record<string, string>>({});
64 const [isSubmitting, setIsSubmitting] = useState(false);
65 const [isSlugEditable, setIsSlugEditable] = useState(false);
66
67 // Get action and id from URL
68 const action = useMemo(() => {
69 const params = new URLSearchParams(window.location.search);
70 return params.get("action") || "create";
71 }, []);
72
73 const categoryId = useMemo(() => {
74 const params = new URLSearchParams(window.location.search);
75 return params.get("id") ? parseInt(params.get("id") || "0") : null;
76 }, []);
77
78 const isEditMode = action === "edit" && categoryId !== null;
79
80 // Fetch category data if editing
81 const { data: categoryData, isLoading: isLoadingCategory } = useQuery({
82 queryKey: ["traveler-category", categoryId],
83 queryFn: async () => {
84 if (!categoryId) return null;
85 try {
86 const response = await apiClient.get(
87 `/traveler-categories/${categoryId}`,
88 );
89 return response;
90 } catch (error: any) {
91 showToast(
92 error?.message || __("Failed to load traveler category", "yatra"),
93 "error",
94 );
95 throw error;
96 }
97 },
98 enabled: isEditMode && can("yatra_view_trips"),
99 });
100
101 // Load category data into form when editing
102 useEffect(() => {
103 if (categoryData && isEditMode) {
104 setFormData({
105 label: categoryData.label || "",
106 slug: categoryData.slug || "",
107 description: categoryData.description || "",
108 age_min: categoryData.age_min?.toString() || "",
109 age_max: categoryData.age_max?.toString() || "",
110 icon: (categoryData.icon as IconPickerValue) || null,
111 status: categoryData.status || "draft",
112 pricing_mode:
113 (categoryData.pricing_mode as "per_person" | "per_group") ||
114 "per_person",
115 min_pax:
116 categoryData.min_pax !== undefined && categoryData.min_pax !== null
117 ? categoryData.min_pax.toString()
118 : "",
119 max_pax:
120 categoryData.max_pax !== undefined && categoryData.max_pax !== null
121 ? categoryData.max_pax.toString()
122 : "",
123 group_overflow:
124 categoryData.group_overflow === "per_block" ? "per_block" : "block",
125 });
126 }
127 }, [categoryData, isEditMode]);
128
129 const handleLabelChange = (value: string) => {
130 // Auto-generate slug from label only on create (and when slug is not manually edited)
131 if (!isEditMode && !isSlugEditable) {
132 const newSlug = generateSlug(value);
133 setFormData((prev) => ({
134 ...prev,
135 label: value,
136 slug: newSlug,
137 }));
138 } else {
139 setFormData((prev) => ({
140 ...prev,
141 label: value,
142 }));
143 }
144 if (errors.label) {
145 setErrors((prev) => ({ ...prev, label: "" }));
146 }
147 };
148
149 const handleSlugChange = (value: string) => {
150 // Only allow manual slug editing if edit mode is enabled
151 if (isSlugEditable) {
152 setFormData((prev) => ({ ...prev, slug: value }));
153 if (errors.slug) {
154 setErrors((prev) => ({ ...prev, slug: "" }));
155 }
156 }
157 };
158
159 const handleToggleSlugEdit = () => {
160 if (isSlugEditable) {
161 // If disabling edit, regenerate slug from label
162 const newSlug = generateSlug(formData.label);
163 setFormData((prev) => ({ ...prev, slug: newSlug }));
164 }
165 setIsSlugEditable(!isSlugEditable);
166 };
167
168 const handleFieldChange = (
169 field: keyof TravelerCategoryFormData,
170 value: string | IconPickerValue | null,
171 ) => {
172 setFormData((prev) => ({ ...prev, [field]: value }));
173 if (errors[field]) {
174 setErrors((prev) => ({ ...prev, [field]: "" }));
175 }
176 };
177
178 const validateForm = (): boolean => {
179 const newErrors: Record<string, string> = {};
180
181 if (!formData.label.trim()) {
182 newErrors.label = __("Label is required", "yatra");
183 }
184
185 if (!formData.slug.trim()) {
186 newErrors.slug = __("Slug is required", "yatra");
187 } else if (!/^[\p{L}\p{N}-]+$/u.test(formData.slug)) {
188 newErrors.slug = __(
189 "Slug can only contain letters, numbers, and hyphens",
190 "yatra",
191 );
192 }
193
194 if (!formData.description.trim()) {
195 newErrors.description = __("Description is required", "yatra");
196 }
197
198 // Validate age range
199 if (formData.age_min && isNaN(parseInt(formData.age_min))) {
200 newErrors.age_min = __("Minimum age must be a valid number", "yatra");
201 }
202 if (formData.age_max && isNaN(parseInt(formData.age_max))) {
203 newErrors.age_max = __("Maximum age must be a valid number", "yatra");
204 }
205 if (
206 formData.age_min &&
207 formData.age_max &&
208 parseInt(formData.age_min) >= parseInt(formData.age_max)
209 ) {
210 newErrors.age_max = __(
211 "Maximum age must be greater than minimum age",
212 "yatra",
213 );
214 }
215
216 // Validate group size when pricing_mode is per_group
217 if (formData.pricing_mode === "per_group") {
218 if (formData.min_pax && isNaN(parseInt(formData.min_pax))) {
219 newErrors.min_pax = __(
220 "Minimum group size must be a valid number",
221 "yatra",
222 );
223 }
224 if (formData.max_pax && isNaN(parseInt(formData.max_pax))) {
225 newErrors.max_pax = __(
226 "Maximum group size must be a valid number",
227 "yatra",
228 );
229 }
230 if (
231 formData.min_pax &&
232 formData.max_pax &&
233 !isNaN(parseInt(formData.min_pax)) &&
234 !isNaN(parseInt(formData.max_pax)) &&
235 parseInt(formData.min_pax) > parseInt(formData.max_pax)
236 ) {
237 newErrors.max_pax = __(
238 "Maximum group size should be greater than or equal to minimum group size",
239 "yatra",
240 );
241 }
242 // "Charge for additional groups" needs a maximum group size to know how
243 // big one group is.
244 if (
245 formData.group_overflow === "per_block" &&
246 (!formData.max_pax ||
247 formData.max_pax.trim() === "" ||
248 isNaN(parseInt(formData.max_pax)) ||
249 parseInt(formData.max_pax) < 1)
250 ) {
251 newErrors.max_pax = __(
252 "Set a maximum group size to charge for additional groups.",
253 "yatra",
254 );
255 }
256 }
257
258 setErrors(newErrors);
259 return Object.keys(newErrors).length === 0;
260 };
261
262 // Create/Update mutation
263 const saveMutation = useMutation({
264 mutationFn: async (data: TravelerCategoryFormData) => {
265 const payload: any = {
266 label: data.label.trim(),
267 slug: data.slug.trim(),
268 description: data.description.trim(),
269 icon: data.icon,
270 status: data.status,
271 };
272
273 // Handle age_min
274 if (data.age_min && data.age_min.trim() !== "") {
275 payload.age_min = parseInt(data.age_min);
276 } else {
277 payload.age_min = null;
278 }
279
280 // Handle age_max
281 if (data.age_max && data.age_max.trim() !== "") {
282 payload.age_max = parseInt(data.age_max);
283 } else {
284 payload.age_max = null;
285 }
286
287 // Pricing mode and group size
288 payload.pricing_mode = data.pricing_mode || "per_person";
289
290 if (data.pricing_mode === "per_group") {
291 if (data.min_pax && data.min_pax.trim() !== "") {
292 payload.min_pax = parseInt(data.min_pax);
293 } else {
294 payload.min_pax = null;
295 }
296
297 if (data.max_pax && data.max_pax.trim() !== "") {
298 payload.max_pax = parseInt(data.max_pax);
299 } else {
300 payload.max_pax = null;
301 }
302
303 payload.group_overflow =
304 data.group_overflow === "per_block" ? "per_block" : "block";
305 } else {
306 payload.min_pax = null;
307 payload.max_pax = null;
308 payload.group_overflow = "block";
309 }
310
311 // If slug was manually edited, add flag to preserve it
312 if (isEditMode && isSlugEditable) {
313 payload.preserve_slug = true;
314 }
315
316 if (isEditMode && categoryId) {
317 return await apiClient.put(
318 `/traveler-categories/${categoryId}`,
319 payload,
320 );
321 } else {
322 return await apiClient.post("/traveler-categories", payload);
323 }
324 },
325 onSuccess: (response: any) => {
326 queryClient.invalidateQueries({ queryKey: ["traveler-categories"] });
327 queryClient.invalidateQueries({
328 queryKey: ["traveler-category", categoryId],
329 });
330 showToast(
331 isEditMode
332 ? __("Traveler category updated successfully", "yatra")
333 : __("Traveler category created successfully", "yatra"),
334 "success",
335 );
336 if (!isEditMode) {
337 const newId = response?.id || response?.data?.id;
338 if (newId) {
339 setTimeout(() => {
340 window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=traveler-categories&action=edit&id=${newId}`;
341 }, 800);
342 }
343 } else {
344 setIsSubmitting(false);
345 }
346 },
347 onError: (error: any) => {
348 const errorMessage =
349 error?.message ||
350 __("An error occurred while saving the traveler category", "yatra");
351 showToast(errorMessage, "error");
352 setIsSubmitting(false);
353 },
354 });
355
356 const handleSubmit = async (e: React.FormEvent) => {
357 e.preventDefault();
358
359 if (!validateForm()) {
360 showToast(__("Please fix the form errors", "yatra"), "warning");
361 return;
362 }
363
364 setIsSubmitting(true);
365 setErrors({});
366 saveMutation.mutate(formData);
367 };
368
369 const handleCancel = () => {
370 window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=traveler-categories`;
371 };
372
373 if (isEditMode && isLoadingCategory) {
374 return (
375 <div className="space-y-3">
376 <PageHeader
377 title={__("Edit Category", "yatra")}
378 description={__("Update traveler category information", "yatra")}
379 actions={<Skeleton className="w-24 h-9 rounded-md" />}
380 />
381
382 <div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
383 <div className="lg:col-span-2 space-y-3">
384 <Card>
385 <CardHeader className="pb-2">
386 <Skeleton className="h-5 w-48" />
387 </CardHeader>
388 <CardContent className="space-y-4">
389 {[1, 2, 3].map((section) => (
390 <div key={section} className="space-y-2">
391 <Skeleton className="h-4 w-32" />
392 <Skeleton className="h-10 w-full" />
393 </div>
394 ))}
395 <div className="space-y-2">
396 <Skeleton className="h-4 w-32" />
397 <Skeleton className="h-24 w-full" />
398 </div>
399 </CardContent>
400 </Card>
401 </div>
402
403 <div className="space-y-3">
404 {[1, 2, 3].map((card) => (
405 <Card key={card}>
406 <CardHeader className="pb-2">
407 <Skeleton className="h-5 w-36" />
408 </CardHeader>
409 <CardContent>
410 <Skeleton className="h-40 w-full" />
411 </CardContent>
412 </Card>
413 ))}
414 </div>
415 </div>
416 </div>
417 );
418 }
419
420 return (
421 <div className="space-y-3">
422 <PageHeader
423 title={
424 isEditMode
425 ? __("Edit Category", "yatra")
426 : __("Add Category", "yatra")
427 }
428 description={
429 isEditMode
430 ? __("Update traveler category information", "yatra")
431 : __("Create a new traveler category for pricing", "yatra")
432 }
433 actions={
434 <Button
435 variant="outline"
436 onClick={handleCancel}
437 className="flex items-center gap-2"
438 >
439 <ArrowLeft className="w-4 h-4" />
440 {__("Back", "yatra")}
441 </Button>
442 }
443 />
444
445 <ConditionalRender capability="yatra_edit_trips">
446 <form onSubmit={handleSubmit}>
447 <div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
448 {/* Main Form Fields */}
449 <div className="lg:col-span-2 space-y-3">
450 {/* Basic Information */}
451 <Card>
452 <CardHeader className="pb-2">
453 <CardTitle className="text-base">
454 {__("Category Information", "yatra")}
455 </CardTitle>
456 </CardHeader>
457 <CardContent className="space-y-3">
458 {/* Label */}
459 <div>
460 <label
461 htmlFor="label"
462 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
463 >
464 {__("Category Label", "yatra")}{" "}
465 <span className="text-red-500">*</span>
466 </label>
467 <HelpText
468 text={__(
469 "The name of this traveler category (e.g., Adult, Child, Senior, Student)",
470 "yatra",
471 )}
472 className="mb-2"
473 />
474 <Input
475 id="label"
476 type="text"
477 value={formData.label}
478 onChange={(e) => handleLabelChange(e.target.value)}
479 placeholder={__("e.g., Adult", "yatra")}
480 className={errors.label ? "border-red-500" : ""}
481 required
482 />
483 {errors.label && (
484 <p className="mt-1.5 text-sm text-red-600 dark:text-red-400 flex items-center gap-1">
485 <AlertCircle className="w-4 h-4" />
486 {errors.label}
487 </p>
488 )}
489 </div>
490
491 {/* Slug */}
492 <div>
493 <label
494 htmlFor="slug"
495 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
496 >
497 {__("Slug", "yatra")}{" "}
498 <span className="text-red-500">*</span>
499 </label>
500 <div className="relative">
501 <Input
502 id="slug"
503 type="text"
504 value={formData.slug}
505 onChange={(e) => handleSlugChange(e.target.value)}
506 placeholder={__("category-slug", "yatra")}
507 className={`pr-10 ${errors.slug ? "border-red-500" : ""} ${!isSlugEditable ? "bg-gray-50 dark:bg-gray-800 cursor-not-allowed" : ""}`}
508 disabled={!isSlugEditable}
509 required
510 />
511 <button
512 type="button"
513 onClick={handleToggleSlugEdit}
514 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"
515 aria-label={
516 isSlugEditable
517 ? __("Cancel editing slug", "yatra")
518 : __("Edit slug", "yatra")
519 }
520 >
521 {isSlugEditable ? (
522 <X className="w-4 h-4" />
523 ) : (
524 <Edit2 className="w-4 h-4" />
525 )}
526 </button>
527 </div>
528 {errors.slug && (
529 <p className="mt-1 text-sm text-red-500">{errors.slug}</p>
530 )}
531 <p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
532 {isSlugEditable
533 ? __(
534 "You are manually editing the slug. Click X to cancel and regenerate from label.",
535 "yatra",
536 )
537 : __(
538 "This slug is auto-generated from the label. Click the edit icon to customize.",
539 "yatra",
540 )}
541 </p>
542 </div>
543
544 {/* Description */}
545 <div>
546 <label
547 htmlFor="description"
548 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
549 >
550 {__("Description", "yatra")}{" "}
551 <span className="text-red-500">*</span>
552 </label>
553 <HelpText
554 text={__(
555 "A brief description of this category and who it applies to",
556 "yatra",
557 )}
558 className="mb-2"
559 />
560 <textarea
561 id="description"
562 value={formData.description}
563 onChange={(e) =>
564 handleFieldChange("description", e.target.value)
565 }
566 placeholder={__(
567 "e.g., Standard adult pricing for travelers aged 18 and above",
568 "yatra",
569 )}
570 rows={3}
571 className={`flex w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm ring-offset-white file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-gray-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:ring-offset-gray-900 dark:placeholder:text-gray-400 dark:focus-visible:ring-blue-400 resize-none ${errors.description ? "border-red-500" : ""}`}
572 required
573 />
574 {errors.description && (
575 <p className="mt-1.5 text-sm text-red-600 dark:text-red-400 flex items-center gap-1">
576 <AlertCircle className="w-4 h-4" />
577 {errors.description}
578 </p>
579 )}
580 </div>
581
582 {/* Age Range */}
583 <div>
584 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
585 {__("Age Range", "yatra")}
586 </label>
587 <HelpText
588 text={__(
589 "Specify the age range for this category (e.g., 0-4 for Infant, 5-17 for Child, 18+ for Adult). Leave empty if no age restriction.",
590 "yatra",
591 )}
592 className="mb-2"
593 />
594 <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
595 <div>
596 <label
597 htmlFor="age_min"
598 className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1.5"
599 >
600 {__("Minimum Age", "yatra")}
601 </label>
602 <Input
603 id="age_min"
604 type="number"
605 min="0"
606 value={formData.age_min}
607 onChange={(e) =>
608 handleFieldChange("age_min", e.target.value)
609 }
610 placeholder={__("e.g., 0, 5, 18", "yatra")}
611 className={errors.age_min ? "border-red-500" : ""}
612 />
613 {errors.age_min && (
614 <p className="mt-1.5 text-sm text-red-600 dark:text-red-400 flex items-center gap-1">
615 <AlertCircle className="w-4 h-4" />
616 {errors.age_min}
617 </p>
618 )}
619 </div>
620 <div>
621 <label
622 htmlFor="age_max"
623 className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1.5"
624 >
625 {__("Maximum Age", "yatra")}
626 </label>
627 <Input
628 id="age_max"
629 type="number"
630 min="0"
631 value={formData.age_max}
632 onChange={(e) =>
633 handleFieldChange("age_max", e.target.value)
634 }
635 placeholder={__(
636 "e.g., 4, 17, leave empty for 18+",
637 "yatra",
638 )}
639 className={errors.age_max ? "border-red-500" : ""}
640 />
641 {errors.age_max && (
642 <p className="mt-1.5 text-sm text-red-600 dark:text-red-400 flex items-center gap-1">
643 <AlertCircle className="w-4 h-4" />
644 {errors.age_max}
645 </p>
646 )}
647 </div>
648 </div>
649 {formData.age_min &&
650 formData.age_max &&
651 parseInt(formData.age_min) >=
652 parseInt(formData.age_max) && (
653 <p className="mt-1.5 text-sm text-yellow-600 dark:text-yellow-400 flex items-center gap-1">
654 <AlertCircle className="w-4 h-4" />
655 {__(
656 "Minimum age should be less than maximum age",
657 "yatra",
658 )}
659 </p>
660 )}
661 </div>
662
663 {/* Pricing Mode & Group Size */}
664 <div>
665 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
666 {__("Pricing Mode", "yatra")}
667 </label>
668 <HelpText
669 text={__(
670 "Choose how this traveler category is priced. Per person charges per traveler, while per group uses a flat price for a group booking.",
671 "yatra",
672 )}
673 className="mb-2"
674 />
675 <Select
676 id="pricing_mode"
677 value={formData.pricing_mode}
678 onChange={(e) =>
679 handleFieldChange(
680 "pricing_mode",
681 e.target.value as "per_person" | "per_group",
682 )
683 }
684 >
685 <option value="per_person">
686 {__("Per person", "yatra")}
687 </option>
688 <option value="per_group">
689 {__("Per group", "yatra")}
690 </option>
691 </Select>
692
693 {formData.pricing_mode === "per_group" && (
694 <div className="mt-3 space-y-2">
695 <HelpText
696 text={__(
697 "Optional group size limits for this category. These are used to validate bookings when this category is priced per group.",
698 "yatra",
699 )}
700 className="mb-1"
701 />
702 <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
703 <div>
704 <label
705 htmlFor="min_pax"
706 className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1.5"
707 >
708 {__("Minimum Group Size", "yatra")}
709 </label>
710 <Input
711 id="min_pax"
712 type="number"
713 min="1"
714 value={formData.min_pax}
715 onChange={(e) =>
716 handleFieldChange("min_pax", e.target.value)
717 }
718 placeholder={__("e.g., 10", "yatra")}
719 className={errors.min_pax ? "border-red-500" : ""}
720 />
721 {errors.min_pax && (
722 <p className="mt-1.5 text-sm text-red-600 dark:text-red-400 flex items-center gap-1">
723 <AlertCircle className="w-4 h-4" />
724 {errors.min_pax}
725 </p>
726 )}
727 </div>
728 <div>
729 <label
730 htmlFor="max_pax"
731 className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1.5"
732 >
733 {__("Maximum Group Size", "yatra")}
734 </label>
735 <Input
736 id="max_pax"
737 type="number"
738 min="1"
739 value={formData.max_pax}
740 onChange={(e) =>
741 handleFieldChange("max_pax", e.target.value)
742 }
743 placeholder={__("e.g., 6 (optional)", "yatra")}
744 className={errors.max_pax ? "border-red-500" : ""}
745 />
746 {errors.max_pax && (
747 <p className="mt-1.5 text-sm text-red-600 dark:text-red-400 flex items-center gap-1">
748 <AlertCircle className="w-4 h-4" />
749 {errors.max_pax}
750 </p>
751 )}
752 </div>
753 </div>
754
755 {/* How a party larger than the max group size is handled */}
756 <div className="mt-3">
757 <label
758 htmlFor="group_overflow"
759 className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1.5"
760 >
761 {__(
762 "If a booking has more people than the maximum group size",
763 "yatra",
764 )}
765 </label>
766 <Select
767 id="group_overflow"
768 value={formData.group_overflow}
769 onChange={(e) =>
770 handleFieldChange(
771 "group_overflow",
772 e.target.value as "block" | "per_block",
773 )
774 }
775 >
776 <option value="block">
777 {__(
778 "Don’t allow it — the maximum is a strict limit",
779 "yatra",
780 )}
781 </option>
782 <option value="per_block">
783 {__(
784 "Allow it — charge for additional groups",
785 "yatra",
786 )}
787 </option>
788 </Select>
789 <HelpText
790 text={__(
791 "What happens when a party is bigger than the maximum group size. “Don’t allow it” caps every booking at the maximum. “Charge for additional groups” lets larger parties book and charges the group price once per group — for example, with a maximum of 5, a party of 7 needs 2 groups and pays 2× the group price.",
792 "yatra",
793 )}
794 className="mt-1"
795 />
796 </div>
797 </div>
798 )}
799 </div>
800 </CardContent>
801 </Card>
802 </div>
803
804 {/* Sidebar */}
805 <div className="space-y-3">
806 {/* Icon/Image Picker */}
807 <Card>
808 <CardHeader className="pb-2">
809 <CardTitle className="text-base">
810 {__("Category Icon or Image", "yatra")}
811 </CardTitle>
812 </CardHeader>
813 <CardContent className="space-y-3">
814 <IconPicker
815 value={formData.icon}
816 onChange={(value) => handleFieldChange("icon", value)}
817 label={undefined}
818 helpText={__(
819 "Select an icon from the library or upload a custom image for this category.",
820 "yatra",
821 )}
822 allowImageUpload={true}
823 allowIconSelection={true}
824 size="md"
825 />
826 </CardContent>
827 </Card>
828
829 {/* Status */}
830 <Card>
831 <CardHeader className="pb-2">
832 <CardTitle className="text-base">
833 {__("Status", "yatra")}
834 </CardTitle>
835 </CardHeader>
836 <CardContent className="space-y-3">
837 <div>
838 <label
839 htmlFor="status"
840 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
841 >
842 {__("Status", "yatra")}
843 </label>
844 <HelpText
845 text={__(
846 "Draft categories will not be available when creating trips",
847 "yatra",
848 )}
849 className="mb-2"
850 />
851 <Select
852 id="status"
853 value={formData.status}
854 onChange={(e) =>
855 handleFieldChange(
856 "status",
857 e.target.value as "draft" | "publish" | "trash",
858 )
859 }
860 >
861 <option value="draft">{__("Draft", "yatra")}</option>
862 <option value="publish">{__("Publish", "yatra")}</option>
863 <option value="trash">{__("Trash", "yatra")}</option>
864 </Select>
865 </div>
866 </CardContent>
867 </Card>
868
869 {/* Submit Actions */}
870 <Card>
871 <CardContent className="p-3">
872 <div className="space-y-2">
873 <div className="flex gap-2">
874 <Button
875 type="submit"
876 disabled={isSubmitting}
877 className="flex-1 flex items-center justify-center gap-2"
878 >
879 {isSubmitting ? (
880 <>
881 <Loader2 className="w-4 h-4 animate-spin" />
882 {__("Saving...", "yatra")}...
883 </>
884 ) : (
885 <>
886 <Save className="w-4 h-4" />
887 {isEditMode
888 ? __("Update Category", "yatra")
889 : __("Create Category", "yatra")}
890 </>
891 )}
892 </Button>
893 <Button
894 type="button"
895 variant="outline"
896 onClick={handleCancel}
897 disabled={isSubmitting}
898 >
899 {__("Cancel", "yatra")}
900 </Button>
901 </div>
902 </div>
903 </CardContent>
904 </Card>
905 </div>
906 </div>
907 </form>
908 </ConditionalRender>
909 </div>
910 );
911 };
912
913 export default TravelerCategoryForm;
914