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

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