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