All checks were successful
Build Frontend / build (push) Successful in 1m12s
Refactored dashboard pages for improved layout and visual consistency using Card components, motion animations, and updated color schemes. Added an OrderTimeline component to the order details page to visualize order lifecycle. Improved customer management page with better sorting, searching, and a detailed customer dialog. Updated storefront settings page with a modernized layout and clearer sectioning.
1225 lines
45 KiB
TypeScript
1225 lines
45 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';
|
|
import OrderTimeline from "@/components/orders/order-timeline";
|
|
import { motion, AnimatePresence } from "framer-motion";
|
|
|
|
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>
|
|
)}
|
|
{/* Order Timeline */}
|
|
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden">
|
|
<CardHeader className="pb-0">
|
|
<CardTitle className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Order Lifecycle</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<OrderTimeline
|
|
status={order?.status || ''}
|
|
orderDate={order?.orderDate || ''}
|
|
paidAt={order?.paidAt}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.4 }}
|
|
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>
|
|
</motion.div>
|
|
</div>
|
|
|
|
{/* Shipping Dialog removed; use inline tracking input above */}
|
|
</Layout>
|
|
);
|
|
}
|