1623 lines
64 KiB
JavaScript
1623 lines
64 KiB
JavaScript
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
|
import axios from "axios";
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "@/components/dashboard/ui/card";
|
|
import { Badge } from "@/components/dashboard/ui/badge";
|
|
import { ScrollArea } from "@/components/dashboard/ui/scroll-area";
|
|
import {
|
|
Package,
|
|
Truck,
|
|
UserPlus,
|
|
XCircle,
|
|
DollarSign,
|
|
ChevronRight,
|
|
Tag,
|
|
Box,
|
|
Activity,
|
|
RefreshCcw,
|
|
FileText,
|
|
AlertCircle,
|
|
} from "lucide-react";
|
|
import { format } from "date-fns";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogTrigger,
|
|
} from "@/components/dashboard/ui/dialog";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/dashboard/ui/select";
|
|
import { Skeleton } from "@/components/dashboard/ui/skeleton";
|
|
import { Button } from "@/components/dashboard/ui/button";
|
|
import { Separator } from "@/components/dashboard/ui/separator";
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipProvider,
|
|
TooltipTrigger,
|
|
} from "@/components/dashboard/ui/tooltip";
|
|
import { Alert, AlertDescription, AlertTitle } from "@/components/dashboard/ui/alert";
|
|
|
|
const METRIC_IDS = {
|
|
PLACED_ORDER: "Y8cqcF",
|
|
SHIPPED_ORDER: "VExpdL",
|
|
ACCOUNT_CREATED: "TeeypV",
|
|
CANCELED_ORDER: "YjVMNg",
|
|
NEW_BLOG_POST: "YcxeDr",
|
|
PAYMENT_REFUNDED: "R7XUYh",
|
|
};
|
|
|
|
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,
|
|
};
|
|
|
|
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",
|
|
},
|
|
};
|
|
|
|
// 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 toTitleCase = (str) => {
|
|
if (!str) return "";
|
|
return str
|
|
.toLowerCase()
|
|
.split(" ")
|
|
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
.join(" ");
|
|
};
|
|
|
|
const formatShipMethod = (method) => {
|
|
if (!method) return "Standard Shipping";
|
|
return method
|
|
.replace("usps_", "USPS ")
|
|
.replace("ups_", "UPS ")
|
|
.replace("fedex_", "FedEx ")
|
|
.replace(/_/g, " ")
|
|
.split(" ")
|
|
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
.join(" ");
|
|
};
|
|
|
|
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";
|
|
};
|
|
|
|
// Loading State Component
|
|
const LoadingState = () => (
|
|
<div className="divide-y divide-gray-100 dark:divide-gray-800">
|
|
{[...Array(8)].map((_, i) => (
|
|
<div key={i} className="flex items-center gap-3 p-4 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors">
|
|
<div className="shrink-0">
|
|
<Skeleton className="h-10 w-10 rounded-full bg-muted" />
|
|
</div>
|
|
<div className="flex-1 min-w-0 space-y-2">
|
|
<div className="flex items-center gap-2">
|
|
<Skeleton className="h-4 w-24 bg-muted rounded-sm" />
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Skeleton className="h-4 w-48 bg-muted rounded-sm" />
|
|
</div>
|
|
<div className="flex gap-1.5 items-center flex-wrap">
|
|
<Skeleton className="h-5 w-16 bg-muted rounded-md" />
|
|
<Skeleton className="h-5 w-20 bg-muted rounded-md" />
|
|
<Skeleton className="h-5 w-14 bg-muted rounded-md" />
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
<Skeleton className="h-4 w-16 bg-muted rounded-sm" />
|
|
<Skeleton className="h-4 w-4 bg-muted rounded-full" />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
|
|
// Empty State Component
|
|
const EmptyState = () => (
|
|
<div className="h-full flex flex-col items-center justify-center py-16 px-4 text-center">
|
|
<div className="bg-gray-100 dark:bg-gray-800 rounded-full p-3 mb-4">
|
|
<Activity className="h-8 w-8 text-muted-foreground" />
|
|
</div>
|
|
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
|
|
No activity yet today
|
|
</h3>
|
|
<p className="text-sm text-muted-foreground max-w-sm">
|
|
Recent activity will appear here as it happens
|
|
</p>
|
|
</div>
|
|
);
|
|
|
|
// Shared Components
|
|
const OrderStatusTags = ({ details }) => (
|
|
<div className="flex flex-wrap gap-2">
|
|
{details.HasPreorder && (
|
|
<span className="px-2 py-1 bg-blue-100 dark:bg-blue-900/20 text-blue-800 dark:text-blue-300 rounded-full text-xs cursor-help">
|
|
Includes Pre-order
|
|
</span>
|
|
)}
|
|
{details.LocalPickup && (
|
|
<span className="px-2 py-1 bg-purple-100 dark:bg-purple-900/20 text-purple-800 dark:text-purple-300 rounded-full text-xs cursor-help">
|
|
Local Pickup
|
|
</span>
|
|
)}
|
|
{details.IsOnHold && (
|
|
<span className="px-2 py-1 bg-yellow-100 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-300 rounded-full text-xs cursor-help">
|
|
On Hold
|
|
</span>
|
|
)}
|
|
{details.HasDigiItem && (
|
|
<span className="px-2 py-1 bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300 rounded-full text-xs cursor-help">
|
|
Digital Items
|
|
</span>
|
|
)}
|
|
{details.HasNotions && (
|
|
<span className="px-2 py-1 bg-pink-100 dark:bg-pink-900/20 text-pink-800 dark:text-pink-300 rounded-full text-xs cursor-help">
|
|
Includes Notions
|
|
</span>
|
|
)}
|
|
{details.HasDigitalGC && (
|
|
<span className="px-2 py-1 bg-indigo-100 dark:bg-indigo-900/20 text-indigo-800 dark:text-indigo-300 rounded-full text-xs cursor-help">
|
|
Gift Card
|
|
</span>
|
|
)}
|
|
{details.StillOwes && (
|
|
<span className="px-2 py-1 bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300 rounded-full text-xs cursor-help">
|
|
Payment Due
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
const ProductCard = ({ product }) => (
|
|
<div className="p-3 bg-gray-50 dark:bg-gray-800 rounded-lg mb-3 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
|
<div className="flex items-start space-x-3">
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center space-x-2">
|
|
<p className="font-medium text-sm text-gray-900 dark:text-gray-100 truncate">
|
|
{product.ProductName || "Unnamed Product"}
|
|
</p>
|
|
{product.ItemStatus === "Pre-Order" && (
|
|
<span className="px-2 py-0.5 bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 rounded-full text-xs cursor-help">
|
|
Pre-order
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="mt-1 flex flex-wrap gap-2">
|
|
{product.Brand && (
|
|
<div className="flex items-center text-xs text-gray-500 dark:text-gray-400 cursor-help">
|
|
<Tag className="w-3 h-3 mr-1" />
|
|
<span>{product.Brand}</span>
|
|
</div>
|
|
)}
|
|
{product.SKU && (
|
|
<div className="flex items-center text-xs text-gray-500 dark:text-gray-400 cursor-help">
|
|
<Box className="w-3 h-3 mr-1" />
|
|
<span>SKU: {product.SKU}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="text-right flex-shrink-0">
|
|
<div className="text-sm font-medium text-gray-900 dark:text-gray-100 cursor-help">
|
|
{formatCurrency(product.ItemPrice)}
|
|
</div>
|
|
<div className="text-xs text-gray-500 dark:text-gray-400 cursor-help">
|
|
Qty: {product.Quantity || product.QuantityOrdered || 1}
|
|
</div>
|
|
{product.RowTotal && (
|
|
<div className="text-xs font-medium text-gray-600 dark:text-gray-300 cursor-help">
|
|
Total: {formatCurrency(product.RowTotal)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
const PromotionalInfo = ({ details }) => {
|
|
if (!details?.PromosUsedReg?.length && !details?.PointsDiscount) return null;
|
|
|
|
return (
|
|
<div className="mt-4 p-3 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
|
<h4 className="text-sm font-medium text-green-800 dark:text-green-400 mb-2">
|
|
<span className="cursor-help">Savings Applied</span>
|
|
</h4>
|
|
<div className="space-y-2">
|
|
{Array.isArray(details.PromosUsedReg) &&
|
|
details.PromosUsedReg.map(([code, amount], index) => (
|
|
<div key={index} className="flex justify-between text-sm">
|
|
<span className="font-mono text-green-700 dark:text-green-300 cursor-help">
|
|
{code}
|
|
</span>
|
|
<span className="font-medium text-green-700 dark:text-green-300 cursor-help">
|
|
-{formatCurrency(amount)}
|
|
</span>
|
|
</div>
|
|
))}
|
|
{details.PointsDiscount > 0 && (
|
|
<div className="flex justify-between text-sm">
|
|
<span className="text-green-700 dark:text-green-300 cursor-help">
|
|
Points Discount
|
|
</span>
|
|
<span className="font-medium text-green-700 dark:text-green-300 cursor-help">
|
|
-{formatCurrency(details.PointsDiscount)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const OrderSummary = ({ details }) => (
|
|
<div className="bg-gray-50 dark:bg-gray-800 p-4 rounded-lg space-y-3">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<h4 className="text-sm font-medium text-gray-500 mb-2 cursor-help">
|
|
Subtotal
|
|
</h4>
|
|
<div className="space-y-1">
|
|
<div className="flex justify-between">
|
|
<span className="cursor-help">
|
|
Items ({details.Items?.length || 0})
|
|
</span>
|
|
<span>{formatCurrency(details.Subtotal)}</span>
|
|
</div>
|
|
{details.PointsDiscount > 0 && (
|
|
<div className="flex justify-between text-green-600">
|
|
<span className="cursor-help">Points Discount</span>
|
|
<span>-{formatCurrency(details.PointsDiscount)}</span>
|
|
</div>
|
|
)}
|
|
{details.TotalDiscounts > 0 && (
|
|
<div className="flex justify-between text-green-600">
|
|
<span className="cursor-help">Discounts</span>
|
|
<span>-{formatCurrency(details.TotalDiscounts)}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<h4 className="text-sm font-medium text-gray-500 mb-2 cursor-help">
|
|
Shipping
|
|
</h4>
|
|
<div className="space-y-1">
|
|
<div className="flex justify-between">
|
|
<span className="cursor-help">Shipping Cost</span>
|
|
<span>{formatCurrency(details.ShippingTotal)}</span>
|
|
</div>
|
|
{details.SalesTax > 0 && (
|
|
<div className="flex justify-between">
|
|
<span className="cursor-help">Sales Tax</span>
|
|
<span>{formatCurrency(details.SalesTax)}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="pt-3 border-t border-gray-200 dark:border-gray-700">
|
|
<div className="flex justify-between items-center">
|
|
<div>
|
|
<span className="text-sm font-medium">Total</span>
|
|
{details.TotalSavings > 0 && (
|
|
<div className="text-xs text-green-600 cursor-help">
|
|
You saved {formatCurrency(details.TotalSavings)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<span className="text-lg font-bold">
|
|
{formatCurrency(details.TotalAmount)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<PromotionalInfo details={details} />
|
|
</div>
|
|
);
|
|
|
|
const ShippingInfo = ({ details }) => (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<h4 className="text-sm font-medium text-gray-500 cursor-help">
|
|
Shipping Address
|
|
</h4>
|
|
<div className="text-sm space-y-1">
|
|
<div className="font-medium cursor-help">
|
|
{details.ShippingName || "Customer"}
|
|
</div>
|
|
<div>{details.ShippingStreet1}</div>
|
|
{details.ShippingStreet2 && <div>{details.ShippingStreet2}</div>}
|
|
<div className="cursor-help">
|
|
{details.ShippingCity}, {details.ShippingState} {details.ShippingZip}
|
|
</div>
|
|
{details.ShippingCountry !== "US" && (
|
|
<div className="cursor-help">{details.ShippingCountry}</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{details.TrackingNumber && (
|
|
<div className="space-y-2">
|
|
<h4 className="text-sm font-medium text-gray-500 cursor-help">
|
|
Tracking Information
|
|
</h4>
|
|
<div className="text-sm space-y-1">
|
|
<div className="font-medium cursor-help">
|
|
{formatShipMethod(details.ShipMethod)}
|
|
</div>
|
|
<div className="font-mono text-blue-600 dark:text-blue-400 cursor-pointer hover:underline">
|
|
{details.TrackingNumber}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
const CustomTooltip = ({ active, payload, label }) => {
|
|
if (active && payload && payload.length) {
|
|
const date = new Date(label);
|
|
const formattedDate = date.toLocaleDateString('en-US', {
|
|
weekday: 'short',
|
|
month: 'short',
|
|
day: 'numeric',
|
|
year: 'numeric'
|
|
});
|
|
|
|
// Group metrics by type (current vs previous)
|
|
const currentMetrics = payload.filter(p => !p.dataKey.toLowerCase().includes('prev'));
|
|
const previousMetrics = payload.filter(p => p.dataKey.toLowerCase().includes('prev'));
|
|
|
|
return (
|
|
<Card className="p-3 shadow-lg bg-white dark:bg-gray-800 border-none">
|
|
<CardContent className="p-0 space-y-2">
|
|
<p className="font-medium text-sm border-b pb-1 mb-2">{formattedDate}</p>
|
|
|
|
<div className="space-y-1">
|
|
{currentMetrics.map((entry, index) => {
|
|
const value = entry.dataKey.toLowerCase().includes('revenue') ||
|
|
entry.dataKey === 'avgOrderValue' ||
|
|
entry.dataKey === 'movingAverage' ||
|
|
entry.dataKey === 'aovMovingAverage'
|
|
? formatCurrency(entry.value)
|
|
: entry.value.toLocaleString();
|
|
|
|
return (
|
|
<div key={index} className="flex justify-between items-center text-sm">
|
|
<span style={{ color: entry.stroke || METRIC_COLORS[entry.dataKey.toLowerCase()] }}>
|
|
{entry.name}:
|
|
</span>
|
|
<span className="font-medium ml-4">{value}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{previousMetrics.length > 0 && (
|
|
<>
|
|
<div className="border-t my-2"></div>
|
|
<div className="space-y-1">
|
|
<p className="text-xs text-muted-foreground mb-1">Previous Period</p>
|
|
{previousMetrics.map((entry, index) => {
|
|
const value = entry.dataKey.toLowerCase().includes('revenue') ||
|
|
entry.dataKey.includes('avgOrderValue')
|
|
? formatCurrency(entry.value)
|
|
: entry.value.toLocaleString();
|
|
|
|
return (
|
|
<div key={index} className="flex justify-between items-center text-sm">
|
|
<span style={{ color: entry.stroke || METRIC_COLORS[entry.dataKey.toLowerCase()] }}>
|
|
{entry.name.replace('Previous ', '')}:
|
|
</span>
|
|
<span className="font-medium ml-4">{value}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const EventDialog = ({ event, children }) => {
|
|
const eventType = EVENT_TYPES[event.metric_id];
|
|
if (!eventType) return children;
|
|
|
|
const details = event.event_properties || {};
|
|
const Icon = EVENT_ICONS[event.metric_id] || Package;
|
|
|
|
return (
|
|
<Dialog>
|
|
<DialogTrigger asChild>{children}</DialogTrigger>
|
|
<DialogContent className="max-w-2xl max-h-[85vh] overflow-hidden flex flex-col">
|
|
<DialogHeader className="border-b border-border px-6 py-4">
|
|
<div className="flex items-center gap-2 mb-2">
|
|
{Icon && <Icon className={`h-5 w-5 ${eventType.textColor}`} />}
|
|
<DialogTitle className="text-lg font-semibold">{eventType.label}</DialogTitle>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<DialogDescription className="text-base">
|
|
{details.OrderId
|
|
? `Order #${details.OrderId}`
|
|
: details.title
|
|
? details.title
|
|
: details.EmailAddress
|
|
? details.EmailAddress
|
|
: "Event Details"}
|
|
</DialogDescription>
|
|
{event.datetime && (
|
|
<time
|
|
className="text-sm text-muted-foreground"
|
|
dateTime={event.datetime}
|
|
>
|
|
{format(new Date(event.datetime), "h:mm a")}
|
|
</time>
|
|
)}
|
|
</div>
|
|
</DialogHeader>
|
|
|
|
<div className="flex-1 overflow-y-auto px-6 py-4">
|
|
<div className="space-y-6">
|
|
{event.metric_id === METRIC_IDS.PLACED_ORDER && (
|
|
<>
|
|
<div className="grid gap-6 sm:grid-cols-2">
|
|
<div className="space-y-4">
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm font-medium">Shipping Information</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-1">
|
|
<p className="text-sm font-medium">{details.ShippingName}</p>
|
|
{details.ShippingStreet1 && (
|
|
<p className="text-sm text-muted-foreground">{details.ShippingStreet1}</p>
|
|
)}
|
|
{details.ShippingStreet2 && (
|
|
<p className="text-sm text-muted-foreground">{details.ShippingStreet2}</p>
|
|
)}
|
|
<p className="text-sm text-muted-foreground">
|
|
{details.ShippingCity}, {details.ShippingState} {details.ShippingZip}
|
|
</p>
|
|
{details.ShippingCountry !== "US" && (
|
|
<p className="text-sm text-muted-foreground">{details.ShippingCountry}</p>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm font-medium">Order Properties</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="flex flex-wrap gap-2">
|
|
{details.IsOnHold && (
|
|
<Badge variant="secondary" className="bg-blue-100 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300">
|
|
On Hold
|
|
</Badge>
|
|
)}
|
|
{details.OnHoldReleased && (
|
|
<Badge variant="secondary" className="bg-green-100 dark:bg-green-900/20 text-green-700 dark:text-green-300">
|
|
Hold Released
|
|
</Badge>
|
|
)}
|
|
{details.StillOwes && (
|
|
<Badge variant="secondary" className="bg-red-100 dark:bg-red-900/20 text-red-700 dark:text-red-300">
|
|
Owes
|
|
</Badge>
|
|
)}
|
|
{details.LocalPickup && (
|
|
<Badge variant="secondary" className="bg-green-100 dark:bg-green-900/20 text-green-700 dark:text-green-300">
|
|
Local
|
|
</Badge>
|
|
)}
|
|
{details.HasPreorder && (
|
|
<Badge variant="secondary" className="bg-purple-100 dark:bg-purple-900/20 text-purple-700 dark:text-purple-300">
|
|
Pre-order
|
|
</Badge>
|
|
)}
|
|
{details.HasNotions && (
|
|
<Badge variant="secondary" className="bg-yellow-100 dark:bg-yellow-900/20 text-yellow-700 dark:text-yellow-300">
|
|
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">
|
|
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">
|
|
Digital
|
|
</Badge>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm font-medium">Order Summary</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="space-y-2">
|
|
<div className="flex justify-between text-sm">
|
|
<span className="text-muted-foreground">Subtotal</span>
|
|
<span className="font-medium">{formatCurrency(details.Subtotal)}</span>
|
|
</div>
|
|
<div className="flex justify-between text-sm">
|
|
<span className="text-muted-foreground">Shipping</span>
|
|
<span className="font-medium">{formatCurrency(details.ShippingTotal)}</span>
|
|
</div>
|
|
<div className="flex justify-between text-sm">
|
|
<span className="text-muted-foreground">Tax</span>
|
|
<span className="font-medium">{formatCurrency(details.SalesTax)}</span>
|
|
</div>
|
|
{details.PointsDiscount > 0 && (
|
|
<div className="flex justify-between text-sm text-green-600 dark:text-green-400">
|
|
<span>Points Discount</span>
|
|
<span className="font-medium">-{formatCurrency(details.PointsDiscount)}</span>
|
|
</div>
|
|
)}
|
|
{Array.isArray(details.PromosUsedReg) &&
|
|
details.PromosUsedReg.map(([code, amount], i) => (
|
|
<div key={i} className="flex justify-between text-sm text-green-600 dark:text-green-400">
|
|
<span>{code}</span>
|
|
<span className="font-medium">-{formatCurrency(amount)}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="pt-2 border-t">
|
|
<div className="flex justify-between font-medium">
|
|
<span>Total</span>
|
|
<span>{formatCurrency(details.TotalAmount)}</span>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm font-medium">Order Items</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="divide-y">
|
|
{details.Items?.map((item, i) => (
|
|
<div key={i} className="flex gap-4 py-4 first:pt-0 last:pb-0">
|
|
{item.ImgThumb && (
|
|
<img
|
|
src={item.ImgThumb}
|
|
alt={item.ProductName}
|
|
className="w-16 h-16 object-cover rounded bg-muted"
|
|
/>
|
|
)}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div>
|
|
<p className="font-medium text-sm">{item.ProductName}</p>
|
|
<p className="text-sm text-muted-foreground">
|
|
{item.Quantity}x @ {formatCurrency(item.ItemPrice)}
|
|
</p>
|
|
</div>
|
|
<p className="text-sm font-medium text-green-600 dark:text-green-400 shrink-0">
|
|
{formatCurrency(item.RowTotal)}
|
|
</p>
|
|
</div>
|
|
{item.ItemStatus && item.ItemStatus !== "Ready" && (
|
|
<Badge variant="secondary" className="mt-2">
|
|
{item.ItemStatus}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</>
|
|
)}
|
|
|
|
{event.metric_id === METRIC_IDS.SHIPPED_ORDER && (
|
|
<>
|
|
<div className="mt-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
|
{toTitleCase(details.ShippingName)}
|
|
</span>
|
|
<span className="text-sm text-gray-500">•</span>
|
|
<span className="text-sm text-gray-500">
|
|
#{details.OrderId}
|
|
</span>
|
|
</div>
|
|
<div className="text-sm text-gray-500">
|
|
{formatShipMethodSimple(details.ShipMethod)}
|
|
{event.event_properties?.ShippedBy && (
|
|
<>
|
|
<span className="text-sm text-gray-500"> • </span>
|
|
<span className="text-sm font-medium text-blue-600 dark:text-blue-400">Shipped by {event.event_properties.ShippedBy}</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{event.metric_id === METRIC_IDS.ACCOUNT_CREATED && (
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm font-medium">Customer Information</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-1">
|
|
<p className="text-sm font-medium">{details.EmailAddress}</p>
|
|
<p className="text-sm text-muted-foreground">
|
|
{details.FirstName} {details.LastName}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{event.metric_id === METRIC_IDS.CANCELED_ORDER && (
|
|
<>
|
|
<div className="grid gap-6 sm:grid-cols-2">
|
|
<div className="space-y-4">
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm font-medium">Cancellation Details</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-1">
|
|
<p className="text-sm font-medium">Reason: {details.CancelReason}</p>
|
|
{details.CancelMessage && (
|
|
<p className="text-sm text-muted-foreground">{details.CancelMessage}</p>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm font-medium">Order Summary</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="space-y-2">
|
|
<div className="flex justify-between text-sm">
|
|
<span className="text-muted-foreground">Subtotal</span>
|
|
<span className="font-medium">{formatCurrency(details.Subtotal)}</span>
|
|
</div>
|
|
<div className="flex justify-between text-sm">
|
|
<span className="text-muted-foreground">Shipping</span>
|
|
<span className="font-medium">{formatCurrency(details.ShippingTotal)}</span>
|
|
</div>
|
|
<div className="flex justify-between text-sm">
|
|
<span className="text-muted-foreground">Tax</span>
|
|
<span className="font-medium">{formatCurrency(details.SalesTax)}</span>
|
|
</div>
|
|
</div>
|
|
<div className="pt-2 border-t">
|
|
<div className="flex justify-between font-medium text-red-600 dark:text-red-400">
|
|
<span>Total Refunded</span>
|
|
<span>{formatCurrency(details.TotalAmount)}</span>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm font-medium">Canceled Items</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="divide-y">
|
|
{details.Items?.map((item, i) => (
|
|
<div key={i} className="flex gap-4 py-4 first:pt-0 last:pb-0">
|
|
{item.ImgThumb && (
|
|
<img
|
|
src={item.ImgThumb}
|
|
alt={item.ProductName}
|
|
className="w-16 h-16 object-cover rounded bg-muted"
|
|
/>
|
|
)}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div>
|
|
<p className="font-medium text-sm">{item.ProductName}</p>
|
|
<p className="text-sm text-muted-foreground">
|
|
{item.Quantity}x @ {formatCurrency(item.ItemPrice)}
|
|
</p>
|
|
</div>
|
|
<p className="text-sm font-medium text-red-600 dark:text-red-400 shrink-0">
|
|
{formatCurrency(item.RowTotal)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{event.metric_id === METRIC_IDS.PAYMENT_REFUNDED && (
|
|
<div className="grid gap-6 sm:grid-cols-2">
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm font-medium">Refund Details</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="flex justify-between items-center">
|
|
<span className="text-sm text-muted-foreground">Amount Refunded</span>
|
|
<span className="text-sm font-medium text-red-600 dark:text-red-400">
|
|
{formatCurrency(details.PaymentAmount)}
|
|
</span>
|
|
</div>
|
|
<div className="flex justify-between items-center">
|
|
<span className="text-sm text-muted-foreground">Payment Method</span>
|
|
<span className="text-sm">{details.PaymentName}</span>
|
|
</div>
|
|
{details.OrderMessage && (
|
|
<div className="pt-2 border-t mt-4">
|
|
<p className="text-sm text-muted-foreground">{details.OrderMessage}</p>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm font-medium">Customer Information</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-1">
|
|
<p className="text-sm font-medium">{details.EmailAddress}</p>
|
|
<p className="text-sm text-muted-foreground">
|
|
{details.FirstName} {details.LastName}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
|
|
{event.metric_id === METRIC_IDS.NEW_BLOG_POST && (
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm font-medium">{details.title}</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
|
|
{details.description}
|
|
</p>
|
|
{details.url && (
|
|
<a
|
|
href={details.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-sm text-blue-600 dark:text-blue-400 hover:underline inline-flex items-center gap-1"
|
|
>
|
|
Read More
|
|
<ChevronRight className="h-4 w-4" />
|
|
</a>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
};
|
|
|
|
export { EventDialog };
|
|
|
|
const EventCard = ({ event }) => {
|
|
const eventType = EVENT_TYPES[event.metric_id] || {
|
|
label: "Unknown Event",
|
|
color: "bg-gray-500",
|
|
textColor: "text-gray-600 dark:text-gray-400",
|
|
};
|
|
|
|
const Icon = EVENT_ICONS[event.metric_id] || Package;
|
|
const details = event.event_properties || {};
|
|
|
|
const datetime = event.attributes?.datetime || event.datetime || event.event_properties?.datetime;
|
|
const timestamp = datetime ? new Date(datetime) : null;
|
|
const isValidDate = timestamp && !isNaN(timestamp.getTime());
|
|
|
|
return (
|
|
<EventDialog event={event}>
|
|
<button className="w-full focus:outline-none text-left">
|
|
<div className="flex items-center gap-3 p-4 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors border-b border-gray-100 dark:border-gray-800 last:border-b-0">
|
|
<div className={`shrink-0 w-10 h-10 rounded-full ${eventType.color} bg-opacity-10 dark:bg-opacity-20 flex items-center justify-center`}>
|
|
<Icon className="h-5 w-5 text-gray-900 dark:text-gray-100" />
|
|
</div>
|
|
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center">
|
|
<span className={`${eventType.textColor} text-sm font-medium`}>
|
|
{eventType.label}
|
|
</span>
|
|
</div>
|
|
|
|
{event.metric_id === METRIC_IDS.PLACED_ORDER && (
|
|
<>
|
|
<div className="mt-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
|
{toTitleCase(details.ShippingName)}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 text-sm text-gray-500">
|
|
<span className="text-sm text-gray-500">
|
|
#{details.OrderId}
|
|
</span>
|
|
<span className="text-sm text-gray-500">•</span>
|
|
<span className="font-medium text-green-600 dark:text-green-400">
|
|
{formatCurrency(details.TotalAmount)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<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="mt-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
|
{toTitleCase(details.ShippingName)}
|
|
</span>
|
|
<span className="text-sm text-gray-500">•</span>
|
|
<span className="text-sm text-gray-500">
|
|
#{details.OrderId}
|
|
</span>
|
|
</div>
|
|
<div className="text-sm text-gray-500">
|
|
{formatShipMethodSimple(details.ShipMethod)}
|
|
{event.event_properties?.ShippedBy && (
|
|
<>
|
|
<span className="text-sm text-gray-500"> • </span>
|
|
<span className="text-sm font-medium text-blue-600 dark:text-blue-400">Shipped by {event.event_properties.ShippedBy}</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{event.metric_id === METRIC_IDS.ACCOUNT_CREATED && (
|
|
<div className="mt-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
|
{details.FirstName && details.LastName
|
|
? `${toTitleCase(details.FirstName)} ${toTitleCase(
|
|
details.LastName
|
|
)}`
|
|
: "New Customer"}
|
|
</span>
|
|
</div>
|
|
<div className="text-sm text-gray-500">
|
|
{details.EmailAddress}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{event.metric_id === METRIC_IDS.CANCELED_ORDER && (
|
|
<div className="mt-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
|
{toTitleCase(details.ShippingName)}
|
|
</span>
|
|
<span className="text-sm text-gray-500">•</span>
|
|
<span className="text-sm text-gray-500">
|
|
#{details.OrderId}
|
|
</span>
|
|
</div>
|
|
<div className="text-sm text-gray-500">
|
|
{formatCurrency(details.TotalAmount)} • {details.CancelReason}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{event.metric_id === METRIC_IDS.PAYMENT_REFUNDED && (
|
|
<div className="mt-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
|
{toTitleCase(details.ShippingName)}
|
|
</span>
|
|
<span className="text-sm text-gray-500">•</span>
|
|
<span className="text-sm text-gray-500">
|
|
#{details.FromOrder}
|
|
</span>
|
|
</div>
|
|
<div className="text-sm text-gray-500">
|
|
{formatCurrency(details.PaymentAmount)} via{" "}
|
|
{details.PaymentName}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{event.metric_id === METRIC_IDS.NEW_BLOG_POST && (
|
|
<div className="mt-1">
|
|
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
|
{details.title}
|
|
</div>
|
|
<div className="text-sm text-gray-500 line-clamp-1">
|
|
{details.description}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
{isValidDate && (
|
|
<time
|
|
className="text-sm text-muted-foreground"
|
|
dateTime={timestamp.toISOString()}
|
|
>
|
|
{format(timestamp, "h:mm a")}
|
|
</time>
|
|
)}
|
|
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
|
</div>
|
|
</div>
|
|
</button>
|
|
</EventDialog>
|
|
);
|
|
};
|
|
|
|
const DEFAULT_METRICS = Object.values(METRIC_IDS);
|
|
|
|
const EventFeed = ({
|
|
title = "Event Feed",
|
|
selectedMetrics = DEFAULT_METRICS,
|
|
}) => {
|
|
const metrics = useMemo(() => selectedMetrics, [selectedMetrics]);
|
|
const [events, setEvents] = useState([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState(null);
|
|
const [lastUpdate, setLastUpdate] = useState(null);
|
|
const [activeEventTypes, setActiveEventTypes] = useState({
|
|
[METRIC_IDS.PLACED_ORDER]: true,
|
|
[METRIC_IDS.SHIPPED_ORDER]: true,
|
|
[METRIC_IDS.ACCOUNT_CREATED]: true,
|
|
[METRIC_IDS.CANCELED_ORDER]: true,
|
|
[METRIC_IDS.PAYMENT_REFUNDED]: true,
|
|
[METRIC_IDS.NEW_BLOG_POST]: true,
|
|
});
|
|
const [orderFilters, setOrderFilters] = useState({
|
|
hasPreorder: false,
|
|
localPickup: false,
|
|
isOnHold: false,
|
|
onHoldReleased: false,
|
|
hasDigiItem: false,
|
|
hasNotions: false,
|
|
hasGiftCard: false,
|
|
stillOwes: false,
|
|
});
|
|
|
|
const fetchEvents = useCallback(async () => {
|
|
try {
|
|
setError(null);
|
|
|
|
// Only set loading true if we don't have any events yet
|
|
if (events.length === 0) {
|
|
setLoading(true);
|
|
}
|
|
|
|
const response = await axios.get("/api/klaviyo/events/feed", {
|
|
params: {
|
|
timeRange: "today",
|
|
metricIds: JSON.stringify(metrics),
|
|
},
|
|
});
|
|
|
|
// Keep the original event structure intact
|
|
const processedEvents = (response.data.data || []).map((event) => ({
|
|
...event,
|
|
datetime: event.attributes?.datetime || event.datetime,
|
|
// Don't spread event_properties to preserve the nested structure
|
|
event_properties: event.attributes?.event_properties || {}
|
|
}));
|
|
|
|
setEvents(processedEvents);
|
|
setLastUpdate(new Date());
|
|
} catch (error) {
|
|
console.error("Error fetching events:", error);
|
|
setError(error.message);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [metrics]);
|
|
|
|
// Fetch events on mount and every minute
|
|
useEffect(() => {
|
|
fetchEvents();
|
|
const interval = setInterval(fetchEvents, 60000); // Refresh every minute
|
|
|
|
return () => {
|
|
clearInterval(interval);
|
|
};
|
|
}, [fetchEvents]);
|
|
|
|
const filteredEvents = useMemo(() => {
|
|
// Check if any order property filters are active
|
|
const hasActiveOrderFilters = Object.values(orderFilters).some(filter => filter);
|
|
|
|
return events.filter(event => {
|
|
// First check event type filter
|
|
if (!activeEventTypes[event.metric_id]) {
|
|
return false;
|
|
}
|
|
|
|
// Then check order property filters if any are active
|
|
if (hasActiveOrderFilters) {
|
|
if (event.metric_id !== METRIC_IDS.PLACED_ORDER) return false;
|
|
|
|
const details = event.event_properties || {};
|
|
if (orderFilters.hasPreorder && !details.HasPreorder) return false;
|
|
if (orderFilters.localPickup && !details.LocalPickup) return false;
|
|
if (orderFilters.isOnHold && !details.IsOnHold) return false;
|
|
if (orderFilters.onHoldReleased && !details.OnHoldReleased) return false;
|
|
if (orderFilters.hasDigiItem && !details.HasDigiItem) return false;
|
|
if (orderFilters.hasNotions && !details.HasNotions) return false;
|
|
if (orderFilters.hasGiftCard && !details.HasDigitalGC) return false;
|
|
if (orderFilters.stillOwes && !details.StillOwes) return false;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
}, [events, activeEventTypes, orderFilters]);
|
|
|
|
// Calculate counts for event types and order properties
|
|
const counts = useMemo(() => {
|
|
const eventTypeCounts = {
|
|
[METRIC_IDS.PLACED_ORDER]: 0,
|
|
[METRIC_IDS.SHIPPED_ORDER]: 0,
|
|
[METRIC_IDS.ACCOUNT_CREATED]: 0,
|
|
[METRIC_IDS.CANCELED_ORDER]: 0,
|
|
[METRIC_IDS.PAYMENT_REFUNDED]: 0,
|
|
[METRIC_IDS.NEW_BLOG_POST]: 0,
|
|
};
|
|
|
|
const orderPropertyCounts = {
|
|
hasPreorder: 0,
|
|
localPickup: 0,
|
|
isOnHold: 0,
|
|
onHoldReleased: 0,
|
|
hasDigiItem: 0,
|
|
hasNotions: 0,
|
|
hasGiftCard: 0,
|
|
stillOwes: 0,
|
|
};
|
|
|
|
events.forEach(event => {
|
|
// Count event types
|
|
if (event.metric_id) {
|
|
eventTypeCounts[event.metric_id]++;
|
|
}
|
|
|
|
// Count order properties
|
|
if (event.metric_id === METRIC_IDS.PLACED_ORDER) {
|
|
const details = event.event_properties || {};
|
|
if (details.HasPreorder) orderPropertyCounts.hasPreorder++;
|
|
if (details.LocalPickup) orderPropertyCounts.localPickup++;
|
|
if (details.IsOnHold) orderPropertyCounts.isOnHold++;
|
|
if (details.OnHoldReleased) orderPropertyCounts.onHoldReleased++;
|
|
if (details.HasDigiItem) orderPropertyCounts.hasDigiItem++;
|
|
if (details.HasNotions) orderPropertyCounts.hasNotions++;
|
|
if (details.HasDigitalGC) orderPropertyCounts.hasGiftCard++;
|
|
if (details.StillOwes) orderPropertyCounts.stillOwes++;
|
|
}
|
|
});
|
|
|
|
return {
|
|
eventTypes: eventTypeCounts,
|
|
orderProperties: orderPropertyCounts,
|
|
};
|
|
}, [events]);
|
|
|
|
const handleOrderPropertyClick = (property) => {
|
|
setOrderFilters(prev => {
|
|
// If clicking the active filter, clear all filters
|
|
if (prev[property]) {
|
|
return {
|
|
hasPreorder: false,
|
|
localPickup: false,
|
|
isOnHold: false,
|
|
onHoldReleased: false,
|
|
hasDigiItem: false,
|
|
hasNotions: false,
|
|
hasGiftCard: false,
|
|
stillOwes: false,
|
|
};
|
|
}
|
|
// Otherwise, set only this filter to true
|
|
return {
|
|
hasPreorder: property === 'hasPreorder',
|
|
localPickup: property === 'localPickup',
|
|
isOnHold: property === 'isOnHold',
|
|
onHoldReleased: property === 'onHoldReleased',
|
|
hasDigiItem: property === 'hasDigiItem',
|
|
hasNotions: property === 'hasNotions',
|
|
hasGiftCard: property === 'hasGiftCard',
|
|
stillOwes: property === 'stillOwes',
|
|
};
|
|
});
|
|
};
|
|
|
|
const handleEventTypeClick = (metricId) => {
|
|
setActiveEventTypes(prev => {
|
|
// If clicking the only active filter, reset to all active
|
|
const activeCount = Object.values(prev).filter(Boolean).length;
|
|
if (activeCount === 1 && prev[metricId]) {
|
|
return {
|
|
[METRIC_IDS.PLACED_ORDER]: true,
|
|
[METRIC_IDS.SHIPPED_ORDER]: true,
|
|
[METRIC_IDS.ACCOUNT_CREATED]: true,
|
|
[METRIC_IDS.CANCELED_ORDER]: true,
|
|
[METRIC_IDS.PAYMENT_REFUNDED]: true,
|
|
[METRIC_IDS.NEW_BLOG_POST]: true,
|
|
};
|
|
}
|
|
// Otherwise, set only this filter to true
|
|
return {
|
|
[METRIC_IDS.PLACED_ORDER]: metricId === METRIC_IDS.PLACED_ORDER,
|
|
[METRIC_IDS.SHIPPED_ORDER]: metricId === METRIC_IDS.SHIPPED_ORDER,
|
|
[METRIC_IDS.ACCOUNT_CREATED]: metricId === METRIC_IDS.ACCOUNT_CREATED,
|
|
[METRIC_IDS.CANCELED_ORDER]: metricId === METRIC_IDS.CANCELED_ORDER,
|
|
[METRIC_IDS.PAYMENT_REFUNDED]: metricId === METRIC_IDS.PAYMENT_REFUNDED,
|
|
[METRIC_IDS.NEW_BLOG_POST]: metricId === METRIC_IDS.NEW_BLOG_POST,
|
|
};
|
|
});
|
|
};
|
|
|
|
const EventTypeTooltipContent = () => (
|
|
<div className="grid gap-2">
|
|
<div className="flex items-center justify-between gap-4">
|
|
<span className="flex items-center gap-2">
|
|
<Package className="h-4 w-4" />
|
|
Orders
|
|
</span>
|
|
<Badge variant="secondary" className="bg-muted">
|
|
{counts.eventTypes[METRIC_IDS.PLACED_ORDER].toLocaleString()}
|
|
</Badge>
|
|
</div>
|
|
<div className="flex items-center justify-between gap-4">
|
|
<span className="flex items-center gap-2">
|
|
<Truck className="h-4 w-4" />
|
|
Shipments
|
|
</span>
|
|
<Badge variant="secondary" className="bg-muted">
|
|
{counts.eventTypes[METRIC_IDS.SHIPPED_ORDER].toLocaleString()}
|
|
</Badge>
|
|
</div>
|
|
<div className="flex items-center justify-between gap-4">
|
|
<span className="flex items-center gap-2">
|
|
<UserPlus className="h-4 w-4" />
|
|
Accounts
|
|
</span>
|
|
<Badge variant="secondary" className="bg-muted">
|
|
{counts.eventTypes[METRIC_IDS.ACCOUNT_CREATED].toLocaleString()}
|
|
</Badge>
|
|
</div>
|
|
<div className="flex items-center justify-between gap-4">
|
|
<span className="flex items-center gap-2">
|
|
<XCircle className="h-4 w-4" />
|
|
Cancellations
|
|
</span>
|
|
<Badge variant="secondary" className="bg-muted">
|
|
{counts.eventTypes[METRIC_IDS.CANCELED_ORDER].toLocaleString()}
|
|
</Badge>
|
|
</div>
|
|
<div className="flex items-center justify-between gap-4">
|
|
<span className="flex items-center gap-2">
|
|
<DollarSign className="h-4 w-4" />
|
|
Refunds
|
|
</span>
|
|
<Badge variant="secondary" className="bg-muted">
|
|
{counts.eventTypes[METRIC_IDS.PAYMENT_REFUNDED].toLocaleString()}
|
|
</Badge>
|
|
</div>
|
|
<div className="flex items-center justify-between gap-4">
|
|
<span className="flex items-center gap-2">
|
|
<FileText className="h-4 w-4" />
|
|
Blog Posts
|
|
</span>
|
|
<Badge variant="secondary" className="bg-muted">
|
|
{counts.eventTypes[METRIC_IDS.NEW_BLOG_POST].toLocaleString()}
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<Card className="flex flex-col h-full bg-white dark:bg-gray-900/60 backdrop-blur-sm w-full">
|
|
<CardHeader className="p-6 pb-2">
|
|
<div className="flex justify-between items-start">
|
|
<div>
|
|
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">{title}</CardTitle>
|
|
{lastUpdate && (
|
|
<CardDescription className="text-sm text-muted-foreground">
|
|
Last updated {format(lastUpdate, "h:mm a")}
|
|
</CardDescription>
|
|
)}
|
|
</div>
|
|
{!error && (
|
|
<div className="flex flex-wrap gap-2">
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant={activeEventTypes[METRIC_IDS.PLACED_ORDER] ? "default" : "outline"}
|
|
size="sm"
|
|
onClick={() => handleEventTypeClick(METRIC_IDS.PLACED_ORDER)}
|
|
className="h-8 w-8 p-0 rounded-md"
|
|
>
|
|
<Package className="h-4 w-4" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<EventTypeTooltipContent />
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant={activeEventTypes[METRIC_IDS.SHIPPED_ORDER] ? "default" : "outline"}
|
|
size="sm"
|
|
onClick={() => handleEventTypeClick(METRIC_IDS.SHIPPED_ORDER)}
|
|
className="h-8 w-8 p-0 rounded-md"
|
|
>
|
|
<Truck className="h-4 w-4" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<EventTypeTooltipContent />
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant={activeEventTypes[METRIC_IDS.ACCOUNT_CREATED] ? "default" : "outline"}
|
|
size="sm"
|
|
onClick={() => handleEventTypeClick(METRIC_IDS.ACCOUNT_CREATED)}
|
|
className="h-8 w-8 p-0 rounded-md"
|
|
>
|
|
<UserPlus className="h-4 w-4" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<EventTypeTooltipContent />
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant={activeEventTypes[METRIC_IDS.CANCELED_ORDER] ? "default" : "outline"}
|
|
size="sm"
|
|
onClick={() => handleEventTypeClick(METRIC_IDS.CANCELED_ORDER)}
|
|
className="h-8 w-8 p-0 rounded-md"
|
|
>
|
|
<XCircle className="h-4 w-4" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<EventTypeTooltipContent />
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant={activeEventTypes[METRIC_IDS.PAYMENT_REFUNDED] ? "default" : "outline"}
|
|
size="sm"
|
|
onClick={() => handleEventTypeClick(METRIC_IDS.PAYMENT_REFUNDED)}
|
|
className="h-8 w-8 p-0 rounded-md"
|
|
>
|
|
<DollarSign className="h-4 w-4" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<EventTypeTooltipContent />
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant={activeEventTypes[METRIC_IDS.NEW_BLOG_POST] ? "default" : "outline"}
|
|
size="sm"
|
|
onClick={() => handleEventTypeClick(METRIC_IDS.NEW_BLOG_POST)}
|
|
className="h-8 w-8 p-0 rounded-md"
|
|
>
|
|
<FileText className="h-4 w-4" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<EventTypeTooltipContent />
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Order Property Filters - update styling */}
|
|
{!error && (
|
|
<div className="flex flex-wrap gap-2 justify-center mt-4 pt-1">
|
|
{counts.orderProperties.hasPreorder > 0 && (
|
|
<Badge
|
|
variant="secondary"
|
|
onClick={() => handleOrderPropertyClick('hasPreorder')}
|
|
className={`${
|
|
orderFilters.hasPreorder
|
|
? 'bg-purple-800 text-purple-200 hover:bg-purple-700'
|
|
: 'bg-purple-100 dark:bg-purple-900/20 text-purple-800 dark:text-purple-300 hover:bg-purple-100 dark:hover:bg-purple-900/20'
|
|
} cursor-pointer rounded-md`}
|
|
>
|
|
Pre-order ({counts.orderProperties.hasPreorder})
|
|
</Badge>
|
|
)}
|
|
{counts.orderProperties.localPickup > 0 && (
|
|
<Badge
|
|
variant="secondary"
|
|
onClick={() => handleOrderPropertyClick('localPickup')}
|
|
className={`${
|
|
orderFilters.localPickup
|
|
? 'bg-green-800 text-green-200 hover:bg-green-700'
|
|
: 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300 hover:bg-green-100 dark:hover:bg-green-900/20'
|
|
} cursor-pointer rounded-md`}
|
|
>
|
|
Local ({counts.orderProperties.localPickup})
|
|
</Badge>
|
|
)}
|
|
{counts.orderProperties.isOnHold > 0 && (
|
|
<Badge
|
|
variant="secondary"
|
|
onClick={() => handleOrderPropertyClick('isOnHold')}
|
|
className={`${
|
|
orderFilters.isOnHold
|
|
? 'bg-blue-800 text-blue-200 hover:bg-blue-700'
|
|
: 'bg-blue-100 dark:bg-blue-900/20 text-blue-800 dark:text-blue-300 hover:bg-blue-100 dark:hover:bg-blue-900/20'
|
|
} cursor-pointer rounded-md`}
|
|
>
|
|
On Hold ({counts.orderProperties.isOnHold})
|
|
</Badge>
|
|
)}
|
|
{counts.orderProperties.onHoldReleased > 0 && (
|
|
<Badge
|
|
variant="secondary"
|
|
onClick={() => handleOrderPropertyClick('onHoldReleased')}
|
|
className={`${
|
|
orderFilters.onHoldReleased
|
|
? 'bg-green-800 text-green-200 hover:bg-green-700'
|
|
: 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300 hover:bg-green-100 dark:hover:bg-green-900/20'
|
|
} cursor-pointer rounded-md`}
|
|
>
|
|
Hold Released ({counts.orderProperties.onHoldReleased})
|
|
</Badge>
|
|
)}
|
|
{counts.orderProperties.hasDigiItem > 0 && (
|
|
<Badge
|
|
variant="secondary"
|
|
onClick={() => handleOrderPropertyClick('hasDigiItem')}
|
|
className={`${
|
|
orderFilters.hasDigiItem
|
|
? 'bg-indigo-800 text-indigo-200 hover:bg-indigo-700'
|
|
: 'bg-indigo-100 dark:bg-indigo-900/20 text-indigo-800 dark:text-indigo-300 hover:bg-indigo-100 dark:hover:bg-indigo-900/20'
|
|
} cursor-pointer rounded-md`}
|
|
>
|
|
Digital ({counts.orderProperties.hasDigiItem})
|
|
</Badge>
|
|
)}
|
|
{counts.orderProperties.hasNotions > 0 && (
|
|
<Badge
|
|
variant="secondary"
|
|
onClick={() => handleOrderPropertyClick('hasNotions')}
|
|
className={`${
|
|
orderFilters.hasNotions
|
|
? 'bg-yellow-800 text-yellow-200 hover:bg-yellow-700'
|
|
: 'bg-yellow-100 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-300 hover:bg-yellow-100 dark:hover:bg-yellow-900/20'
|
|
} cursor-pointer rounded-md`}
|
|
>
|
|
Notions ({counts.orderProperties.hasNotions})
|
|
</Badge>
|
|
)}
|
|
{counts.orderProperties.hasGiftCard > 0 && (
|
|
<Badge
|
|
variant="secondary"
|
|
onClick={() => handleOrderPropertyClick('hasGiftCard')}
|
|
className={`${
|
|
orderFilters.hasGiftCard
|
|
? 'bg-pink-800 text-pink-200 hover:bg-pink-700'
|
|
: 'bg-pink-100 dark:bg-pink-900/20 text-pink-800 dark:text-pink-300 hover:bg-pink-100 dark:hover:bg-pink-900/20'
|
|
} cursor-pointer rounded-md`}
|
|
>
|
|
eGift Card ({counts.orderProperties.hasGiftCard})
|
|
</Badge>
|
|
)}
|
|
{counts.orderProperties.stillOwes > 0 && (
|
|
<Badge
|
|
variant="secondary"
|
|
onClick={() => handleOrderPropertyClick('stillOwes')}
|
|
className={`${
|
|
orderFilters.stillOwes
|
|
? 'bg-red-800 text-red-200 hover:bg-red-700'
|
|
: 'bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300 hover:bg-red-100 dark:hover:bg-red-900/20'
|
|
} cursor-pointer rounded-md`}
|
|
>
|
|
Owes ({counts.orderProperties.stillOwes})
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
)}
|
|
</CardHeader>
|
|
|
|
<CardContent className="px-0 pb-6 pt-0 md:px-6 flex-1 overflow-hidden -mt-2">
|
|
<ScrollArea className="h-full">
|
|
{loading && !events.length ? (
|
|
<LoadingState />
|
|
) : error ? (
|
|
<Alert variant="destructive" className="mt-1 mx-6">
|
|
<AlertCircle className="h-4 w-4" />
|
|
<AlertTitle>Error</AlertTitle>
|
|
<AlertDescription>
|
|
Failed to load event feed: {error}
|
|
</AlertDescription>
|
|
</Alert>
|
|
) : !filteredEvents || filteredEvents.length === 0 ? (
|
|
<EmptyState />
|
|
) : (
|
|
<div className="divide-y divide-gray-100 dark:divide-gray-800">
|
|
{filteredEvents.map((event) => (
|
|
<EventCard key={event.id} event={event} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</ScrollArea>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
};
|
|
|
|
export default EventFeed;
|