PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.17
Yatra – Travel Booking & Tour Operator Software v3.0.17
3.0.17 3.0.16 3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 All 85 releases
← All changes | resources/js/pages/DynamicPricing.tsx +147 -43 3.0.3 → 3.0.17 View file →
@@ -28,8 +28,9 @@
28 28 import { Table as SharedTable } from "../components/shared/Table";
29 29 import { SearchFilterToolbar, BulkActionToolbar } from "../components/shared";
30 30 import { apiClient } from "../lib/api-client";
31 31 import { __ } from "../lib/i18n";
32 +import { toDateValue } from "../lib/dateFormat";
32 33 import { formatYatraMoney } from "../lib/currency-display";
33 34 import PremiumUpgradeCard from "./premium-pages/DynamicPricing";
34 35 import {
35 36 ResponsiveContainer,
@@ -63,8 +64,13 @@
63 64 };
64 65
65 66 // Main Component
66 67 const DynamicPricingPage: React.FC = () => {
68 + // Pro gate is a stable, server-injected flag — but we compute it once
69 + // at the top so every hook below can read it and the early return can
70 + // live below all hook calls (rules-of-hooks).
71 + const moduleAvailable = isModuleAvailable();
72 +
67 73 const [activeTab, setActiveTab] = useState("rules");
68 74 const [showRuleTypeModal, setShowRuleTypeModal] = useState(false);
69 75 const [settings, setSettings] = useState({
70 76 rule_priority_mode: "highest",
@@ -93,9 +99,12 @@
93 99 isOpen: boolean;
94 100 rule: any | null;
95 101 title?: string;
96 102 message?: string;
97 - onConfirm?: () => void;
103 + confirmText?: string;
104 + variant?: "danger" | "warning" | "info";
105 + isLoading?: boolean;
106 + onConfirm?: () => void | Promise<void>;
98 107 }>({
99 108 isOpen: false,
100 109 rule: null,
101 110 });
@@ -100,8 +109,27 @@
100 109 rule: null,
101 110 });
102 111 const [isSaving, setIsSaving] = useState(false);
103 112
113 + const closeConfirmDialog = () =>
114 + setConfirmDialog({
115 + isOpen: false,
116 + rule: null,
117 + title: "",
118 + message: "",
119 + confirmText: "",
120 + variant: "danger",
121 + isLoading: false,
122 + onConfirm: () => {},
123 + });
124 +
125 + const invalidateRules = () => {
126 + queryClient.invalidateQueries({ queryKey: ["dynamic-pricing-rules"] });
127 + queryClient.invalidateQueries({
128 + queryKey: ["dynamic-pricing-statistics"],
129 + });
130 + };
131 +
104 132 const handleSettingChange = (key: string, value: any) => {
105 133 setSettings((prev) => ({ ...prev, [key]: value }));
106 134 };
107 135
@@ -122,11 +150,8 @@
122 150 const baseUrl = window.location.href.split("&action=")[0];
123 151 window.location.href = `${baseUrl}&action=create-pricing-rule&rule_type=${ruleType}`;
124 152 };
125 153
126 - // Show premium upgrade content if module is not available
127 - if (!isModuleAvailable()) return <PremiumUpgradeCard />;
128 -
129 154 // Fetch settings from backend
130 155 const { data: settingsData } = useQuery({
131 156 queryKey: ["dynamic-pricing-settings"],
132 157 queryFn: async () => {
@@ -134,8 +159,9 @@
134 159 const body = (response as any)?.data ?? response;
135 160 const payload = (body as any)?.data ?? body;
136 161 return payload && typeof payload === "object" ? payload : {};
137 162 },
163 + enabled: moduleAvailable,
138 164 });
139 165
140 166 // Update settings state when data is loaded
141 167 React.useEffect(() => {
@@ -158,8 +184,9 @@
158 184 const response = await apiClient.get("/dynamic-pricing/rules");
159 185
160 186 return response;
161 187 },
188 + enabled: moduleAvailable,
162 189 });
163 190
164 191 // Fetch statistics
165 192 const {
@@ -172,8 +199,9 @@
172 199 const response = await apiClient.get("/dynamic-pricing/statistics");
173 200
174 201 return response;
175 202 },
203 + enabled: moduleAvailable,
176 204 });
177 205
178 206 // The API client returns the decoded JSON body.
179 207 // Some endpoints return { data: {...} } while others might return { data: { data: {...} } }.
@@ -181,8 +209,9 @@
181 209 (rulesData as any)?.data?.data ?? (rulesData as any)?.data ?? [];
182 210 const statsPayload =
183 211 (statsData as any)?.data?.data ?? (statsData as any)?.data ?? {};
184 212
213 + // eslint-disable-next-line react-hooks/exhaustive-deps
185 214 const rules = rulesPayload || [];
186 215 const stats = statsPayload || {};
187 216
188 217 const globalCurrency = (window as any)?.yatraAdmin?.currency || "USD";
@@ -195,9 +224,9 @@
195 224 (stats as any)?.pricing_history_trend_last_30_days,
196 225 )
197 226 ? (stats as any).pricing_history_trend_last_30_days.map((row: any) => {
198 227 const day = String(row.day || "");
199 - const dateLabel = day ? new Date(day).toLocaleDateString() : "";
228 + const dateLabel = day ? toDateValue(day).toLocaleDateString() : "";
200 229 return {
201 230 day,
202 231 dateLabel,
203 232 events: Number(row.events) || 0,
@@ -230,10 +259,13 @@
230 259 rule.rule_type?.toLowerCase().includes(searchTerm.toLowerCase()),
231 260 );
232 261 }
233 262
234 - // Apply status filter
235 - if (statusFilter !== "all") {
263 + // Apply status filter. The "All" view excludes Trash by WordPress
264 + // convention — trashed rules are only visible from the Trash filter.
265 + if (statusFilter === "all") {
266 + filtered = filtered.filter((rule: any) => rule.status !== "trash");
267 + } else {
236 268 filtered = filtered.filter((rule: any) => rule.status === statusFilter);
237 269 }
238 270
239 271 // Apply sorting
@@ -255,12 +287,13 @@
255 287
256 288 return filtered;
257 289 }, [rules, searchTerm, statusFilter, sortBy, sortOrder]);
258 290
259 - // Bulk action mutation
291 + // Bulk action mutation. Uses Promise.allSettled so a single failed row does
292 + // not throw away the work that succeeded; the toast reports partial state.
260 293 const bulkMutation = useMutation({
261 294 mutationFn: async ({ action, ids }: { action: string; ids: number[] }) => {
262 - const promises = ids.map((id) => {
295 + const requests = ids.map((id) => {
263 296 if (action === "delete") {
264 297 return apiClient.delete(`/dynamic-pricing/rules/${id}`);
265 298 } else if (action === "restore") {
266 299 return apiClient.put(`/dynamic-pricing/rules/${id}`, {
@@ -276,18 +309,29 @@
276 309 });
277 310 }
278 311 return Promise.resolve();
279 312 });
280 - return Promise.all(promises);
313 +
314 + const results = await Promise.allSettled(requests);
315 + const succeeded = results.filter((r) => r.status === "fulfilled").length;
316 + const failed = results.length - succeeded;
317 + return { succeeded, failed, total: results.length };
281 318 },
282 - onSuccess: () => {
283 - queryClient.invalidateQueries({ queryKey: ["dynamic-pricing-rules"] });
284 - queryClient.invalidateQueries({
285 - queryKey: ["dynamic-pricing-statistics"],
286 - });
319 + onSuccess: (result) => {
320 + invalidateRules();
287 321 setSelectedIds([]);
288 322 setBulkAction("");
289 - showToast(__("Bulk action completed successfully"), "success");
323 +
324 + if (result.failed === 0) {
325 + showToast(__("Bulk action completed successfully"), "success");
326 + } else if (result.succeeded === 0) {
327 + showToast(__("Failed to complete bulk action"), "error");
328 + } else {
329 + showToast(
330 + `${result.succeeded}/${result.total} ${__("rules updated; some failed")}`,
331 + "warning",
332 + );
333 + }
290 334 },
291 335 onError: () => {
292 336 showToast(__("Failed to complete bulk action"), "error");
293 337 },
@@ -304,8 +348,11 @@
304 348 }
305 349 bulkMutation.mutate({ action: bulkAction, ids: selectedIds });
306 350 };
307 351
352 + // Gate after every hook is registered so hook order stays consistent.
353 + if (!moduleAvailable) return <PremiumUpgradeCard />;
354 +
308 355 return (
309 356 <div className="space-y-6">
310 357 <PageHeader
311 358 title={__("Dynamic Pricing")}
@@ -500,10 +547,16 @@
500 547 onClearSelection={() => setSelectedIds([])}
501 548 statusFilter={statusFilter}
502 549 setStatusFilter={setStatusFilter}
503 550 statusOptions={[
504 - { key: "all", label: __("All"), count: rules.length },
505 551 {
552 + key: "all",
553 + label: __("All"),
554 + count:
555 + stats.total_rules ??
556 + rules.filter((r: any) => r.status !== "trash").length,
557 + },
558 + {
506 559 key: "active",
507 560 label: __("Active"),
508 561 count: stats.active_rules || 0,
509 562 },
@@ -819,14 +872,40 @@
819 872 onClick: (rule: any) => {
820 873 setConfirmDialog({
821 874 isOpen: true,
822 875 rule,
823 - title: __("Confirm Action"),
876 + title: __("Move Rule to Trash"),
824 877 message: __(
825 - "Are you sure you want to perform this action?",
878 + "Are you sure you want to move this pricing rule to Trash? It will stop applying to trip pricing immediately. You can restore it from the Trash filter.",
826 879 ),
827 - onConfirm: () => {
828 - // Handle action
880 + confirmText: __("Move to Trash"),
881 + variant: "warning",
882 + onConfirm: async () => {
883 + setConfirmDialog((prev) => ({
884 + ...prev,
885 + isLoading: true,
886 + }));
887 + try {
888 + await apiClient.put(
889 + `/dynamic-pricing/rules/${rule.id}`,
890 + { status: "trash" },
891 + );
892 + showToast(
893 + __("Pricing rule moved to Trash"),
894 + "success",
895 + );
896 + invalidateRules();
897 + closeConfirmDialog();
898 + } catch (error) {
899 + setConfirmDialog((prev) => ({
900 + ...prev,
901 + isLoading: false,
902 + }));
903 + showToast(
904 + __("Failed to move rule to Trash"),
905 + "error",
906 + );
907 + }
829 908 },
830 909 });
831 910 },
832 911 condition: (rule: any) =>
@@ -839,14 +918,36 @@
839 918 onClick: (rule: any) => {
840 919 setConfirmDialog({
841 920 isOpen: true,
842 921 rule,
843 - title: __("Confirm Action"),
922 + title: __("Delete Pricing Rule Permanently"),
844 923 message: __(
845 - "Are you sure you want to perform this action?",
924 + "This will permanently delete the pricing rule. This action cannot be undone. Continue?",
846 925 ),
847 - onConfirm: () => {
848 - // Handle action
926 + confirmText: __("Delete Permanently"),
927 + variant: "danger",
928 + onConfirm: async () => {
929 + setConfirmDialog((prev) => ({
930 + ...prev,
931 + isLoading: true,
932 + }));
933 + try {
934 + await apiClient.delete(
935 + `/dynamic-pricing/rules/${rule.id}`,
936 + );
937 + showToast(
938 + __("Pricing rule deleted permanently"),
939 + "success",
940 + );
941 + invalidateRules();
942 + closeConfirmDialog();
943 + } catch (error) {
944 + setConfirmDialog((prev) => ({
945 + ...prev,
946 + isLoading: false,
947 + }));
948 + showToast(__("Failed to delete rule"), "error");
949 + }
849 950 },
850 951 });
851 952 },
852 953 condition: (rule: any) =>
@@ -1216,9 +1317,9 @@
1216 1317 <XAxis
1217 1318 dataKey="day"
1218 1319 tick={{ fontSize: 12 }}
1219 1320 tickFormatter={(v: any) =>
1220 - v ? new Date(String(v)).toLocaleDateString() : ""
1321 + v ? toDateValue(String(v)).toLocaleDateString() : ""
1221 1322 }
1222 1323 />
1223 1324 <YAxis
1224 1325 tick={{ fontSize: 12 }}
@@ -1282,9 +1383,9 @@
1282 1383 className="border-b border-gray-100 dark:border-gray-800"
1283 1384 >
1284 1385 <td className="py-3 px-4 text-gray-900 dark:text-white">
1285 1386 {row.day
1286 - ? new Date(
1387 + ? toDateValue(
1287 1388 String(row.day),
1288 1389 ).toLocaleDateString()
1289 1390 : ""}
1290 1391 </td>
@@ -1362,9 +1463,11 @@
1362 1463 value={settings.calculation_base || "discounted"}
1363 1464 onChange={(e) =>
1364 1465 handleSettingChange(
1365 1466 "calculation_base",
1366 - e.target.value === "regular" ? "regular" : "discounted",
1467 + e.target.value === "regular"
1468 + ? "regular"
1469 + : "discounted",
1367 1470 )
1368 1471 }
1369 1472 className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"
1370 1473 >
@@ -1400,20 +1503,22 @@
1400 1503 }
1401 1504 className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"
1402 1505 >
1403 1506 <option value="highest">
1404 - {__("Apply Highest Priority Rule Only")}
1507 + {__(
1508 + "Apply Largest Adjustment Only (one rule wins by magnitude)",
1509 + )}
1405 1510 </option>
1406 1511 <option value="cumulative">
1407 - {__("Apply All Matching Rules (Cumulative)")}
1512 + {__("Apply All Matching Rules (Cumulative / Stack)")}
1408 1513 </option>
1409 1514 <option value="best">
1410 - {__("Apply Best Price for Customer")}
1515 + {__("Apply Best Price for Customer (Lowest Final)")}
1411 1516 </option>
1412 1517 </select>
1413 1518 <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
1414 1519 {__(
1415 - "How to handle multiple rules matching the same booking",
1520 + "How to combine matching rules. Caps below still apply afterwards. Per-rule numeric Priority is used as a tie-breaker when multiple rules tie for largest / best.",
1416 1521 )}
1417 1522 </p>
1418 1523 </div>
1419 1524
@@ -1434,9 +1539,11 @@
1434 1539 max="100"
1435 1540 className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"
1436 1541 />
1437 1542 <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
1438 - {__("Cap maximum price increase to protect customers")}
1543 + {__(
1544 + "Hard cap on upward adjustments (markup) across all matched rules to avoid surprising customers with sudden surges.",
1545 + )}
1439 1546 </p>
1440 1547 </div>
1441 1548
1442 1549 <div>
@@ -1456,9 +1563,11 @@
1456 1563 max="100"
1457 1564 className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"
1458 1565 />
1459 1566 <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
1460 - {__("Cap maximum discount to maintain profitability")}
1567 + {__(
1568 + "Hard cap on downward adjustments (discount) so dynamic pricing can never push a trip below this fraction of its base price.",
1569 + )}
1461 1570 </p>
1462 1571 </div>
1463 1572 </div>
1464 1573 )}
@@ -1646,20 +1755,15 @@
1646 1755
1647 1756 {/* Confirmation Dialog */}
1648 1757 <ConfirmationDialog
1649 1758 isOpen={confirmDialog.isOpen}
1650 - onClose={() =>
1651 - setConfirmDialog({
1652 - isOpen: false,
1653 - rule: null,
1654 - title: "",
1655 - message: "",
1656 - onConfirm: () => {},
1657 - })
1658 - }
1759 + onClose={closeConfirmDialog}
1659 1760 onConfirm={() => confirmDialog.onConfirm?.()}
1660 1761 title={confirmDialog.title || ""}
1661 1762 message={confirmDialog.message || ""}
1763 + confirmText={confirmDialog.confirmText}
1764 + variant={confirmDialog.variant ?? "danger"}
1765 + isLoading={confirmDialog.isLoading ?? false}
1662 1766 />
1663 1767 </div>
1664 1768 );
1665 1769 };