Files
inventory/inventory/src/components/dashboard/MiniEventFeed.jsx
2025-09-18 13:13:15 -04:00

487 lines
17 KiB
JavaScript

import React, { useState, useEffect, useCallback, useRef } from "react";
import axios from "axios";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Package,
Truck,
UserPlus,
XCircle,
DollarSign,
Activity,
AlertCircle,
FileText,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import { format } from "date-fns";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Skeleton } from "@/components/ui/skeleton";
import { EventDialog } from "./EventFeed.jsx";
import { Button } from "@/components/ui/button";
const METRIC_IDS = {
PLACED_ORDER: "Y8cqcF",
SHIPPED_ORDER: "VExpdL",
ACCOUNT_CREATED: "TeeypV",
CANCELED_ORDER: "YjVMNg",
NEW_BLOG_POST: "YcxeDr",
PAYMENT_REFUNDED: "R7XUYh",
};
const EVENT_TYPES = {
[METRIC_IDS.PLACED_ORDER]: {
label: "Order Placed",
color: "bg-green-200",
textColor: "text-green-50",
iconColor: "text-green-800",
gradient: "from-green-800 to-green-700",
},
[METRIC_IDS.SHIPPED_ORDER]: {
label: "Order Shipped",
color: "bg-blue-200",
textColor: "text-blue-50",
iconColor: "text-blue-800",
gradient: "from-blue-800 to-blue-700",
},
[METRIC_IDS.ACCOUNT_CREATED]: {
label: "New Account",
color: "bg-purple-200",
textColor: "text-purple-50",
iconColor: "text-purple-800",
gradient: "from-purple-800 to-purple-700",
},
[METRIC_IDS.CANCELED_ORDER]: {
label: "Order Canceled",
color: "bg-red-200",
textColor: "text-red-50",
iconColor: "text-red-800",
gradient: "from-red-800 to-red-700",
},
[METRIC_IDS.PAYMENT_REFUNDED]: {
label: "Payment Refunded",
color: "bg-orange-200",
textColor: "text-orange-50",
iconColor: "text-orange-800",
gradient: "from-orange-800 to-orange-700",
},
[METRIC_IDS.NEW_BLOG_POST]: {
label: "New Blog Post",
color: "bg-indigo-200",
textColor: "text-indigo-50",
iconColor: "text-indigo-800",
gradient: "from-indigo-800 to-indigo-700",
},
};
const EVENT_ICONS = {
[METRIC_IDS.PLACED_ORDER]: Package,
[METRIC_IDS.SHIPPED_ORDER]: Truck,
[METRIC_IDS.ACCOUNT_CREATED]: UserPlus,
[METRIC_IDS.CANCELED_ORDER]: XCircle,
[METRIC_IDS.PAYMENT_REFUNDED]: DollarSign,
[METRIC_IDS.NEW_BLOG_POST]: FileText,
};
// Loading State Component
const LoadingState = () => (
<div className="flex gap-3 px-4">
{[...Array(6)].map((_, i) => (
<Card key={i} className="w-[210px] h-[125px] shrink-0 bg-gradient-to-br from-gray-900 to-gray-800 backdrop-blur-sm">
<CardHeader className="flex flex-row items-center justify-between space-y-0 px-3 py-2 pb-0">
<div className="flex items-baseline justify-between w-full pr-1">
<Skeleton className="h-4 w-20 bg-gray-700" />
<Skeleton className="h-3 w-14 bg-gray-700" />
</div>
<div className="relative p-2">
<div className="absolute inset-0 rounded-full bg-gray-300" />
<Skeleton className="h-4 w-4 bg-gray-700 relative rounded-full" />
</div>
</CardHeader>
<CardContent className="p-3 pt-1">
<div className="space-y-2">
<Skeleton className="h-7 w-36 bg-gray-700" />
<div className="flex items-center justify-between">
<Skeleton className="h-4 w-28 bg-gray-700" />
</div>
</div>
</CardContent>
</Card>
))}
</div>
);
// Empty State Component
const EmptyState = () => (
<Card className="w-[210px] h-[125px] bg-gradient-to-br from-gray-900 to-gray-800 backdrop-blur-sm">
<CardContent className="flex flex-col items-center justify-center h-full text-center p-4">
<div className="bg-gray-800 rounded-full p-2 mb-2">
<Activity className="h-4 w-4 text-gray-400" />
</div>
<p className="text-sm text-gray-400 font-medium">
No recent activity
</p>
</CardContent>
</Card>
);
const EventCard = ({ event }) => {
const eventType = EVENT_TYPES[event.metric_id];
if (!eventType) return null;
const Icon = EVENT_ICONS[event.metric_id] || Package;
const details = event.event_properties || {};
return (
<EventDialog event={event}>
<Card className={`w-[210px] border-none shrink-0 hover:brightness-110 cursor-pointer transition-colors h-[125px] bg-gradient-to-br ${eventType.gradient} backdrop-blur-sm`}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 px-3 py-2 pb-0">
<div className="flex items-baseline justify-between w-full pr-1">
<CardTitle className={`text-sm font-bold ${eventType.textColor}`}>
{eventType.label}
</CardTitle>
{event.datetime && (
<CardDescription className={`text-xs ${eventType.textColor} opacity-80`}>
{format(new Date(event.datetime), "h:mm a")}
</CardDescription>
)}
</div>
<div className="relative p-2">
<div className={`absolute inset-0 rounded-full ${eventType.color}`} />
<Icon className={`h-4 w-4 ${eventType.iconColor} relative`} />
</div>
</CardHeader>
<CardContent className="p-3 pt-1">
{event.metric_id === METRIC_IDS.PLACED_ORDER && (
<>
<div className={`text-xl truncate font-bold ${eventType.textColor}`}>
{details.ShippingName}
</div>
<div className="flex items-center justify-between mt-1">
<div className={`text-sm font-semibold ${eventType.textColor} opacity-80 truncate`}>
#{details.OrderId} {formatCurrency(details.TotalAmount)}
</div>
</div>
{(details.IsOnHold || details.OnHoldReleased || details.StillOwes || details.LocalPickup || details.HasPreorder || details.HasNotions || details.OnlyDigitalGC || details.HasDigitalGC || details.HasDigiItem || details.OnlyDigiItem) && (
<div className="flex gap-1.5 items-center flex-wrap mt-1">
{details.IsOnHold && (
<Badge
variant="secondary"
className="bg-blue-100 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 text-xs py-0"
>
On Hold
</Badge>
)}
{details.OnHoldReleased && (
<Badge
variant="secondary"
className="bg-green-100 dark:bg-green-900/20 text-green-700 dark:text-green-300 text-xs py-0"
>
Hold Released
</Badge>
)}
{details.StillOwes && (
<Badge
variant="secondary"
className="bg-red-100 dark:bg-red-900/20 text-red-700 dark:text-red-300 text-xs py-0"
>
Owes
</Badge>
)}
{details.LocalPickup && (
<Badge
variant="secondary"
className="bg-green-100 dark:bg-green-900/20 text-green-700 dark:text-green-300 text-xs py-0"
>
Local
</Badge>
)}
{details.HasPreorder && (
<Badge
variant="secondary"
className="bg-purple-100 dark:bg-purple-900/20 text-purple-700 dark:text-purple-300 text-xs py-0"
>
Pre-order
</Badge>
)}
{details.HasNotions && (
<Badge
variant="secondary"
className="bg-yellow-100 dark:bg-yellow-900/20 text-yellow-700 dark:text-yellow-300 text-xs py-0"
>
Notions
</Badge>
)}
{(details.OnlyDigitalGC || details.HasDigitalGC) && (
<Badge
variant="secondary"
className="bg-pink-100 dark:bg-pink-900/20 text-pink-700 dark:text-pink-300 text-xs py-0"
>
eGift Card
</Badge>
)}
{(details.HasDigiItem || details.OnlyDigiItem) && (
<Badge
variant="secondary"
className="bg-indigo-100 dark:bg-indigo-900/20 text-indigo-700 dark:text-indigo-300 text-xs py-0"
>
Digital
</Badge>
)}
</div>
)}
</>
)}
{event.metric_id === METRIC_IDS.SHIPPED_ORDER && (
<>
<div className={`text-xl truncate font-bold ${eventType.textColor}`}>
{details.ShippingName}
</div>
<div className="flex items-center justify-between mt-1">
<div className={`text-sm font-semibold ${eventType.textColor} opacity-80 truncate`}>
#{details.OrderId} {formatShipMethodSimple(details.ShipMethod)}
</div>
</div>
{event.event_properties?.ShippedBy && (
<div className={`text-sm font-medium ${eventType.textColor} opacity-90 truncate mt-1`}>
Shipped by {event.event_properties.ShippedBy}
</div>
)}
</>
)}
{event.metric_id === METRIC_IDS.ACCOUNT_CREATED && (
<>
<div className={`text-xl truncate font-bold ${eventType.textColor}`}>
{details.FirstName} {details.LastName}
</div>
<div className={`text-sm font-semibold ${eventType.textColor} opacity-80 truncate mt-1`}>
{details.EmailAddress}
</div>
</>
)}
{event.metric_id === METRIC_IDS.CANCELED_ORDER && (
<>
<div className={`text-xl truncate font-bold ${eventType.textColor}`}>
{details.ShippingName}
</div>
<div className="flex items-center justify-between mt-1">
<div className={`text-sm font-semibold ${eventType.textColor} opacity-80 truncate`}>
#{details.OrderId} {formatCurrency(details.TotalAmount)}
</div>
</div>
<div className={`text-xs ${eventType.textColor} opacity-80 mt-1.5 truncate`}>
{details.CancelReason}
</div>
</>
)}
{event.metric_id === METRIC_IDS.PAYMENT_REFUNDED && (
<>
<div className={`text-xl truncate font-bold ${eventType.textColor}`}>
{details.ShippingName}
</div>
<div className="flex items-center justify-between mt-1">
<div className={`text-sm font-semibold ${eventType.textColor} opacity-80 truncate`}>
#{details.FromOrder} {formatCurrency(details.PaymentAmount)}
</div>
</div>
<div className={`text-xs ${eventType.textColor} opacity-80 mt-1.5 truncate`}>
via {details.PaymentName}
</div>
</>
)}
{event.metric_id === METRIC_IDS.NEW_BLOG_POST && (
<>
<div className={`text-lg truncate font-bold ${eventType.textColor}`}>
{details.title}
</div>
<div className={`text-sm font-semibold ${eventType.textColor} opacity-80 line-clamp-2 mt-1`}>
{details.description}
</div>
</>
)}
</CardContent>
</Card>
</EventDialog>
);
};
const DEFAULT_METRICS = Object.values(METRIC_IDS);
const MiniEventFeed = ({
selectedMetrics = DEFAULT_METRICS,
}) => {
const [events, setEvents] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [lastUpdate, setLastUpdate] = useState(null);
const scrollRef = useRef(null);
const [showLeftArrow, setShowLeftArrow] = useState(false);
const [showRightArrow, setShowRightArrow] = useState(false);
const handleScroll = () => {
if (scrollRef.current) {
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
setShowLeftArrow(scrollLeft > 0);
setShowRightArrow(scrollLeft < scrollWidth - clientWidth - 1);
}
};
const scrollToEnd = () => {
if (scrollRef.current) {
scrollRef.current.scrollTo({
left: scrollRef.current.scrollWidth,
behavior: 'smooth'
});
}
};
const scrollToStart = () => {
if (scrollRef.current) {
scrollRef.current.scrollTo({
left: 0,
behavior: 'smooth'
});
}
};
const fetchEvents = useCallback(async () => {
try {
setError(null);
if (events.length === 0) {
setLoading(true);
}
const response = await axios.get("/api/klaviyo/events/feed", {
params: {
timeRange: "today",
metricIds: JSON.stringify(selectedMetrics),
},
});
const processedEvents = (response.data.data || []).map((event) => ({
...event,
datetime: event.attributes?.datetime || event.datetime,
event_properties: event.attributes?.event_properties || {}
}));
setEvents(processedEvents);
setLastUpdate(new Date());
// Scroll to the right after events are loaded
if (scrollRef.current) {
setTimeout(() => {
scrollRef.current.scrollTo({
left: scrollRef.current.scrollWidth,
behavior: 'instant'
});
handleScroll();
}, 0);
}
} catch (error) {
console.error("Error fetching events:", error);
setError(error.message);
} finally {
setLoading(false);
}
}, [selectedMetrics]);
useEffect(() => {
fetchEvents();
const interval = setInterval(fetchEvents, 30000);
return () => clearInterval(interval);
}, [fetchEvents]);
useEffect(() => {
handleScroll();
}, [events]);
return (
<div className="fixed bottom-0 left-0 right-0">
<Card className="bg-gradient-to-br rounded-none from-gray-900 to-gray-600 backdrop-blur">
<div className="px-1 pt-2 pb-3 relative">
{showLeftArrow && (
<Button
variant="ghost"
className="absolute left-0 top-1/2 -translate-y-1/2 z-10 bg-gray-900/50 hover:bg-gray-900/75 h-12 w-8 p-0 [&_svg]:!h-8 [&_svg]:!w-8"
onClick={scrollToStart}
>
<ChevronLeft className="text-white" />
</Button>
)}
{showRightArrow && (
<Button
variant="ghost"
className="absolute right-0 top-1/2 -translate-y-1/2 z-10 bg-gray-900/50 hover:bg-gray-900/75 h-12 w-8 p-0 [&_svg]:!h-8 [&_svg]:!w-8"
onClick={scrollToEnd}
>
<ChevronRight className="text-white" />
</Button>
)}
<div
ref={scrollRef}
onScroll={handleScroll}
className="overflow-x-auto overflow-y-hidden [&::-webkit-scrollbar]:hidden [-ms-overflow-style:'none'] [scrollbar-width:'none']"
>
<div className="flex flex-row gap-3 pr-4" style={{ width: 'max-content' }}>
{loading && !events.length ? (
<LoadingState />
) : error ? (
<Alert variant="destructive" className="mx-4">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>
Failed to load event feed: {error}
</AlertDescription>
</Alert>
) : !events || events.length === 0 ? (
<div className="px-4">
<EmptyState />
</div>
) : (
[...events].reverse().map((event) => (
<EventCard
key={event.id}
event={event}
/>
))
)}
</div>
</div>
</div>
</Card>
</div>
);
};
export default MiniEventFeed;
// Helper Functions
const formatCurrency = (amount) => {
// Convert to number if it's a string
const num = typeof amount === "string" ? parseFloat(amount) : amount;
// Handle negative numbers
const absNum = Math.abs(num);
// Format to 2 decimal places and add negative sign if needed
return `${num < 0 ? "-" : ""}$${absNum.toFixed(2)}`;
};
const formatShipMethodSimple = (method) => {
if (!method) return "Digital";
if (method.includes("usps")) return "USPS";
if (method.includes("fedex")) return "FedEx";
if (method.includes("ups")) return "UPS";
return "Standard";
};