Add eventfeed cards
This commit is contained in:
@@ -30,6 +30,7 @@ import TypeformDashboard from "@/components/dashboard/TypeformDashboard";
|
||||
import MiniStatCards from "@/components/dashboard/MiniStatCards";
|
||||
import MiniRealtimeAnalytics from "@/components/dashboard/MiniRealtimeAnalytics";
|
||||
import MiniSalesChart from "@/components/dashboard/MiniSalesChart";
|
||||
import MiniEventFeed from "@/components/dashboard/MiniEventFeed";
|
||||
|
||||
// Public layout
|
||||
const PublicLayout = () => (
|
||||
@@ -68,9 +69,10 @@ const SmallLayout = () => {
|
||||
const STATS_SCALE = 1.65;
|
||||
const ANALYTICS_SCALE = 1.65;
|
||||
const SALES_SCALE = 1.65;
|
||||
const FEED_SCALE = 1.65;
|
||||
|
||||
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">
|
||||
<LockButton />
|
||||
</span>
|
||||
@@ -132,6 +134,18 @@ const SmallLayout = () => {
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -913,6 +913,8 @@ const EventDialog = ({ event, children }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export { EventDialog };
|
||||
|
||||
const EventCard = ({ event }) => {
|
||||
const eventType = EVENT_TYPES[event.metric_id] || {
|
||||
label: "Unknown Event",
|
||||
|
||||
435
dashboard/src/components/dashboard/MiniEventFeed.jsx
Normal file
435
dashboard/src/components/dashboard/MiniEventFeed.jsx
Normal 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";
|
||||
};
|
||||
Reference in New Issue
Block a user