1029 lines
38 KiB
JavaScript
1029 lines
38 KiB
JavaScript
import React, { useState, useEffect, useMemo, useCallback } from "react";
|
||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from "@/components/ui/dialog";
|
||
import {
|
||
ShoppingCart,
|
||
UserPlus,
|
||
AlertCircle,
|
||
Package,
|
||
DollarSign,
|
||
FileText,
|
||
XCircle,
|
||
ChevronRight,
|
||
Tag,
|
||
Box,
|
||
Activity,
|
||
RefreshCcw,
|
||
} from "lucide-react";
|
||
import { DateTime } from "luxon";
|
||
import { Skeleton } from "@/components/ui/skeleton";
|
||
|
||
const LoadingState = () => (
|
||
<div className="flex items-center justify-center p-8">
|
||
<div className="space-y-4 w-full">
|
||
{[...Array(5)].map((_, i) => (
|
||
<div key={i} className="flex items-center space-x-4">
|
||
<Skeleton className="h-12 w-12 rounded-full" />
|
||
<div className="space-y-2 flex-1">
|
||
<Skeleton className="h-4 w-[250px]" />
|
||
<Skeleton className="h-4 w-[200px]" />
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
const formatCurrency = (value) => {
|
||
if (!value || isNaN(value)) return "$0.00";
|
||
return new Intl.NumberFormat("en-US", {
|
||
style: "currency",
|
||
currency: "USD",
|
||
minimumFractionDigits: 2,
|
||
maximumFractionDigits: 2,
|
||
}).format(value);
|
||
};
|
||
|
||
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 'Standard';
|
||
if (method.includes('usps')) return 'USPS';
|
||
if (method.includes('fedex')) return 'FedEx';
|
||
if (method.includes('ups')) return 'UPS';
|
||
return 'Standard';
|
||
};
|
||
|
||
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 EmptyState = () => (
|
||
<div className="h-full flex flex-col items-center justify-center py-16 px-4">
|
||
<div className="bg-gray-100 dark:bg-gray-800 rounded-full p-3 mb-4">
|
||
<Activity className="h-8 w-8 text-gray-400 dark:text-gray-500" />
|
||
</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-gray-500 dark:text-gray-400 text-center max-w-sm">
|
||
Recent activity will appear here as it happens
|
||
</p>
|
||
</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 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.RushFee > 0 && (
|
||
<div className="flex justify-between">
|
||
<span className="cursor-help">Rush Fee</span>
|
||
<span>{formatCurrency(details.RushFee)}</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>
|
||
|
||
{details.Payments?.length > 0 && (
|
||
<div className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
|
||
<h4 className="text-sm font-medium text-gray-500 mb-2 cursor-help">
|
||
Payment Details
|
||
</h4>
|
||
<div className="space-y-1">
|
||
{details.Payments.map(([method, amount], index) => (
|
||
<div key={index} className="flex justify-between text-sm">
|
||
<span className="cursor-help">{method}</span>
|
||
<span>{formatCurrency(amount)}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</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 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>
|
||
)}
|
||
{details.PaymentsHasCustomerCredit && (
|
||
<span className="px-2 py-1 bg-teal-100 dark:bg-teal-900/20 text-teal-800 dark:text-teal-300 rounded-full text-xs cursor-help">
|
||
Store Credit Used
|
||
</span>
|
||
)}
|
||
{details.WasHeld && (
|
||
<span className="px-2 py-1 bg-orange-100 dark:bg-orange-900/20 text-orange-800 dark:text-orange-300 rounded-full text-xs cursor-help">
|
||
Was On Hold
|
||
</span>
|
||
)}
|
||
{details.IsShorted && (
|
||
<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">
|
||
Partial Shipment
|
||
</span>
|
||
)}
|
||
</div>
|
||
);
|
||
|
||
const EventFeed = () => {
|
||
const [events, setEvents] = useState([]);
|
||
const [selectedEvent, setSelectedEvent] = useState(null);
|
||
const [isLoading, setIsLoading] = useState(true);
|
||
const [error, setError] = useState(null);
|
||
const [lastUpdate, setLastUpdate] = useState(null);
|
||
|
||
// Updated fetchEvents function to handle a simpler response
|
||
const fetchEvents = async () => {
|
||
try {
|
||
setIsLoading(true);
|
||
const response = await fetch("/api/klaviyo/events/feed");
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`Failed to fetch events: ${response.status}`);
|
||
}
|
||
|
||
const data = await response.json();
|
||
|
||
// Handle both possible response formats
|
||
const eventData = data.data || data;
|
||
|
||
if (!Array.isArray(eventData)) {
|
||
throw new Error('Invalid event data format - expected an array');
|
||
}
|
||
|
||
// Process the events
|
||
const processedEvents = eventData
|
||
.filter(event => event && event.type && event.timestamp) // Filter out invalid events
|
||
.map(event => ({
|
||
id: event.id,
|
||
type: event.type.toLowerCase(),
|
||
timestamp: DateTime.fromISO(event.timestamp),
|
||
details: {
|
||
OrderId: event.details?.OrderId || event.details?.FromOrder || 'Unknown',
|
||
ShippingName: event.details?.ShippingName || event.details?.EmailAddress || 'Unknown Customer',
|
||
EmailAddress: event.details?.EmailAddress || '',
|
||
TotalAmount: parseFloat(event.details?.TotalAmount || event.details?.PaymentAmount) || 0,
|
||
Items: Array.isArray(event.details?.Items) ? event.details.Items.map(item => ({
|
||
...item,
|
||
ItemPrice: parseFloat(item.ItemPrice) || 0,
|
||
Quantity: parseInt(item.Quantity) || 1,
|
||
RowTotal: parseFloat(item.RowTotal) || parseFloat(item.ItemPrice) * (parseInt(item.Quantity) || 1)
|
||
})) : [],
|
||
HasPreorder: event.details?.HasPreorder || event.details?.Items?.some(item => item.ItemStatus === 'Pre-Order'),
|
||
LocalPickup: !!event.details?.LocalPickup,
|
||
IsOnHold: !!event.details?.IsOnHold,
|
||
TrackingNumber: event.details?.TrackingNumber,
|
||
ShipMethod: event.details?.ShipMethod,
|
||
PaymentMethod: event.details?.PaymentMethod,
|
||
...event.details
|
||
}
|
||
}))
|
||
.sort((a, b) => b.timestamp - a.timestamp);
|
||
|
||
setEvents(processedEvents);
|
||
setLastUpdate(DateTime.now());
|
||
setError(null);
|
||
} catch (err) {
|
||
console.error("[EVENT FEED] Error:", err);
|
||
setError(err.message);
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
fetchEvents();
|
||
const interval = setInterval(fetchEvents, 60000);
|
||
return () => clearInterval(interval);
|
||
}, []);
|
||
|
||
const getEventIcon = (type) => {
|
||
switch (type) {
|
||
case "placed_order":
|
||
return <ShoppingCart className="h-5 w-5" />;
|
||
case "shipped_order":
|
||
return <Package className="h-5 w-5" />;
|
||
case "account_created":
|
||
return <UserPlus className="h-5 w-5" />;
|
||
case "canceled_order":
|
||
return <XCircle className="h-5 w-5" />;
|
||
case "payment_refunded":
|
||
return <DollarSign className="h-5 w-5" />;
|
||
case "new_blog_post":
|
||
return <FileText className="h-5 w-5" />;
|
||
default:
|
||
return <AlertCircle className="h-5 w-5" />;
|
||
}
|
||
};
|
||
|
||
const getEventColor = (type) => {
|
||
switch (type) {
|
||
case "placed_order":
|
||
return "bg-green-500";
|
||
case "shipped_order":
|
||
return "bg-purple-500";
|
||
case "account_created":
|
||
return "bg-blue-500";
|
||
case "canceled_order":
|
||
return "bg-red-500";
|
||
case "payment_refunded":
|
||
return "bg-orange-500";
|
||
case "new_blog_post":
|
||
return "bg-yellow-500";
|
||
default:
|
||
return "bg-gray-500";
|
||
}
|
||
};
|
||
|
||
const getEventTitle = (type) => {
|
||
switch (type) {
|
||
case "placed_order":
|
||
return "Order Placed";
|
||
case "shipped_order":
|
||
return "Order Shipped";
|
||
case "account_created":
|
||
return "Account Created";
|
||
case "canceled_order":
|
||
return "Order Canceled";
|
||
case "payment_refunded":
|
||
return "Payment Refunded";
|
||
case "new_blog_post":
|
||
return "New Blog Post";
|
||
default:
|
||
return "Event";
|
||
}
|
||
};
|
||
|
||
const ShipmentStatus = ({ status, quantity }) => {
|
||
const getStatusInfo = (status) => {
|
||
switch (status?.toLowerCase()) {
|
||
case "shipped":
|
||
return {
|
||
color: "bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400",
|
||
};
|
||
case "partial":
|
||
return {
|
||
color: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-400",
|
||
};
|
||
case "pre-order":
|
||
return {
|
||
color: "bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-400",
|
||
};
|
||
case "refunded":
|
||
return {
|
||
color: "bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400",
|
||
};
|
||
default:
|
||
return {
|
||
color: "bg-gray-100 text-gray-800 dark:bg-gray-900/20 dark:text-gray-400",
|
||
};
|
||
}
|
||
};
|
||
|
||
const statusInfo = getStatusInfo(status);
|
||
|
||
return (
|
||
<span className={`px-2 py-1 rounded-full text-xs cursor-help ${statusInfo.color}`}>
|
||
{status} {quantity > 1 && `(${quantity})`}
|
||
</span>
|
||
);
|
||
};
|
||
|
||
const renderEventInlineInfo = useCallback((event) => {
|
||
const details = event.details || {};
|
||
switch (event.type) {
|
||
case 'placed_order':
|
||
return (
|
||
<div className="space-y-2">
|
||
<div className="flex items-center">
|
||
<span className="font-medium">{details.ShippingName}</span>
|
||
<span className="mx-2">•</span>
|
||
<span className="text-gray-500">#{details.OrderId}</span>
|
||
</div>
|
||
<div className="flex items-center text-sm">
|
||
<span className="font-medium text-green-600">
|
||
{formatCurrency(details.TotalAmount)}
|
||
</span>
|
||
<span className="mx-2">•</span>
|
||
<span>{details.Items?.length || 0} items</span>
|
||
</div>
|
||
<OrderStatusTags details={details} />
|
||
</div>
|
||
);
|
||
case 'shipped_order':
|
||
return (
|
||
<>
|
||
<div className="flex items-center">
|
||
<span className="font-medium">{details.ShippingName}</span>
|
||
<span className="mx-2">•</span>
|
||
<span className="text-gray-500">#{details.OrderId}</span>
|
||
</div>
|
||
<div className="flex items-center text-sm">
|
||
<span>{formatShipMethodSimple(details.ShipMethod)}</span>
|
||
{details.TrackingNumber && (
|
||
<>
|
||
<span className="mx-2">•</span>
|
||
<span className="font-mono">{details.TrackingNumber}</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
</>
|
||
);
|
||
case "account_created":
|
||
return (
|
||
<>
|
||
<div className="font-medium">
|
||
{details.FirstName || ''} {details.LastName || ''}
|
||
</div>
|
||
<div className="text-sm text-gray-500">{details.EmailAddress}</div>
|
||
</>
|
||
);
|
||
case "canceled_order":
|
||
return (
|
||
<>
|
||
<div className="flex items-center">
|
||
<span className="font-medium">{details.ShippingName}</span>
|
||
<span className="mx-2">•</span>
|
||
<span className="text-gray-500">#{details.OrderId}</span>
|
||
</div>
|
||
<div className="text-sm text-red-500">
|
||
{details.CancelReason || details.CancelMessage || "No reason provided"}
|
||
</div>
|
||
</>
|
||
);
|
||
case "payment_refunded":
|
||
return (
|
||
<>
|
||
<div className="flex items-center">
|
||
<span className="font-medium">
|
||
{details.FirstName || ''} {details.LastName || ''}
|
||
</span>
|
||
<span className="mx-2">•</span>
|
||
<span className="text-gray-500">Order #{details.FromOrder}</span>
|
||
</div>
|
||
<div className="flex items-center text-sm">
|
||
<span className="font-medium text-orange-600">
|
||
{formatCurrency(details.PaymentAmount)}
|
||
</span>
|
||
<span className="mx-2">•</span>
|
||
<span>{details.PaymentName}</span>
|
||
</div>
|
||
</>
|
||
);
|
||
case "new_blog_post":
|
||
return (
|
||
<>
|
||
<div className="font-medium">{details.title}</div>
|
||
<div className="text-sm text-gray-500 line-clamp-1">
|
||
{details.description}
|
||
</div>
|
||
</>
|
||
);
|
||
default:
|
||
return (
|
||
<div className="text-sm text-gray-500">No details available</div>
|
||
);
|
||
}
|
||
}, []);
|
||
|
||
const renderEventDetails = (event) => {
|
||
const details = event.details || {};
|
||
|
||
switch (event.type) {
|
||
case 'placed_order':
|
||
return (
|
||
<div className="space-y-6">
|
||
<OrderStatusTags details={details} />
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||
<ShippingInfo details={details} />
|
||
|
||
{details.BillingName && details.BillingName !== details.ShippingName && (
|
||
<div className="space-y-2">
|
||
<h4 className="text-sm font-medium text-gray-500">Billing Address</h4>
|
||
<div className="text-sm space-y-1">
|
||
<div className="font-medium">{details.BillingName}</div>
|
||
<div>{details.BillingStreet1}</div>
|
||
{details.BillingStreet2 && <div>{details.BillingStreet2}</div>}
|
||
<div>
|
||
{details.BillingCity}, {details.BillingState} {details.BillingZip}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{details.Items?.length > 0 && (
|
||
<div>
|
||
<h4 className="text-sm font-medium text-gray-500 mb-3">
|
||
Order Items ({details.Items.length})
|
||
</h4>
|
||
<div className="space-y-2 max-h-[400px] overflow-y-auto">
|
||
{details.Items.map((item, index) => (
|
||
<div key={index} className="flex items-start space-x-4 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||
{item.ImgThumb && (
|
||
<div className="w-20 h-20 flex-shrink-0">
|
||
<img
|
||
src={item.ImgThumb}
|
||
alt={item.ProductName}
|
||
className="w-full h-full object-cover rounded-lg border border-gray-200 dark:border-gray-600"
|
||
/>
|
||
</div>
|
||
)}
|
||
<div className="flex-grow min-w-0">
|
||
<div className="flex items-center space-x-2">
|
||
<h4 className="font-medium text-sm">{item.ProductName}</h4>
|
||
{item.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">
|
||
Pre-order {item.ItemEta && `(ETA: ${item.ItemEta})`}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="mt-1 text-sm text-gray-500">
|
||
<div>SKU: {item.SKU}</div>
|
||
{item.Brand && <div>Brand: {item.Brand}</div>}
|
||
<div className="mt-2">
|
||
Qty: {item.Quantity} × {formatCurrency(item.ItemPrice)}
|
||
</div>
|
||
</div>
|
||
{item.Categories?.length > 0 && (
|
||
<div className="mt-2 flex flex-wrap gap-1">
|
||
{item.Categories.map((category, idx) => (
|
||
<span
|
||
key={idx}
|
||
className="px-2 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 rounded text-xs"
|
||
>
|
||
{category}
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="text-right flex-shrink-0">
|
||
<div className="font-medium">
|
||
{formatCurrency(item.RowTotal)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<OrderSummary details={details} />
|
||
</div>
|
||
);
|
||
|
||
case "shipped_order":
|
||
return (
|
||
<div className="space-y-6">
|
||
<div className="bg-purple-50 dark:bg-purple-900/20 p-4 rounded-lg">
|
||
<div className="flex items-center space-x-3">
|
||
<Package className="h-6 w-6 text-purple-500" />
|
||
<div>
|
||
<h3 className="font-medium">Shipment Details</h3>
|
||
<p className="text-sm text-gray-500">
|
||
Shipped via {formatShipMethod(details.ShipMethod)}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
{details.TrackingNumber && (
|
||
<div className="mt-3 text-sm">
|
||
<span className="text-gray-500">Tracking Number: </span>
|
||
<span className="font-mono font-medium">
|
||
{details.TrackingNumber}
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<ShippingInfo details={details} />
|
||
|
||
{details.Items?.length > 0 && (
|
||
<div>
|
||
<h4 className="text-sm font-medium text-gray-500 mb-3">
|
||
Shipped Items ({details.Items.length})
|
||
</h4>
|
||
<div className="space-y-2 max-h-[400px] overflow-y-auto">
|
||
{details.Items.map((item, index) => (
|
||
<ProductCard
|
||
key={index}
|
||
product={{
|
||
...item,
|
||
Quantity: item.QuantitySent,
|
||
Status:
|
||
item.QuantityBackordered > 0 ? "Partial" : "Shipped",
|
||
}}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<OrderSummary details={details} />
|
||
</div>
|
||
);
|
||
|
||
case "account_created":
|
||
return (
|
||
<div className="space-y-6">
|
||
<div className="bg-blue-50 dark:bg-blue-900/20 p-6 rounded-lg">
|
||
<div className="flex items-center justify-center space-x-4">
|
||
<div className="bg-blue-100 dark:bg-blue-900/50 p-3 rounded-full">
|
||
<UserPlus className="h-8 w-8 text-blue-500" />
|
||
</div>
|
||
<div className="text-center">
|
||
<h3 className="text-lg font-medium">
|
||
{details.FirstName} {details.LastName}
|
||
</h3>
|
||
<p className="text-gray-500">{details.EmailAddress}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
case "canceled_order":
|
||
return (
|
||
<div className="space-y-6">
|
||
<div className="bg-red-50 dark:bg-red-900/20 p-4 rounded-lg">
|
||
<div className="flex items-center space-x-3">
|
||
<XCircle className="h-6 w-6 text-red-500" />
|
||
<div>
|
||
<h3 className="font-medium">Cancellation Details</h3>
|
||
{(details.CancelReason || details.CancelMessage) && (
|
||
<p className="text-sm text-red-600 mt-1">
|
||
{details.CancelReason || details.CancelMessage}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{details.Items?.length > 0 && (
|
||
<div>
|
||
<h4 className="text-sm font-medium text-gray-500 mb-3">
|
||
Canceled Items ({details.Items.length})
|
||
</h4>
|
||
<div className="space-y-2 max-h-[400px] overflow-y-auto">
|
||
{details.Items.map((item, index) => (
|
||
<ProductCard
|
||
key={index}
|
||
product={{
|
||
...item,
|
||
Quantity: item.QuantityHadOrdered,
|
||
}}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<OrderSummary details={details} />
|
||
</div>
|
||
);
|
||
|
||
case "payment_refunded":
|
||
return (
|
||
<div className="space-y-6">
|
||
<div className="bg-orange-50 dark:bg-orange-900/20 p-4 rounded-lg">
|
||
<div className="flex items-center space-x-3">
|
||
<RefreshCcw className="h-6 w-6 text-orange-500" />
|
||
<div className="flex-1">
|
||
<h3 className="font-medium">Refund Details</h3>
|
||
<div className="mt-2 space-y-2">
|
||
<div className="flex justify-between text-sm">
|
||
<span className="text-gray-500">Customer</span>
|
||
<span className="font-medium">
|
||
{details.FirstName} {details.LastName}
|
||
</span>
|
||
</div>
|
||
<div className="flex justify-between text-sm">
|
||
<span className="text-gray-500">Original Order</span>
|
||
<span className="font-medium">#{details.FromOrder}</span>
|
||
</div>
|
||
<div className="flex justify-between text-sm">
|
||
<span className="text-gray-500">Payment Method</span>
|
||
<span className="font-medium">{details.PaymentName}</span>
|
||
</div>
|
||
<div className="flex justify-between text-sm">
|
||
<span className="text-gray-500">Refund Amount</span>
|
||
<span className="font-medium text-orange-600">
|
||
{formatCurrency(details.PaymentAmount)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{details.OrderMessage && (
|
||
<div className="bg-gray-50 dark:bg-gray-800 p-4 rounded-lg">
|
||
<h4 className="text-sm font-medium text-gray-500 mb-2">
|
||
Refund Reason
|
||
</h4>
|
||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||
{details.OrderMessage}
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{details.EmailAddress && (
|
||
<div className="text-sm text-gray-500">
|
||
<span className="mr-2">Email:</span>
|
||
<span className="font-medium">{details.EmailAddress}</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* If there are refunded items */}
|
||
{details.RefundedItems?.length > 0 && (
|
||
<div>
|
||
<h4 className="text-sm font-medium text-gray-500 mb-3">
|
||
Refunded Items ({details.RefundedItems.length})
|
||
</h4>
|
||
<div className="space-y-2 max-h-[400px] overflow-y-auto">
|
||
{details.RefundedItems.map((item, index) => (
|
||
<ProductCard
|
||
key={index}
|
||
product={{
|
||
...item,
|
||
Status: "Refunded",
|
||
}}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
|
||
case "new_blog_post":
|
||
return (
|
||
<div className="space-y-6">
|
||
<div className="bg-yellow-50 dark:bg-yellow-900/20 p-6 rounded-lg">
|
||
<div className="flex flex-col items-center text-center">
|
||
<div className="bg-yellow-100 dark:bg-yellow-900/50 p-3 rounded-full mb-4">
|
||
<FileText className="h-8 w-8 text-yellow-600" />
|
||
</div>
|
||
<h3 className="text-xl font-medium mb-2">{details.title}</h3>
|
||
<div className="prose dark:prose-invert max-w-none">
|
||
{details.description && (
|
||
<div
|
||
className="text-gray-600 dark:text-gray-300"
|
||
dangerouslySetInnerHTML={{
|
||
__html: details.description.replace(/\n/g, "<br />"),
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
default:
|
||
return (
|
||
<div className="bg-gray-50 dark:bg-gray-800 p-4 rounded-lg">
|
||
<pre className="overflow-auto text-sm">
|
||
{JSON.stringify(details, null, 2)}
|
||
</pre>
|
||
</div>
|
||
);
|
||
}
|
||
};
|
||
|
||
|
||
|
||
const renderContent = useMemo(() => {
|
||
if (isLoading && events.length === 0) {
|
||
return <LoadingState />;
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<div className="flex flex-col items-center justify-center p-6 text-center">
|
||
<AlertCircle className="w-12 h-12 text-red-500 mb-4" />
|
||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
|
||
Error Loading Feed
|
||
</h3>
|
||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||
{error}
|
||
</p>
|
||
<button
|
||
onClick={fetchEvents}
|
||
className="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors"
|
||
>
|
||
Try Again
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!events || events.length === 0) {
|
||
return <EmptyState />;
|
||
}
|
||
|
||
return (
|
||
<div className="max-h-[500px] md:max-h-[887px] lg:max-h-[610px] xl:max-h-[420px] overflow-auto scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent">
|
||
{events.map((event) => (
|
||
<div
|
||
key={event.id}
|
||
className="flex items-center space-x-4 pt-4 pb-4 hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer border-b dark:border-gray-800 last:border-b-0 transition-colors px-4"
|
||
onClick={() => setSelectedEvent(event)}
|
||
>
|
||
<div className={`rounded-full p-2 ${getEventColor(event.type)}`}>
|
||
{getEventIcon(event.type)}
|
||
</div>
|
||
<div className="flex-1 min-w-0">
|
||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||
{getEventTitle(event.type)}
|
||
</p>
|
||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||
{renderEventInlineInfo(event)}
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center">
|
||
<span className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||
{event.timestamp instanceof DateTime
|
||
? event.timestamp.toFormat("h:mm a")
|
||
: DateTime.fromISO(event.timestamp).toFormat("h:mm a")}
|
||
</span>
|
||
<ChevronRight className="h-5 w-5 text-gray-400 dark:text-gray-600 ml-2" />
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}, [events, isLoading, error, renderEventInlineInfo]);
|
||
|
||
return (
|
||
<>
|
||
<Card className="bg-white dark:bg-gray-900 p-6">
|
||
<CardHeader className="p-0 pb-4">
|
||
<div className="flex justify-between items-center">
|
||
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||
Activity Feed
|
||
</CardTitle>
|
||
{lastUpdate && (
|
||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||
Last updated: {lastUpdate.toFormat("hh:mm a")}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="p-0">{renderContent}</CardContent>
|
||
</Card>
|
||
|
||
{selectedEvent && (
|
||
<Dialog
|
||
open={!!selectedEvent}
|
||
onOpenChange={() => setSelectedEvent(null)}
|
||
>
|
||
<DialogContent className="max-w-2xl p-6 bg-white dark:bg-gray-900 max-h-[90vh] flex flex-col">
|
||
<DialogHeader className="flex-shrink-0">
|
||
<div className="flex items-center space-x-3">
|
||
<div className={`rounded-full p-2 ${getEventColor(selectedEvent.type)}`}>
|
||
{getEventIcon(selectedEvent.type)}
|
||
</div>
|
||
<div>
|
||
<DialogTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||
{getEventTitle(selectedEvent.type)}
|
||
</DialogTitle>
|
||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||
{selectedEvent.timestamp.toFormat('MMMM d, yyyy - h:mm a')}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</DialogHeader>
|
||
<div className="mt-6 overflow-y-auto flex-grow pr-2">
|
||
{renderEventDetails(selectedEvent)}
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
)}
|
||
</>
|
||
);
|
||
};
|
||
|
||
export default EventFeed; |