PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.15
Yatra – Travel Booking & Tour Operator Software v3.0.15
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 / RecurringRuleForm.tsx

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

2,644 lines 126.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Recurring Availability Rule Form
3 * Create and edit recurring availability patterns
4 */
5
6 import React, { useState, useEffect } from "react";
7 import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
8 import {
9 ArrowLeft,
10 Save,
11 Clock,
12 DollarSign,
13 RefreshCw,
14 Plus,
15 X,
16 AlertCircle,
17 Eye,
18 MapPin,
19 CheckCircle2,
20 } from "lucide-react";
21 import { __ } from "../lib/i18n";
22 import { toDateValue, todayYmd } from "../lib/dateFormat";
23 import { Button } from "../components/ui/button";
24 import { Input } from "../components/ui/input";
25 import { Select } from "../components/ui/select";
26 import { SearchableSelect } from "../components/ui/searchable-select";
27 import { DatePicker } from "../components/ui/date-picker";
28 import { TimePicker } from "../components/ui/time-picker";
29 import { PageHeader } from "../components/common/PageHeader";
30 import {
31 Card,
32 CardContent,
33 CardHeader,
34 CardTitle,
35 CardDescription,
36 } from "../components/ui/card";
37 import { Badge } from "../components/ui/badge";
38 import { Alert } from "../components/ui/alert";
39 import { useNavigate } from "../hooks/useNavigate";
40 import { apiClient } from "../lib/api-client";
41 import { useToast } from "../components/ui/toast";
42 import { RecurringRuleFormSkeleton } from "../components/availability/RecurringRuleFormSkeleton";
43 import { LocationPicker } from "../components/trip-form/LocationPicker";
44
45 function coordFromApi(v: unknown): string {
46 if (v == null || v === "") return "";
47 return String(v);
48 }
49
50 interface Trip {
51 id: number;
52 title: string;
53 trip_type?: "single_day" | "multi_day";
54 duration_days?: number;
55 starting_location?: string;
56 ending_location?: string;
57 starting_latitude?: string | number;
58 starting_longitude?: string | number;
59 ending_latitude?: string | number;
60 ending_longitude?: string | number;
61 pricing_type?: "regular" | "traveler_based";
62 }
63
64 interface TravelerCategory {
65 id: number;
66 name?: string;
67 label?: string;
68 description?: string;
69 min_age?: number;
70 max_age?: number;
71 age_min?: number;
72 age_max?: number;
73 status?: string;
74 }
75
76 interface TravelerPricing {
77 category_id: number;
78 original_price: number;
79 sale_price?: number;
80 }
81
82 interface TimeSlot {
83 departure_time: string;
84 arrival_time: string;
85 seats: number;
86 price: number;
87 sale_price?: number;
88 traveler_pricing?: TravelerPricing[];
89 }
90
91 interface RecurringRule {
92 id?: number;
93 trip_id: number;
94 name: string;
95 rule_type: "weekly" | "monthly" | "interval";
96 days_of_week: number[];
97 week_of_month?: "first" | "second" | "third" | "fourth" | "last";
98 day_of_week?: number;
99 interval_days?: number;
100 start_date: string;
101 end_date?: string;
102 excluded_dates: string[];
103 months: number[]; // Array of month numbers (1-12) to filter by
104 time_slots: TimeSlot[]; // For single-day trips with multiple slots
105 pricing_type: "regular" | "traveler_based"; // Allow override of trip's pricing type
106 original_price?: number;
107 sale_price?: number;
108 traveler_pricing?: TravelerPricing[]; // For traveler-based pricing
109 seats_total: number;
110 departure_time?: string;
111 arrival_time?: string;
112 from_location?: string;
113 to_location?: string;
114 from_latitude?: string;
115 from_longitude?: string;
116 to_latitude?: string;
117 to_longitude?: string;
118 cutoff_hours: number;
119 alert_threshold: number;
120 status: "active" | "inactive";
121 }
122
123 const dayOptions = [
124 { value: 0, label: "Sunday" },
125 { value: 1, label: "Monday" },
126 { value: 2, label: "Tuesday" },
127 { value: 3, label: "Wednesday" },
128 { value: 4, label: "Thursday" },
129 { value: 5, label: "Friday" },
130 { value: 6, label: "Saturday" },
131 ];
132
133 const weekOptions = [
134 { value: "first", label: "First" },
135 { value: "second", label: "Second" },
136 { value: "third", label: "Third" },
137 { value: "fourth", label: "Fourth" },
138 { value: "last", label: "Last" },
139 ];
140
141 const RecurringRuleForm: React.FC = () => {
142 const { navigate } = useNavigate();
143 const queryClient = useQueryClient();
144 const { showToast } = useToast();
145
146 // Get parameters from URL
147 const params = new URLSearchParams(window.location.search);
148 const ruleId = params.get("id");
149 const tripIdFromUrl = params.get("trip_id");
150 const isEditing = !!ruleId;
151
152 // Form state
153 const [formData, setFormData] = useState<RecurringRule>({
154 trip_id: tripIdFromUrl ? parseInt(tripIdFromUrl) : 0,
155 name: "",
156 rule_type: "weekly",
157 days_of_week: [0], // Sunday by default
158 week_of_month: "first",
159 day_of_week: 0,
160 interval_days: 7,
161 start_date: todayYmd(),
162 end_date: "",
163 excluded_dates: [],
164 months: [], // Empty = all months, otherwise specific months (1-12)
165 time_slots: [], // For single-day trips with multiple slots
166 pricing_type: "regular", // Will be updated based on trip's pricing type
167 original_price: undefined,
168 sale_price: undefined,
169 traveler_pricing: [], // For traveler-based pricing
170 seats_total: 20,
171 departure_time: "",
172 arrival_time: "",
173 from_location: "",
174 to_location: "",
175 from_latitude: "",
176 from_longitude: "",
177 to_latitude: "",
178 to_longitude: "",
179 cutoff_hours: 24,
180 alert_threshold: 5,
181 status: "active",
182 });
183
184 const [newExcludedDate, setNewExcludedDate] = useState("");
185 const [previewData, setPreviewData] = useState<{
186 total: number;
187 dates: any[];
188 } | null>(null);
189 const [showCategorySelector, setShowCategorySelector] = useState(false);
190
191 // Fetch trips for dropdown
192 const { data: tripsData } = useQuery({
193 queryKey: ["trips", "all"],
194 queryFn: async () => {
195 const response = await apiClient.get("/trips", {
196 params: { per_page: 100, status: "publish" },
197 });
198 return {
199 trips: (response?.data || []).map((trip: any) => {
200 // Some endpoints return `pricing_type` as "regular" even when the trip is
201 // effectively traveler-based (price types configured). Infer the effective
202 // pricing type from `price_types` when present so the Rules UI reflects
203 // real trip configuration.
204 const rawPriceTypes = trip.price_types;
205 const hasTravelerPricing = Array.isArray(rawPriceTypes)
206 ? rawPriceTypes.length > 0
207 : false;
208 const effectivePricingType = hasTravelerPricing
209 ? "traveler_based"
210 : trip.pricing_type || "regular";
211
212 return {
213 id: Number(trip.id) || 0,
214 title: trip.title,
215 trip_type:
216 trip.trip_type ||
217 (trip.duration_days <= 1 ? "single_day" : "multi_day"),
218 duration_days: trip.duration_days || 1,
219 starting_location: trip.starting_location,
220 ending_location: trip.ending_location,
221 pricing_type: effectivePricingType,
222 };
223 }) as Trip[],
224 };
225 },
226 });
227
228 // Fetch traveler categories
229 const { data: travelerCategories = [] } = useQuery({
230 queryKey: ["traveler-categories"],
231 queryFn: async () => {
232 const response = await apiClient.get("/traveler-categories", {
233 params: { per_page: 100 },
234 });
235 return (response?.data || []) as TravelerCategory[];
236 },
237 });
238
239 // Get selected trip details
240 const selectedTrip = tripsData?.trips.find((t) => t.id === formData.trip_id);
241 const { data: tripForLocations } = useQuery({
242 queryKey: ["trip", formData.trip_id, "recurring-rule-locations"],
243 queryFn: async () => {
244 const response = await apiClient.get(`/trips/${formData.trip_id}`);
245 return response?.data || response || null;
246 },
247 enabled: formData.trip_id > 0,
248 staleTime: 5 * 60 * 1000,
249 });
250 const { data: fallbackTripData } = useQuery({
251 queryKey: ["trip", formData.trip_id, "recurring-rule-form"],
252 queryFn: async () => {
253 if (!formData.trip_id || selectedTrip) {
254 return null;
255 }
256 const response = await apiClient.get(`/trips/${formData.trip_id}`);
257 return response?.data || response || null;
258 },
259 enabled: !!formData.trip_id && !selectedTrip,
260 staleTime: 5 * 60 * 1000,
261 });
262 const effectiveTrip =
263 selectedTrip ||
264 (fallbackTripData
265 ? {
266 id: Number(fallbackTripData.id) || formData.trip_id,
267 title: fallbackTripData.title,
268 }
269 : null);
270 const tripNameLabel = effectiveTrip?.title || __("Unnamed Trip", "yatra");
271 let headerDescription = __(
272 "Set up automatic availability patterns for your trips",
273 "yatra",
274 );
275 if (effectiveTrip) {
276 headerDescription = `${isEditing ? __("Edit", "yatra") : __("Add", "yatra")} ${__("availability rule for", "yatra")} ${tripNameLabel} (Trip ID: ${effectiveTrip.id})`;
277 } else if (formData.trip_id) {
278 headerDescription = `${isEditing ? __("Edit", "yatra") : __("Add", "yatra")} ${__("availability rule for Trip ID:", "yatra")} ${formData.trip_id}`;
279 }
280 const isSingleDayTrip =
281 selectedTrip?.trip_type === "single_day" ||
282 (selectedTrip?.duration_days || 1) <= 1;
283 // Use form's pricing_type which defaults to trip's pricing type but can be overridden
284 const isTravelerBasedPricing = formData.pricing_type === "traveler_based";
285
286 // Fetch existing rule if editing
287 const { data: existingRule, isLoading: isLoadingRule } = useQuery({
288 queryKey: ["recurring-availability", ruleId],
289 queryFn: async () => {
290 if (!ruleId) return null;
291 const response = await apiClient.get(`/recurring-availability/${ruleId}`);
292 return response?.data || response; // Unwrap data property from API response
293 },
294 enabled: !!ruleId,
295 });
296
297 // Update form when existing rule is loaded
298 useEffect(() => {
299 if (existingRule && tripsData) {
300 // Parse days_of_week - handle both array and string formats
301 let daysOfWeek = [0]; // Default to Sunday
302 if (
303 existingRule.days_of_week_array &&
304 Array.isArray(existingRule.days_of_week_array)
305 ) {
306 daysOfWeek = existingRule.days_of_week_array;
307 } else if (existingRule.days_of_week) {
308 if (typeof existingRule.days_of_week === "string") {
309 daysOfWeek = existingRule.days_of_week
310 .split(",")
311 .map(Number)
312 .filter((n: number) => !isNaN(n));
313 } else if (Array.isArray(existingRule.days_of_week)) {
314 daysOfWeek = existingRule.days_of_week.map(Number);
315 }
316 }
317
318 // Ensure rule_type is properly typed
319 const ruleType = (existingRule.rule_type || "weekly") as
320 | "weekly"
321 | "monthly"
322 | "interval";
323
324 // Prefer the *trip's effective* pricing type over any stale rule.pricing_type.
325 // Trips can be traveler-based simply by having price_types configured, even if
326 // trip.pricing_type is still "regular".
327 const tripRow = tripsData.trips.find(
328 (t) => t.id === Number(existingRule.trip_id),
329 );
330 const effectivePricingType =
331 tripRow?.pricing_type ||
332 ((tripForLocations as any)?.price_types &&
333 Array.isArray((tripForLocations as any).price_types) &&
334 (tripForLocations as any).price_types.length > 0
335 ? "traveler_based"
336 : (tripForLocations as any)?.pricing_type) ||
337 "regular";
338
339 setFormData({
340 trip_id: existingRule.trip_id || 0,
341 name: existingRule.name || "",
342 rule_type: ruleType,
343 days_of_week: daysOfWeek.length > 0 ? daysOfWeek : [0],
344 week_of_month: existingRule.week_of_month || "first",
345 day_of_week: existingRule.day_of_week ?? 0,
346 interval_days: existingRule.interval_days || 7,
347 start_date: existingRule.start_date || todayYmd(),
348 end_date: existingRule.end_date || "",
349 excluded_dates: Array.isArray(existingRule.excluded_dates)
350 ? existingRule.excluded_dates
351 : [],
352 months: Array.isArray(existingRule.months) ? existingRule.months : [],
353 time_slots: Array.isArray(existingRule.time_slots)
354 ? existingRule.time_slots
355 : [],
356 pricing_type: effectivePricingType as "regular" | "traveler_based",
357 original_price: existingRule.original_price,
358 sale_price: existingRule.sale_price,
359 traveler_pricing: Array.isArray(existingRule.traveler_pricing)
360 ? existingRule.traveler_pricing
361 : [],
362 seats_total: existingRule.seats_total || 20,
363 departure_time: existingRule.departure_time || "",
364 arrival_time: existingRule.arrival_time || "",
365 from_location: existingRule.from_location || "",
366 to_location: existingRule.to_location || "",
367 from_latitude: coordFromApi(existingRule.from_latitude),
368 from_longitude: coordFromApi(existingRule.from_longitude),
369 to_latitude: coordFromApi(existingRule.to_latitude),
370 to_longitude: coordFromApi(existingRule.to_longitude),
371 cutoff_hours: existingRule.cutoff_hours || 24,
372 alert_threshold: existingRule.alert_threshold || 5,
373 status: existingRule.status || "active",
374 });
375 }
376 }, [existingRule, tripsData, tripForLocations]);
377
378 // Set pricing type based on selected trip when not editing
379 useEffect(() => {
380 if (!isEditing && !existingRule) {
381 const inferred = (() => {
382 const priceTypes = (tripForLocations as any)?.price_types;
383 const hasTravelerPricing =
384 Array.isArray(priceTypes) && priceTypes.length > 0;
385 return (
386 hasTravelerPricing
387 ? "traveler_based"
388 : selectedTrip?.pricing_type ||
389 (tripForLocations as Trip)?.pricing_type ||
390 "regular"
391 ) as "regular" | "traveler_based";
392 })();
393
394 setFormData((prev) => ({
395 ...prev,
396 ...(selectedTrip || tripForLocations
397 ? {
398 pricing_type: inferred,
399 }
400 : {}),
401 ...(tripForLocations
402 ? {
403 from_location:
404 prev.from_location ||
405 (tripForLocations as Trip).starting_location ||
406 "",
407 to_location:
408 prev.to_location ||
409 (tripForLocations as Trip).ending_location ||
410 "",
411 from_latitude:
412 prev.from_latitude ||
413 coordFromApi((tripForLocations as Trip).starting_latitude),
414 from_longitude:
415 prev.from_longitude ||
416 coordFromApi((tripForLocations as Trip).starting_longitude),
417 to_latitude:
418 prev.to_latitude ||
419 coordFromApi((tripForLocations as Trip).ending_latitude),
420 to_longitude:
421 prev.to_longitude ||
422 coordFromApi((tripForLocations as Trip).ending_longitude),
423 }
424 : {}),
425 }));
426 }
427 }, [isEditing, selectedTrip, tripForLocations, existingRule]);
428
429 // Create mutation
430 const createMutation = useMutation({
431 mutationFn: async (data: RecurringRule) => {
432 // `days_of_week` is a JSON column on the backend; send the array as-is
433 // and let the API JSON-encode it. Sending a CSV string (e.g. "0,1,2,3")
434 // is rejected by MySQL with "Invalid JSON text".
435 return await apiClient.post("/recurring-availability", {
436 ...data,
437 days_of_week: Array.isArray(data.days_of_week) ? data.days_of_week : [],
438 // Normalize month-week selector to backend contract.
439 week_of_month:
440 data.rule_type === "monthly"
441 ? ((data.week_of_month || "first") as string).toLowerCase()
442 : data.week_of_month,
443 // On create, omit empty arrays to keep payload small.
444 time_slots: data.time_slots.length > 0 ? data.time_slots : undefined,
445 traveler_pricing:
446 data.traveler_pricing && data.traveler_pricing.length > 0
447 ? data.traveler_pricing
448 : undefined,
449 });
450 },
451 onSuccess: () => {
452 queryClient.invalidateQueries({ queryKey: ["recurring-availability"] });
453 showToast(__("Recurring rule created successfully", "yatra"), "success");
454 navigate({
455 subpage: "trips",
456 tab: "availability",
457 trip_id: formData.trip_id.toString(),
458 });
459 },
460 onError: (error: any) => {
461 showToast(
462 error?.message || __("Failed to create rule", "yatra"),
463 "error",
464 );
465 },
466 });
467
468 // Update mutation
469 const updateMutation = useMutation({
470 mutationFn: async (data: RecurringRule) => {
471 return await apiClient.put(`/recurring-availability/${ruleId}`, {
472 ...data,
473 days_of_week: Array.isArray(data.days_of_week) ? data.days_of_week : [],
474 week_of_month:
475 data.rule_type === "monthly"
476 ? ((data.week_of_month || "first") as string).toLowerCase()
477 : data.week_of_month,
478 // IMPORTANT: on update, send empty arrays explicitly to clear persisted
479 // JSON columns; omitting the key leaves the old value in DB.
480 time_slots: Array.isArray(data.time_slots) ? data.time_slots : [],
481 traveler_pricing: Array.isArray(data.traveler_pricing)
482 ? data.traveler_pricing
483 : [],
484 });
485 },
486 onSuccess: () => {
487 queryClient.invalidateQueries({ queryKey: ["recurring-availability"] });
488 showToast(__("Recurring rule updated successfully", "yatra"), "success");
489 navigate({
490 subpage: "trips",
491 tab: "availability",
492 trip_id: formData.trip_id.toString(),
493 });
494 },
495 onError: (error: any) => {
496 showToast(
497 error?.message || __("Failed to update rule", "yatra"),
498 "error",
499 );
500 },
501 });
502
503 // Preview mutation
504 const previewMutation = useMutation({
505 mutationFn: async (data: RecurringRule) => {
506 return await apiClient.post("/recurring-availability/preview", {
507 ...data,
508 days_of_week: Array.isArray(data.days_of_week) ? data.days_of_week : [],
509 week_of_month:
510 data.rule_type === "monthly"
511 ? ((data.week_of_month || "first") as string).toLowerCase()
512 : data.week_of_month,
513 time_slots: data.time_slots.length > 0 ? data.time_slots : undefined,
514 traveler_pricing:
515 data.traveler_pricing && data.traveler_pricing.length > 0
516 ? data.traveler_pricing
517 : undefined,
518 preview_limit: 20,
519 });
520 },
521 onSuccess: (response) => {
522 // Unwrap the data property from API response
523 const previewResult = response?.data || response;
524 setPreviewData(previewResult);
525 },
526 onError: (error: any) => {
527 showToast(
528 error?.message || __("Failed to generate preview", "yatra"),
529 "error",
530 );
531 },
532 });
533
534 // Handle form submit
535 const handleSubmit = (e: React.FormEvent) => {
536 e.preventDefault();
537
538 if (!formData.trip_id) {
539 showToast(__("Please select a trip", "yatra"), "error");
540 return;
541 }
542
543 if (formData.rule_type === "weekly" && formData.days_of_week.length === 0) {
544 showToast(
545 __("Please select at least one day of the week", "yatra"),
546 "error",
547 );
548 return;
549 }
550
551 if (isEditing) {
552 updateMutation.mutate(formData);
553 } else {
554 createMutation.mutate(formData);
555 }
556 };
557
558 // Handle day toggle for weekly rules
559 const toggleDay = (day: number) => {
560 setFormData((prev) => ({
561 ...prev,
562 days_of_week: prev.days_of_week.includes(day)
563 ? prev.days_of_week.filter((d) => d !== day)
564 : [...prev.days_of_week, day].sort((a, b) => a - b),
565 }));
566 };
567
568 // Add excluded date
569 const addExcludedDate = () => {
570 if (newExcludedDate && !formData.excluded_dates.includes(newExcludedDate)) {
571 setFormData((prev) => ({
572 ...prev,
573 excluded_dates: [...prev.excluded_dates, newExcludedDate].sort(),
574 }));
575 setNewExcludedDate("");
576 }
577 };
578
579 // Remove excluded date
580 const removeExcludedDate = (date: string) => {
581 setFormData((prev) => ({
582 ...prev,
583 excluded_dates: prev.excluded_dates.filter((d) => d !== date),
584 }));
585 };
586
587 const isLoading = createMutation.isPending || updateMutation.isPending;
588
589 if (isEditing && isLoadingRule) {
590 return <RecurringRuleFormSkeleton />;
591 }
592
593 return (
594 <div className="space-y-6">
595 <PageHeader
596 title={
597 isEditing
598 ? __("Edit Recurring Rule", "yatra")
599 : __("Create Recurring Rule", "yatra")
600 }
601 description={headerDescription}
602 actions={
603 <Button
604 variant="outline"
605 onClick={() =>
606 navigate({
607 subpage: "trips",
608 tab: "availability",
609 trip_id: formData.trip_id?.toString() || tripIdFromUrl || "",
610 })
611 }
612 >
613 <ArrowLeft className="w-4 h-4 mr-2" />
614 {__("Back to Availability", "yatra")}
615 </Button>
616 }
617 />
618
619 <form onSubmit={handleSubmit}>
620 <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
621 {/* Main Form */}
622 <div className="lg:col-span-2 space-y-6">
623 {/* Basic Info */}
624 <Card>
625 <CardHeader>
626 <CardTitle>{__("Basic Information", "yatra")}</CardTitle>
627 </CardHeader>
628 <CardContent className="space-y-4">
629 <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
630 <div>
631 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
632 {__("Trip", "yatra")}{" "}
633 <span className="text-red-500">*</span>
634 </label>
635 <SearchableSelect
636 value={formData.trip_id?.toString() || ""}
637 onChange={(value) =>
638 setFormData((prev) => ({
639 ...prev,
640 trip_id: parseInt(value) || 0,
641 // Reset pricing when trip changes and set pricing_type based on new trip
642 pricing_type: (tripsData?.trips.find(
643 (t) => t.id === parseInt(value),
644 )?.pricing_type || "regular") as
645 | "regular"
646 | "traveler_based",
647 traveler_pricing: [],
648 original_price: undefined,
649 sale_price: undefined,
650 }))
651 }
652 options={[
653 { value: "", label: __("-- Select Trip --", "yatra") },
654 ...(tripsData?.trips.map((trip) => ({
655 value: trip.id.toString(),
656 label: `${trip.title} (${trip.pricing_type === "traveler_based" ? "Traveler-Based" : "Regular"})`,
657 })) || []),
658 ]}
659 placeholder={__("Select a trip", "yatra")}
660 disabled={isEditing}
661 />
662 </div>
663 <div>
664 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
665 {__("Rule Name", "yatra")}
666 </label>
667 <Input
668 type="text"
669 value={formData.name}
670 onChange={(e) =>
671 setFormData((prev) => ({
672 ...prev,
673 name: e.target.value,
674 }))
675 }
676 placeholder={__("e.g., Weekend Departures", "yatra")}
677 />
678 </div>
679 </div>
680
681 <div>
682 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
683 {__("Rule Type", "yatra")}{" "}
684 <span className="text-red-500">*</span>
685 </label>
686 <Select
687 value={formData.rule_type}
688 onChange={(e) =>
689 setFormData((prev) => ({
690 ...prev,
691 rule_type: e.target.value as
692 | "weekly"
693 | "monthly"
694 | "interval",
695 }))
696 }
697 >
698 <option value="weekly">
699 {__("Weekly (specific days)", "yatra")}
700 </option>
701 <option value="monthly">
702 {__("Monthly (e.g., first Sunday)", "yatra")}
703 </option>
704 <option value="interval">
705 {__("Interval (every X days)", "yatra")}
706 </option>
707 </Select>
708 </div>
709 </CardContent>
710 </Card>
711
712 {/* Pattern Configuration */}
713 <Card>
714 <CardHeader>
715 <CardTitle className="flex items-center gap-2">
716 <RefreshCw className="w-5 h-5" />
717 {__("Recurrence Pattern", "yatra")}
718 </CardTitle>
719 </CardHeader>
720 <CardContent className="space-y-4">
721 {formData.rule_type === "weekly" && (
722 <div>
723 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
724 {__("Days of Week", "yatra")}{" "}
725 <span className="text-red-500">*</span>
726 </label>
727 <div className="flex flex-wrap gap-2">
728 {dayOptions.map((day) => (
729 <button
730 key={day.value}
731 type="button"
732 onClick={() => toggleDay(day.value)}
733 className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
734 formData.days_of_week.includes(day.value)
735 ? "bg-blue-600 text-white"
736 : "bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700"
737 }`}
738 >
739 {day.label}
740 </button>
741 ))}
742 </div>
743 </div>
744 )}
745
746 {formData.rule_type === "monthly" && (
747 <div className="grid grid-cols-2 gap-4">
748 <div>
749 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
750 {__("Week of Month", "yatra")}
751 </label>
752 <Select
753 value={formData.week_of_month || "first"}
754 onChange={(e) =>
755 setFormData((prev) => ({
756 ...prev,
757 week_of_month: e.target.value as any,
758 }))
759 }
760 >
761 {weekOptions.map((week) => (
762 <option key={week.value} value={week.value}>
763 {week.label}
764 </option>
765 ))}
766 </Select>
767 </div>
768 <div>
769 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
770 {__("Day of Week", "yatra")}
771 </label>
772 <Select
773 value={formData.day_of_week?.toString() || "0"}
774 onChange={(e) =>
775 setFormData((prev) => ({
776 ...prev,
777 day_of_week: parseInt(e.target.value),
778 }))
779 }
780 >
781 {dayOptions.map((day) => (
782 <option key={day.value} value={day.value}>
783 {day.label}
784 </option>
785 ))}
786 </Select>
787 </div>
788 </div>
789 )}
790
791 {formData.rule_type === "interval" && (
792 <div>
793 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
794 {__("Every X Days", "yatra")}
795 </label>
796 <Input
797 type="number"
798 min={1}
799 max={365}
800 value={formData.interval_days || 7}
801 onChange={(e) =>
802 setFormData((prev) => ({
803 ...prev,
804 interval_days: parseInt(e.target.value) || 7,
805 }))
806 }
807 />
808 </div>
809 )}
810
811 {/* Date Range */}
812 <div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-4 border-t border-gray-200 dark:border-gray-700">
813 <div>
814 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
815 {__("Start Date", "yatra")}{" "}
816 <span className="text-red-500">*</span>
817 </label>
818 <DatePicker
819 value={formData.start_date}
820 onChange={(value: string) =>
821 setFormData((prev) => ({ ...prev, start_date: value }))
822 }
823 placeholder={__("Select start date", "yatra")}
824 />
825 </div>
826 <div>
827 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
828 {__("End Date", "yatra")}{" "}
829 <span className="text-gray-400">
830 ({__("optional", "yatra")})
831 </span>
832 </label>
833 <DatePicker
834 value={formData.end_date || ""}
835 onChange={(value: string) =>
836 setFormData((prev) => ({ ...prev, end_date: value }))
837 }
838 minDate={
839 formData.start_date
840 ? toDateValue(formData.start_date)
841 : undefined
842 }
843 placeholder={__("Select end date (optional)", "yatra")}
844 />
845 </div>
846 </div>
847
848 {/* Month Filter */}
849 <div className="pt-4 border-t border-gray-200 dark:border-gray-700">
850 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
851 {__("Specific Months", "yatra")}{" "}
852 <span className="text-gray-400">
853 ({__("optional - leave empty for all months", "yatra")})
854 </span>
855 </label>
856 <div className="grid grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-2">
857 {[
858 { value: 1, label: __("January", "yatra") },
859 { value: 2, label: __("February", "yatra") },
860 { value: 3, label: __("March", "yatra") },
861 { value: 4, label: __("April", "yatra") },
862 { value: 5, label: __("May", "yatra") },
863 { value: 6, label: __("June", "yatra") },
864 { value: 7, label: __("July", "yatra") },
865 { value: 8, label: __("August", "yatra") },
866 { value: 9, label: __("September", "yatra") },
867 { value: 10, label: __("October", "yatra") },
868 { value: 11, label: __("November", "yatra") },
869 { value: 12, label: __("December", "yatra") },
870 ].map((month) => (
871 <label
872 key={month.value}
873 className={`flex items-center justify-center px-3 py-2 rounded-md border cursor-pointer transition-colors ${
874 formData.months.includes(month.value)
875 ? "bg-blue-50 border-blue-500 text-blue-700 dark:bg-blue-900/20 dark:border-blue-600 dark:text-blue-300"
876 : "border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-800"
877 }`}
878 >
879 <input
880 type="checkbox"
881 checked={formData.months.includes(month.value)}
882 onChange={(e) => {
883 const checked = e.target.checked;
884 setFormData((prev) => ({
885 ...prev,
886 months: checked
887 ? [...prev.months, month.value].sort(
888 (a, b) => a - b,
889 )
890 : prev.months.filter((m) => m !== month.value),
891 }));
892 }}
893 className="sr-only"
894 />
895 <span className="text-sm font-medium">
896 {month.label.slice(0, 3)}
897 </span>
898 </label>
899 ))}
900 </div>
901 {formData.months.length > 0 && (
902 <p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
903 {__("Selected:", "yatra")} {formData.months.length}{" "}
904 {formData.months.length === 1
905 ? __("month", "yatra")
906 : __("months", "yatra")}
907 </p>
908 )}
909 </div>
910
911 {/* Excluded Dates */}
912 <div className="pt-4 border-t border-gray-200 dark:border-gray-700">
913 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
914 {__("Excluded Dates", "yatra")}{" "}
915 <span className="text-gray-400">
916 ({__("holidays, etc.", "yatra")})
917 </span>
918 </label>
919 <div className="flex gap-2 mb-2">
920 <div className="flex-1">
921 <DatePicker
922 value={newExcludedDate}
923 onChange={(value: string) => setNewExcludedDate(value)}
924 placeholder={__("Select date to exclude", "yatra")}
925 />
926 </div>
927 <Button
928 type="button"
929 variant="outline"
930 onClick={addExcludedDate}
931 disabled={!newExcludedDate}
932 >
933 <Plus className="w-4 h-4" />
934 </Button>
935 </div>
936 {formData.excluded_dates.length > 0 && (
937 <div className="flex flex-wrap gap-2">
938 {formData.excluded_dates.map((date) => (
939 <Badge
940 key={date}
941 variant="outline"
942 className="flex items-center gap-1"
943 >
944 {new Date(date + "T00:00:00").toLocaleDateString(
945 "en-US",
946 {
947 weekday: "short",
948 month: "short",
949 day: "numeric",
950 year: "numeric",
951 },
952 )}
953 <button
954 type="button"
955 onClick={() => removeExcludedDate(date)}
956 className="ml-1 hover:text-red-500"
957 >
958 <X className="w-3 h-3" />
959 </button>
960 </Badge>
961 ))}
962 </div>
963 )}
964 </div>
965 </CardContent>
966 </Card>
967
968 {/* Pricing & Capacity */}
969 <Card>
970 <CardHeader>
971 <CardTitle className="flex items-center gap-2">
972 <DollarSign className="w-5 h-5" />
973 {__("Pricing & Availability", "yatra")}
974 </CardTitle>
975 <CardDescription>
976 {__(
977 "Set pricing for traveler categories and seat availability for this rule",
978 "yatra",
979 )}
980 </CardDescription>
981 </CardHeader>
982 <CardContent className="space-y-6">
983 {/* Pricing Override Info */}
984 <div className="p-4 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg mb-4">
985 <div className="flex items-start gap-3">
986 <div className="p-2 rounded-full bg-amber-100 dark:bg-amber-900/30">
987 <svg
988 className="w-5 h-5 text-amber-600 dark:text-amber-400"
989 fill="none"
990 stroke="currentColor"
991 viewBox="0 0 24 24"
992 >
993 <path
994 strokeLinecap="round"
995 strokeLinejoin="round"
996 strokeWidth={2}
997 d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
998 />
999 </svg>
1000 </div>
1001 <div className="flex-1">
1002 <p className="text-sm font-medium text-gray-900 dark:text-white mb-1">
1003 {__("Pricing Override (Optional)", "yatra")}
1004 </p>
1005 <p className="text-xs text-gray-600 dark:text-gray-400">
1006 {__(
1007 "Leave pricing fields empty to use the trip's default pricing. Fill them in only if you want to override the default pricing for dates generated by this rule.",
1008 "yatra",
1009 )}
1010 </p>
1011 </div>
1012 </div>
1013 </div>
1014
1015 {/* Pricing Type Info - Inherited from Trip */}
1016 <div className="p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
1017 <div className="flex items-center gap-3">
1018 <div
1019 className={`p-2 rounded-full ${isTravelerBasedPricing ? "bg-green-100 dark:bg-green-900/30" : "bg-blue-100 dark:bg-blue-900/30"}`}
1020 >
1021 {isTravelerBasedPricing ? (
1022 <svg
1023 className="w-5 h-5 text-green-600 dark:text-green-400"
1024 fill="none"
1025 stroke="currentColor"
1026 viewBox="0 0 24 24"
1027 >
1028 <path
1029 strokeLinecap="round"
1030 strokeLinejoin="round"
1031 strokeWidth={2}
1032 d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
1033 />
1034 </svg>
1035 ) : (
1036 <svg
1037 className="w-5 h-5 text-blue-600 dark:text-blue-400"
1038 fill="none"
1039 stroke="currentColor"
1040 viewBox="0 0 24 24"
1041 >
1042 <path
1043 strokeLinecap="round"
1044 strokeLinejoin="round"
1045 strokeWidth={2}
1046 d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
1047 />
1048 </svg>
1049 )}
1050 </div>
1051 <div>
1052 <p className="text-sm font-medium text-gray-900 dark:text-white">
1053 {isTravelerBasedPricing
1054 ? __("Traveler-Based Pricing", "yatra")
1055 : __("Regular Pricing", "yatra")}
1056 </p>
1057 <p className="text-xs text-gray-600 dark:text-gray-400">
1058 {isTravelerBasedPricing
1059 ? __(
1060 "This trip uses traveler category pricing. Set prices for each category below.",
1061 "yatra",
1062 )
1063 : __(
1064 "This trip uses regular pricing. Set a single price for all travelers below.",
1065 "yatra",
1066 )}
1067 </p>
1068 </div>
1069 </div>
1070 </div>
1071
1072 {/* Regular Pricing Fields */}
1073 {!isTravelerBasedPricing && (
1074 <div className="space-y-4">
1075 <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
1076 <div>
1077 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
1078 {__("Original Price", "yatra")}{" "}
1079 <span className="text-gray-400 text-xs">
1080 ({__("Optional", "yatra")})
1081 </span>
1082 </label>
1083 <Input
1084 type="number"
1085 min={0}
1086 step="0.01"
1087 value={formData.original_price || ""}
1088 onChange={(e) =>
1089 setFormData((prev) => ({
1090 ...prev,
1091 original_price:
1092 parseFloat(e.target.value) || undefined,
1093 }))
1094 }
1095 placeholder="0.00"
1096 />
1097 </div>
1098 <div>
1099 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
1100 {__("Sale Price", "yatra")}
1101 </label>
1102 <Input
1103 type="number"
1104 min={0}
1105 step="0.01"
1106 value={formData.sale_price || ""}
1107 onChange={(e) =>
1108 setFormData((prev) => ({
1109 ...prev,
1110 sale_price:
1111 parseFloat(e.target.value) || undefined,
1112 }))
1113 }
1114 placeholder="0.00"
1115 />
1116 <p className="mt-1 text-xs text-gray-500">
1117 {__("Leave empty if no discount", "yatra")}
1118 </p>
1119 </div>
1120 </div>
1121 </div>
1122 )}
1123
1124 {/* Traveler-Based Pricing */}
1125 {isTravelerBasedPricing && (
1126 <div className="space-y-4">
1127 <div>
1128 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
1129 {__("Traveler Category Pricing", "yatra")}{" "}
1130 <span className="text-red-500">*</span>
1131 </label>
1132 <p className="text-xs text-gray-500 dark:text-gray-400 mb-4">
1133 {__(
1134 "Add pricing for traveler categories. Categories are managed in Traveler Categories page.",
1135 "yatra",
1136 )}
1137 </p>
1138 </div>
1139
1140 {/* Active Categories Filter */}
1141 {(() => {
1142 const activeCategories = travelerCategories.filter(
1143 (cat: TravelerCategory) =>
1144 cat.status === "active" || cat.status === "publish",
1145 );
1146
1147 if (activeCategories.length === 0) {
1148 return (
1149 <div className="p-6 border border-gray-200 dark:border-gray-700 rounded-lg text-center">
1150 <p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
1151 {__(
1152 "No active traveler categories found.",
1153 "yatra",
1154 )}
1155 </p>
1156 <Button
1157 type="button"
1158 variant="outline"
1159 onClick={() =>
1160 (window.location.href =
1161 "?page=yatra&subpage=traveler-categories&action=create")
1162 }
1163 className="flex items-center gap-2 mx-auto"
1164 >
1165 <Plus className="w-4 h-4" />
1166 {__("Create Category", "yatra")}
1167 </Button>
1168 </div>
1169 );
1170 }
1171
1172 return (
1173 <div className="space-y-4">
1174 {/* Add Pricing Button with Dropdown */}
1175 <div className="relative">
1176 <Button
1177 type="button"
1178 variant="outline"
1179 onClick={() =>
1180 setShowCategorySelector(!showCategorySelector)
1181 }
1182 className="flex items-center gap-2"
1183 disabled={
1184 activeCategories.filter(
1185 (cat) =>
1186 !formData.traveler_pricing?.some(
1187 (tp) => tp.category_id === cat.id,
1188 ),
1189 ).length === 0
1190 }
1191 >
1192 <Plus className="w-4 h-4" />
1193 {__("Add Pricing", "yatra")}
1194 </Button>
1195
1196 {/* Category Selection Dropdown */}
1197 {showCategorySelector && (
1198 <>
1199 <div
1200 className="fixed inset-0 z-10"
1201 onClick={() => setShowCategorySelector(false)}
1202 />
1203 <div className="absolute top-full left-0 mt-2 w-full max-w-md bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg z-20 max-h-96 overflow-y-auto">
1204 <div className="p-2">
1205 <div className="text-xs font-medium text-gray-700 dark:text-gray-300 px-3 py-2 mb-1">
1206 {__(
1207 "Select a category to add pricing",
1208 "yatra",
1209 )}
1210 </div>
1211 {activeCategories.filter(
1212 (cat) =>
1213 !formData.traveler_pricing?.some(
1214 (tp) => tp.category_id === cat.id,
1215 ),
1216 ).length === 0 ? (
1217 <div className="px-3 py-4 text-sm text-gray-500 dark:text-gray-400 text-center">
1218 {__(
1219 "All categories have pricing added",
1220 "yatra",
1221 )}
1222 </div>
1223 ) : (
1224 <div className="space-y-1">
1225 {activeCategories
1226 .filter(
1227 (cat) =>
1228 !formData.traveler_pricing?.some(
1229 (tp) =>
1230 tp.category_id === cat.id,
1231 ),
1232 )
1233 .map((category: TravelerCategory) => {
1234 const minAge =
1235 category.age_min ??
1236 category.min_age;
1237 const maxAge =
1238 category.age_max ??
1239 category.max_age;
1240 const ageRange =
1241 minAge !== undefined ||
1242 maxAge !== undefined
1243 ? minAge !== undefined &&
1244 maxAge !== undefined
1245 ? `${minAge}-${maxAge} ${__("years", "yatra")}`
1246 : minAge !== undefined
1247 ? `${minAge}+ ${__("years", "yatra")}`
1248 : maxAge !== undefined
1249 ? `${__("Under", "yatra")} ${maxAge} ${__("years", "yatra")}`
1250 : ""
1251 : null;
1252 const categoryName =
1253 category.label ||
1254 category.name ||
1255 `Category ${category.id}`;
1256
1257 return (
1258 <button
1259 key={category.id}
1260 type="button"
1261 onClick={() => {
1262 setFormData((prev) => ({
1263 ...prev,
1264 traveler_pricing: [
1265 ...(prev.traveler_pricing ||
1266 []),
1267 {
1268 category_id:
1269 category.id,
1270 original_price: 0,
1271 sale_price: undefined,
1272 },
1273 ],
1274 }));
1275 setShowCategorySelector(
1276 false,
1277 );
1278 }}
1279 className="w-full text-left px-3 py-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
1280 >
1281 <div className="font-medium text-sm text-gray-900 dark:text-white">
1282 {categoryName}
1283 {ageRange && (
1284 <span className="ml-2 text-xs font-normal text-gray-500 dark:text-gray-400">
1285 ({ageRange})
1286 </span>
1287 )}
1288 </div>
1289 {category.description && (
1290 <div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
1291 {category.description}
1292 </div>
1293 )}
1294 </button>
1295 );
1296 })}
1297 </div>
1298 )}
1299 </div>
1300 </div>
1301 </>
1302 )}
1303 </div>
1304
1305 {/* Added Pricing List */}
1306 {formData.traveler_pricing &&
1307 formData.traveler_pricing.length > 0 && (
1308 <div className="space-y-3">
1309 {formData.traveler_pricing.map(
1310 (pricing, index) => {
1311 const category = activeCategories.find(
1312 (cat) => cat.id === pricing.category_id,
1313 );
1314 if (!category) return null;
1315
1316 const minAge =
1317 category.age_min ?? category.min_age;
1318 const maxAge =
1319 category.age_max ?? category.max_age;
1320 const categoryName =
1321 category.label ||
1322 category.name ||
1323 `Category ${pricing.category_id}`;
1324
1325 return (
1326 <div
1327 key={pricing.category_id}
1328 className="p-4 border border-blue-300 dark:border-blue-700 bg-blue-50 dark:bg-blue-900/20 rounded-lg"
1329 >
1330 <div className="flex items-start justify-between mb-3">
1331 <div className="flex-1">
1332 <div className="flex items-center gap-2 mb-1">
1333 <h4 className="text-sm font-semibold text-gray-900 dark:text-white">
1334 {categoryName}
1335 {(minAge !== undefined ||
1336 maxAge !== undefined) && (
1337 <span className="ml-2 text-xs font-normal text-gray-500 dark:text-gray-400">
1338 (
1339 {minAge !== undefined &&
1340 maxAge !== undefined
1341 ? `${minAge}-${maxAge} ${__("years", "yatra")}`
1342 : minAge !== undefined
1343 ? `${minAge}+ ${__("years", "yatra")}`
1344 : maxAge !== undefined
1345 ? `${__("Under", "yatra")} ${maxAge} ${__("years", "yatra")}`
1346 : ""}
1347 )
1348 </span>
1349 )}
1350 </h4>
1351 </div>
1352 {category.description && (
1353 <p className="text-xs text-gray-600 dark:text-gray-400">
1354 {category.description}
1355 </p>
1356 )}
1357 </div>
1358 <button
1359 type="button"
1360 onClick={() => {
1361 const categoryIdToRemove =
1362 pricing.category_id;
1363 setFormData((prev) => ({
1364 ...prev,
1365 traveler_pricing: (
1366 prev.traveler_pricing || []
1367 ).filter(
1368 (tp) =>
1369 tp.category_id !==
1370 categoryIdToRemove,
1371 ),
1372 }));
1373 }}
1374 className="p-1 text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors"
1375 title={__(
1376 "Remove Pricing",
1377 "yatra",
1378 )}
1379 >
1380 <X className="w-4 h-4" />
1381 </button>
1382 </div>
1383 <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
1384 <div>
1385 <label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1.5">
1386 {__("Original Price", "yatra")}{" "}
1387 <span className="text-red-500">
1388 *
1389 </span>
1390 </label>
1391 <Input
1392 type="number"
1393 min={0}
1394 step="0.01"
1395 value={
1396 pricing.original_price || ""
1397 }
1398 onChange={(e) => {
1399 const newPricing = [
1400 ...(formData.traveler_pricing ||
1401 []),
1402 ];
1403 newPricing[
1404 index
1405 ].original_price =
1406 parseFloat(e.target.value) ||
1407 0;
1408 setFormData((prev) => ({
1409 ...prev,
1410 traveler_pricing: newPricing,
1411 }));
1412 }}
1413 placeholder="0.00"
1414 />
1415 </div>
1416 <div>
1417 <label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1.5">
1418 {__("Sale Price", "yatra")} (
1419 {__("Optional", "Optional")})
1420 </label>
1421 <Input
1422 type="number"
1423 min={0}
1424 step="0.01"
1425 value={pricing.sale_price || ""}
1426 onChange={(e) => {
1427 const newPricing = [
1428 ...(formData.traveler_pricing ||
1429 []),
1430 ];
1431 newPricing[index].sale_price =
1432 parseFloat(e.target.value) ||
1433 undefined;
1434 setFormData((prev) => ({
1435 ...prev,
1436 traveler_pricing: newPricing,
1437 }));
1438 }}
1439 className="text-sm"
1440 placeholder="0.00"
1441 />
1442 </div>
1443 </div>
1444 </div>
1445 );
1446 },
1447 )}
1448 </div>
1449 )}
1450 </div>
1451 );
1452 })()}
1453 </div>
1454 )}
1455
1456 {/* Inventory Management - Common for both pricing types */}
1457 {selectedTrip && (
1458 <div className="pt-4 border-t border-gray-200 dark:border-gray-700">
1459 <h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-4">
1460 {__("Inventory Management", "yatra")}
1461 </h4>
1462 <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
1463 <div>
1464 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
1465 {__("Total Capacity", "yatra")}{" "}
1466 <span className="text-red-500">*</span>
1467 </label>
1468 <Input
1469 type="number"
1470 min={1}
1471 value={formData.seats_total}
1472 onChange={(e) =>
1473 setFormData((prev) => ({
1474 ...prev,
1475 seats_total: parseInt(e.target.value) || 1,
1476 }))
1477 }
1478 />
1479 <p className="mt-1 text-xs text-gray-500">
1480 {__(
1481 "Maximum number of seats available for this rule",
1482 "yatra",
1483 )}
1484 </p>
1485 </div>
1486 <div>
1487 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
1488 {__("Alert Threshold", "yatra")}
1489 </label>
1490 <Input
1491 type="number"
1492 min={0}
1493 value={formData.alert_threshold}
1494 onChange={(e) =>
1495 setFormData((prev) => ({
1496 ...prev,
1497 alert_threshold: parseInt(e.target.value) || 0,
1498 }))
1499 }
1500 />
1501 <p className="mt-1 text-xs text-gray-500">
1502 {__(
1503 "Alert when available seats drop below this number",
1504 "yatra",
1505 )}
1506 </p>
1507 </div>
1508 </div>
1509 </div>
1510 )}
1511
1512 {/* No trip selected message */}
1513 {!selectedTrip && (
1514 <div className="text-center py-6 bg-gray-50 dark:bg-gray-800 rounded-lg">
1515 <AlertCircle className="w-8 h-8 text-gray-400 mx-auto mb-2" />
1516 <p className="text-sm text-gray-500 dark:text-gray-400">
1517 {__("Select a trip above to configure pricing", "yatra")}
1518 </p>
1519 </div>
1520 )}
1521 </CardContent>
1522 </Card>
1523
1524 {/* Time & Location */}
1525 <Card>
1526 <CardHeader>
1527 <CardTitle className="flex items-center gap-2">
1528 <Clock className="w-5 h-5" />
1529 {__("Time & Location", "yatra")}
1530 </CardTitle>
1531 {selectedTrip && (
1532 <CardDescription>
1533 <Badge
1534 variant={isSingleDayTrip ? "default" : "outline"}
1535 className="mr-2"
1536 >
1537 {isSingleDayTrip
1538 ? __("Single-Day Trip", "yatra")
1539 : __("Multi-Day Trip", "yatra")}
1540 </Badge>
1541 {!isSingleDayTrip && selectedTrip.duration_days && (
1542 <span className="text-gray-500">
1543 ({selectedTrip.duration_days} {__("days", "yatra")})
1544 </span>
1545 )}
1546 </CardDescription>
1547 )}
1548 </CardHeader>
1549 <CardContent className="space-y-4">
1550 {/* Single-Day Trip: Multiple Time Slots */}
1551 {isSingleDayTrip && formData.trip_id > 0 && (
1552 <div className="space-y-4">
1553 <div className="flex items-center justify-between">
1554 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
1555 {__("Time Slots", "yatra")}
1556 <span className="text-gray-400 ml-1">
1557 ({__("for each recurring day", "yatra")})
1558 </span>
1559 </label>
1560 <Button
1561 type="button"
1562 variant="outline"
1563 size="sm"
1564 onClick={() =>
1565 setFormData((prev) => ({
1566 ...prev,
1567 time_slots: [
1568 ...prev.time_slots,
1569 {
1570 departure_time: "09:00",
1571 arrival_time: "17:00",
1572 seats: 20,
1573 price: 0,
1574 traveler_pricing: isTravelerBasedPricing
1575 ? []
1576 : undefined,
1577 },
1578 ],
1579 }))
1580 }
1581 >
1582 <Plus className="w-4 h-4 mr-1" />
1583 {__("Add Slot", "yatra")}
1584 </Button>
1585 </div>
1586
1587 {formData.time_slots.length === 0 ? (
1588 <div className="text-center py-6 bg-gray-50 dark:bg-gray-800 rounded-lg border-2 border-dashed border-gray-200 dark:border-gray-700">
1589 <Clock className="w-8 h-8 text-gray-400 mx-auto mb-2" />
1590 <p className="text-sm text-gray-500 dark:text-gray-400 mb-2">
1591 {__("No time slots added yet", "yatra")}
1592 </p>
1593 <p className="text-xs text-gray-400 dark:text-gray-500">
1594 {__(
1595 "Add multiple time slots for tours throughout the day (e.g., morning, afternoon, evening)",
1596 "yatra",
1597 )}
1598 </p>
1599 </div>
1600 ) : (
1601 <div className="space-y-3">
1602 {formData.time_slots.map((slot, index) => (
1603 <div
1604 key={index}
1605 className="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700"
1606 >
1607 <div className="flex items-center justify-between mb-3">
1608 <span className="text-sm font-medium text-gray-700 dark:text-gray-300">
1609 {__("Slot", "yatra")} {index + 1}
1610 </span>
1611 <Button
1612 type="button"
1613 variant="ghost"
1614 size="sm"
1615 onClick={() =>
1616 setFormData((prev) => ({
1617 ...prev,
1618 time_slots: prev.time_slots.filter(
1619 (_, i) => i !== index,
1620 ),
1621 }))
1622 }
1623 className="text-red-500 hover:text-red-600 h-8 w-8 p-0"
1624 >
1625 <X className="w-4 h-4" />
1626 </Button>
1627 </div>
1628 <div className="space-y-3">
1629 {/* Time fields on first line */}
1630 <div className="grid grid-cols-2 gap-3">
1631 <div>
1632 <label className="block text-xs text-gray-500 dark:text-gray-400 mb-1">
1633 {__("Departure Time", "yatra")}
1634 </label>
1635 <TimePicker
1636 value={slot.departure_time || ""}
1637 onChange={(value: string) => {
1638 const newSlots = [...formData.time_slots];
1639 newSlots[index].departure_time = value;
1640 setFormData((prev) => ({
1641 ...prev,
1642 time_slots: newSlots,
1643 }));
1644 }}
1645 placeholder="09:00"
1646 />
1647 </div>
1648 <div>
1649 <label className="block text-xs text-gray-500 dark:text-gray-400 mb-1">
1650 {__("Arrival Time", "yatra")}
1651 </label>
1652 <TimePicker
1653 value={slot.arrival_time || ""}
1654 onChange={(value: string) => {
1655 const newSlots = [...formData.time_slots];
1656 newSlots[index].arrival_time = value;
1657 setFormData((prev) => ({
1658 ...prev,
1659 time_slots: newSlots,
1660 }));
1661 }}
1662 placeholder="17:00"
1663 />
1664 </div>
1665 </div>
1666
1667 {/* Seats, Price and Sale Price on second line */}
1668 {!isTravelerBasedPricing && (
1669 <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
1670 <div>
1671 <label className="block text-xs text-gray-500 dark:text-gray-400 mb-1">
1672 {__("Seats", "yatra")}
1673 </label>
1674 <Input
1675 type="number"
1676 min={1}
1677 value={slot.seats}
1678 onChange={(e) => {
1679 const newSlots = [
1680 ...formData.time_slots,
1681 ];
1682 newSlots[index].seats =
1683 parseInt(e.target.value) || 1;
1684 setFormData((prev) => ({
1685 ...prev,
1686 time_slots: newSlots,
1687 }));
1688 }}
1689 className="text-sm"
1690 />
1691 </div>
1692 <div>
1693 <label className="block text-xs text-gray-500 dark:text-gray-400 mb-1">
1694 {__("Price", "yatra")}
1695 </label>
1696 <Input
1697 type="number"
1698 min={0}
1699 step="0.01"
1700 value={slot.price || ""}
1701 onChange={(e) => {
1702 const newSlots = [
1703 ...formData.time_slots,
1704 ];
1705 newSlots[index].price =
1706 parseFloat(e.target.value) || 0;
1707 setFormData((prev) => ({
1708 ...prev,
1709 time_slots: newSlots,
1710 }));
1711 }}
1712 className="text-sm"
1713 placeholder="0.00"
1714 />
1715 </div>
1716 <div>
1717 <label className="block text-xs text-gray-500 dark:text-gray-400 mb-1">
1718 {__("Sale Price", "yatra")}
1719 </label>
1720 <Input
1721 type="number"
1722 min={0}
1723 step="0.01"
1724 value={slot.sale_price || ""}
1725 onChange={(e) => {
1726 const newSlots = [
1727 ...formData.time_slots,
1728 ];
1729 newSlots[index].sale_price =
1730 parseFloat(e.target.value) ||
1731 undefined;
1732 setFormData((prev) => ({
1733 ...prev,
1734 time_slots: newSlots,
1735 }));
1736 }}
1737 className="text-sm"
1738 placeholder="0.00"
1739 />
1740 </div>
1741 </div>
1742 )}
1743
1744 {/* For traveler-based pricing, only show seats on second line */}
1745 {isTravelerBasedPricing && (
1746 <div>
1747 <label className="block text-xs text-gray-500 dark:text-gray-400 mb-1">
1748 {__("Seats", "yatra")}
1749 </label>
1750 <Input
1751 type="number"
1752 min={1}
1753 value={slot.seats}
1754 onChange={(e) => {
1755 const newSlots = [...formData.time_slots];
1756 newSlots[index].seats =
1757 parseInt(e.target.value) || 1;
1758 setFormData((prev) => ({
1759 ...prev,
1760 time_slots: newSlots,
1761 }));
1762 }}
1763 className="text-sm"
1764 />
1765 </div>
1766 )}
1767 </div>
1768
1769 {/* Traveler Pricing for Time Slot - only show when traveler-based */}
1770 {isTravelerBasedPricing && (
1771 <div className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
1772 <div className="flex items-center justify-between mb-3">
1773 <div>
1774 <span className="text-xs font-medium text-gray-700 dark:text-gray-300">
1775 {__("Traveler Category Pricing", "yatra")}
1776 </span>
1777 <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
1778 {__(
1779 "Set pricing for each traveler category for this time slot",
1780 "yatra",
1781 )}
1782 </p>
1783 </div>
1784 </div>
1785
1786 {(() => {
1787 const activeCategories =
1788 travelerCategories.filter(
1789 (cat: TravelerCategory) =>
1790 cat.status === "active" ||
1791 cat.status === "publish",
1792 );
1793 const slotTravelerPricing =
1794 slot.traveler_pricing || [];
1795 const availableCategories =
1796 activeCategories.filter(
1797 (cat) =>
1798 !slotTravelerPricing.some(
1799 (tp) => tp.category_id === cat.id,
1800 ),
1801 );
1802
1803 return (
1804 <div className="space-y-3">
1805 {/* Add Pricing Button with Dropdown */}
1806 <div className="relative">
1807 <Button
1808 type="button"
1809 variant="outline"
1810 size="sm"
1811 onClick={() => {
1812 // Toggle dropdown for this specific slot
1813 const dropdownId = `slot-${index}-category-dropdown`;
1814 const dropdown =
1815 document.getElementById(
1816 dropdownId,
1817 );
1818 if (dropdown) {
1819 dropdown.classList.toggle(
1820 "hidden",
1821 );
1822 }
1823 }}
1824 disabled={
1825 availableCategories.length === 0
1826 }
1827 >
1828 <Plus className="w-3 h-3 mr-1" />
1829 {__("Add Pricing", "yatra")}
1830 </Button>
1831
1832 {/* Category Selection Dropdown */}
1833 <div
1834 id={`slot-${index}-category-dropdown`}
1835 className="hidden absolute top-full left-0 mt-2 w-full max-w-sm bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg z-20 max-h-64 overflow-y-auto"
1836 >
1837 <div className="p-2">
1838 <div className="text-xs font-medium text-gray-700 dark:text-gray-300 px-3 py-2 mb-1">
1839 {__(
1840 "Select a category to add pricing",
1841 "yatra",
1842 )}
1843 </div>
1844 {availableCategories.length ===
1845 0 ? (
1846 <div className="px-3 py-3 text-xs text-gray-500 dark:text-gray-400 text-center">
1847 {__(
1848 "All categories have pricing added",
1849 "yatra",
1850 )}
1851 </div>
1852 ) : (
1853 <div className="space-y-1">
1854 {availableCategories.map(
1855 (
1856 category: TravelerCategory,
1857 ) => {
1858 const minAge =
1859 category.age_min ??
1860 category.min_age;
1861 const maxAge =
1862 category.age_max ??
1863 category.max_age;
1864 const ageRange =
1865 minAge !== undefined ||
1866 maxAge !== undefined
1867 ? minAge !==
1868 undefined &&
1869 maxAge !== undefined
1870 ? `${minAge}-${maxAge} ${__("years", "yatra")}`
1871 : minAge !== undefined
1872 ? `${minAge}+ ${__("years", "yatra")}`
1873 : `${__("Under", "yatra")} ${maxAge} ${__("years", "yatra")}`
1874 : null;
1875 const categoryName =
1876 category.label ||
1877 category.name ||
1878 `Category ${category.id}`;
1879
1880 return (
1881 <button
1882 key={category.id}
1883 type="button"
1884 onClick={() => {
1885 const newSlots = [
1886 ...formData.time_slots,
1887 ];
1888 if (
1889 !newSlots[index]
1890 .traveler_pricing
1891 ) {
1892 newSlots[
1893 index
1894 ].traveler_pricing =
1895 [];
1896 }
1897 newSlots[
1898 index
1899 ].traveler_pricing = [
1900 ...newSlots[index]
1901 .traveler_pricing!,
1902 {
1903 category_id:
1904 category.id,
1905 original_price: 0,
1906 sale_price:
1907 undefined,
1908 },
1909 ];
1910 setFormData(
1911 (prev) => ({
1912 ...prev,
1913 time_slots:
1914 newSlots,
1915 }),
1916 );
1917 // Hide dropdown
1918 const dropdown =
1919 document.getElementById(
1920 `slot-${index}-category-dropdown`,
1921 );
1922 if (dropdown)
1923 dropdown.classList.add(
1924 "hidden",
1925 );
1926 }}
1927 className="w-full text-left px-3 py-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
1928 >
1929 <div className="font-medium text-xs text-gray-900 dark:text-white">
1930 {categoryName}
1931 {ageRange && (
1932 <span className="ml-2 text-xs font-normal text-gray-500 dark:text-gray-400">
1933 ({ageRange})
1934 </span>
1935 )}
1936 </div>
1937 {category.description && (
1938 <div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 truncate">
1939 {
1940 category.description
1941 }
1942 </div>
1943 )}
1944 </button>
1945 );
1946 },
1947 )}
1948 </div>
1949 )}
1950 </div>
1951 </div>
1952 </div>
1953
1954 {/* Added Pricing List */}
1955 {slotTravelerPricing.length > 0 && (
1956 <div className="space-y-2">
1957 {slotTravelerPricing.map(
1958 (tp, tpIndex) => {
1959 const category =
1960 activeCategories.find(
1961 (cat) =>
1962 cat.id === tp.category_id,
1963 );
1964 if (!category) return null;
1965
1966 const minAge =
1967 category.age_min ??
1968 category.min_age;
1969 const maxAge =
1970 category.age_max ??
1971 category.max_age;
1972 const categoryName =
1973 category.label ||
1974 category.name ||
1975 `Category ${tp.category_id}`;
1976
1977 return (
1978 <div
1979 key={tp.category_id}
1980 className="p-3 border border-blue-300 dark:border-blue-700 bg-blue-50 dark:bg-blue-900/20 rounded-lg"
1981 >
1982 <div className="flex items-start justify-between mb-2">
1983 <div className="flex-1">
1984 <span className="text-xs font-semibold text-gray-900 dark:text-white">
1985 {categoryName}
1986 {(minAge !==
1987 undefined ||
1988 maxAge !==
1989 undefined) && (
1990 <span className="ml-1 text-xs font-normal text-gray-500 dark:text-gray-400">
1991 (
1992 {minAge !==
1993 undefined &&
1994 maxAge !== undefined
1995 ? `${minAge}-${maxAge}`
1996 : minAge !==
1997 undefined
1998 ? `${minAge}+`
1999 : `<${maxAge}`}{" "}
2000 {__(
2001 "years",
2002 "yatra",
2003 )}
2004 )
2005 </span>
2006 )}
2007 </span>
2008 </div>
2009 <button
2010 type="button"
2011 onClick={() => {
2012 const categoryIdToRemove =
2013 tp.category_id;
2014 setFormData((prev) => {
2015 const newSlots = [
2016 ...prev.time_slots,
2017 ];
2018 if (
2019 !newSlots[index]
2020 ) {
2021 return prev;
2022 }
2023 newSlots[index] = {
2024 ...newSlots[index],
2025 traveler_pricing: (
2026 newSlots[index]
2027 .traveler_pricing ||
2028 []
2029 ).filter(
2030 (p) =>
2031 p.category_id !==
2032 categoryIdToRemove,
2033 ),
2034 };
2035 return {
2036 ...prev,
2037 time_slots:
2038 newSlots,
2039 };
2040 });
2041 }}
2042 className="p-0.5 text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors"
2043 >
2044 <X className="w-3 h-3" />
2045 </button>
2046 </div>
2047 <div className="grid grid-cols-2 gap-2">
2048 <div>
2049 <label className="block text-xs text-gray-600 dark:text-gray-400 mb-1">
2050 {__("Price", "yatra")}{" "}
2051 <span className="text-red-500">
2052 *
2053 </span>
2054 </label>
2055 <Input
2056 type="number"
2057 min={0}
2058 step="0.01"
2059 value={
2060 tp.original_price ||
2061 ""
2062 }
2063 onChange={(e) => {
2064 const newSlots = [
2065 ...formData.time_slots,
2066 ];
2067 if (
2068 newSlots[index] &&
2069 newSlots[index]
2070 .traveler_pricing
2071 ) {
2072 newSlots[
2073 index
2074 ].traveler_pricing![
2075 tpIndex
2076 ].original_price =
2077 parseFloat(
2078 e.target.value,
2079 ) || 0;
2080 setFormData(
2081 (prev) => ({
2082 ...prev,
2083 time_slots:
2084 newSlots,
2085 }),
2086 );
2087 }
2088 }}
2089 className="text-xs"
2090 placeholder="0.00"
2091 />
2092 </div>
2093 <div>
2094 <label className="block text-xs text-gray-600 dark:text-gray-400 mb-1">
2095 {__("Sale", "yatra")}
2096 </label>
2097 <Input
2098 type="number"
2099 min={0}
2100 step="0.01"
2101 value={
2102 tp.sale_price || ""
2103 }
2104 onChange={(e) => {
2105 const newSlots = [
2106 ...formData.time_slots,
2107 ];
2108 if (
2109 newSlots[index] &&
2110 newSlots[index]
2111 .traveler_pricing
2112 ) {
2113 newSlots[
2114 index
2115 ].traveler_pricing![
2116 tpIndex
2117 ].sale_price =
2118 parseFloat(
2119 e.target.value,
2120 ) || undefined;
2121 setFormData(
2122 (prev) => ({
2123 ...prev,
2124 time_slots:
2125 newSlots,
2126 }),
2127 );
2128 }
2129 }}
2130 className="text-xs"
2131 placeholder="0.00"
2132 />
2133 </div>
2134 </div>
2135 </div>
2136 );
2137 },
2138 )}
2139 </div>
2140 )}
2141
2142 {slotTravelerPricing.length === 0 && (
2143 <div className="text-center py-3 bg-gray-100 dark:bg-gray-800 rounded border border-dashed border-gray-300 dark:border-gray-600">
2144 <p className="text-xs text-gray-500">
2145 {__(
2146 'Click "Add Pricing" to set prices for traveler categories',
2147 "yatra",
2148 )}
2149 </p>
2150 </div>
2151 )}
2152 </div>
2153 );
2154 })()}
2155 </div>
2156 )}
2157 </div>
2158 ))}
2159 </div>
2160 )}
2161
2162 {/* If no time slots, show default fields as fallback */}
2163 {formData.time_slots.length === 0 && (
2164 <div className="pt-4 border-t border-gray-200 dark:border-gray-700">
2165 <p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
2166 {__(
2167 "Or use default time (applies to all generated dates):",
2168 "yatra",
2169 )}
2170 </p>
2171 <div className="grid grid-cols-2 gap-4">
2172 <div>
2173 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
2174 {__("Default Start Time", "yatra")}
2175 </label>
2176 <TimePicker
2177 value={formData.departure_time || ""}
2178 onChange={(value: string) =>
2179 setFormData((prev) => ({
2180 ...prev,
2181 departure_time: value,
2182 }))
2183 }
2184 placeholder="09:00"
2185 />
2186 </div>
2187 <div>
2188 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
2189 {__("Default End Time", "yatra")}
2190 </label>
2191 <TimePicker
2192 value={formData.arrival_time || ""}
2193 onChange={(value: string) =>
2194 setFormData((prev) => ({
2195 ...prev,
2196 arrival_time: value,
2197 }))
2198 }
2199 placeholder="17:00"
2200 />
2201 </div>
2202 </div>
2203 </div>
2204 )}
2205 </div>
2206 )}
2207
2208 {/* Multi-Day Trip: Single Departure Time */}
2209 {!isSingleDayTrip && formData.trip_id > 0 && (
2210 <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
2211 <div>
2212 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
2213 {__("Departure Time", "yatra")}
2214 </label>
2215 <TimePicker
2216 value={formData.departure_time || ""}
2217 onChange={(value: string) =>
2218 setFormData((prev) => ({
2219 ...prev,
2220 departure_time: value,
2221 }))
2222 }
2223 placeholder="08:00"
2224 />
2225 </div>
2226 <div>
2227 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
2228 {__("Return Time", "yatra")}{" "}
2229 <span className="text-gray-400">
2230 ({__("on final day", "yatra")})
2231 </span>
2232 </label>
2233 <TimePicker
2234 value={formData.arrival_time || ""}
2235 onChange={(value: string) =>
2236 setFormData((prev) => ({
2237 ...prev,
2238 arrival_time: value,
2239 }))
2240 }
2241 placeholder="18:00"
2242 />
2243 </div>
2244 </div>
2245 )}
2246
2247 {/* No trip selected */}
2248 {!formData.trip_id && (
2249 <div className="text-center py-6 bg-gray-50 dark:bg-gray-800 rounded-lg">
2250 <AlertCircle className="w-8 h-8 text-gray-400 mx-auto mb-2" />
2251 <p className="text-sm text-gray-500 dark:text-gray-400">
2252 {__(
2253 "Select a trip above to configure time settings",
2254 "yatra",
2255 )}
2256 </p>
2257 </div>
2258 )}
2259
2260 {/* Location & Cutoff - always shown when trip selected */}
2261 {formData.trip_id > 0 && (
2262 <>
2263 <div className="grid grid-cols-1 lg:grid-cols-2 gap-8 pt-4 border-t border-gray-200 dark:border-gray-700">
2264 <div className="space-y-4">
2265 <div className="flex items-center gap-3 p-4 bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 rounded-xl border border-blue-200 dark:border-blue-800">
2266 <div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl flex items-center justify-center shadow-lg">
2267 <MapPin className="w-5 h-5 text-white" />
2268 </div>
2269 <div className="flex-1">
2270 <h4 className="text-base font-semibold text-blue-900 dark:text-blue-100">
2271 {__("Starting Point", "yatra")}
2272 </h4>
2273 <p className="text-xs text-blue-700 dark:text-blue-300">
2274 {__("Where the journey begins", "yatra")}
2275 </p>
2276 </div>
2277 {formData.from_latitude &&
2278 formData.from_longitude && (
2279 <div
2280 className="w-2 h-2 bg-blue-500 rounded-full"
2281 title={__("Coordinates set", "yatra")}
2282 />
2283 )}
2284 </div>
2285 <div className="space-y-2">
2286 <label className="text-sm font-medium text-gray-700 dark:text-gray-300">
2287 {__("From location (departure)", "yatra")}
2288 </label>
2289 <LocationPicker
2290 value={{
2291 name: formData.from_location || "",
2292 latitude: formData.from_latitude || "",
2293 longitude: formData.from_longitude || "",
2294 }}
2295 onChange={(loc) =>
2296 setFormData((prev) => ({
2297 ...prev,
2298 from_location: loc.name,
2299 from_latitude: loc.latitude,
2300 from_longitude: loc.longitude,
2301 }))
2302 }
2303 label=""
2304 placeholder={__(
2305 "Search for starting location...",
2306 "yatra",
2307 )}
2308 helpText=""
2309 required={false}
2310 defaultMapCenter={
2311 formData.from_latitude && formData.from_longitude
2312 ? [
2313 parseFloat(formData.from_latitude),
2314 parseFloat(formData.from_longitude),
2315 ]
2316 : tripForLocations?.starting_latitude &&
2317 tripForLocations?.starting_longitude
2318 ? [
2319 parseFloat(
2320 String(
2321 tripForLocations.starting_latitude,
2322 ),
2323 ),
2324 parseFloat(
2325 String(
2326 tripForLocations.starting_longitude,
2327 ),
2328 ),
2329 ]
2330 : [20, 0]
2331 }
2332 defaultZoom={
2333 formData.from_latitude && formData.from_longitude
2334 ? 13
2335 : tripForLocations?.starting_latitude &&
2336 tripForLocations?.starting_longitude
2337 ? 13
2338 : 2
2339 }
2340 mapHeight="300px"
2341 showMapButton={false}
2342 searchLimit={8}
2343 __={__}
2344 className=""
2345 mapClassName="rounded-lg"
2346 showManualCoordinateFields
2347 />
2348 <p className="text-xs text-gray-500 dark:text-gray-400">
2349 {__(
2350 "Default: trip starting location. Set per rule to override.",
2351 "yatra",
2352 )}
2353 </p>
2354 </div>
2355 </div>
2356
2357 <div className="space-y-4">
2358 <div className="flex items-center gap-3 p-4 bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 rounded-xl border border-green-200 dark:border-green-800">
2359 <div className="w-10 h-10 bg-gradient-to-br from-green-500 to-green-600 rounded-xl flex items-center justify-center shadow-lg">
2360 <CheckCircle2 className="w-5 h-5 text-white" />
2361 </div>
2362 <div className="flex-1">
2363 <h4 className="text-base font-semibold text-green-900 dark:text-green-100">
2364 {__("Ending Point", "yatra")}
2365 </h4>
2366 <p className="text-xs text-green-700 dark:text-green-300">
2367 {__("Where the journey concludes", "yatra")}
2368 </p>
2369 </div>
2370 {formData.to_latitude && formData.to_longitude && (
2371 <div
2372 className="w-2 h-2 bg-green-500 rounded-full"
2373 title={__("Coordinates set", "yatra")}
2374 />
2375 )}
2376 </div>
2377 <div className="space-y-2">
2378 <label className="text-sm font-medium text-gray-700 dark:text-gray-300">
2379 {__("To location (destination)", "yatra")}
2380 </label>
2381 <LocationPicker
2382 value={{
2383 name: formData.to_location || "",
2384 latitude: formData.to_latitude || "",
2385 longitude: formData.to_longitude || "",
2386 }}
2387 onChange={(loc) =>
2388 setFormData((prev) => ({
2389 ...prev,
2390 to_location: loc.name,
2391 to_latitude: loc.latitude,
2392 to_longitude: loc.longitude,
2393 }))
2394 }
2395 label=""
2396 placeholder={__(
2397 "Search for ending location...",
2398 "yatra",
2399 )}
2400 helpText=""
2401 required={false}
2402 defaultMapCenter={
2403 formData.to_latitude && formData.to_longitude
2404 ? [
2405 parseFloat(formData.to_latitude),
2406 parseFloat(formData.to_longitude),
2407 ]
2408 : tripForLocations?.ending_latitude &&
2409 tripForLocations?.ending_longitude
2410 ? [
2411 parseFloat(
2412 String(
2413 tripForLocations.ending_latitude,
2414 ),
2415 ),
2416 parseFloat(
2417 String(
2418 tripForLocations.ending_longitude,
2419 ),
2420 ),
2421 ]
2422 : formData.from_latitude &&
2423 formData.from_longitude
2424 ? [
2425 parseFloat(formData.from_latitude),
2426 parseFloat(formData.from_longitude),
2427 ]
2428 : tripForLocations?.starting_latitude &&
2429 tripForLocations?.starting_longitude
2430 ? [
2431 parseFloat(
2432 String(
2433 tripForLocations.starting_latitude,
2434 ),
2435 ),
2436 parseFloat(
2437 String(
2438 tripForLocations.starting_longitude,
2439 ),
2440 ),
2441 ]
2442 : [20, 0]
2443 }
2444 defaultZoom={
2445 formData.to_latitude && formData.to_longitude
2446 ? 13
2447 : tripForLocations?.ending_latitude &&
2448 tripForLocations?.ending_longitude
2449 ? 13
2450 : formData.from_latitude &&
2451 formData.from_longitude
2452 ? 13
2453 : tripForLocations?.starting_latitude &&
2454 tripForLocations?.starting_longitude
2455 ? 13
2456 : 2
2457 }
2458 mapHeight="300px"
2459 showMapButton={false}
2460 searchLimit={8}
2461 __={__}
2462 className=""
2463 mapClassName="rounded-lg"
2464 showManualCoordinateFields
2465 />
2466 <p className="text-xs text-gray-500 dark:text-gray-400">
2467 {__(
2468 "Default: trip ending location. Set per rule to override.",
2469 "yatra",
2470 )}
2471 </p>
2472 </div>
2473 </div>
2474 </div>
2475 <div>
2476 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
2477 {__("Booking Cutoff (hours before)", "yatra")}
2478 </label>
2479 <Input
2480 type="number"
2481 min={0}
2482 value={formData.cutoff_hours}
2483 onChange={(e) =>
2484 setFormData((prev) => ({
2485 ...prev,
2486 cutoff_hours: parseInt(e.target.value) || 0,
2487 }))
2488 }
2489 />
2490 </div>
2491 </>
2492 )}
2493 </CardContent>
2494 </Card>
2495 </div>
2496
2497 {/* Sidebar */}
2498 <div className="space-y-6">
2499 {/* Status */}
2500 <Card>
2501 <CardHeader>
2502 <CardTitle>{__("Status", "yatra")}</CardTitle>
2503 </CardHeader>
2504 <CardContent>
2505 <Select
2506 value={formData.status}
2507 onChange={(e) =>
2508 setFormData((prev) => ({
2509 ...prev,
2510 status: e.target.value as "active" | "inactive",
2511 }))
2512 }
2513 >
2514 <option value="active">{__("Active", "yatra")}</option>
2515 <option value="inactive">{__("Inactive", "yatra")}</option>
2516 </Select>
2517 </CardContent>
2518 </Card>
2519
2520 {/* Preview */}
2521 <Card>
2522 <CardHeader>
2523 <CardTitle className="flex items-center gap-2">
2524 <Eye className="w-5 h-5" />
2525 {__("Preview", "yatra")}
2526 </CardTitle>
2527 <CardDescription>
2528 {__("See which dates will be generated", "yatra")}
2529 </CardDescription>
2530 </CardHeader>
2531 <CardContent>
2532 <Button
2533 type="button"
2534 variant="outline"
2535 className="w-full mb-4"
2536 onClick={() => previewMutation.mutate(formData)}
2537 disabled={previewMutation.isPending || !formData.trip_id}
2538 >
2539 {previewMutation.isPending ? (
2540 <RefreshCw className="w-4 h-4 mr-2 animate-spin" />
2541 ) : (
2542 <Eye className="w-4 h-4 mr-2" />
2543 )}
2544 {__("Generate Preview", "yatra")}
2545 </Button>
2546
2547 {previewData && (
2548 <div className="space-y-3">
2549 <div className="flex items-center justify-between text-sm">
2550 <span className="text-gray-600 dark:text-gray-400">
2551 {__("Total dates:", "yatra")}
2552 </span>
2553 <Badge variant="success">{previewData.total}</Badge>
2554 </div>
2555 <div className="max-h-48 overflow-y-auto space-y-1">
2556 {previewData.dates &&
2557 Array.isArray(previewData.dates) &&
2558 previewData.dates.map((date: any, index: number) => (
2559 <div
2560 key={index}
2561 className="text-xs px-2 py-1 bg-gray-50 dark:bg-gray-800 rounded flex justify-between"
2562 >
2563 <span>
2564 {toDateValue(
2565 date.departure_date,
2566 ).toLocaleDateString("en-US", {
2567 weekday: "short",
2568 month: "short",
2569 day: "numeric",
2570 year: "numeric",
2571 })}
2572 </span>
2573 {date.departure_time && (
2574 <span className="text-gray-500">
2575 {date.departure_time}
2576 </span>
2577 )}
2578 </div>
2579 ))}
2580 {previewData.total > 20 && (
2581 <div className="text-xs text-gray-500 text-center py-1">
2582 +{previewData.total - 20} {__("more dates", "yatra")}
2583 </div>
2584 )}
2585 </div>
2586 </div>
2587 )}
2588 </CardContent>
2589 </Card>
2590
2591 {/* Actions */}
2592 <Card>
2593 <CardContent className="pt-6">
2594 <div className="space-y-3">
2595 <Button type="submit" className="w-full" disabled={isLoading}>
2596 {isLoading ? (
2597 <RefreshCw className="w-4 h-4 mr-2 animate-spin" />
2598 ) : (
2599 <Save className="w-4 h-4 mr-2" />
2600 )}
2601 {isEditing
2602 ? __("Update Rule", "yatra")
2603 : __("Create Rule", "yatra")}
2604 </Button>
2605 <Button
2606 type="button"
2607 variant="outline"
2608 className="w-full"
2609 onClick={() =>
2610 navigate({
2611 subpage: "trips",
2612 tab: "availability",
2613 trip_id:
2614 formData.trip_id?.toString() || tripIdFromUrl || "",
2615 })
2616 }
2617 >
2618 {__("Cancel", "yatra")}
2619 </Button>
2620 </div>
2621 </CardContent>
2622 </Card>
2623
2624 {/* Help */}
2625 <Alert>
2626 <div className="ml-2">
2627 <h4 className="font-medium">{__("How it works", "yatra")}</h4>
2628 <p className="text-xs text-gray-600 dark:text-gray-400 mt-1">
2629 {__(
2630 "Dates are generated automatically based on your pattern. Manually added specific dates will take priority over generated dates.",
2631 "yatra",
2632 )}
2633 </p>
2634 </div>
2635 </Alert>
2636 </div>
2637 </div>
2638 </form>
2639 </div>
2640 );
2641 };
2642
2643 export default RecurringRuleForm;
2644