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 +240 -85 3.0.2.6 → 3.0.17 View file →
@@ -28,9 +28,10 @@
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 { getCurrencySymbol } from "../data/currencies";
32 +import { toDateValue } from "../lib/dateFormat";
33 +import { formatYatraMoney } from "../lib/currency-display";
33 34 import PremiumUpgradeCard from "./premium-pages/DynamicPricing";
34 35 import {
35 36 ResponsiveContainer,
36 37 LineChart,
@@ -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",
@@ -74,8 +80,10 @@
74 80 update_frequency: "hourly",
75 81 show_original_price: true,
76 82 show_savings_badge: true,
77 83 show_urgency_messages: false,
84 + /** discounted = rules use sale/effective price; regular = rules use list price when known */
85 + calculation_base: "discounted" as "discounted" | "regular",
78 86 });
79 87 const { showToast } = useToast();
80 88 const queryClient = useQueryClient();
81 89
@@ -91,9 +99,12 @@
91 99 isOpen: boolean;
92 100 rule: any | null;
93 101 title?: string;
94 102 message?: string;
95 - onConfirm?: () => void;
103 + confirmText?: string;
104 + variant?: "danger" | "warning" | "info";
105 + isLoading?: boolean;
106 + onConfirm?: () => void | Promise<void>;
96 107 }>({
97 108 isOpen: false,
98 109 rule: null,
99 110 });
@@ -98,8 +109,27 @@
98 109 rule: null,
99 110 });
100 111 const [isSaving, setIsSaving] = useState(false);
101 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 +
102 132 const handleSettingChange = (key: string, value: any) => {
103 133 setSettings((prev) => ({ ...prev, [key]: value }));
104 134 };
105 135
@@ -120,24 +150,31 @@
120 150 const baseUrl = window.location.href.split("&action=")[0];
121 151 window.location.href = `${baseUrl}&action=create-pricing-rule&rule_type=${ruleType}`;
122 152 };
123 153
124 - // Show premium upgrade content if module is not available
125 - if (!isModuleAvailable()) return <PremiumUpgradeCard />;
126 -
127 154 // Fetch settings from backend
128 155 const { data: settingsData } = useQuery({
129 156 queryKey: ["dynamic-pricing-settings"],
130 157 queryFn: async () => {
131 158 const response = await apiClient.get("/dynamic-pricing/settings");
132 - return response.data || response;
159 + const body = (response as any)?.data ?? response;
160 + const payload = (body as any)?.data ?? body;
161 + return payload && typeof payload === "object" ? payload : {};
133 162 },
163 + enabled: moduleAvailable,
134 164 });
135 165
136 166 // Update settings state when data is loaded
137 167 React.useEffect(() => {
138 - if (settingsData) {
139 - setSettings(settingsData);
168 + if (settingsData && typeof settingsData === "object") {
169 + setSettings((prev) => ({
170 + ...prev,
171 + ...settingsData,
172 + calculation_base:
173 + (settingsData as any).calculation_base === "regular"
174 + ? "regular"
175 + : "discounted",
176 + }));
140 177 }
141 178 }, [settingsData]);
142 179
143 180 // Fetch pricing rules
@@ -147,8 +184,9 @@
147 184 const response = await apiClient.get("/dynamic-pricing/rules");
148 185
149 186 return response;
150 187 },
188 + enabled: moduleAvailable,
151 189 });
152 190
153 191 // Fetch statistics
154 192 const {
@@ -161,8 +199,9 @@
161 199 const response = await apiClient.get("/dynamic-pricing/statistics");
162 200
163 201 return response;
164 202 },
203 + enabled: moduleAvailable,
165 204 });
166 205
167 206 // The API client returns the decoded JSON body.
168 207 // Some endpoints return { data: {...} } while others might return { data: { data: {...} } }.
@@ -170,49 +209,24 @@
170 209 (rulesData as any)?.data?.data ?? (rulesData as any)?.data ?? [];
171 210 const statsPayload =
172 211 (statsData as any)?.data?.data ?? (statsData as any)?.data ?? {};
173 212
213 + // eslint-disable-next-line react-hooks/exhaustive-deps
174 214 const rules = rulesPayload || [];
175 215 const stats = statsPayload || {};
176 216
177 217 const globalCurrency = (window as any)?.yatraAdmin?.currency || "USD";
178 - const currencyPosition =
179 - (window as any)?.yatraAdmin?.currencyPosition ||
180 - (window as any)?.yatraAdmin?.currency_position ||
181 - "before";
182 - const decimalPlaces = Number(
183 - (window as any)?.yatraAdmin?.decimalPlaces ||
184 - (window as any)?.yatraAdmin?.currency_decimals ||
185 - 2,
186 - );
187 - const thousandSeparator =
188 - (window as any)?.yatraAdmin?.thousandSeparator || ",";
189 - const decimalSeparator = (window as any)?.yatraAdmin?.decimalSeparator || ".";
218 + const formatCurrencyAmount = (amount: number) =>
219 + formatYatraMoney(Number(amount) || 0, globalCurrency, {
220 + zeroAsUnknown: false,
221 + });
190 222
191 - const formatCurrencyAmount = (amount: number) => {
192 - const numPrice = Number(amount) || 0;
193 - const formattedAmount = new Intl.NumberFormat(undefined, {
194 - minimumFractionDigits: decimalPlaces,
195 - maximumFractionDigits: decimalPlaces,
196 - })
197 - .format(numPrice)
198 - .replace(/,/g, "TEMP_THOUSAND")
199 - .replace(/\./g, decimalSeparator)
200 - .replace(/TEMP_THOUSAND/g, thousandSeparator);
201 -
202 - const currencySymbol = getCurrencySymbol(globalCurrency);
203 - if (currencyPosition === "after" || currencyPosition === "right") {
204 - return `${formattedAmount} ${currencySymbol}`;
205 - }
206 - return `${currencySymbol}${formattedAmount}`;
207 - };
208 -
209 223 const trendData = Array.isArray(
210 224 (stats as any)?.pricing_history_trend_last_30_days,
211 225 )
212 226 ? (stats as any).pricing_history_trend_last_30_days.map((row: any) => {
213 227 const day = String(row.day || "");
214 - const dateLabel = day ? new Date(day).toLocaleDateString() : "";
228 + const dateLabel = day ? toDateValue(day).toLocaleDateString() : "";
215 229 return {
216 230 day,
217 231 dateLabel,
218 232 events: Number(row.events) || 0,
@@ -245,10 +259,13 @@
245 259 rule.rule_type?.toLowerCase().includes(searchTerm.toLowerCase()),
246 260 );
247 261 }
248 262
249 - // Apply status filter
250 - 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 {
251 268 filtered = filtered.filter((rule: any) => rule.status === statusFilter);
252 269 }
253 270
254 271 // Apply sorting
@@ -270,12 +287,13 @@
270 287
271 288 return filtered;
272 289 }, [rules, searchTerm, statusFilter, sortBy, sortOrder]);
273 290
274 - // 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.
275 293 const bulkMutation = useMutation({
276 294 mutationFn: async ({ action, ids }: { action: string; ids: number[] }) => {
277 - const promises = ids.map((id) => {
295 + const requests = ids.map((id) => {
278 296 if (action === "delete") {
279 297 return apiClient.delete(`/dynamic-pricing/rules/${id}`);
280 298 } else if (action === "restore") {
281 299 return apiClient.put(`/dynamic-pricing/rules/${id}`, {
@@ -291,18 +309,29 @@
291 309 });
292 310 }
293 311 return Promise.resolve();
294 312 });
295 - 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 };
296 318 },
297 - onSuccess: () => {
298 - queryClient.invalidateQueries({ queryKey: ["dynamic-pricing-rules"] });
299 - queryClient.invalidateQueries({
300 - queryKey: ["dynamic-pricing-statistics"],
301 - });
319 + onSuccess: (result) => {
320 + invalidateRules();
302 321 setSelectedIds([]);
303 322 setBulkAction("");
304 - 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 + }
305 334 },
306 335 onError: () => {
307 336 showToast(__("Failed to complete bulk action"), "error");
308 337 },
@@ -319,8 +348,11 @@
319 348 }
320 349 bulkMutation.mutate({ action: bulkAction, ids: selectedIds });
321 350 };
322 351
352 + // Gate after every hook is registered so hook order stays consistent.
353 + if (!moduleAvailable) return <PremiumUpgradeCard />;
354 +
323 355 return (
324 356 <div className="space-y-6">
325 357 <PageHeader
326 358 title={__("Dynamic Pricing")}
@@ -515,10 +547,16 @@
515 547 onClearSelection={() => setSelectedIds([])}
516 548 statusFilter={statusFilter}
517 549 setStatusFilter={setStatusFilter}
518 550 statusOptions={[
519 - { key: "all", label: __("All"), count: rules.length },
520 551 {
552 + key: "all",
553 + label: __("All"),
554 + count:
555 + stats.total_rules ??
556 + rules.filter((r: any) => r.status !== "trash").length,
557 + },
558 + {
521 559 key: "active",
522 560 label: __("Active"),
523 561 count: stats.active_rules || 0,
524 562 },
@@ -612,9 +650,13 @@
612 650 render: (rule: any) => (
613 651 <span className="font-medium text-gray-900 dark:text-white">
614 652 {rule.adjustment_type === "percentage"
615 653 ? `${rule.adjustment_value > 0 ? "+" : ""}${rule.adjustment_value}%`
616 - : `$${rule.adjustment_value}`}
654 + : formatYatraMoney(
655 + Number(rule.adjustment_value) || 0,
656 + globalCurrency,
657 + { zeroAsUnknown: false },
658 + )}
617 659 </span>
618 660 ),
619 661 },
620 662 {
@@ -830,14 +872,40 @@
830 872 onClick: (rule: any) => {
831 873 setConfirmDialog({
832 874 isOpen: true,
833 875 rule,
834 - title: __("Confirm Action"),
876 + title: __("Move Rule to Trash"),
835 877 message: __(
836 - "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.",
837 879 ),
838 - onConfirm: () => {
839 - // 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 + }
840 908 },
841 909 });
842 910 },
843 911 condition: (rule: any) =>
@@ -850,14 +918,36 @@
850 918 onClick: (rule: any) => {
851 919 setConfirmDialog({
852 920 isOpen: true,
853 921 rule,
854 - title: __("Confirm Action"),
922 + title: __("Delete Pricing Rule Permanently"),
855 923 message: __(
856 - "Are you sure you want to perform this action?",
924 + "This will permanently delete the pricing rule. This action cannot be undone. Continue?",
857 925 ),
858 - onConfirm: () => {
859 - // 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 + }
860 950 },
861 951 });
862 952 },
863 953 condition: (rule: any) =>
@@ -1165,14 +1255,31 @@
1165 1255 </div>
1166 1256 ))
1167 1257 ) : (
1168 1258 <div className="text-center py-8 text-gray-500 dark:text-gray-400">
1169 - <p>{__("No pricing rules created yet")}</p>
1170 - <p className="text-sm mt-2">
1171 - {__(
1172 - "Create your first rule to see performance metrics",
1173 - )}
1174 - </p>
1259 + {rules.length > 0 ? (
1260 + <>
1261 + <p>
1262 + {__(
1263 + "No rule-level impact recorded in the last 30 days",
1264 + )}
1265 + </p>
1266 + <p className="text-sm mt-2 max-w-lg mx-auto">
1267 + {__(
1268 + "Charts use dynamic pricing history when a booking applies an adjusted price. Complete a checkout (or recalculate pricing) after rules are active to populate metrics.",
1269 + )}
1270 + </p>
1271 + </>
1272 + ) : (
1273 + <>
1274 + <p>{__("No pricing rules created yet")}</p>
1275 + <p className="text-sm mt-2">
1276 + {__(
1277 + "Create your first rule to see performance metrics",
1278 + )}
1279 + </p>
1280 + </>
1281 + )}
1175 1282 </div>
1176 1283 )}
1177 1284 </div>
1178 1285 )}
@@ -1210,9 +1317,9 @@
1210 1317 <XAxis
1211 1318 dataKey="day"
1212 1319 tick={{ fontSize: 12 }}
1213 1320 tickFormatter={(v: any) =>
1214 - v ? new Date(String(v)).toLocaleDateString() : ""
1321 + v ? toDateValue(String(v)).toLocaleDateString() : ""
1215 1322 }
1216 1323 />
1217 1324 <YAxis
1218 1325 tick={{ fontSize: 12 }}
@@ -1276,9 +1383,9 @@
1276 1383 className="border-b border-gray-100 dark:border-gray-800"
1277 1384 >
1278 1385 <td className="py-3 px-4 text-gray-900 dark:text-white">
1279 1386 {row.day
1280 - ? new Date(
1387 + ? toDateValue(
1281 1388 String(row.day),
1282 1389 ).toLocaleDateString()
1283 1390 : ""}
1284 1391 </td>
@@ -1299,12 +1406,25 @@
1299 1406 </div>
1300 1407 </div>
1301 1408 ) : (
1302 1409 <div className="h-40 flex items-center justify-center border border-dashed border-gray-300 dark:border-gray-600 rounded-lg">
1303 - <div className="text-center">
1410 + <div className="text-center px-4">
1304 1411 <p className="text-gray-500 dark:text-gray-400">
1305 - {__("No pricing history in the last 30 days")}
1412 + {Number(stats.pricing_history_total) > 0 &&
1413 + Number(stats.pricing_history_last_30_days) === 0
1414 + ? __(
1415 + "No pricing events in the last 30 days (older history exists).",
1416 + )
1417 + : __("No pricing history in the last 30 days")}
1306 1418 </p>
1419 + {rules.length > 0 &&
1420 + Number(stats.pricing_history_total) === 0 ? (
1421 + <p className="text-sm text-gray-500 dark:text-gray-400 mt-2 max-w-md mx-auto">
1422 + {__(
1423 + "Revenue impact is recorded when a booking uses a trip price adjusted by your rules. Place a test booking to see this chart fill in.",
1424 + )}
1425 + </p>
1426 + ) : null}
1307 1427 </div>
1308 1428 </div>
1309 1429 )}
1310 1430 </CardContent>
@@ -1336,8 +1456,42 @@
1336 1456 ) : (
1337 1457 <div className="space-y-6">
1338 1458 <div>
1339 1459 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
1460 + {__("Rule calculation base")}
1461 + </label>
1462 + <select
1463 + value={settings.calculation_base || "discounted"}
1464 + onChange={(e) =>
1465 + handleSettingChange(
1466 + "calculation_base",
1467 + e.target.value === "regular"
1468 + ? "regular"
1469 + : "discounted",
1470 + )
1471 + }
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"
1473 + >
1474 + <option value="discounted">
1475 + {__(
1476 + "Promotional / sale price (stack dynamic pricing on the price customers already see after trip discounts)",
1477 + )}
1478 + </option>
1479 + <option value="regular">
1480 + {__(
1481 + "Regular list price (compute adjustments from catalog price; requires original price in context)",
1482 + )}
1483 + </option>
1484 + </select>
1485 + <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
1486 + {__(
1487 + "Choose whether percentage and fixed rules use the discounted trip price or the regular list price as their starting point. Bookings and availability pass both values when possible.",
1488 + )}
1489 + </p>
1490 + </div>
1491 +
1492 + <div>
1493 + <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
1340 1494 {__("Rule Priority Mode")}
1341 1495 </label>
1342 1496 <select
1343 1497 value={settings.rule_priority_mode}
@@ -1349,20 +1503,22 @@
1349 1503 }
1350 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"
1351 1505 >
1352 1506 <option value="highest">
1353 - {__("Apply Highest Priority Rule Only")}
1507 + {__(
1508 + "Apply Largest Adjustment Only (one rule wins by magnitude)",
1509 + )}
1354 1510 </option>
1355 1511 <option value="cumulative">
1356 - {__("Apply All Matching Rules (Cumulative)")}
1512 + {__("Apply All Matching Rules (Cumulative / Stack)")}
1357 1513 </option>
1358 1514 <option value="best">
1359 - {__("Apply Best Price for Customer")}
1515 + {__("Apply Best Price for Customer (Lowest Final)")}
1360 1516 </option>
1361 1517 </select>
1362 1518 <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
1363 1519 {__(
1364 - "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.",
1365 1521 )}
1366 1522 </p>
1367 1523 </div>
1368 1524
@@ -1383,9 +1539,11 @@
1383 1539 max="100"
1384 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"
1385 1541 />
1386 1542 <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
1387 - {__("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 + )}
1388 1546 </p>
1389 1547 </div>
1390 1548
1391 1549 <div>
@@ -1405,9 +1563,11 @@
1405 1563 max="100"
1406 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"
1407 1565 />
1408 1566 <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
1409 - {__("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 + )}
1410 1570 </p>
1411 1571 </div>
1412 1572 </div>
1413 1573 )}
@@ -1595,20 +1755,15 @@
1595 1755
1596 1756 {/* Confirmation Dialog */}
1597 1757 <ConfirmationDialog
1598 1758 isOpen={confirmDialog.isOpen}
1599 - onClose={() =>
1600 - setConfirmDialog({
1601 - isOpen: false,
1602 - rule: null,
1603 - title: "",
1604 - message: "",
1605 - onConfirm: () => {},
1606 - })
1607 - }
1759 + onClose={closeConfirmDialog}
1608 1760 onConfirm={() => confirmDialog.onConfirm?.()}
1609 1761 title={confirmDialog.title || ""}
1610 1762 message={confirmDialog.message || ""}
1763 + confirmText={confirmDialog.confirmText}
1764 + variant={confirmDialog.variant ?? "danger"}
1765 + isLoading={confirmDialog.isLoading ?? false}
1611 1766 />
1612 1767 </div>
1613 1768 );
1614 1769 };