PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.10
Yatra – Travel Booking & Tour Operator Software v3.0.10
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 2.0.10 All 83 releases
yatra / resources / js / pages / DynamicPricing.tsx

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

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