| 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 |
|