Some checks failed
Build Frontend / build (push) Failing after 7s
Replaces imports from 'components/ui' with 'components/common' across the app and dashboard pages, and updates model and API imports to use new paths under 'lib'. Removes redundant authentication checks from several dashboard pages. Adds new dashboard components and utility files, and reorganizes hooks and services into the 'lib' directory for improved structure.
304 lines
10 KiB
TypeScript
304 lines
10 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect, useCallback, useRef } from "react";
|
|
import { Package, ShoppingBag, Info, ExternalLink, Loader2, AlertTriangle } from "lucide-react";
|
|
import { Badge } from "@/components/common/badge";
|
|
import { Button } from "@/components/common/button";
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipProvider,
|
|
TooltipTrigger,
|
|
} from "@/components/common/tooltip";
|
|
import { getCookie } from "@/lib/api";
|
|
import axios from "axios";
|
|
import { useRouter } from "next/navigation";
|
|
import { cacheUtils } from "@/lib/api/api-client";
|
|
|
|
interface Order {
|
|
_id: string;
|
|
orderId: number;
|
|
status: string;
|
|
totalPrice: number;
|
|
orderDate: string;
|
|
products: Array<{
|
|
productId: string;
|
|
quantity: number;
|
|
pricePerUnit: number;
|
|
totalItemPrice: number;
|
|
}>;
|
|
underpaid?: boolean;
|
|
underpaymentAmount?: number;
|
|
lastBalanceReceived?: number;
|
|
cryptoTotal?: number;
|
|
}
|
|
|
|
interface BuyerOrderInfoProps {
|
|
buyerId: string;
|
|
chatId: string;
|
|
}
|
|
|
|
export default function BuyerOrderInfo({ buyerId, chatId }: BuyerOrderInfoProps) {
|
|
const router = useRouter();
|
|
const [loading, setLoading] = useState(false);
|
|
const [orders, setOrders] = useState<Order[]>([]);
|
|
const [hasOrders, setHasOrders] = useState<boolean | null>(null);
|
|
const [isTooltipOpen, setIsTooltipOpen] = useState(false);
|
|
const lastFetchedRef = useRef<number>(0);
|
|
const isFetchingRef = useRef<boolean>(false);
|
|
const tooltipDelayRef = useRef<NodeJS.Timeout | null>(null);
|
|
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
|
|
|
// Add order refresh subscription
|
|
useEffect(() => {
|
|
const unsubscribe = cacheUtils.onOrderRefresh(() => {
|
|
console.log("Order refresh triggered in BuyerOrderInfo");
|
|
setRefreshTrigger(prev => prev + 1);
|
|
});
|
|
|
|
return unsubscribe;
|
|
}, []);
|
|
|
|
// Fetch data without unnecessary dependencies to reduce render cycles
|
|
const fetchBuyerOrders = useCallback(async (force = false) => {
|
|
// Prevent multiple simultaneous fetches
|
|
if (isFetchingRef.current) return;
|
|
|
|
// Don't fetch if we already know there are no orders
|
|
if (hasOrders === false && !force) return;
|
|
|
|
// Don't fetch if we already have orders and data was fetched less than 10 seconds ago
|
|
const now = Date.now();
|
|
if (!force && !refreshTrigger && orders.length > 0 && now - lastFetchedRef.current < 10000) return;
|
|
|
|
// Only continue if we have a chatId
|
|
if (!chatId) return;
|
|
|
|
isFetchingRef.current = true;
|
|
setLoading(true);
|
|
|
|
try {
|
|
const authToken = getCookie("Authorization");
|
|
|
|
if (!authToken) {
|
|
console.error("No auth token found for buyer orders");
|
|
setHasOrders(false);
|
|
return;
|
|
}
|
|
|
|
const authAxios = axios.create({
|
|
baseURL: process.env.NEXT_PUBLIC_API_URL,
|
|
headers: {
|
|
Authorization: `Bearer ${authToken}`
|
|
}
|
|
});
|
|
|
|
// Use the new endpoint that works with sub-users
|
|
const response = await authAxios.get(`/chats/${chatId}/orders?limit=10`); // Limit to fewer orders for faster response
|
|
|
|
if (response.data && response.data.orders) {
|
|
setOrders(response.data.orders);
|
|
setHasOrders(response.data.orders.length > 0);
|
|
} else {
|
|
setHasOrders(false);
|
|
}
|
|
|
|
lastFetchedRef.current = Date.now();
|
|
} catch (error: any) {
|
|
console.error("Error fetching buyer orders:", error);
|
|
|
|
if (error.response?.status === 404) {
|
|
console.log("No orders found for this buyer");
|
|
setOrders([]);
|
|
setHasOrders(false);
|
|
} else {
|
|
console.error("API error:", error.response?.data || error.message);
|
|
setHasOrders(null);
|
|
}
|
|
} finally {
|
|
setLoading(false);
|
|
isFetchingRef.current = false;
|
|
}
|
|
}, [chatId, refreshTrigger]); // Add refreshTrigger as dependency
|
|
|
|
// Start fetching immediately when component mounts
|
|
useEffect(() => {
|
|
if (chatId) {
|
|
// Immediately attempt to fetch in the background
|
|
fetchBuyerOrders();
|
|
}
|
|
|
|
return () => {
|
|
// Clean up any pending timeouts
|
|
if (tooltipDelayRef.current) {
|
|
clearTimeout(tooltipDelayRef.current);
|
|
}
|
|
};
|
|
}, [chatId, fetchBuyerOrders]);
|
|
|
|
const handleViewOrder = (orderId: string) => {
|
|
router.push(`/dashboard/orders/${orderId}`);
|
|
};
|
|
|
|
// Handle hover with immediate tooltip opening
|
|
const handleButtonMouseEnter = () => {
|
|
// Start fetching data, but don't wait for it to complete
|
|
if (!isFetchingRef.current) {
|
|
queueMicrotask(() => {
|
|
fetchBuyerOrders();
|
|
});
|
|
}
|
|
};
|
|
|
|
// Handle tooltip state change
|
|
const handleTooltipOpenChange = (open: boolean) => {
|
|
setIsTooltipOpen(open);
|
|
if (open && !isFetchingRef.current) {
|
|
queueMicrotask(() => {
|
|
fetchBuyerOrders();
|
|
});
|
|
}
|
|
};
|
|
|
|
// Format the price as currency
|
|
const formatPrice = (price: number) => {
|
|
return `£${price.toFixed(2)}`;
|
|
};
|
|
|
|
// Helper function to check if order is underpaid (improved)
|
|
const isOrderUnderpaid = (order: Order) => {
|
|
return order.underpaid === true &&
|
|
order.underpaymentAmount &&
|
|
order.underpaymentAmount > 0 &&
|
|
order.status !== "paid" &&
|
|
order.status !== "completed" &&
|
|
order.status !== "shipped" &&
|
|
order.status !== "cancelled";
|
|
};
|
|
|
|
// Helper function to get underpaid percentage
|
|
const getUnderpaidPercentage = (order: Order) => {
|
|
if (!isOrderUnderpaid(order)) return 0;
|
|
|
|
const received = order.lastBalanceReceived || 0;
|
|
const required = order.cryptoTotal || 0;
|
|
|
|
return required > 0 ? ((received / required) * 100) : 0;
|
|
};
|
|
|
|
// If we know there are no orders, don't show the component at all
|
|
if (hasOrders === false) {
|
|
return null;
|
|
}
|
|
|
|
// Precompute product count for button display (only if we have orders)
|
|
const productCount = orders.length > 0
|
|
? orders.reduce((total, order) => {
|
|
return total + order.products.reduce((sum, product) => sum + product.quantity, 0);
|
|
}, 0)
|
|
: 0;
|
|
|
|
return (
|
|
<TooltipProvider>
|
|
<Tooltip onOpenChange={handleTooltipOpenChange}>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="ml-2 flex items-center gap-1.5"
|
|
onMouseEnter={handleButtonMouseEnter}
|
|
>
|
|
<ShoppingBag className="h-4 w-4" />
|
|
<span className="text-xs font-medium">
|
|
{orders.length > 0 ? `${orders.length} Orders` : "Orders"}
|
|
</span>
|
|
{productCount > 0 && (
|
|
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 ml-1">
|
|
{productCount} items
|
|
</Badge>
|
|
)}
|
|
{orders.some(order => isOrderUnderpaid(order)) && (
|
|
<AlertTriangle className="h-3 w-3 text-red-500" />
|
|
)}
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="left" className="p-0 max-w-xs">
|
|
{loading ? (
|
|
<div className="p-3 flex items-center gap-2">
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
<span className="text-sm">Loading orders...</span>
|
|
</div>
|
|
) : orders.length === 0 ? (
|
|
<div className="p-3">
|
|
<span className="text-sm text-muted-foreground">No orders found</span>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="p-3 border-b">
|
|
<h4 className="font-medium text-sm">Customer Orders</h4>
|
|
<p className="text-xs text-muted-foreground">
|
|
{orders.length} {orders.length === 1 ? 'order' : 'orders'} • Total: {formatPrice(
|
|
orders.reduce((sum, order) => sum + order.totalPrice, 0)
|
|
)}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="max-h-64 overflow-y-auto divide-y divide-border">
|
|
{orders.map((order) => (
|
|
<div
|
|
key={order._id}
|
|
className="px-3 py-2 hover:bg-accent/50 cursor-pointer"
|
|
onClick={() => handleViewOrder(order._id)}
|
|
>
|
|
<div className="flex items-center justify-between mb-1">
|
|
<div className="flex items-center gap-2">
|
|
<Package className="h-3.5 w-3.5" />
|
|
<span className="text-xs font-medium">Order #{order.orderId}</span>
|
|
{isOrderUnderpaid(order) && (
|
|
<AlertTriangle className="h-3 w-3 text-red-500" />
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<Badge variant={
|
|
order.status === "paid" ? "paid" :
|
|
order.status === "unpaid" ? "unpaid" :
|
|
order.status === "shipped" ? "shipped" :
|
|
order.status === "completed" ? "completed" :
|
|
"secondary"
|
|
} className="text-[10px] h-5 px-1.5">
|
|
{order.status.toUpperCase()}
|
|
</Badge>
|
|
{isOrderUnderpaid(order) && (
|
|
<Badge variant="destructive" className="text-[10px] h-5 px-1.5">
|
|
{getUnderpaidPercentage(order).toFixed(0)}% PAID
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-between items-center text-xs text-muted-foreground pl-5">
|
|
<div className="flex gap-1">
|
|
<span>{order.products.length} {order.products.length === 1 ? 'product' : 'products'}</span>
|
|
<span>·</span>
|
|
<span>{formatPrice(order.totalPrice)}</span>
|
|
{isOrderUnderpaid(order) && (
|
|
<>
|
|
<span>·</span>
|
|
<span className="text-red-500">Underpaid</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
<span className="text-[10px]">{new Date(order.orderDate).toLocaleDateString()}</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
);
|
|
}
|
|
|