/** * Dynamic Pricing Rule Form Page * * Create and edit dynamic pricing rules with sidebar layout * * @package Yatra * @since 3.0.0 */ import React, { useState, useEffect } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { Card, CardContent, CardHeader, CardTitle, CardDescription, } from "../components/ui/card"; import { Button } from "../components/ui/button"; import { Label } from "../components/ui/label"; import { Input } from "../components/ui/input"; import { Select } from "../components/ui/select"; import { Skeleton } from "../components/ui/skeleton"; import { DatePicker } from "../components/ui/date-picker"; import { PageHeader } from "../components/common/PageHeader"; import { RuleTypeSelectionModal } from "../components/modals/RuleTypeSelectionModal"; import { useToast } from "../components/ui/toast"; import { apiClient } from "../lib/api-client"; import { __ } from "../lib/i18n"; import { ArrowLeft, Save, Loader2, Calendar, Clock, TrendingUp, Package, Sun, Target, Check, } from "lucide-react"; interface RuleFormData { name: string; rule_type: string; adjustment_type: string; adjustment_value: number; min_days_before: number; max_days_before: number; min_inventory: number; max_inventory: number; start_date: string; end_date: string; applicable_trips: string; trip_ids: number[]; status: string; priority: number; // Demand-based conditions demand_threshold_high?: number; demand_threshold_low?: number; // Time-based conditions. `weekend_days` is the source of truth — the engine // evaluates the rule only on those weekdays. The `apply_on_weekends` / // `apply_on_weekdays` columns still exist in the DB schema for legacy reasons // but the engine never reads them, so we don't surface them in the form to // avoid the impression they do anything. weekend_days?: string[]; } const RULE_TYPES = [ { id: "early_bird", name: __("Early Bird Discount"), description: __( "Reward customers who book well in advance. Always behaves as a discount.", ), icon: Calendar, color: "blue", example: __( "10% off when departure is at least 30 days away (set Min Days Before = 30).", ), }, { id: "last_minute", name: __("Last Minute Deals"), description: __( "Discount close to departure to fill remaining seats. Always behaves as a discount.", ), icon: Clock, color: "orange", example: __( "15% off when departure is within 7 days (set Max Days Before = 7).", ), }, { id: "demand", name: __("Demand-Based Pricing"), description: __( "Adjusts price by booking velocity score (recomputed by cron / on each booking).", ), icon: TrendingUp, color: "green", example: __( "Score above 70 → markup; below 30 → discount. Magnitude scales with how far the score is outside the band.", ), }, { id: "inventory", name: __("Inventory-Based"), description: __( "Reacts to seats remaining on the chosen departure (works with time slots too).", ), icon: Package, color: "purple", example: __( "Seats ≤ 5 → +10% scarcity markup; seats ≥ 20 → 50% of magnitude as discount.", ), }, { id: "seasonal", name: __("Seasonal Pricing"), description: __( "Applies when the DEPARTURE date falls within the season window.", ), icon: Sun, color: "yellow", example: __( "+25% on departures between Jul 1 – Aug 31. Use a negative value for off-peak discounts.", ), }, { id: "time_based", name: __("Time-Based (Weekday/Weekend)"), description: __( "Applies on selected weekdays of the DEPARTURE date, optionally limited to a date window.", ), icon: Target, color: "indigo", example: __( "+15% premium on Saturday & Sunday departures. Add a date range to make it effective only for that period.", ), }, ]; const DynamicPricingRuleForm: React.FC = () => { const { showToast } = useToast(); const queryClient = useQueryClient(); const urlParams = new URLSearchParams(window.location.search); const ruleId = urlParams.get("id"); const ruleTypeFromUrl = urlParams.get("rule_type") || ""; const isEdit = !!ruleId; const [formData, setFormData] = useState({ name: "", rule_type: ruleTypeFromUrl, adjustment_type: "percentage", adjustment_value: 0, min_days_before: 0, max_days_before: 0, min_inventory: 0, max_inventory: 0, start_date: "", end_date: "", applicable_trips: "all", trip_ids: [], status: "active", priority: 1, demand_threshold_high: 80, demand_threshold_low: 30, weekend_days: [], }); const [showTripDropdown, setShowTripDropdown] = useState(false); const [tripSearchQuery, setTripSearchQuery] = useState(""); const [debouncedTripSearch, setDebouncedTripSearch] = useState(""); const [showRuleTypeModal, setShowRuleTypeModal] = useState(false); // Rule simulation state — drives the "When will this apply?" panel. // We keep a small base price input so admins can see absolute dollar amounts // for the discount/markup rather than just percentages. const [previewBasePrice, setPreviewBasePrice] = useState(100); const [previewLoading, setPreviewLoading] = useState(false); const [previewError, setPreviewError] = useState(null); const [previewResult, setPreviewResult] = useState<{ rule_type: string; base_price: number; current_date: string; scenarios: Array<{ label: string; departure_date: string | null; spots_remaining: number | null; applies: boolean; base_price: number; final_price: number; adjustment: number; adjustment_percent: number; }>; } | null>(null); const handleSelectRuleType = (ruleType: string) => { handleChange("rule_type", ruleType); setShowRuleTypeModal(false); // A new rule type changes which scenarios are meaningful — drop the old // preview so stale results don't mislead the admin. setPreviewResult(null); setPreviewError(null); }; const handleRunSimulation = async () => { if (!formData.rule_type) { setPreviewError(__("Pick a rule type first.")); return; } setPreviewLoading(true); setPreviewError(null); try { const response = await apiClient.post("/dynamic-pricing/simulate-rule", { rule_type: formData.rule_type, adjustment_type: formData.adjustment_type, adjustment_value: formData.adjustment_value, min_days_before: formData.min_days_before, max_days_before: formData.max_days_before, min_inventory: formData.min_inventory, max_inventory: formData.max_inventory, start_date: formData.start_date || null, end_date: formData.end_date || null, weekend_days: formData.weekend_days || [], demand_threshold_low: formData.demand_threshold_low, demand_threshold_high: formData.demand_threshold_high, base_price: previewBasePrice > 0 ? previewBasePrice : 100, }); // Controller returns { success: true, data: {...scenarios...} } — checking // .success on .data would always be undefined and throw a false negative. const envelope = response as any; if (envelope?.success === false) { throw new Error(envelope?.message || __("Simulation failed.")); } const result = envelope?.data ?? envelope; if (!result || !Array.isArray(result.scenarios)) { throw new Error(__("Simulation returned an unexpected response.")); } setPreviewResult(result); } catch (e: any) { setPreviewError(e?.message || __("Simulation failed.")); } finally { setPreviewLoading(false); } }; // Debounce trip search useEffect(() => { const timer = setTimeout(() => { setDebouncedTripSearch(tripSearchQuery); }, 300); return () => clearTimeout(timer); }, [tripSearchQuery]); // Fetch trips for selection const { data: tripsData } = useQuery({ queryKey: ["trips-list", debouncedTripSearch], queryFn: async () => { const params = new URLSearchParams(); if (debouncedTripSearch) { params.append("search", debouncedTripSearch); } params.append("per_page", "50"); const response = await apiClient.get(`/trips?${params.toString()}`); return response.data || []; }, }); const tripOptions = (tripsData || []).map((trip: any) => ({ value: trip.id, label: trip.title || `Trip #${trip.id}`, })); // Fetch existing rule if editing const { data: ruleData, isLoading, isError, error, } = useQuery({ queryKey: ["dynamic-pricing-rule", ruleId], queryFn: async () => { if (!ruleId) return null; const response = await apiClient.get(`/dynamic-pricing/rules/${ruleId}`); return response; }, enabled: isEdit, retry: 1, }); // Log loading state // Populate form with existing data useEffect(() => { if (ruleData?.data) { const ruleDetails = ruleData.data; setFormData({ name: ruleDetails.name || "", rule_type: ruleDetails.rule_type || "", adjustment_type: ruleDetails.adjustment_type || "percentage", adjustment_value: Number(ruleDetails.adjustment_value) || 0, min_days_before: Number(ruleDetails.min_days_before) || 0, max_days_before: Number(ruleDetails.max_days_before) || 0, min_inventory: Number(ruleDetails.min_inventory) || 0, max_inventory: Number(ruleDetails.max_inventory) || 0, start_date: ruleDetails.start_date || "", end_date: ruleDetails.end_date || "", applicable_trips: ruleDetails.applicable_trips || "all", trip_ids: Array.isArray(ruleDetails.trip_ids) ? ruleDetails.trip_ids : ruleDetails.trip_ids ? JSON.parse(ruleDetails.trip_ids) : [], status: ruleDetails.status || "active", priority: Number(ruleDetails.priority) || 1, demand_threshold_high: ruleDetails.demand_threshold_high ? Number(ruleDetails.demand_threshold_high) : 80, demand_threshold_low: ruleDetails.demand_threshold_low ? Number(ruleDetails.demand_threshold_low) : 30, weekend_days: Array.isArray(ruleDetails.weekend_days) ? ruleDetails.weekend_days : ruleDetails.weekend_days ? JSON.parse(ruleDetails.weekend_days) : [], }); } }, [ruleData]); // Create/Update mutation const saveMutation = useMutation({ mutationFn: async (data: RuleFormData) => { if (isEdit) { return await apiClient.put(`/dynamic-pricing/rules/${ruleId}`, data); } else { return await apiClient.post("/dynamic-pricing/rules", data); } }, onSuccess: (response) => { showToast( isEdit ? __("Pricing rule updated successfully") : __("Pricing rule created successfully"), "success", ); queryClient.invalidateQueries({ queryKey: ["dynamic-pricing-rules"] }); if (!isEdit && response?.data?.id) { // For new rules, redirect to edit page const baseUrl = window.location.href.split("&action=")[0]; const editUrl = `${baseUrl}&action=edit-pricing-rule&id=${response.data.id}`; window.location.href = editUrl; } // For updates, stay on the same page (no redirect) }, onError: (error: any) => { showToast(error?.message || __("Failed to save pricing rule"), "error"); }, }); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); saveMutation.mutate(formData); }; const handleCancel = () => { window.location.href = window.location.href.split("&action=")[0]; }; const handleChange = (field: keyof RuleFormData, value: any) => { setFormData((prev) => ({ ...prev, [field]: value })); }; // Show error state if API call fails if (isError) { return (

{__("Failed to load pricing rule")}

{error?.message || __("An error occurred while fetching the rule data")}

); } if (isLoading) { return (
{/* Main Content Skeleton - Left Side (2/3) */}
{/* Rule Type Badge Skeleton */}
{/* Rule Details Skeleton */}
{/* Price Adjustment Skeleton */}
{/* Conditions Skeleton */}
{/* Applicable Trips Skeleton */}
{/* Sidebar Skeleton - Right Side (1/3) */}
{/* Status & Priority Skeleton */}
{/* Actions Skeleton */}
); } const selectedRuleType = RULE_TYPES.find( (rt) => rt.id === formData.rule_type, ); return (
{/* Two Column Layout: Main Content + Sidebar */}
{/* Main Content - Left Side (2/3) */}
{/* Step 1: Select Rule Type (Card-Based Selection) */} {!isEdit && !formData.rule_type && ( {__("Step 1: Select Rule Type")} {__( "Choose the type of dynamic pricing rule you want to create", )}
{RULE_TYPES.map((ruleType) => { const Icon = ruleType.icon; const colorClasses = { blue: "border-blue-200 dark:border-blue-800 hover:border-blue-400 dark:hover:border-blue-600 bg-blue-50 dark:bg-blue-900/20", orange: "border-orange-200 dark:border-orange-800 hover:border-orange-400 dark:hover:border-orange-600 bg-orange-50 dark:bg-orange-900/20", green: "border-green-200 dark:border-green-800 hover:border-green-400 dark:hover:border-green-600 bg-green-50 dark:bg-green-900/20", purple: "border-purple-200 dark:border-purple-800 hover:border-purple-400 dark:hover:border-purple-600 bg-purple-50 dark:bg-purple-900/20", yellow: "border-yellow-200 dark:border-yellow-800 hover:border-yellow-400 dark:hover:border-yellow-600 bg-yellow-50 dark:bg-yellow-900/20", indigo: "border-indigo-200 dark:border-indigo-800 hover:border-indigo-400 dark:hover:border-indigo-600 bg-indigo-50 dark:bg-indigo-900/20", }; return ( ); })}
)} {/* Show selected rule type badge if editing or type selected */} {(isEdit || formData.rule_type) && selectedRuleType && (
{React.createElement(selectedRuleType.icon, { className: "w-5 h-5 text-gray-600 dark:text-gray-400", })}

{__("Rule Type")}

{selectedRuleType.name}

{selectedRuleType.description}

)} {/* Step 2: Basic Information (Only show after rule type selected) */} {formData.rule_type && ( <> {__("Rule Details")} {__( "Internal name for this pricing rule (visible to admins only).", )}
handleChange("name", e.target.value)} placeholder={__("e.g., Summer Early Bird Discount")} required />

{__( "Used in admin lists, analytics, and price-history exports. Customers never see this name.", )}

{__("Price Adjustment")} {__("How much to adjust the price")}
handleChange( "adjustment_value", parseFloat(e.target.value), ) } placeholder={ formData.adjustment_type === "percentage" ? "10" : "100" } required />

{(() => { const rt = formData.rule_type; // Early bird & last minute always behave as discounts: // enter a positive magnitude. if (rt === "early_bird" || rt === "last_minute") { return formData.adjustment_type === "percentage" ? __( "Enter the discount magnitude as a positive number (e.g. 10 = 10% off). The rule always applies as a discount.", ) : __( "Enter the discount magnitude as a positive number (e.g. 100 = 100 off). The rule always applies as a discount.", ); } // Inventory & demand auto-derive direction from // capacity / demand score; admins enter only the // magnitude. if (rt === "inventory" || rt === "demand") { return formData.adjustment_type === "percentage" ? __( "Enter the magnitude (positive). Direction is derived automatically: scarce inventory / high demand → markup, plenty / low demand → discount.", ) : __( "Enter the magnitude (positive). Direction is derived automatically: scarce inventory / high demand → markup, plenty / low demand → discount.", ); } // Seasonal & time-based use the sign as entered. return formData.adjustment_type === "percentage" ? __( "Use a positive value to increase price (e.g. 10 = +10%) and a negative value to discount (e.g. -10 = 10% off).", ) : __( "Use a positive value to increase price (e.g. 100 = +100) and a negative value to discount (e.g. -100 = 100 off).", ); })()}

{__("Conditions")} {__("When this rule should apply")} {/* Early Bird & Last Minute - Days before departure */} {(formData.rule_type === "early_bird" || formData.rule_type === "last_minute") && ( <>
handleChange( "min_days_before", parseInt(e.target.value), ) } min="0" />

{__( "Apply only when the selected departure date is at least this many days away from today. Example: 30 means 30+ days before departure.", )}

handleChange( "max_days_before", parseInt(e.target.value), ) } min="0" />

{__( "Apply only when the selected departure date is within this many days from today. Example: 7 means 0–7 days before departure.", )}

{__( "If you set both Minimum and Maximum, the rule applies only inside that window (Minimum ≤ days before departure ≤ Maximum).", )}

handleChange("start_date", value) } placeholder={__("Select start date")} />

{__( "Optional lifetime limit. If set, this rule will run only on or after this date. Leave empty for no restriction.", )}

handleChange("end_date", value) } placeholder={__("Select end date")} />

{__( "Optional lifetime limit. If set, this rule will run only on or before this date. Leave empty for no restriction.", )}

)} {formData.rule_type === "inventory" && ( <>
handleChange( "min_inventory", parseInt(e.target.value), ) } min="0" />

{__( "When seats remaining ≤ this number, the rule applies a markup (scarcity).", )}

handleChange( "max_inventory", parseInt(e.target.value), ) } min="0" />

{__( "When seats remaining ≥ this number, the rule applies a partial discount (50% of the magnitude).", )}

)} {/* Seasonal - Departure window */} {formData.rule_type === "seasonal" && ( <>
handleChange("start_date", value) } placeholder={__("Select start date")} />

{__( "Earliest departure date this seasonal rule applies to.", )}

handleChange("end_date", value) } placeholder={__("Select end date")} />

{__( "Latest departure date this seasonal rule applies to. The rule matches whenever the booking's departure date falls between these two dates (inclusive).", )}

{__( "💡 Tip: Use a positive Adjustment Value for peak-season markup (e.g. 25% during summer) or a negative value for an off-peak discount.", )}

)} {/* Demand-Based - Booking velocity thresholds */} {formData.rule_type === "demand" && ( <>
handleChange( "demand_threshold_high", parseInt(e.target.value), ) } min="0" max="100" />

{__( "Apply pricing when booking velocity exceeds this percentage (e.g., 80 = high demand)", )}

handleChange( "demand_threshold_low", parseInt(e.target.value), ) } min="0" max="100" />

{__( "Apply pricing when booking velocity falls below this percentage (e.g., 30 = low demand)", )}

{__( "💡 Demand scores are calculated automatically via cron job based on booking velocity.", )}

)} {/* Time-Based - Day of week selection */} {formData.rule_type === "time_based" && ( <>
{[ "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday", ].map((day) => (
{ const current = formData.weekend_days || []; if (e.target.checked) { handleChange("weekend_days", [ ...current, day, ]); } else { handleChange( "weekend_days", current.filter((d) => d !== day), ); } }} className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500" />
))}

{__( "Selected days are matched against the booking's DEPARTURE date — e.g. tick Saturday + Sunday to apply a weekend premium to Saturday/Sunday departures, regardless of when the customer books.", )}

handleChange("start_date", value) } placeholder={__("Select start date")} />

{__( "Limits the rule to departures on or after this date. Leave empty for no restriction.", )}

handleChange("end_date", value) } placeholder={__("Select end date")} />

{__( "Limits the rule to departures on or before this date. Leave empty for no restriction.", )}

{__( "💡 Sign matters: a positive Adjustment Value adds a premium on the selected days; a negative value gives a discount.", )}

)}
)}
{/* Sidebar - Right Side (1/3) */} {formData.rule_type && (
{/* Status */} {__("Status")}

{__("Only active rules will be applied to trips.")}

{/* Priority */} {__("Priority")} handleChange("priority", parseInt(e.target.value)) } min="1" max="100" />

{__( "A higher number = higher priority (1–100). When more than one rule matches, the global Rule Priority Mode in Settings decides whether the highest-priority rule wins, every rule stacks, or the customer gets the best price.", )}

{/* Applicable Trips */} {__("Applicable To")} {formData.applicable_trips === "specific" && (
setShowTripDropdown(!showTripDropdown)} > {formData.trip_ids.length > 0 ? (
{formData.trip_ids.slice(0, 2).map((tripId) => { const trip = tripOptions.find( (t: any) => t.value === tripId, ); return ( {trip?.label || `Trip #${tripId}`}{" "} #{tripId} ); })} {formData.trip_ids.length > 2 && ( +{formData.trip_ids.length - 2} {__("more")} )}
) : ( {__("Click to select trips...")} )}
{showTripDropdown && (
setTripSearchQuery(e.target.value) } className="w-full text-sm" onClick={(e) => e.stopPropagation()} />
{tripOptions.map((trip: any) => { const isSelected = formData.trip_ids.includes( trip.value, ); return (
{ e.stopPropagation(); if (isSelected) { handleChange( "trip_ids", formData.trip_ids.filter( (id) => id !== trip.value, ), ); } else { handleChange("trip_ids", [ ...formData.trip_ids, trip.value, ]); } }} >
{isSelected && ( )}
{trip.label} #{trip.value}
); })}
)}
)}
{/* "When will this apply?" preview panel. Runs the current (unsaved) form data through the engine against rule-type-appropriate sample scenarios so admins can see exactly when their rule fires, before saving. */} {__("When will this rule apply?")} {__( "Run a simulation with the current settings to see which scenarios trigger the discount/markup. Nothing is saved.", )}
setPreviewBasePrice(Number(e.target.value) || 0) } />
{previewError && (

{previewError}

)} {previewResult && (
{previewResult.scenarios.map((s, i) => ( ))}
{__("Scenario")} {__("Final Price")} {__("Change")} {__("Rule")}
{s.label}
{s.departure_date && (
{s.departure_date}
)}
{previewResult.base_price > 0 ? `$${s.final_price.toFixed(2)}` : "—"} {s.applies ? ( {s.adjustment > 0 ? "+" : ""}$ {s.adjustment.toFixed(2)} ( {s.adjustment_percent > 0 ? "+" : ""} {s.adjustment_percent.toFixed(2)}%) ) : ( )} {s.applies ? ( {__("Fires")} ) : ( {__("Skipped")} )}
)}
{/* Create Rule Button - Below Applicable To */}
)}
{/* Remove old actions section */} {false && formData.rule_type && (
)}
{/* Rule Type Selection Modal */} setShowRuleTypeModal(false)} onSelectType={handleSelectRuleType} />
); }; export default DynamicPricingRuleForm;