PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.4
Yatra – Travel Booking & Tour Operator Software v3.0.4
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 2.0.1 All 84 releases
yatra / resources / js / pages / DynamicPricing.tsx

DynamicPricing.tsx in Yatra – Travel Booking & Tour Operator Software 3.0.4, at resources/js/pages/DynamicPricing.tsx

1,765 lines 71.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Dynamic Pricing Page
3 *
4 * Premium module for intelligent price adjustments.
5 * This page only loads when Yatra Pro is active and module is enabled.
6 * Premium upgrade content is handled by premium-pages/DynamicPricing.tsx
7 *
8 * @package Yatra
9 * @since 3.0.0
10 */
11
12 import React, { useState, useMemo } from "react";
13 import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
14 import {
15 Card,
16 CardContent,
17 CardHeader,
18 CardTitle,
19 CardDescription,
20 } from "../components/ui/card";
21 import { Button } from "../components/ui/button";
22 import { Badge } from "../components/ui/badge";
23 import { PageHeader } from "../components/common/PageHeader";
24 import { RuleTypeSelectionModal } from "../components/modals/RuleTypeSelectionModal";
25 import { Toggle } from "../components/ui/toggle";
26 import { useToast } from "../components/ui/toast";
27 import { ConfirmationDialog } from "../components/ui/confirmation-dialog";
28 import { Table as SharedTable } from "../components/shared/Table";
29 import { SearchFilterToolbar, BulkActionToolbar } from "../components/shared";
30 import { apiClient } from "../lib/api-client";
31 import { __ } from "../lib/i18n";
32 import { formatYatraMoney } from "../lib/currency-display";
33 import PremiumUpgradeCard from "./premium-pages/DynamicPricing";
34 import {
35 ResponsiveContainer,
36 LineChart,
37 Line,
38 XAxis,
39 YAxis,
40 CartesianGrid,
41 Tooltip,
42 } from "recharts";
43 import {
44 Clock,
45 Target,
46 Check,
47 Package,
48 BarChart,
49 Settings,
50 Plus,
51 AlertCircle,
52 Edit,
53 Trash2,
54 DollarSign,
55 CheckCircle,
56 Save,
57 } from "lucide-react";
58
59 // Check if module is available (Pro active)
60 const isModuleAvailable = (): boolean => {
61 const yatraAdmin = (window as any)?.yatraAdmin;
62 return Boolean(yatraAdmin?.isPro);
63 };
64
65 // Main Component
66 const DynamicPricingPage: React.FC = () => {
67 const [activeTab, setActiveTab] = useState("rules");
68 const [showRuleTypeModal, setShowRuleTypeModal] = useState(false);
69 const [settings, setSettings] = useState({
70 rule_priority_mode: "highest",
71 maximum_markup_percent: 50,
72 maximum_discount_percent: 30,
73 calculation_period: 7,
74 update_frequency: "hourly",
75 show_original_price: true,
76 show_savings_badge: true,
77 show_urgency_messages: false,
78 /** discounted = rules use sale/effective price; regular = rules use list price when known */
79 calculation_base: "discounted" as "discounted" | "regular",
80 });
81 const { showToast } = useToast();
82 const queryClient = useQueryClient();
83
84 // Search and filter state
85 const [searchTerm, setSearchTerm] = useState("");
86 const [statusFilter, setStatusFilter] = useState("all");
87 const [sortBy, setSortBy] = useState("name");
88 const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
89 const [selectedIds, setSelectedIds] = useState<number[]>([]);
90 const [bulkAction, setBulkAction] = useState("");
91 const [showColumnsDropdown, setShowColumnsDropdown] = useState(false);
92 const [confirmDialog, setConfirmDialog] = useState<{
93 isOpen: boolean;
94 rule: any | null;
95 title?: string;
96 message?: string;
97 confirmText?: string;
98 variant?: "danger" | "warning" | "info";
99 isLoading?: boolean;
100 onConfirm?: () => void | Promise<void>;
101 }>({
102 isOpen: false,
103 rule: null,
104 });
105 const [isSaving, setIsSaving] = useState(false);
106
107 const closeConfirmDialog = () =>
108 setConfirmDialog({
109 isOpen: false,
110 rule: null,
111 title: "",
112 message: "",
113 confirmText: "",
114 variant: "danger",
115 isLoading: false,
116 onConfirm: () => {},
117 });
118
119 const invalidateRules = () => {
120 queryClient.invalidateQueries({ queryKey: ["dynamic-pricing-rules"] });
121 queryClient.invalidateQueries({
122 queryKey: ["dynamic-pricing-statistics"],
123 });
124 };
125
126 const handleSettingChange = (key: string, value: any) => {
127 setSettings((prev) => ({ ...prev, [key]: value }));
128 };
129
130 const handleSaveSettings = async () => {
131 setIsSaving(true);
132 try {
133 await apiClient.post("/dynamic-pricing/settings", settings);
134 showToast(__("Settings saved successfully"), "success");
135 } catch (error) {
136 console.error("Failed to save settings:", error);
137 showToast(__("Failed to save settings. Please try again."), "error");
138 } finally {
139 setIsSaving(false);
140 }
141 };
142
143 const handleSelectRuleType = (ruleType: string) => {
144 const baseUrl = window.location.href.split("&action=")[0];
145 window.location.href = `${baseUrl}&action=create-pricing-rule&rule_type=${ruleType}`;
146 };
147
148 // Show premium upgrade content if module is not available
149 if (!isModuleAvailable()) return <PremiumUpgradeCard />;
150
151 // Fetch settings from backend
152 const { data: settingsData } = useQuery({
153 queryKey: ["dynamic-pricing-settings"],
154 queryFn: async () => {
155 const response = await apiClient.get("/dynamic-pricing/settings");
156 const body = (response as any)?.data ?? response;
157 const payload = (body as any)?.data ?? body;
158 return payload && typeof payload === "object" ? payload : {};
159 },
160 });
161
162 // Update settings state when data is loaded
163 React.useEffect(() => {
164 if (settingsData && typeof settingsData === "object") {
165 setSettings((prev) => ({
166 ...prev,
167 ...settingsData,
168 calculation_base:
169 (settingsData as any).calculation_base === "regular"
170 ? "regular"
171 : "discounted",
172 }));
173 }
174 }, [settingsData]);
175
176 // Fetch pricing rules
177 const { data: rulesData, isLoading } = useQuery({
178 queryKey: ["dynamic-pricing-rules"],
179 queryFn: async () => {
180 const response = await apiClient.get("/dynamic-pricing/rules");
181
182 return response;
183 },
184 });
185
186 // Fetch statistics
187 const {
188 data: statsData,
189 isLoading: isStatsLoading,
190 error: statsError,
191 } = useQuery({
192 queryKey: ["dynamic-pricing-statistics"],
193 queryFn: async () => {
194 const response = await apiClient.get("/dynamic-pricing/statistics");
195
196 return response;
197 },
198 });
199
200 // The API client returns the decoded JSON body.
201 // Some endpoints return { data: {...} } while others might return { data: { data: {...} } }.
202 const rulesPayload =
203 (rulesData as any)?.data?.data ?? (rulesData as any)?.data ?? [];
204 const statsPayload =
205 (statsData as any)?.data?.data ?? (statsData as any)?.data ?? {};
206
207 const rules = rulesPayload || [];
208 const stats = statsPayload || {};
209
210 const globalCurrency = (window as any)?.yatraAdmin?.currency || "USD";
211 const formatCurrencyAmount = (amount: number) =>
212 formatYatraMoney(Number(amount) || 0, globalCurrency, {
213 zeroAsUnknown: false,
214 });
215
216 const trendData = Array.isArray(
217 (stats as any)?.pricing_history_trend_last_30_days,
218 )
219 ? (stats as any).pricing_history_trend_last_30_days.map((row: any) => {
220 const day = String(row.day || "");
221 const dateLabel = day ? new Date(day).toLocaleDateString() : "";
222 return {
223 day,
224 dateLabel,
225 events: Number(row.events) || 0,
226 totalAdjustmentAmount: Number(row.total_adjustment_amount) || 0,
227 avgAdjustmentPercentage: Number(row.avg_adjustment_percentage) || 0,
228 };
229 })
230 : [];
231
232 const ruleImpact = Array.isArray(
233 (stats as any)?.pricing_history_rule_impact_last_30_days,
234 )
235 ? (stats as any).pricing_history_rule_impact_last_30_days
236 : [];
237
238 const analyticsLoading = isLoading || isStatsLoading;
239 const statsErrorMessage = statsError
240 ? (statsError as any)?.message || String(statsError)
241 : "";
242
243 // Filter and sort rules
244 const filteredRules = useMemo(() => {
245 let filtered = [...rules];
246
247 // Apply search filter
248 if (searchTerm) {
249 filtered = filtered.filter(
250 (rule: any) =>
251 rule.name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
252 rule.rule_type?.toLowerCase().includes(searchTerm.toLowerCase()),
253 );
254 }
255
256 // Apply status filter. The "All" view excludes Trash by WordPress
257 // convention — trashed rules are only visible from the Trash filter.
258 if (statusFilter === "all") {
259 filtered = filtered.filter((rule: any) => rule.status !== "trash");
260 } else {
261 filtered = filtered.filter((rule: any) => rule.status === statusFilter);
262 }
263
264 // Apply sorting
265 filtered.sort((a: any, b: any) => {
266 let aVal = a[sortBy];
267 let bVal = b[sortBy];
268
269 if (sortBy === "created_at" || sortBy === "updated_at") {
270 aVal = new Date(aVal).getTime();
271 bVal = new Date(bVal).getTime();
272 }
273
274 if (sortOrder === "asc") {
275 return aVal > bVal ? 1 : -1;
276 } else {
277 return aVal < bVal ? 1 : -1;
278 }
279 });
280
281 return filtered;
282 }, [rules, searchTerm, statusFilter, sortBy, sortOrder]);
283
284 // Bulk action mutation. Uses Promise.allSettled so a single failed row does
285 // not throw away the work that succeeded; the toast reports partial state.
286 const bulkMutation = useMutation({
287 mutationFn: async ({ action, ids }: { action: string; ids: number[] }) => {
288 const requests = ids.map((id) => {
289 if (action === "delete") {
290 return apiClient.delete(`/dynamic-pricing/rules/${id}`);
291 } else if (action === "restore") {
292 return apiClient.put(`/dynamic-pricing/rules/${id}`, {
293 status: "active",
294 });
295 } else if (action === "trash") {
296 return apiClient.put(`/dynamic-pricing/rules/${id}`, {
297 status: "trash",
298 });
299 } else if (action === "active" || action === "inactive") {
300 return apiClient.put(`/dynamic-pricing/rules/${id}`, {
301 status: action,
302 });
303 }
304 return Promise.resolve();
305 });
306
307 const results = await Promise.allSettled(requests);
308 const succeeded = results.filter((r) => r.status === "fulfilled").length;
309 const failed = results.length - succeeded;
310 return { succeeded, failed, total: results.length };
311 },
312 onSuccess: (result) => {
313 invalidateRules();
314 setSelectedIds([]);
315 setBulkAction("");
316
317 if (result.failed === 0) {
318 showToast(__("Bulk action completed successfully"), "success");
319 } else if (result.succeeded === 0) {
320 showToast(__("Failed to complete bulk action"), "error");
321 } else {
322 showToast(
323 `${result.succeeded}/${result.total} ${__("rules updated; some failed")}`,
324 "warning",
325 );
326 }
327 },
328 onError: () => {
329 showToast(__("Failed to complete bulk action"), "error");
330 },
331 });
332
333 const handleBulkApply = () => {
334 if (!bulkAction) {
335 showToast(__("Select a bulk action first"), "warning");
336 return;
337 }
338 if (selectedIds.length === 0) {
339 showToast(__("Select at least one rule"), "warning");
340 return;
341 }
342 bulkMutation.mutate({ action: bulkAction, ids: selectedIds });
343 };
344
345 return (
346 <div className="space-y-6">
347 <PageHeader
348 title={__("Dynamic Pricing")}
349 description={__(
350 "Intelligent price adjustments based on demand, seasonality, and booking patterns",
351 )}
352 />
353
354 {/* Tabs with Create Button */}
355 <div className="border-b border-gray-200 dark:border-gray-700">
356 <div className="flex items-center justify-between">
357 <nav className="-mb-px flex space-x-8">
358 <button
359 onClick={() => setActiveTab("rules")}
360 className={`py-4 px-1 border-b-2 font-medium text-sm ${
361 activeTab === "rules"
362 ? "border-blue-500 text-blue-600 dark:text-blue-400"
363 : "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300"
364 }`}
365 >
366 <Target className="w-5 h-5 inline-block mr-2" />
367 {__("Pricing Rules")}
368 </button>
369 <button
370 onClick={() => setActiveTab("analytics")}
371 className={`py-4 px-1 border-b-2 font-medium text-sm ${
372 activeTab === "analytics"
373 ? "border-blue-500 text-blue-600 dark:text-blue-400"
374 : "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300"
375 }`}
376 >
377 <BarChart className="w-5 h-5 inline-block mr-2" />
378 {__("Analytics")}
379 </button>
380 <button
381 onClick={() => setActiveTab("settings")}
382 className={`py-4 px-1 border-b-2 font-medium text-sm ${
383 activeTab === "settings"
384 ? "border-blue-500 text-blue-600 dark:text-blue-400"
385 : "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300"
386 }`}
387 >
388 <Settings className="w-5 h-5 inline-block mr-2" />
389 {__("Settings")}
390 </button>
391 </nav>
392
393 {activeTab === "rules" && (
394 <Button onClick={() => setShowRuleTypeModal(true)} className="mb-4">
395 <Plus className="w-4 h-4 mr-2" />
396 {__("Create Rule")}
397 </Button>
398 )}
399 </div>
400 </div>
401
402 {/* Rules Tab */}
403 {activeTab === "rules" && (
404 <div className="space-y-6">
405 {/* Stats Cards */}
406 {isLoading ? (
407 <div className="grid grid-cols-1 md:grid-cols-4 gap-6">
408 {[1, 2, 3, 4].map((i) => (
409 <Card key={i}>
410 <CardContent className="pt-6">
411 <div className="flex items-center justify-between">
412 <div className="flex-1">
413 <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-24 mb-2 animate-pulse"></div>
414 <div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-16 animate-pulse"></div>
415 </div>
416 <div className="w-8 h-8 bg-gray-200 dark:bg-gray-700 rounded-full animate-pulse"></div>
417 </div>
418 </CardContent>
419 </Card>
420 ))}
421 </div>
422 ) : (
423 <div className="grid grid-cols-1 md:grid-cols-4 gap-6">
424 <Card>
425 <CardContent className="pt-6">
426 <div className="flex items-center justify-between">
427 <div>
428 <p className="text-sm text-gray-600 dark:text-gray-400">
429 {__("Total Rules")}
430 </p>
431 <p className="text-3xl font-bold">
432 {stats.total_rules || 0}
433 </p>
434 </div>
435 <Target className="w-8 h-8 text-blue-600 dark:text-blue-400" />
436 </div>
437 </CardContent>
438 </Card>
439
440 <Card>
441 <CardContent className="pt-6">
442 <div className="flex items-center justify-between">
443 <div>
444 <p className="text-sm text-gray-600 dark:text-gray-400">
445 {__("Active Rules")}
446 </p>
447 <p className="text-3xl font-bold text-green-600 dark:text-green-400">
448 {stats.active_rules || 0}
449 </p>
450 </div>
451 <CheckCircle className="w-8 h-8 text-green-600 dark:text-green-400" />
452 </div>
453 </CardContent>
454 </Card>
455
456 <Card>
457 <CardContent className="pt-6">
458 <div className="flex items-center justify-between">
459 <div>
460 <p className="text-sm text-gray-600 dark:text-gray-400">
461 {__("Inactive Rules")}
462 </p>
463 <p className="text-3xl font-bold text-gray-600 dark:text-gray-400">
464 {stats.inactive_rules || 0}
465 </p>
466 </div>
467 <AlertCircle className="w-8 h-8 text-gray-600 dark:text-gray-400" />
468 </div>
469 </CardContent>
470 </Card>
471
472 <Card>
473 <CardContent className="pt-6">
474 <div className="flex items-center justify-between">
475 <div>
476 <p className="text-sm text-gray-600 dark:text-gray-400">
477 {__("Rule Types")}
478 </p>
479 <p className="text-3xl font-bold text-purple-600 dark:text-purple-400">
480 {stats.rule_types_used || 0}
481 </p>
482 </div>
483 <Package className="w-8 h-8 text-purple-600 dark:text-purple-400" />
484 </div>
485 </CardContent>
486 </Card>
487 </div>
488 )}
489
490 {/* Search and Filter Toolbar */}
491 <Card>
492 <CardContent className="pt-6">
493 <SearchFilterToolbar
494 searchTerm={searchTerm}
495 onSearchChange={setSearchTerm}
496 statusFilter={statusFilter}
497 onStatusChange={setStatusFilter}
498 statusOptions={[
499 { value: "all", label: __("All Status") },
500 { value: "active", label: __("Active") },
501 { value: "inactive", label: __("Inactive") },
502 { value: "trash", label: __("Trash") },
503 ]}
504 sortBy={sortBy}
505 onSortByChange={setSortBy}
506 sortOrder={sortOrder}
507 onSortOrderChange={setSortOrder}
508 sortOptions={[
509 { value: "name", label: __("Name") },
510 { value: "priority", label: __("Priority") },
511 { value: "created_at", label: __("Created Date") },
512 { value: "updated_at", label: __("Updated Date") },
513 ]}
514 onResetFilters={() => {
515 setSearchTerm("");
516 setStatusFilter("all");
517 setSortBy("name");
518 setSortOrder("asc");
519 }}
520 hasFilters={
521 !!searchTerm ||
522 statusFilter !== "all" ||
523 sortBy !== "name" ||
524 sortOrder !== "asc"
525 }
526 placeholder={__("Search rules...")}
527 />
528 </CardContent>
529 </Card>
530
531 {/* Bulk Action Toolbar */}
532 <BulkActionToolbar
533 selectedIds={selectedIds}
534 bulkAction={bulkAction}
535 setBulkAction={setBulkAction}
536 onApply={handleBulkApply}
537 onClearSelection={() => setSelectedIds([])}
538 statusFilter={statusFilter}
539 setStatusFilter={setStatusFilter}
540 statusOptions={[
541 {
542 key: "all",
543 label: __("All"),
544 count:
545 stats.total_rules ??
546 rules.filter((r: any) => r.status !== "trash").length,
547 },
548 {
549 key: "active",
550 label: __("Active"),
551 count: stats.active_rules || 0,
552 },
553 {
554 key: "inactive",
555 label: __("Inactive"),
556 count: stats.inactive_rules || 0,
557 },
558 {
559 key: "trash",
560 label: __("Trash"),
561 count: stats.trash_rules || 0,
562 },
563 ]}
564 showColumnsDropdown={showColumnsDropdown}
565 setShowColumnsDropdown={setShowColumnsDropdown}
566 columnOptions={[
567 { key: "name", label: __("Rule Name"), visible: true },
568 { key: "adjustment", label: __("Adjustment"), visible: true },
569 {
570 key: "applicable_trips",
571 label: __("Applicable To"),
572 visible: true,
573 },
574 { key: "priority", label: __("Priority"), visible: true },
575 { key: "status", label: __("Status"), visible: true },
576 { key: "created_at", label: __("Created"), visible: true },
577 ]}
578 onToggleColumn={() => {}}
579 bulkMutationPending={bulkMutation.isPending}
580 totalItems={filteredRules.length}
581 bulkActionOptions={
582 statusFilter === "trash"
583 ? [
584 { value: "restore", label: __("Restore") },
585 { value: "delete", label: __("Delete Permanently") },
586 ]
587 : [
588 { value: "active", label: __("Mark as Active") },
589 { value: "inactive", label: __("Mark as Inactive") },
590 { value: "trash", label: __("Move to Trash") },
591 ]
592 }
593 />
594
595 {/* Rules List */}
596 <Card>
597 <CardHeader>
598 <CardTitle>{__("Pricing Rules")}</CardTitle>
599 <CardDescription>
600 {__("Manage your dynamic pricing rules")}
601 </CardDescription>
602 </CardHeader>
603 <CardContent className="p-0">
604 <SharedTable
605 data={filteredRules}
606 columns={[
607 {
608 key: "name",
609 label: __("Rule Name"),
610 sortable: true,
611 visible: true,
612 render: (rule: any) => (
613 <div>
614 <a
615 href={`${window.location.href.split("&action=")[0]}&action=edit-pricing-rule&id=${rule.id}`}
616 className={`font-medium hover:underline transition-colors cursor-pointer ${
617 rule.status === "trash" || statusFilter === "trash"
618 ? "text-gray-400 dark:text-gray-600"
619 : "text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300"
620 }`}
621 >
622 {rule.name}
623 </a>
624 <div
625 className={`text-sm ${
626 rule.status === "trash" || statusFilter === "trash"
627 ? "text-gray-400 dark:text-gray-600"
628 : "text-gray-500 dark:text-gray-400"
629 }`}
630 >
631 {rule.rule_type?.replace("_", " ")}
632 </div>
633 </div>
634 ),
635 },
636 {
637 key: "adjustment",
638 label: __("Adjustment"),
639 visible: true,
640 render: (rule: any) => (
641 <span className="font-medium text-gray-900 dark:text-white">
642 {rule.adjustment_type === "percentage"
643 ? `${rule.adjustment_value > 0 ? "+" : ""}${rule.adjustment_value}%`
644 : formatYatraMoney(
645 Number(rule.adjustment_value) || 0,
646 globalCurrency,
647 { zeroAsUnknown: false },
648 )}
649 </span>
650 ),
651 },
652 {
653 key: "applicable_trips",
654 label: __("Applicable To"),
655 visible: true,
656 render: (rule: any) => (
657 <span className="text-sm text-gray-600 dark:text-gray-400">
658 {rule.applicable_trips === "all"
659 ? __("All Trips")
660 : __("Specific Trips")}
661 </span>
662 ),
663 },
664 {
665 key: "priority",
666 label: __("Priority"),
667 sortable: true,
668 visible: true,
669 render: (rule: any) => (
670 <Badge variant="outline">{rule.priority}</Badge>
671 ),
672 },
673 {
674 key: "status",
675 label: __("Status"),
676 sortable: true,
677 visible: true,
678 render: (rule: any) => (
679 <Badge
680 variant={
681 rule.status === "active" ? "success" : "outline"
682 }
683 >
684 {rule.status}
685 </Badge>
686 ),
687 },
688 {
689 key: "created_at",
690 label: __("Created"),
691 sortable: true,
692 visible: true,
693 render: (rule: any) => (
694 <span className="text-sm text-gray-500 dark:text-gray-400">
695 {new Date(rule.created_at).toLocaleDateString()}
696 </span>
697 ),
698 },
699 ]}
700 actions={[
701 {
702 key: "edit",
703 label: __("Edit"),
704 icon: <Edit className="w-4 h-4" />,
705 onClick: (rule: any) => {
706 const baseUrl = window.location.href.split("&action=")[0];
707 window.location.href = `${baseUrl}&action=edit-pricing-rule&id=${rule.id}`;
708 },
709 },
710 {
711 key: "active",
712 label: __("Mark as Active"),
713 icon: <Check className="w-4 h-4" />,
714 onClick: (rule: any) => {
715 setConfirmDialog({
716 isOpen: true,
717 rule,
718 title: __("Mark as Active"),
719 message: __(
720 "Are you sure you want to mark this pricing rule as active? It will start applying to trip pricing.",
721 ),
722 onConfirm: async () => {
723 try {
724 await apiClient.put(
725 `/dynamic-pricing/rules/${rule.id}`,
726 {
727 status: "active",
728 },
729 );
730 showToast(
731 __("Pricing rule marked as active successfully"),
732 "success",
733 );
734 queryClient.invalidateQueries({
735 queryKey: ["dynamic-pricing-rules"],
736 });
737 queryClient.invalidateQueries({
738 queryKey: ["dynamic-pricing-statistics"],
739 });
740 setConfirmDialog({
741 isOpen: false,
742 rule: null,
743 title: "",
744 message: "",
745 onConfirm: () => {},
746 });
747 } catch (error) {
748 showToast(
749 __("Failed to update pricing rule status"),
750 "error",
751 );
752 }
753 },
754 });
755 },
756 condition: (rule: any) =>
757 rule.status !== "active" && rule.status !== "trash",
758 },
759 {
760 key: "inactive",
761 label: __("Mark as Inactive"),
762 icon: <AlertCircle className="w-4 h-4" />,
763 onClick: (rule: any) => {
764 setConfirmDialog({
765 isOpen: true,
766 rule,
767 title: __("Mark as Inactive"),
768 message: __(
769 "Are you sure you want to mark this pricing rule as inactive? It will no longer apply to trip pricing.",
770 ),
771 onConfirm: async () => {
772 try {
773 await apiClient.put(
774 `/dynamic-pricing/rules/${rule.id}`,
775 {
776 status: "inactive",
777 },
778 );
779 showToast(
780 __(
781 "Pricing rule marked as inactive successfully",
782 ),
783 "success",
784 );
785 queryClient.invalidateQueries({
786 queryKey: ["dynamic-pricing-rules"],
787 });
788 queryClient.invalidateQueries({
789 queryKey: ["dynamic-pricing-statistics"],
790 });
791 setConfirmDialog({
792 isOpen: false,
793 rule: null,
794 title: "",
795 message: "",
796 onConfirm: () => {},
797 });
798 } catch (error) {
799 showToast(
800 __("Failed to update pricing rule status"),
801 "error",
802 );
803 }
804 },
805 });
806 },
807 condition: (rule: any) => rule.status === "active",
808 },
809 {
810 key: "restore",
811 label: __("Restore"),
812 icon: <Check className="w-4 h-4" />,
813 onClick: (rule: any) => {
814 setConfirmDialog({
815 isOpen: true,
816 rule,
817 title: __("Restore Rule"),
818 message: __(
819 "Are you sure you want to restore this pricing rule? It will be moved back to active status.",
820 ),
821 onConfirm: async () => {
822 try {
823 await apiClient.put(
824 `/dynamic-pricing/rules/${rule.id}`,
825 {
826 status: "active",
827 },
828 );
829 showToast(
830 __("Pricing rule restored successfully"),
831 "success",
832 );
833 queryClient.invalidateQueries({
834 queryKey: ["dynamic-pricing-rules"],
835 });
836 queryClient.invalidateQueries({
837 queryKey: ["dynamic-pricing-statistics"],
838 });
839 setConfirmDialog({
840 isOpen: false,
841 rule: null,
842 title: "",
843 message: "",
844 onConfirm: () => {},
845 });
846 } catch (error) {
847 showToast(
848 __("Failed to restore pricing rule"),
849 "error",
850 );
851 }
852 },
853 });
854 },
855 condition: (rule: any) =>
856 rule.status === "trash" || statusFilter === "trash",
857 },
858 {
859 key: "trash",
860 label: __("Move to Trash"),
861 icon: <Trash2 className="w-4 h-4" />,
862 onClick: (rule: any) => {
863 setConfirmDialog({
864 isOpen: true,
865 rule,
866 title: __("Move Rule to Trash"),
867 message: __(
868 "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.",
869 ),
870 confirmText: __("Move to Trash"),
871 variant: "warning",
872 onConfirm: async () => {
873 setConfirmDialog((prev) => ({
874 ...prev,
875 isLoading: true,
876 }));
877 try {
878 await apiClient.put(
879 `/dynamic-pricing/rules/${rule.id}`,
880 { status: "trash" },
881 );
882 showToast(
883 __("Pricing rule moved to Trash"),
884 "success",
885 );
886 invalidateRules();
887 closeConfirmDialog();
888 } catch (error) {
889 setConfirmDialog((prev) => ({
890 ...prev,
891 isLoading: false,
892 }));
893 showToast(
894 __("Failed to move rule to Trash"),
895 "error",
896 );
897 }
898 },
899 });
900 },
901 condition: (rule: any) =>
902 rule.status !== "trash" && statusFilter !== "trash",
903 },
904 {
905 key: "delete",
906 label: __("Delete Permanently"),
907 icon: <Trash2 className="w-4 h-4" />,
908 onClick: (rule: any) => {
909 setConfirmDialog({
910 isOpen: true,
911 rule,
912 title: __("Delete Pricing Rule Permanently"),
913 message: __(
914 "This will permanently delete the pricing rule. This action cannot be undone. Continue?",
915 ),
916 confirmText: __("Delete Permanently"),
917 variant: "danger",
918 onConfirm: async () => {
919 setConfirmDialog((prev) => ({
920 ...prev,
921 isLoading: true,
922 }));
923 try {
924 await apiClient.delete(
925 `/dynamic-pricing/rules/${rule.id}`,
926 );
927 showToast(
928 __("Pricing rule deleted permanently"),
929 "success",
930 );
931 invalidateRules();
932 closeConfirmDialog();
933 } catch (error) {
934 setConfirmDialog((prev) => ({
935 ...prev,
936 isLoading: false,
937 }));
938 showToast(
939 __("Failed to delete rule"),
940 "error",
941 );
942 }
943 },
944 });
945 },
946 condition: (rule: any) =>
947 rule.status === "trash" || statusFilter === "trash",
948 variant: "destructive",
949 },
950 ]}
951 selectedItemIds={selectedIds}
952 onSelectItem={(id, checked) => {
953 if (checked) {
954 setSelectedIds([...selectedIds, id as number]);
955 } else {
956 setSelectedIds(
957 selectedIds.filter((selectedId) => selectedId !== id),
958 );
959 }
960 }}
961 onSelectAll={(checked) => {
962 if (checked) {
963 setSelectedIds(filteredRules.map((rule: any) => rule.id));
964 } else {
965 setSelectedIds([]);
966 }
967 }}
968 isAllSelected={
969 selectedIds.length === filteredRules.length &&
970 filteredRules.length > 0
971 }
972 getItemId={(rule: any) => rule.id}
973 isLoading={isLoading}
974 emptyText={__("No pricing rules found")}
975 emptyDescription={__(
976 'Click "Create Rule" button above to get started',
977 )}
978 />
979 </CardContent>
980 </Card>
981 </div>
982 )}
983
984 {/* Analytics Tab */}
985 {activeTab === "analytics" && (
986 <div className="space-y-6">
987 {/* Performance Metrics */}
988 {analyticsLoading ? (
989 <div className="grid grid-cols-1 md:grid-cols-4 gap-6">
990 {[1, 2, 3, 4].map((i) => (
991 <Card key={i}>
992 <CardContent className="pt-6">
993 <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-24 mb-2 animate-pulse"></div>
994 <div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-16 mb-2 animate-pulse"></div>
995 <div className="h-3 bg-gray-200 dark:bg-gray-700 rounded w-20 animate-pulse"></div>
996 </CardContent>
997 </Card>
998 ))}
999 </div>
1000 ) : (
1001 <div className="grid grid-cols-1 md:grid-cols-4 gap-6">
1002 <Card>
1003 <CardContent className="pt-6">
1004 <div className="flex items-center justify-between">
1005 <div>
1006 <p className="text-sm text-gray-600 dark:text-gray-400">
1007 {__("Total Rules")}
1008 </p>
1009 <p className="text-3xl font-bold text-blue-600 dark:text-blue-400">
1010 {stats.total_rules || 0}
1011 </p>
1012 <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
1013 {__("All pricing rules")}
1014 </p>
1015 </div>
1016 <Package className="w-8 h-8 text-blue-600 dark:text-blue-400" />
1017 </div>
1018 </CardContent>
1019 </Card>
1020
1021 <Card>
1022 <CardContent className="pt-6">
1023 <div className="flex items-center justify-between">
1024 <div>
1025 <p className="text-sm text-gray-600 dark:text-gray-400">
1026 {__("Active Rules")}
1027 </p>
1028 <p className="text-3xl font-bold text-green-600 dark:text-green-400">
1029 {stats.active_rules || 0}
1030 </p>
1031 <p className="text-xs text-green-600 dark:text-green-400 mt-1">
1032 {__("Currently active")}
1033 </p>
1034 </div>
1035 <CheckCircle className="w-8 h-8 text-green-600 dark:text-green-400" />
1036 </div>
1037 </CardContent>
1038 </Card>
1039
1040 <Card>
1041 <CardContent className="pt-6">
1042 <div className="flex items-center justify-between">
1043 <div>
1044 <p className="text-sm text-gray-600 dark:text-gray-400">
1045 {__("Inactive Rules")}
1046 </p>
1047 <p className="text-3xl font-bold text-orange-600 dark:text-orange-400">
1048 {stats.inactive_rules || 0}
1049 </p>
1050 <p className="text-xs text-orange-600 dark:text-orange-400 mt-1">
1051 {__("Paused rules")}
1052 </p>
1053 </div>
1054 <AlertCircle className="w-8 h-8 text-orange-600 dark:text-orange-400" />
1055 </div>
1056 </CardContent>
1057 </Card>
1058
1059 <Card>
1060 <CardContent className="pt-6">
1061 <div className="flex items-center justify-between">
1062 <div>
1063 <p className="text-sm text-gray-600 dark:text-gray-400">
1064 {__("Rule Types Used")}
1065 </p>
1066 <p className="text-3xl font-bold text-purple-600 dark:text-purple-400">
1067 {stats.rule_types_used || 0}
1068 </p>
1069 <p className="text-xs text-purple-600 dark:text-purple-400 mt-1">
1070 {__("Different types")}
1071 </p>
1072 </div>
1073 <Target className="w-8 h-8 text-purple-600 dark:text-purple-400" />
1074 </div>
1075 </CardContent>
1076 </Card>
1077 </div>
1078 )}
1079
1080 {/* Pricing History Metrics (what powers analytics) */}
1081 {analyticsLoading ? (
1082 <div className="grid grid-cols-1 md:grid-cols-4 gap-6">
1083 {[1, 2, 3, 4].map((i) => (
1084 <Card key={i}>
1085 <CardContent className="pt-6">
1086 <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-28 mb-2 animate-pulse"></div>
1087 <div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-20 mb-2 animate-pulse"></div>
1088 <div className="h-3 bg-gray-200 dark:bg-gray-700 rounded w-32 animate-pulse"></div>
1089 </CardContent>
1090 </Card>
1091 ))}
1092 </div>
1093 ) : (
1094 <div className="grid grid-cols-1 md:grid-cols-4 gap-6">
1095 <Card>
1096 <CardContent className="pt-6">
1097 <div className="flex items-center justify-between">
1098 <div>
1099 <p className="text-sm text-gray-600 dark:text-gray-400">
1100 {__("Pricing Events")}
1101 </p>
1102 <p className="text-3xl font-bold text-blue-600 dark:text-blue-400">
1103 {stats.pricing_history_total || 0}
1104 </p>
1105 <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
1106 {__("Recorded price adjustments")}
1107 </p>
1108 </div>
1109 <BarChart className="w-8 h-8 text-blue-600 dark:text-blue-400" />
1110 </div>
1111 </CardContent>
1112 </Card>
1113
1114 <Card>
1115 <CardContent className="pt-6">
1116 <div className="flex items-center justify-between">
1117 <div>
1118 <p className="text-sm text-gray-600 dark:text-gray-400">
1119 {__("Trips Affected")}
1120 </p>
1121 <p className="text-3xl font-bold text-orange-600 dark:text-orange-400">
1122 {stats.pricing_history_trips_affected || 0}
1123 </p>
1124 <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
1125 {__("Unique trips with adjustments")}
1126 </p>
1127 </div>
1128 <Package className="w-8 h-8 text-orange-600 dark:text-orange-400" />
1129 </div>
1130 </CardContent>
1131 </Card>
1132
1133 <Card>
1134 <CardContent className="pt-6">
1135 <div className="flex items-center justify-between">
1136 <div>
1137 <p className="text-sm text-gray-600 dark:text-gray-400">
1138 {__("Avg Adjustment")}
1139 </p>
1140 <p className="text-3xl font-bold text-purple-600 dark:text-purple-400">
1141 {typeof stats.pricing_history_avg_adjustment_percentage ===
1142 "number"
1143 ? `${stats.pricing_history_avg_adjustment_percentage.toFixed(2)}%`
1144 : "0%"}
1145 </p>
1146 <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
1147 {__("Average % change")}
1148 </p>
1149 </div>
1150 <DollarSign className="w-8 h-8 text-purple-600 dark:text-purple-400" />
1151 </div>
1152 </CardContent>
1153 </Card>
1154
1155 <Card>
1156 <CardContent className="pt-6">
1157 <div className="flex items-center justify-between">
1158 <div>
1159 <p className="text-sm text-gray-600 dark:text-gray-400">
1160 {__("Last 30 Days")}
1161 </p>
1162 <p className="text-3xl font-bold text-green-600 dark:text-green-400">
1163 {stats.pricing_history_last_30_days || 0}
1164 </p>
1165 <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
1166 {__("Recent pricing events")}
1167 </p>
1168 </div>
1169 <Clock className="w-8 h-8 text-green-600 dark:text-green-400" />
1170 </div>
1171 </CardContent>
1172 </Card>
1173 </div>
1174 )}
1175
1176 {/* Rule Performance */}
1177 <Card>
1178 <CardHeader>
1179 <CardTitle>{__("Rule Performance")}</CardTitle>
1180 <CardDescription>
1181 {__("How each pricing rule is performing")}
1182 </CardDescription>
1183 </CardHeader>
1184 <CardContent>
1185 {analyticsLoading ? (
1186 <div className="space-y-4">
1187 {[1, 2, 3].map((i) => (
1188 <div
1189 key={i}
1190 className="flex items-center justify-between p-4 border border-gray-200 dark:border-gray-700 rounded-lg"
1191 >
1192 <div className="flex-1 space-y-2">
1193 <div className="h-5 bg-gray-200 dark:bg-gray-700 rounded w-48 animate-pulse"></div>
1194 <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-32 animate-pulse"></div>
1195 </div>
1196 <div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-24 animate-pulse"></div>
1197 </div>
1198 ))}
1199 </div>
1200 ) : (
1201 <div className="space-y-4">
1202 {ruleImpact.length > 0 ? (
1203 ruleImpact.map((ri: any) => (
1204 <div
1205 key={ri.rule_id}
1206 className="flex items-center justify-between p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
1207 >
1208 <div className="flex-1">
1209 <div className="flex items-center gap-3 mb-2">
1210 <h4 className="font-semibold text-gray-900 dark:text-white">
1211 {ri.name || `${__("Rule")} #${ri.rule_id}`}
1212 </h4>
1213 <Badge
1214 variant="default"
1215 className="bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300"
1216 >
1217 {(ri.rule_type || "").replace("_", " ")}
1218 </Badge>
1219 <Badge variant="outline">
1220 {ri.adjustment_type || ""}
1221 </Badge>
1222 </div>
1223 <div className="flex items-center gap-4 text-sm text-gray-600 dark:text-gray-400">
1224 <span>
1225 {__("Events")}: {ri.events || 0}
1226 </span>
1227 <span>•</span>
1228 <span>
1229 {__("Avg Adjustment")}:{" "}
1230 {typeof ri.avg_adjustment_value === "number"
1231 ? ri.avg_adjustment_value.toFixed(2)
1232 : Number(ri.avg_adjustment_value || 0).toFixed(
1233 2,
1234 )}
1235 </span>
1236 </div>
1237 </div>
1238 <div className="text-right">
1239 <p className="text-sm text-gray-500 dark:text-gray-400">
1240 {__("Impact (30d)")}
1241 </p>
1242 <p className="text-sm font-semibold text-gray-900 dark:text-white">
1243 {formatCurrencyAmount(
1244 Number(ri.total_adjustment_amount) || 0,
1245 )}
1246 </p>
1247 </div>
1248 </div>
1249 ))
1250 ) : (
1251 <div className="text-center py-8 text-gray-500 dark:text-gray-400">
1252 {rules.length > 0 ? (
1253 <>
1254 <p>
1255 {__(
1256 "No rule-level impact recorded in the last 30 days",
1257 )}
1258 </p>
1259 <p className="text-sm mt-2 max-w-lg mx-auto">
1260 {__(
1261 "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.",
1262 )}
1263 </p>
1264 </>
1265 ) : (
1266 <>
1267 <p>{__("No pricing rules created yet")}</p>
1268 <p className="text-sm mt-2">
1269 {__(
1270 "Create your first rule to see performance metrics",
1271 )}
1272 </p>
1273 </>
1274 )}
1275 </div>
1276 )}
1277 </div>
1278 )}
1279 </CardContent>
1280 </Card>
1281
1282 {/* Revenue Trend Chart */}
1283 <Card>
1284 <CardHeader>
1285 <CardTitle>{__("Revenue Impact Trend")}</CardTitle>
1286 <CardDescription>
1287 {__(
1288 "30-day revenue comparison with and without dynamic pricing",
1289 )}
1290 </CardDescription>
1291 </CardHeader>
1292 <CardContent>
1293 {!analyticsLoading && statsErrorMessage ? (
1294 <div className="mb-4 text-sm text-red-600 dark:text-red-400">
1295 {__("Failed to load statistics:")} {statsErrorMessage}
1296 </div>
1297 ) : null}
1298
1299 {analyticsLoading ? (
1300 <div className="h-80 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
1301 ) : trendData.length > 0 ? (
1302 <div className="space-y-4">
1303 <div className="w-full" style={{ height: 280 }}>
1304 <ResponsiveContainer width="100%" height="100%">
1305 <LineChart
1306 data={trendData}
1307 margin={{ top: 10, right: 20, left: 0, bottom: 0 }}
1308 >
1309 <CartesianGrid strokeDasharray="3 3" />
1310 <XAxis
1311 dataKey="day"
1312 tick={{ fontSize: 12 }}
1313 tickFormatter={(v: any) =>
1314 v ? new Date(String(v)).toLocaleDateString() : ""
1315 }
1316 />
1317 <YAxis
1318 tick={{ fontSize: 12 }}
1319 tickFormatter={(v: any) =>
1320 formatCurrencyAmount(Number(v) || 0)
1321 }
1322 />
1323 <Tooltip
1324 formatter={(value: any, name: any) => {
1325 if (name === "totalAdjustmentAmount") {
1326 return [
1327 formatCurrencyAmount(Number(value) || 0),
1328 __("Revenue Impact"),
1329 ];
1330 }
1331 if (name === "events") {
1332 return [Number(value) || 0, __("Events")];
1333 }
1334 if (name === "avgAdjustmentPercentage") {
1335 return [
1336 `${Number(value || 0).toFixed(2)}%`,
1337 __("Avg Adjustment"),
1338 ];
1339 }
1340 return [value, name];
1341 }}
1342 />
1343 <Line
1344 type="monotone"
1345 dataKey="totalAdjustmentAmount"
1346 stroke="#22c55e"
1347 strokeWidth={2}
1348 dot={{ r: 2 }}
1349 activeDot={{ r: 4 }}
1350 />
1351 </LineChart>
1352 </ResponsiveContainer>
1353 </div>
1354
1355 <div className="w-full overflow-x-auto">
1356 <div className="max-h-[420px] overflow-y-auto rounded-lg border border-gray-200 dark:border-gray-700">
1357 <table className="w-full table-fixed border-collapse text-sm">
1358 <thead className="sticky top-0 bg-white dark:bg-gray-900">
1359 <tr className="text-left text-gray-600 dark:text-gray-400 border-b border-gray-200 dark:border-gray-700">
1360 <th className="py-3 px-4 w-1/4">{__("Date")}</th>
1361 <th className="py-3 px-4 w-1/6 text-right">
1362 {__("Events")}
1363 </th>
1364 <th className="py-3 px-4 w-1/3 text-right">
1365 {__("Revenue Impact")}
1366 </th>
1367 <th className="py-3 px-4 w-1/4 text-right">
1368 {__("Avg Adjustment")}
1369 </th>
1370 </tr>
1371 </thead>
1372 <tbody>
1373 {trendData.map((row: any) => (
1374 <tr
1375 key={row.day}
1376 className="border-b border-gray-100 dark:border-gray-800"
1377 >
1378 <td className="py-3 px-4 text-gray-900 dark:text-white">
1379 {row.day
1380 ? new Date(
1381 String(row.day),
1382 ).toLocaleDateString()
1383 : ""}
1384 </td>
1385 <td className="py-3 px-4 text-right text-gray-900 dark:text-white">
1386 {row.events ?? 0}
1387 </td>
1388 <td className="py-3 px-4 text-right text-gray-900 dark:text-white tabular-nums">
1389 {formatCurrencyAmount(
1390 Number(row.totalAdjustmentAmount) || 0,
1391 )}
1392 </td>
1393 <td className="py-3 px-4 text-right text-gray-900 dark:text-white tabular-nums">{`${Number(row.avgAdjustmentPercentage || 0).toFixed(2)}%`}</td>
1394 </tr>
1395 ))}
1396 </tbody>
1397 </table>
1398 </div>
1399 </div>
1400 </div>
1401 ) : (
1402 <div className="h-40 flex items-center justify-center border border-dashed border-gray-300 dark:border-gray-600 rounded-lg">
1403 <div className="text-center px-4">
1404 <p className="text-gray-500 dark:text-gray-400">
1405 {Number(stats.pricing_history_total) > 0 &&
1406 Number(stats.pricing_history_last_30_days) === 0
1407 ? __(
1408 "No pricing events in the last 30 days (older history exists).",
1409 )
1410 : __("No pricing history in the last 30 days")}
1411 </p>
1412 {rules.length > 0 &&
1413 Number(stats.pricing_history_total) === 0 ? (
1414 <p className="text-sm text-gray-500 dark:text-gray-400 mt-2 max-w-md mx-auto">
1415 {__(
1416 "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.",
1417 )}
1418 </p>
1419 ) : null}
1420 </div>
1421 </div>
1422 )}
1423 </CardContent>
1424 </Card>
1425 </div>
1426 )}
1427
1428 {/* Settings Tab */}
1429 {activeTab === "settings" && (
1430 <div className="space-y-6">
1431 {/* General Settings */}
1432 <Card>
1433 <CardHeader>
1434 <CardTitle>{__("General Settings")}</CardTitle>
1435 <CardDescription>
1436 {__("Configure global dynamic pricing behavior")}
1437 </CardDescription>
1438 </CardHeader>
1439 <CardContent>
1440 {isLoading ? (
1441 <div className="space-y-6">
1442 {[1, 2, 3].map((i) => (
1443 <div key={i} className="space-y-2">
1444 <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-32 animate-pulse"></div>
1445 <div className="h-10 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
1446 </div>
1447 ))}
1448 </div>
1449 ) : (
1450 <div className="space-y-6">
1451 <div>
1452 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
1453 {__("Rule calculation base")}
1454 </label>
1455 <select
1456 value={settings.calculation_base || "discounted"}
1457 onChange={(e) =>
1458 handleSettingChange(
1459 "calculation_base",
1460 e.target.value === "regular"
1461 ? "regular"
1462 : "discounted",
1463 )
1464 }
1465 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"
1466 >
1467 <option value="discounted">
1468 {__(
1469 "Promotional / sale price (stack dynamic pricing on the price customers already see after trip discounts)",
1470 )}
1471 </option>
1472 <option value="regular">
1473 {__(
1474 "Regular list price (compute adjustments from catalog price; requires original price in context)",
1475 )}
1476 </option>
1477 </select>
1478 <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
1479 {__(
1480 "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.",
1481 )}
1482 </p>
1483 </div>
1484
1485 <div>
1486 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
1487 {__("Rule Priority Mode")}
1488 </label>
1489 <select
1490 value={settings.rule_priority_mode}
1491 onChange={(e) =>
1492 handleSettingChange(
1493 "rule_priority_mode",
1494 e.target.value,
1495 )
1496 }
1497 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"
1498 >
1499 <option value="highest">
1500 {__(
1501 "Apply Largest Adjustment Only (one rule wins by magnitude)",
1502 )}
1503 </option>
1504 <option value="cumulative">
1505 {__("Apply All Matching Rules (Cumulative / Stack)")}
1506 </option>
1507 <option value="best">
1508 {__("Apply Best Price for Customer (Lowest Final)")}
1509 </option>
1510 </select>
1511 <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
1512 {__(
1513 "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.",
1514 )}
1515 </p>
1516 </div>
1517
1518 <div>
1519 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
1520 {__("Maximum Price Increase (%)")}
1521 </label>
1522 <input
1523 type="number"
1524 value={settings.maximum_markup_percent}
1525 onChange={(e) =>
1526 handleSettingChange(
1527 "maximum_markup_percent",
1528 parseInt(e.target.value),
1529 )
1530 }
1531 min="0"
1532 max="100"
1533 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"
1534 />
1535 <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
1536 {__(
1537 "Hard cap on upward adjustments (markup) across all matched rules to avoid surprising customers with sudden surges.",
1538 )}
1539 </p>
1540 </div>
1541
1542 <div>
1543 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
1544 {__("Maximum Price Decrease (%)")}
1545 </label>
1546 <input
1547 type="number"
1548 value={settings.maximum_discount_percent}
1549 onChange={(e) =>
1550 handleSettingChange(
1551 "maximum_discount_percent",
1552 parseInt(e.target.value),
1553 )
1554 }
1555 min="0"
1556 max="100"
1557 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"
1558 />
1559 <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
1560 {__(
1561 "Hard cap on downward adjustments (discount) so dynamic pricing can never push a trip below this fraction of its base price.",
1562 )}
1563 </p>
1564 </div>
1565 </div>
1566 )}
1567 </CardContent>
1568 </Card>
1569
1570 {/* Demand Calculation Settings */}
1571 <Card>
1572 <CardHeader>
1573 <CardTitle>{__("Demand Calculation")}</CardTitle>
1574 <CardDescription>
1575 {__("Configure how booking demand is calculated")}
1576 </CardDescription>
1577 </CardHeader>
1578 <CardContent>
1579 {isLoading ? (
1580 <div className="space-y-6">
1581 {[1, 2].map((i) => (
1582 <div key={i} className="space-y-2">
1583 <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-32 animate-pulse"></div>
1584 <div className="h-10 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
1585 </div>
1586 ))}
1587 </div>
1588 ) : (
1589 <div className="space-y-6">
1590 <div>
1591 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
1592 {__("Calculation Period (Days)")}
1593 </label>
1594 <input
1595 type="number"
1596 value={settings.calculation_period}
1597 onChange={(e) =>
1598 handleSettingChange(
1599 "calculation_period",
1600 parseInt(e.target.value),
1601 )
1602 }
1603 min="1"
1604 max="30"
1605 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"
1606 />
1607 <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
1608 {__("Number of days to analyze for demand trends")}
1609 </p>
1610 </div>
1611
1612 <div>
1613 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
1614 {__("Update Frequency")}
1615 </label>
1616 <select
1617 value={settings.update_frequency}
1618 onChange={(e) =>
1619 handleSettingChange("update_frequency", e.target.value)
1620 }
1621 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"
1622 >
1623 <option value="hourly">{__("Every Hour")}</option>
1624 <option value="daily">{__("Once Daily")}</option>
1625 <option value="realtime">
1626 {__("Real-time (On Each Booking)")}
1627 </option>
1628 </select>
1629 <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
1630 {__("How often to recalculate demand scores")}
1631 </p>
1632 </div>
1633 </div>
1634 )}
1635 </CardContent>
1636 </Card>
1637
1638 {/* Display Settings */}
1639 <Card>
1640 <CardHeader>
1641 <CardTitle>{__("Display Settings")}</CardTitle>
1642 <CardDescription>
1643 {__("How dynamic pricing is shown to customers")}
1644 </CardDescription>
1645 </CardHeader>
1646 <CardContent>
1647 {isLoading ? (
1648 <div className="space-y-6">
1649 {[1, 2].map((i) => (
1650 <div key={i} className="space-y-2">
1651 <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-32 animate-pulse"></div>
1652 <div className="h-10 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
1653 </div>
1654 ))}
1655 </div>
1656 ) : (
1657 <div className="space-y-6">
1658 <div className="flex items-center justify-between p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
1659 <div>
1660 <h4 className="font-semibold text-gray-900 dark:text-white">
1661 {__("Show Original Price")}
1662 </h4>
1663 <p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
1664 {__(
1665 "Display crossed-out original price when discount applied",
1666 )}
1667 </p>
1668 </div>
1669 <Toggle
1670 checked={settings.show_original_price}
1671 onChange={(checked) =>
1672 handleSettingChange("show_original_price", checked)
1673 }
1674 />
1675 </div>
1676
1677 <div className="flex items-center justify-between p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
1678 <div>
1679 <h4 className="font-semibold text-gray-900 dark:text-white">
1680 {__("Show Savings Badge")}
1681 </h4>
1682 <p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
1683 {__('Display "Save X%" badge on discounted trips')}
1684 </p>
1685 </div>
1686 <Toggle
1687 checked={settings.show_savings_badge}
1688 onChange={(checked) =>
1689 handleSettingChange("show_savings_badge", checked)
1690 }
1691 />
1692 </div>
1693
1694 <div className="flex items-center justify-between p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
1695 <div>
1696 <h4 className="font-semibold text-gray-900 dark:text-white">
1697 {__("Show Urgency Messages")}
1698 </h4>
1699 <p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
1700 {__(
1701 'Display messages like "Price increases soon" for demand-based rules',
1702 )}
1703 </p>
1704 </div>
1705 <Toggle
1706 checked={settings.show_urgency_messages}
1707 onChange={(checked) =>
1708 handleSettingChange("show_urgency_messages", checked)
1709 }
1710 />
1711 </div>
1712 </div>
1713 )}
1714 </CardContent>
1715 </Card>
1716
1717 {/* Save Button */}
1718 {!isLoading && (
1719 <div className="flex justify-end">
1720 <Button
1721 onClick={handleSaveSettings}
1722 disabled={isSaving}
1723 className="px-6"
1724 >
1725 {isSaving ? (
1726 <>
1727 <Save className="w-4 h-4 mr-2 animate-spin" />
1728 {__("Saving...")}
1729 </>
1730 ) : (
1731 <>
1732 <Save className="w-4 h-4 mr-2" />
1733 {__("Save Settings")}
1734 </>
1735 )}
1736 </Button>
1737 </div>
1738 )}
1739 </div>
1740 )}
1741
1742 {/* Rule Type Selection Modal */}
1743 <RuleTypeSelectionModal
1744 isOpen={showRuleTypeModal}
1745 onClose={() => setShowRuleTypeModal(false)}
1746 onSelectType={handleSelectRuleType}
1747 />
1748
1749 {/* Confirmation Dialog */}
1750 <ConfirmationDialog
1751 isOpen={confirmDialog.isOpen}
1752 onClose={closeConfirmDialog}
1753 onConfirm={() => confirmDialog.onConfirm?.()}
1754 title={confirmDialog.title || ""}
1755 message={confirmDialog.message || ""}
1756 confirmText={confirmDialog.confirmText}
1757 variant={confirmDialog.variant ?? "danger"}
1758 isLoading={confirmDialog.isLoading ?? false}
1759 />
1760 </div>
1761 );
1762 };
1763
1764 export default DynamicPricingPage;
1765