487 lines
17 KiB
JavaScript
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";
|
|
};
|