PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.3
Yatra – Travel Booking & Tour Operator Software v3.0.3
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.3, at resources/js/pages/DynamicPricing.tsx

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