1207 lines
44 KiB
TypeScript
1207 lines
44 KiB
TypeScript
"use client";
|
|
|
|
import { fetchData } from '@/lib/api';
|
|
import { clientFetch } from '@/lib/api';
|
|
import { useEffect, useState } from "react";
|
|
import { useParams } from "next/navigation";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "@/components/ui/card";
|
|
import { Clipboard, Truck, Package, ArrowRight, ChevronDown, AlertTriangle, Copy, Loader2, RefreshCw, MessageCircle } from "lucide-react";
|
|
import Link from "next/link";
|
|
import { useRouter } from "next/navigation";
|
|
import { toast } from "sonner";
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
AlertDialogTrigger,
|
|
} from "@/components/ui/alert-dialog";
|
|
import Layout from "@/components/layout/layout";
|
|
import { cacheUtils } from '@/lib/api-client';
|
|
|
|
interface Order {
|
|
orderId: string;
|
|
status: string;
|
|
pgpAddress: string;
|
|
shippingMethod: { type: string; price: number };
|
|
txid: Array<string>;
|
|
products: Array<{
|
|
_id: string;
|
|
productId: string;
|
|
quantity: number;
|
|
pricePerUnit: number;
|
|
totalItemPrice: number;
|
|
}>;
|
|
totalPrice: number;
|
|
orderDate: Date;
|
|
paidAt?: Date;
|
|
trackingNumber?: string;
|
|
telegramUsername?: string;
|
|
telegramBuyerId?: string;
|
|
review?: {
|
|
text: string;
|
|
date: string;
|
|
stars: number;
|
|
_id: string;
|
|
};
|
|
underpaid?: boolean;
|
|
underpaymentAmount?: number;
|
|
lastBalanceReceived?: number;
|
|
cryptoTotal?: number;
|
|
paymentAddress?: string;
|
|
// Promotion fields
|
|
promotion?: string;
|
|
promotionCode?: string;
|
|
discountAmount?: number;
|
|
subtotalBeforeDiscount?: number;
|
|
}
|
|
|
|
interface CustomerInsights {
|
|
totalOrders: number;
|
|
completedOrders: number;
|
|
cancelledOrders: number;
|
|
paidOrders: number;
|
|
totalSpent: number;
|
|
averageOrderValue: number;
|
|
completionRate: number;
|
|
paymentSuccessRate: number;
|
|
cancellationRate: number;
|
|
firstOrder: string | null;
|
|
lastOrder: string | null;
|
|
customerSince: number; // days since first order
|
|
}
|
|
|
|
interface OrderResponse {
|
|
order: Order;
|
|
customerInsights: CustomerInsights;
|
|
}
|
|
|
|
interface OrderInList extends Order {
|
|
_id: string;
|
|
}
|
|
|
|
interface OrdersResponse {
|
|
orders: OrderInList[];
|
|
page: number;
|
|
totalPages: number;
|
|
totalOrders: number;
|
|
}
|
|
|
|
type ProductNames = Record<string, string>;
|
|
type NavigationInfo = {
|
|
nextOrderId: string | null;
|
|
prevOrderId: string | null;
|
|
totalOrders: number;
|
|
currentOrderNumber: number;
|
|
totalPages: number;
|
|
currentPage: number;
|
|
};
|
|
|
|
const getStatusStyle = (status: string) => {
|
|
switch (status) {
|
|
case 'acknowledged':
|
|
return 'bg-purple-500/10 text-purple-500 border-purple-500/20';
|
|
case 'paid':
|
|
return 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20';
|
|
case 'shipped':
|
|
return 'bg-blue-500/10 text-blue-500 border-blue-500/20';
|
|
case 'completed':
|
|
return 'bg-green-500/10 text-green-500 border-green-500/20';
|
|
case 'cancelled':
|
|
return 'bg-red-500/10 text-red-500 border-red-500/20';
|
|
case 'unpaid':
|
|
return 'bg-yellow-500/10 text-yellow-500 border-yellow-500/20';
|
|
case 'confirming':
|
|
return 'bg-orange-500/10 text-orange-500 border-orange-500/20';
|
|
default:
|
|
return 'bg-gray-500/10 text-gray-500 border-gray-500/20';
|
|
}
|
|
};
|
|
|
|
export default function OrderDetailsPage() {
|
|
const [order, setOrder] = useState<Order | null>(null);
|
|
const [customerInsights, setCustomerInsights] = useState<CustomerInsights | null>(null);
|
|
const [trackingNumber, setTrackingNumber] = useState("");
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState("");
|
|
const [productNames, setProductNames] = useState<Record<string, string>>({});
|
|
const [isPaid, setIsPaid] = useState(false);
|
|
const [isSending, setIsSending] = useState(false);
|
|
const [isMarkingShipped, setIsMarkingShipped] = useState(false);
|
|
const [isDiscarding, setIsDiscarding] = useState(false);
|
|
const [nextOrderId, setNextOrderId] = useState<string | null>(null);
|
|
const [prevOrderId, setPrevOrderId] = useState<string | null>(null);
|
|
const [totalOrders, setTotalOrders] = useState(0);
|
|
const [currentOrderNumber, setCurrentOrderNumber] = useState(0);
|
|
const [totalPages, setTotalPages] = useState(1);
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const [isAcknowledging, setIsAcknowledging] = useState(false);
|
|
const [isCancelling, setIsCancelling] = useState(false);
|
|
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
|
// shipping dialog removed; use inline tracking input instead
|
|
|
|
const router = useRouter();
|
|
const params = useParams();
|
|
const orderId = params?.id;
|
|
|
|
const fetchProductNames = async (
|
|
productIds: string[],
|
|
authToken: string
|
|
): Promise<Record<string, string>> => {
|
|
const productNamesMap: Record<string, string> = {};
|
|
|
|
// Process each product ID independently
|
|
const fetchPromises = productIds.map(async (id) => {
|
|
try {
|
|
const product = await fetchData(`${process.env.NEXT_PUBLIC_API_URL}/products/${id}`, {
|
|
method: "GET",
|
|
headers: { Authorization: `Bearer ${authToken}` },
|
|
});
|
|
productNamesMap[id] = product?.name || "Unknown Product (Deleted)";
|
|
} catch (err) {
|
|
console.error(`Failed to fetch product ${id}:`, err);
|
|
productNamesMap[id] = "Unknown Product (Deleted)";
|
|
}
|
|
});
|
|
|
|
// Wait for all fetch operations to complete (successful or failed)
|
|
await Promise.all(fetchPromises);
|
|
|
|
return productNamesMap;
|
|
};
|
|
|
|
const handleMarkAsPaid = async () => {
|
|
try {
|
|
// Add a loading state to give feedback
|
|
const loadingToast = toast.loading("Marking order as paid...");
|
|
|
|
// Log the request for debugging
|
|
console.log(`Sending request to /orders/${orderId}/status with clientFetch`);
|
|
console.log("Request payload:", { status: "paid" });
|
|
|
|
// Use clientFetch which handles API URL and auth token automatically
|
|
const response = await clientFetch(`/orders/${orderId}/status`, {
|
|
method: "PUT",
|
|
body: JSON.stringify({ status: "paid" }),
|
|
});
|
|
|
|
// Log the response
|
|
console.log("API response:", response);
|
|
|
|
toast.dismiss(loadingToast);
|
|
|
|
if (response && response.message === "Order status updated successfully") {
|
|
// Update both states
|
|
setIsPaid(true);
|
|
setOrder((prevOrder) => (prevOrder ? {
|
|
...prevOrder,
|
|
status: "paid",
|
|
// Clear underpayment flags when marking as paid
|
|
underpaid: false,
|
|
underpaymentAmount: 0
|
|
} : null));
|
|
|
|
// Invalidate order cache to ensure fresh data everywhere
|
|
cacheUtils.invalidateOrderData(orderId as string);
|
|
|
|
toast.success("Order marked as paid successfully");
|
|
|
|
// Refresh order data to get latest status
|
|
setTimeout(() => {
|
|
setRefreshTrigger(prev => prev + 1);
|
|
}, 500);
|
|
} else {
|
|
// Handle unexpected response format
|
|
console.error("Unexpected response format:", response);
|
|
throw new Error(
|
|
response.error || response.message || "Failed to mark order as paid"
|
|
);
|
|
}
|
|
} catch (error: any) {
|
|
console.error("Failed to mark order as paid:", error);
|
|
|
|
// More detailed error handling
|
|
let errorMessage = "Failed to mark order as paid";
|
|
|
|
if (error.message) {
|
|
errorMessage += `: ${error.message}`;
|
|
}
|
|
|
|
if (error.response) {
|
|
try {
|
|
const errorData = await error.response.json();
|
|
console.error("Error response data:", errorData);
|
|
if (errorData.message) {
|
|
errorMessage = errorData.message;
|
|
}
|
|
} catch (e) {
|
|
console.error("Could not parse error response:", e);
|
|
}
|
|
}
|
|
|
|
toast.error(errorMessage);
|
|
}
|
|
};
|
|
|
|
const handleMarkAsShipped = async () => {
|
|
try {
|
|
setIsMarkingShipped(true);
|
|
|
|
// First mark as shipped (clientFetch handles API URL and auth token)
|
|
const response = await clientFetch(`/orders/${orderId}/status`, {
|
|
method: "PUT",
|
|
body: JSON.stringify({ status: "shipped" }),
|
|
});
|
|
|
|
if (response && response.message === "Order status updated successfully") {
|
|
setOrder((prevOrder) => prevOrder ? { ...prevOrder, status: "shipped" } : null);
|
|
toast.success("Order marked as shipped successfully!");
|
|
|
|
// If a tracking number is present in the inline box, add it after marking as shipped
|
|
if (trackingNumber && trackingNumber.trim()) {
|
|
await handleAddTrackingNumber(trackingNumber.trim());
|
|
setTrackingNumber("");
|
|
}
|
|
} else {
|
|
throw new Error(response.error || "Failed to mark order as shipped");
|
|
}
|
|
} catch (error: any) {
|
|
console.error("Failed to mark order as shipped:", error);
|
|
toast.error(error.message || "Failed to mark order as shipped");
|
|
} finally {
|
|
setIsMarkingShipped(false);
|
|
}
|
|
};
|
|
|
|
const handleAddTrackingNumber = async (trackingNumber: string) => {
|
|
try {
|
|
const authToken = document.cookie.split("Authorization=")[1];
|
|
|
|
const response = await fetchData(
|
|
`${process.env.NEXT_PUBLIC_API_URL}/orders/${orderId}/tracking`,
|
|
{
|
|
method: "PUT",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${authToken}`,
|
|
},
|
|
body: JSON.stringify({ trackingNumber }),
|
|
}
|
|
);
|
|
|
|
if (response.error) throw new Error(response.error);
|
|
|
|
// Update the local state
|
|
setOrder(prevOrder => prevOrder ? {
|
|
...prevOrder,
|
|
trackingNumber: trackingNumber
|
|
} : null);
|
|
|
|
toast.success("Tracking number added successfully!");
|
|
} catch (err: any) {
|
|
console.error("Failed to add tracking number:", err);
|
|
toast.error(err.message || "Failed to add tracking number");
|
|
}
|
|
};
|
|
|
|
// shipping dialog removed
|
|
|
|
const handleMarkAsAcknowledged = async () => {
|
|
try {
|
|
setIsAcknowledging(true);
|
|
|
|
// Use clientFetch which handles API URL and auth token automatically
|
|
const response = await clientFetch(`/orders/${orderId}/status`, {
|
|
method: "PUT",
|
|
body: JSON.stringify({ status: "acknowledged" }),
|
|
});
|
|
|
|
if (response && response.message === "Order status updated successfully") {
|
|
setOrder((prevOrder) => prevOrder ? { ...prevOrder, status: "acknowledged" } : null);
|
|
toast.success("Order marked as acknowledged!");
|
|
} else {
|
|
throw new Error(response.error || "Failed to mark order as acknowledged");
|
|
}
|
|
} catch (error: any) {
|
|
console.error("Failed to mark order as acknowledged:", error);
|
|
toast.error(error.message || "Failed to mark order as acknowledged");
|
|
} finally {
|
|
setIsAcknowledging(false);
|
|
}
|
|
};
|
|
|
|
const handleDiscardOrder = async () => {
|
|
try {
|
|
setIsDiscarding(true);
|
|
const authToken = document.cookie.split("Authorization=")[1];
|
|
const response = await fetchData(
|
|
`${process.env.NEXT_PUBLIC_API_URL}/orders/${orderId}`,
|
|
{
|
|
method: "DELETE",
|
|
headers: {
|
|
Authorization: `Bearer ${authToken}`,
|
|
},
|
|
}
|
|
);
|
|
|
|
if (response && response.message === "Order deleted successfully") {
|
|
toast.success("Order deleted successfully!");
|
|
router.push('/dashboard/orders');
|
|
} else {
|
|
throw new Error(response.error || "Failed to delete order");
|
|
}
|
|
} catch (error: any) {
|
|
console.error("Failed to delete order:", error);
|
|
toast.error(error.message || "Failed to delete order");
|
|
} finally {
|
|
setIsDiscarding(false);
|
|
}
|
|
};
|
|
|
|
const handleCancelOrder = async () => {
|
|
try {
|
|
setIsCancelling(true);
|
|
|
|
// Use clientFetch which handles API URL and auth token automatically
|
|
const response = await clientFetch(`/orders/${orderId}/status`, {
|
|
method: "PUT",
|
|
body: JSON.stringify({ status: "cancelled" }),
|
|
});
|
|
|
|
if (response && response.message === "Order status updated successfully") {
|
|
setOrder((prevOrder) => prevOrder ? { ...prevOrder, status: "cancelled" } : null);
|
|
toast.success("Order cancelled successfully");
|
|
} else {
|
|
throw new Error(response.error || "Failed to cancel order");
|
|
}
|
|
} catch (error: any) {
|
|
console.error("Failed to cancel order:", error);
|
|
toast.error(error.message || "Failed to cancel order");
|
|
} finally {
|
|
setIsCancelling(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
const fetchOrderDetails = async () => {
|
|
try {
|
|
if (!orderId) return;
|
|
|
|
const authToken = document.cookie.split("Authorization=")[1];
|
|
|
|
const res = await fetchData(
|
|
`${process.env.NEXT_PUBLIC_API_URL}/orders/${orderId}?includeInsights=true`,
|
|
{
|
|
method: "GET",
|
|
headers: { Authorization: `Bearer ${authToken}` },
|
|
}
|
|
);
|
|
|
|
if (!res) throw new Error("Failed to fetch order details");
|
|
|
|
const data: OrderResponse = await res;
|
|
setOrder(data.order);
|
|
setCustomerInsights(data.customerInsights);
|
|
console.log("Fresh order data:", data.order);
|
|
console.log("Customer insights:", data.customerInsights);
|
|
|
|
const productIds = data.order.products.map((product) => product.productId);
|
|
const productNamesMap = await fetchProductNames(productIds, authToken);
|
|
setProductNames(productNamesMap);
|
|
|
|
setTimeout(() => {
|
|
setProductNames(prev => {
|
|
const newMap = {...prev};
|
|
productIds.forEach(id => {
|
|
if (!newMap[id] || newMap[id] === "Loading...") {
|
|
newMap[id] = "Unknown Product (Deleted)";
|
|
}
|
|
});
|
|
return newMap;
|
|
});
|
|
}, 3000);
|
|
|
|
if (data.order.status === "paid") {
|
|
setIsPaid(true);
|
|
}
|
|
} catch (err: any) {
|
|
router.push("/dashboard/orders");
|
|
setError(err.message);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
fetchOrderDetails();
|
|
}, [orderId, refreshTrigger]);
|
|
|
|
useEffect(() => {
|
|
const fetchAdjacentOrders = async () => {
|
|
try {
|
|
const authToken = document.cookie.split("Authorization=")[1];
|
|
|
|
if (!order?.orderId) return;
|
|
|
|
// Get the current numerical orderId
|
|
const currentOrderId = parseInt(order.orderId);
|
|
console.log('Current orderId (numerical):', currentOrderId);
|
|
|
|
// Use the new optimized backend endpoint to get adjacent orders
|
|
const adjacentOrdersUrl = `${process.env.NEXT_PUBLIC_API_URL}/orders/adjacent/${currentOrderId}`;
|
|
console.log('Fetching adjacent orders:', adjacentOrdersUrl);
|
|
|
|
const adjacentOrdersRes = await fetchData(
|
|
adjacentOrdersUrl,
|
|
{
|
|
method: "GET",
|
|
headers: { Authorization: `Bearer ${authToken}` },
|
|
}
|
|
);
|
|
|
|
console.log('Adjacent orders response:', adjacentOrdersRes);
|
|
|
|
if (!adjacentOrdersRes) {
|
|
console.error("Invalid response from adjacent orders endpoint");
|
|
return;
|
|
}
|
|
|
|
// Set the next and previous order IDs
|
|
const { newer, older } = adjacentOrdersRes;
|
|
|
|
// Set IDs for navigation
|
|
setPrevOrderId(newer?._id || null);
|
|
setNextOrderId(older?._id || null);
|
|
|
|
if (newer) {
|
|
console.log(`Newer order: ${newer.orderId} (ID: ${newer._id})`);
|
|
} else {
|
|
console.log('No newer order found');
|
|
}
|
|
|
|
if (older) {
|
|
console.log(`Older order: ${older.orderId} (ID: ${older._id})`);
|
|
} else {
|
|
console.log('No older order found');
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error("Failed to fetch adjacent orders:", error);
|
|
}
|
|
};
|
|
|
|
if (order) {
|
|
fetchAdjacentOrders();
|
|
}
|
|
}, [order]);
|
|
|
|
const handleAddTracking = async () => {
|
|
if (!trackingNumber) {
|
|
toast.error("Please enter a tracking number");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setIsSending(true);
|
|
const authToken = document.cookie.split("Authorization=")[1];
|
|
|
|
const response = await fetchData(
|
|
`${process.env.NEXT_PUBLIC_API_URL}/orders/${orderId}/tracking`,
|
|
{
|
|
method: "PUT",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${authToken}`,
|
|
},
|
|
body: JSON.stringify({ trackingNumber }),
|
|
}
|
|
);
|
|
|
|
if (response.error) throw new Error(response.error);
|
|
|
|
// Update the local state
|
|
setOrder(prevOrder => prevOrder ? {
|
|
...prevOrder,
|
|
trackingNumber: trackingNumber
|
|
} : null);
|
|
|
|
toast.success("Tracking number updated successfully!");
|
|
setTrackingNumber(""); // Clear the input
|
|
} catch (err: any) {
|
|
console.error("Failed to update tracking:", err);
|
|
toast.error(err.message || "Failed to update tracking number");
|
|
} finally {
|
|
setIsSending(false);
|
|
}
|
|
};
|
|
|
|
const copyToClipboard = async (text: string) => {
|
|
try {
|
|
await navigator.clipboard.writeText(text);
|
|
toast.success("Payment address copied to clipboard");
|
|
} catch (error) {
|
|
toast.error("Failed to copy to clipboard");
|
|
}
|
|
};
|
|
|
|
const copyAllOrderData = async () => {
|
|
if (!order) return;
|
|
|
|
try {
|
|
const lines = [];
|
|
|
|
// Order number
|
|
lines.push(`Order Number: ${order.orderId}`);
|
|
lines.push('');
|
|
|
|
// Order details
|
|
lines.push('Order Details:');
|
|
if (order.products && order.products.length > 0) {
|
|
order.products.forEach((product) => {
|
|
const productName = productNames[product.productId] || 'Unknown Product';
|
|
lines.push(` - ${productName} (Qty: ${product.quantity} @ £${product.pricePerUnit.toFixed(2)} = £${product.totalItemPrice.toFixed(2)})`);
|
|
});
|
|
}
|
|
|
|
// Shipping
|
|
if (order.shippingMethod) {
|
|
lines.push(` - Shipping: ${order.shippingMethod.type} (£${order.shippingMethod.price.toFixed(2)})`);
|
|
}
|
|
|
|
// Discount
|
|
if (order.discountAmount && order.discountAmount > 0) {
|
|
lines.push(` - Discount: -£${order.discountAmount.toFixed(2)}${order.promotionCode ? ` (Promo: ${order.promotionCode})` : ''}`);
|
|
}
|
|
|
|
// Subtotal if different from total
|
|
if (order.subtotalBeforeDiscount && order.subtotalBeforeDiscount !== order.totalPrice) {
|
|
lines.push(` - Subtotal: £${order.subtotalBeforeDiscount.toFixed(2)}`);
|
|
}
|
|
|
|
// Total
|
|
lines.push(` - Total: £${order.totalPrice.toFixed(2)}`);
|
|
lines.push('');
|
|
|
|
// Address
|
|
lines.push('Address:');
|
|
lines.push(order.pgpAddress || 'N/A');
|
|
|
|
const textToCopy = lines.join('\n');
|
|
await navigator.clipboard.writeText(textToCopy);
|
|
toast.success("Order data copied to clipboard!");
|
|
} catch (error) {
|
|
toast.error("Failed to copy order data");
|
|
console.error("Copy error:", error);
|
|
}
|
|
};
|
|
|
|
// Helper function to check if order is underpaid
|
|
const isOrderUnderpaid = (order: Order | null) => {
|
|
// More robust check - only show underpaid if status is NOT paid and underpayment exists
|
|
return order?.underpaid === true &&
|
|
order?.underpaymentAmount &&
|
|
order.underpaymentAmount > 0 &&
|
|
order.status !== "paid" &&
|
|
order.status !== "completed" &&
|
|
order.status !== "shipped";
|
|
};
|
|
|
|
// Helper function to get underpaid information
|
|
const getUnderpaidInfo = (order: Order | null) => {
|
|
if (!isOrderUnderpaid(order)) return null;
|
|
|
|
const received = order?.lastBalanceReceived || 0;
|
|
const required = order?.cryptoTotal || 0;
|
|
const missing = order?.underpaymentAmount || 0;
|
|
|
|
// Calculate LTC to GBP exchange rate from order data
|
|
const ltcToGbpRate = required > 0 ? (order?.totalPrice || 0) / required : 0;
|
|
const receivedGbp = received * ltcToGbpRate;
|
|
const requiredGbp = order?.totalPrice || 0;
|
|
const missingGbp = missing * ltcToGbpRate;
|
|
|
|
return {
|
|
received,
|
|
required,
|
|
missing,
|
|
receivedGbp,
|
|
requiredGbp,
|
|
missingGbp,
|
|
percentage: required > 0 ? ((received / required) * 100).toFixed(1) : 0
|
|
};
|
|
};
|
|
|
|
const underpaidInfo = getUnderpaidInfo(order);
|
|
|
|
// Add order refresh subscription
|
|
useEffect(() => {
|
|
const unsubscribe = cacheUtils.onOrderRefresh(() => {
|
|
setRefreshTrigger(prev => prev + 1);
|
|
});
|
|
|
|
return unsubscribe;
|
|
}, []);
|
|
|
|
if (loading)
|
|
return (
|
|
<Layout>
|
|
<div className="text-center py-10">Loading order details...</div>
|
|
</Layout>
|
|
);
|
|
if (error)
|
|
return (
|
|
<Layout>
|
|
<div className="text-center text-red-500 py-10">Error: {error}</div>
|
|
</Layout>
|
|
);
|
|
|
|
return (
|
|
<Layout>
|
|
<div className="space-y-6">
|
|
{/* Header Section */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-4">
|
|
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white flex items-center">
|
|
<Package className="mr-2 h-6 w-6" />
|
|
Order {order?.orderId}
|
|
</h1>
|
|
<div className="flex gap-2">
|
|
<div className={`px-3 py-1 rounded-full border ${getStatusStyle(order?.status || '')}`}>
|
|
{order?.status?.toUpperCase()}
|
|
</div>
|
|
{isOrderUnderpaid(order) && (
|
|
<div className="flex items-center gap-1 px-3 py-1 rounded-full border bg-red-500/10 text-red-500 border-red-500/20">
|
|
<AlertTriangle className="h-4 w-4" />
|
|
UNDERPAID ({underpaidInfo?.percentage}%)
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={copyAllOrderData}
|
|
disabled={!order}
|
|
>
|
|
<Clipboard className="h-4 w-4 mr-1" />
|
|
Copy All
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setRefreshTrigger(prev => prev + 1)}
|
|
disabled={loading}
|
|
>
|
|
{loading ? (
|
|
<Loader2 className="h-4 w-4 animate-spin mr-1" />
|
|
) : (
|
|
<RefreshCw className="h-4 w-4 mr-1" />
|
|
)}
|
|
Refresh
|
|
</Button>
|
|
{prevOrderId && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => router.push(`/dashboard/orders/${prevOrderId}`)}
|
|
>
|
|
Previous Order
|
|
</Button>
|
|
)}
|
|
{nextOrderId && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => router.push(`/dashboard/orders/${nextOrderId}`)}
|
|
>
|
|
Next Order
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Underpaid Alert Card */}
|
|
{isOrderUnderpaid(order) && underpaidInfo && (
|
|
<Card className="border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-950/20">
|
|
<CardHeader>
|
|
<CardTitle className="text-red-700 dark:text-red-400 flex items-center gap-2">
|
|
<AlertTriangle className="h-5 w-5" />
|
|
Payment Underpaid
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3">
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
|
<div>
|
|
<p className="text-muted-foreground">Required Amount</p>
|
|
<p className="font-mono">£{underpaidInfo.requiredGbp.toFixed(2)}</p>
|
|
<p className="font-mono text-xs text-muted-foreground">{underpaidInfo.required.toFixed(8)} LTC</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-muted-foreground">Received Amount</p>
|
|
<p className="font-mono">£{underpaidInfo.receivedGbp.toFixed(2)}</p>
|
|
<p className="font-mono text-xs text-muted-foreground">{underpaidInfo.received.toFixed(8)} LTC</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-muted-foreground">Missing Amount</p>
|
|
<p className="font-mono text-red-600">£{underpaidInfo.missingGbp.toFixed(2)}</p>
|
|
<p className="font-mono text-xs text-red-400">{underpaidInfo.missing.toFixed(8)} LTC</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-muted-foreground">Payment Progress</p>
|
|
<p className="font-semibold">{underpaidInfo.percentage}% paid</p>
|
|
</div>
|
|
</div>
|
|
|
|
{order?.paymentAddress && (
|
|
<div className="pt-3 border-t border-red-200 dark:border-red-800">
|
|
<p className="text-sm text-muted-foreground mb-2">Payment Address:</p>
|
|
<div className="flex items-center gap-2 p-2 bg-background rounded border">
|
|
<code className="flex-1 text-xs break-all">{order.paymentAddress}</code>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => copyToClipboard(order.paymentAddress!)}
|
|
>
|
|
<Copy className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
|
|
|
|
<div className="grid grid-cols-3 gap-6">
|
|
{/* Left Column - Order Details */}
|
|
<div className="col-span-2 space-y-6">
|
|
{/* Products Card */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-lg font-medium">Products</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Product</TableHead>
|
|
<TableHead className="text-right">Quantity</TableHead>
|
|
<TableHead className="text-right">Price/Unit</TableHead>
|
|
<TableHead className="text-right">Total</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{order?.products.map((product) => (
|
|
<TableRow key={product._id}>
|
|
<TableCell className="font-medium">
|
|
{productNames[product.productId] || "Loading..."}
|
|
</TableCell>
|
|
<TableCell className="text-right">{product.quantity}</TableCell>
|
|
<TableCell className="text-right">£{product.pricePerUnit.toFixed(2)}</TableCell>
|
|
<TableCell className="text-right">£{product.totalItemPrice.toFixed(2)}</TableCell>
|
|
</TableRow>
|
|
))}
|
|
<TableRow>
|
|
<TableCell colSpan={2} />
|
|
<TableCell className="text-right font-medium">Shipping ({order?.shippingMethod.type})</TableCell>
|
|
<TableCell className="text-right">£{order?.shippingMethod.price.toFixed(2)}</TableCell>
|
|
</TableRow>
|
|
{order?.promotionCode && order?.discountAmount && order?.discountAmount > 0 && (
|
|
<TableRow>
|
|
<TableCell colSpan={2} />
|
|
<TableCell className="text-right font-medium text-green-600">
|
|
Promotion ({order.promotionCode})
|
|
</TableCell>
|
|
<TableCell className="text-right text-green-600">
|
|
-£{order.discountAmount.toFixed(2)}
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
<TableRow>
|
|
<TableCell colSpan={2} />
|
|
<TableCell className="text-right font-bold">Total</TableCell>
|
|
<TableCell className="text-right font-bold">£{order?.totalPrice.toFixed(2)}</TableCell>
|
|
</TableRow>
|
|
</TableBody>
|
|
</Table>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Customer Details Card */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-lg font-medium">Customer Details</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{(order?.telegramUsername || order?.telegramBuyerId) && (
|
|
<div className="space-y-2">
|
|
<Label className="text-sm font-medium text-zinc-500">Telegram Information</Label>
|
|
<div className="space-y-1">
|
|
{order?.telegramUsername && (
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm text-zinc-400">Username</span>
|
|
<div className="flex items-center gap-2">
|
|
<code className="px-2 py-1 text-sm bg-zinc-950 rounded">@{order.telegramUsername}</code>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => copyToClipboard(order.telegramUsername || "")}
|
|
>
|
|
<Clipboard className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{order?.telegramBuyerId && (
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm text-zinc-400">Buyer ID</span>
|
|
<div className="flex items-center gap-2">
|
|
<code className="px-2 py-1 text-sm bg-zinc-950 rounded">{order?.telegramBuyerId}</code>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => copyToClipboard(order?.telegramBuyerId?.toString() || "")}
|
|
>
|
|
<Clipboard className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{(order?.telegramBuyerId || order?.telegramUsername) && (
|
|
<div className="pt-2">
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
asChild
|
|
>
|
|
<Link href={`/dashboard/chats/new?buyerId=${order?.telegramBuyerId || order?.telegramUsername}`}
|
|
title={`Chat with customer${order?.telegramUsername ? ` @${order.telegramUsername}` : ''}`}
|
|
>
|
|
<MessageCircle className="h-4 w-4 mr-2" />
|
|
Message customer
|
|
</Link>
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Customer History */}
|
|
{customerInsights && (
|
|
<div className="space-y-2">
|
|
<Label className="text-sm font-medium text-zinc-500">Customer History</Label>
|
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-zinc-400">Total Orders</span>
|
|
<span className="font-medium">{customerInsights.totalOrders}</span>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-zinc-400">Total Spent</span>
|
|
<span className="font-medium">£{customerInsights.totalSpent.toFixed(2)}</span>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-zinc-400">Success Rate</span>
|
|
<span className="font-medium">{customerInsights.paymentSuccessRate.toFixed(1)}%</span>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-zinc-400">Customer Since</span>
|
|
<span className="font-medium">
|
|
{customerInsights.firstOrder ?
|
|
new Date(customerInsights.firstOrder).toLocaleDateString('en-GB', {
|
|
day: '2-digit',
|
|
month: 'short',
|
|
year: 'numeric'
|
|
}) : 'N/A'
|
|
}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
{customerInsights.cancellationRate > 20 && (
|
|
<div className="mt-2 p-2 bg-yellow-50 dark:bg-yellow-950/20 border border-yellow-200 dark:border-yellow-800 rounded text-xs">
|
|
<div className="flex items-center gap-1 text-yellow-800 dark:text-yellow-200">
|
|
<AlertTriangle className="h-3 w-3" />
|
|
<span>High cancellation rate: {customerInsights.cancellationRate.toFixed(1)}%</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div>
|
|
<Label className="text-sm font-medium text-zinc-500">PGP Address</Label>
|
|
<div className="flex items-start gap-2 mt-1">
|
|
<Textarea
|
|
value={order?.pgpAddress || ""}
|
|
readOnly
|
|
className="font-mono text-sm bg-zinc-950 h-32 resize-none"
|
|
/>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => copyToClipboard(order?.pgpAddress || "")}
|
|
>
|
|
<Clipboard className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Right Column - Actions and Status */}
|
|
<div className="space-y-6">
|
|
{/* Order Information Card */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-lg font-medium">Order Information</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3">
|
|
<div>
|
|
<Label className="text-sm font-medium text-zinc-500">Order Date</Label>
|
|
<div className="text-sm mt-1">
|
|
{order?.orderDate ? new Date(order.orderDate).toLocaleDateString("en-GB", {
|
|
day: '2-digit',
|
|
month: 'short',
|
|
year: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
hour12: false
|
|
}) : "-"}
|
|
</div>
|
|
</div>
|
|
{order?.paidAt && (
|
|
<div>
|
|
<Label className="text-sm font-medium text-zinc-500">Paid At</Label>
|
|
<div className="text-sm mt-1 text-green-600 font-medium">
|
|
{new Date(order.paidAt).toLocaleDateString("en-GB", {
|
|
day: '2-digit',
|
|
month: 'short',
|
|
year: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
hour12: false
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Order Actions Card */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-lg font-medium">Order Actions</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{order?.status === "unpaid" && (
|
|
<Button
|
|
className="w-full"
|
|
onClick={handleMarkAsPaid}
|
|
disabled={isPaid}
|
|
>
|
|
Mark as Paid
|
|
</Button>
|
|
)}
|
|
|
|
{order?.status === "paid" && (
|
|
<Button
|
|
className="w-full"
|
|
onClick={handleMarkAsAcknowledged}
|
|
disabled={isAcknowledging}
|
|
>
|
|
{isAcknowledging ? "Processing..." : "Acknowledge Order"}
|
|
</Button>
|
|
)}
|
|
|
|
{order?.status === "acknowledged" && (
|
|
<Button
|
|
className="w-full"
|
|
onClick={handleMarkAsShipped}
|
|
disabled={isMarkingShipped}
|
|
>
|
|
{isMarkingShipped ? "Processing..." : "Mark as Shipped"}
|
|
</Button>
|
|
)}
|
|
|
|
{/* Tracking Number Section */}
|
|
{(order?.status === "acknowledged" || order?.status === "shipped") && (
|
|
<div className="space-y-4">
|
|
{order.trackingNumber === "" ? (
|
|
<div className="text-sm text-zinc-400 text-center py-2">
|
|
Tracking number has been viewed by the buyer
|
|
</div>
|
|
) : order.trackingNumber ? (
|
|
<div className="flex items-center gap-2">
|
|
<Input
|
|
value={order.trackingNumber}
|
|
readOnly
|
|
className="font-mono text-sm bg-zinc-950"
|
|
/>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => copyToClipboard(order.trackingNumber || "")}
|
|
>
|
|
<Clipboard className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
<Label htmlFor="tracking">Tracking Number (Optional)</Label>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
id="tracking"
|
|
value={trackingNumber}
|
|
onChange={(e) => setTrackingNumber(e.target.value)}
|
|
placeholder="Enter tracking number"
|
|
/>
|
|
<Button
|
|
variant="outline"
|
|
onClick={handleAddTracking}
|
|
disabled={!trackingNumber || isSending}
|
|
>
|
|
<Truck className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Cancel Order Button */}
|
|
{order?.status !== "cancelled" &&
|
|
order?.status !== "completed" &&
|
|
order?.status !== "shipped" && (
|
|
<AlertDialog>
|
|
<AlertDialogTrigger asChild>
|
|
<Button variant="destructive" className="w-full">
|
|
Cancel Order
|
|
</Button>
|
|
</AlertDialogTrigger>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Cancel Order</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
Are you sure you want to cancel this order? This action cannot be undone.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={handleCancelOrder}
|
|
className="bg-red-500 hover:bg-red-600"
|
|
>
|
|
Confirm Cancel
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
)}
|
|
|
|
{/* No Actions Available Message */}
|
|
{(order?.status === "completed" || order?.status === "cancelled") && (
|
|
<div className="text-center py-6 text-sm text-zinc-500">
|
|
No actions available - Order is {order.status}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Transaction Details Card */}
|
|
{order?.txid && order.txid.length > 0 && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-lg font-medium">Transaction Details</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-2">
|
|
{order.txid.map((tx, index) => (
|
|
<div key={index} className="flex items-center gap-2">
|
|
<Input
|
|
value={tx}
|
|
readOnly
|
|
className="font-mono text-sm bg-zinc-950"
|
|
/>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => copyToClipboard(tx)}
|
|
>
|
|
<Clipboard className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Review Card */}
|
|
{order?.review && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-lg font-medium">Customer Review</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3">
|
|
<div className="flex items-center gap-1">
|
|
{[...Array(5)].map((_, i) => (
|
|
<svg
|
|
key={i}
|
|
className={`w-4 h-4 ${
|
|
i < (order?.review?.stars || 0)
|
|
? "text-yellow-400"
|
|
: "text-zinc-600"
|
|
}`}
|
|
fill="currentColor"
|
|
viewBox="0 0 20 20"
|
|
>
|
|
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
|
</svg>
|
|
))}
|
|
</div>
|
|
<div className="text-sm text-zinc-400">
|
|
{new Date(order?.review?.date || '').toLocaleString('en-GB', {
|
|
day: 'numeric',
|
|
month: 'short',
|
|
year: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
second: '2-digit',
|
|
hour12: false
|
|
})}
|
|
</div>
|
|
<div className="text-sm">
|
|
{order?.review?.text}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Shipping Dialog removed; use inline tracking input above */}
|
|
</div>
|
|
</Layout>
|
|
);
|
|
}
|