Add eventfeed cards

This commit is contained in:
2025-01-03 16:35:29 -05:00
parent 08ddba358a
commit 2c5f7cc446
3 changed files with 452 additions and 1 deletions

View File

@@ -30,6 +30,7 @@ import TypeformDashboard from "@/components/dashboard/TypeformDashboard";
import MiniStatCards from "@/components/dashboard/MiniStatCards"; import MiniStatCards from "@/components/dashboard/MiniStatCards";
import MiniRealtimeAnalytics from "@/components/dashboard/MiniRealtimeAnalytics"; import MiniRealtimeAnalytics from "@/components/dashboard/MiniRealtimeAnalytics";
import MiniSalesChart from "@/components/dashboard/MiniSalesChart"; import MiniSalesChart from "@/components/dashboard/MiniSalesChart";
import MiniEventFeed from "@/components/dashboard/MiniEventFeed";
// Public layout // Public layout
const PublicLayout = () => ( const PublicLayout = () => (
@@ -68,9 +69,10 @@ const SmallLayout = () => {
const STATS_SCALE = 1.65; const STATS_SCALE = 1.65;
const ANALYTICS_SCALE = 1.65; const ANALYTICS_SCALE = 1.65;
const SALES_SCALE = 1.65; const SALES_SCALE = 1.65;
const FEED_SCALE = 1.65;
return ( return (
<div className="min-h-screen w-screen"> <div className="min-h-screen w-screen relative">
<span className="absolute top-4 left-4 z-50"> <span className="absolute top-4 left-4 z-50">
<LockButton /> <LockButton />
</span> </span>
@@ -132,6 +134,18 @@ const SmallLayout = () => {
</div> </div>
</div> </div>
</div> </div>
{/* Event Feed at bottom */}
<div className="absolute bottom-0 left-0 right-0">
<div style={{
transform: `scale(${FEED_SCALE})`,
transformOrigin: 'bottom center',
width: `${100/FEED_SCALE}%`,
margin: '0 auto'
}}>
<MiniEventFeed />
</div>
</div>
</div> </div>
); );
}; };

View File

@@ -913,6 +913,8 @@ const EventDialog = ({ event, children }) => {
); );
}; };
export { EventDialog };
const EventCard = ({ event }) => { const EventCard = ({ event }) => {
const eventType = EVENT_TYPES[event.metric_id] || { const eventType = EVENT_TYPES[event.metric_id] || {
label: "Unknown Event", label: "Unknown Event",

View File

@@ -0,0 +1,435 @@
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,
} 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";
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-500 dark:bg-green-600",
textColor: "text-green-600 dark:text-green-400",
},
[METRIC_IDS.SHIPPED_ORDER]: {
label: "Order Shipped",
color: "bg-blue-500 dark:bg-blue-600",
textColor: "text-blue-600 dark:text-blue-400",
},
[METRIC_IDS.ACCOUNT_CREATED]: {
label: "New Account",
color: "bg-purple-500 dark:bg-purple-600",
textColor: "text-purple-600 dark:text-purple-400",
},
[METRIC_IDS.CANCELED_ORDER]: {
label: "Order Canceled",
color: "bg-red-500 dark:bg-red-600",
textColor: "text-red-600 dark:text-red-400",
},
[METRIC_IDS.PAYMENT_REFUNDED]: {
label: "Payment Refunded",
color: "bg-orange-500 dark:bg-orange-600",
textColor: "text-orange-600 dark:text-orange-400",
},
[METRIC_IDS.NEW_BLOG_POST]: {
label: "New Blog Post",
color: "bg-indigo-500 dark:bg-indigo-600",
textColor: "text-indigo-600 dark:text-indigo-400",
},
};
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-4 px-4">
{[...Array(4)].map((_, i) => (
<Card key={i} className="w-[280px] shrink-0">
<CardHeader className="p-3 pb-2">
<div className="flex items-center gap-3">
<Skeleton className="h-10 w-10 rounded-full bg-muted" />
<div className="space-y-2">
<Skeleton className="h-4 w-24 bg-muted" />
<Skeleton className="h-3 w-16 bg-muted" />
</div>
</div>
</CardHeader>
<CardContent className="p-3 pt-2">
<div className="space-y-2">
<Skeleton className="h-4 w-full bg-muted" />
<Skeleton className="h-4 w-3/4 bg-muted" />
</div>
</CardContent>
</Card>
))}
</div>
);
// Empty State Component
const EmptyState = () => (
<Card className="w-full">
<CardContent className="flex flex-col items-center justify-center py-6 px-4 text-center">
<div className="bg-gray-100 dark:bg-gray-800 rounded-full p-3 mb-4">
<Activity className="h-6 w-6 text-muted-foreground" />
</div>
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
No activity yet
</h3>
<p className="text-sm text-muted-foreground max-w-sm">
Recent activity will appear here as it happens
</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 || {};
const getBgGradient = (type) => {
switch (type) {
case METRIC_IDS.PLACED_ORDER:
return 'from-green-900 to-green-800';
case METRIC_IDS.SHIPPED_ORDER:
return 'from-blue-900 to-blue-800';
case METRIC_IDS.ACCOUNT_CREATED:
return 'from-purple-900 to-purple-800';
case METRIC_IDS.CANCELED_ORDER:
return 'from-red-900 to-red-800';
case METRIC_IDS.PAYMENT_REFUNDED:
return 'from-orange-900 to-orange-800';
case METRIC_IDS.NEW_BLOG_POST:
return 'from-indigo-900 to-indigo-800';
default:
return 'from-gray-900 to-gray-800';
}
};
return (
<EventDialog event={event}>
<Card className={`w-[240px] shrink-0 hover:brightness-110 cursor-pointer transition-colors h-[140px] bg-gradient-to-br ${getBgGradient(event.metric_id)} backdrop-blur-sm`}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-3 pb-2">
<div className="flex items-center gap-2">
<CardTitle className="text-sm font-bold text-gray-100">
{eventType.label}
</CardTitle>
{event.datetime && (
<CardDescription className="text-xs text-gray-300">
{format(new Date(event.datetime), "h:mm a")}
</CardDescription>
)}
</div>
<div className="relative p-2">
<div className={`absolute inset-0 rounded-full ${eventType.color} bg-opacity-20`} />
<Icon className={`h-4 w-4 ${eventType.textColor} relative`} />
</div>
</CardHeader>
<CardContent className="p-3 pt-1">
{event.metric_id === METRIC_IDS.PLACED_ORDER && (
<>
<div className="text-xl font-bold text-gray-100 truncate">
{details.ShippingName}
</div>
<div className="flex items-center justify-between mt-1">
<div className="text-sm font-semibold text-gray-300 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-2">
{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 font-bold text-gray-100 truncate">
{details.ShippingName}
</div>
<div className="flex items-center justify-between mt-1">
<div className="text-sm font-semibold text-gray-300 truncate">
#{details.OrderId} {formatShipMethodSimple(details.ShipMethod)}
</div>
</div>
<div className="text-xs text-gray-400 mt-1.5 truncate">
{details.ShippingStreet1}, {details.ShippingCity}, {details.ShippingState} {details.ShippingZip}
</div>
</>
)}
{event.metric_id === METRIC_IDS.ACCOUNT_CREATED && (
<>
<div className="text-xl font-bold text-gray-100 truncate">
{details.FirstName} {details.LastName}
</div>
<div className="text-sm font-semibold text-gray-300 truncate mt-1">
{details.EmailAddress}
</div>
</>
)}
{event.metric_id === METRIC_IDS.CANCELED_ORDER && (
<>
<div className="text-xl font-bold text-gray-100 truncate">
{details.ShippingName}
</div>
<div className="flex items-center justify-between mt-1">
<div className="text-sm font-semibold text-gray-300 truncate">
#{details.OrderId} {formatCurrency(details.TotalAmount)}
</div>
</div>
<div className="text-xs text-red-300 mt-1.5 truncate">
{details.CancelReason}
</div>
</>
)}
{event.metric_id === METRIC_IDS.PAYMENT_REFUNDED && (
<>
<div className="text-xl font-bold text-gray-100 truncate">
{details.ShippingName}
</div>
<div className="flex items-center justify-between mt-1">
<div className="text-sm font-semibold text-gray-300 truncate">
#{details.FromOrder} {formatCurrency(details.PaymentAmount)}
</div>
</div>
<div className="text-xs text-orange-300 mt-1.5 truncate">
via {details.PaymentName}
</div>
</>
)}
{event.metric_id === METRIC_IDS.NEW_BLOG_POST && (
<>
<div className="text-xl font-bold text-gray-100 truncate">
{details.title}
</div>
<div className="text-sm font-semibold text-gray-300 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 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.event_properties,
datetime: event.attributes?.datetime || event.datetime,
},
}));
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'
});
}, 0);
}
} catch (error) {
console.error("Error fetching events:", error);
setError(error.message);
} finally {
setLoading(false);
}
}, [selectedMetrics]);
useEffect(() => {
fetchEvents();
const interval = setInterval(fetchEvents, 60000);
return () => clearInterval(interval);
}, [fetchEvents]);
return (
<div className="fixed bottom-0 left-0 right-0 bg-background/80 backdrop-blur-sm border-t">
<div className="p-4">
<div className="overflow-x-auto">
<div className="flex flex-row-reverse 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.map((event) => (
<EventCard
key={event.id}
event={event}
/>
))
)}
</div>
</div>
</div>
</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";
};