| @@ -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 | }; |