From 2db13cc9b7e2b1f3cbca3587084b6674dd0defe4 Mon Sep 17 00:00:00 2001 From: g Date: Wed, 17 Dec 2025 23:38:17 +0000 Subject: [PATCH] Add admin order details modal and improve admin UI Introduces an admin-only OrderDetailsModal component for viewing and managing order details, including status updates and transaction info. Updates OrdersTable to support the modal, and enhances the admin dashboard page with lazy loading and skeletons for better UX. Also fixes API client base URL handling for /api prefix. --- app/dashboard/admin/page.tsx | 77 +++- components/admin/OrderDetailsModal.tsx | 553 +++++++++++++++++++++++++ components/admin/OrdersTable.tsx | 50 ++- lib/api-client.ts | 4 +- public/git-info.json | 4 +- 5 files changed, 667 insertions(+), 21 deletions(-) create mode 100644 components/admin/OrderDetailsModal.tsx diff --git a/app/dashboard/admin/page.tsx b/app/dashboard/admin/page.tsx index c4b6b75..9a7cc8c 100644 --- a/app/dashboard/admin/page.tsx +++ b/app/dashboard/admin/page.tsx @@ -1,16 +1,61 @@ +"use client"; export const dynamic = "force-dynamic"; -import React from "react"; -import AdminAnalytics from "@/components/admin/AdminAnalytics"; -import InviteVendorCard from "@/components/admin/InviteVendorCard"; -import BanUserCard from "@/components/admin/BanUserCard"; -import InvitationsListCard from "@/components/admin/InvitationsListCard"; -import VendorsCard from "@/components/admin/VendorsCard"; +import React, { Suspense, lazy, useState } from "react"; import { Button } from "@/components/ui/button"; import Link from "next/link"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; + +// Lazy load admin components +const AdminAnalytics = lazy(() => import("@/components/admin/AdminAnalytics")); +const InviteVendorCard = lazy(() => import("@/components/admin/InviteVendorCard")); +const BanUserCard = lazy(() => import("@/components/admin/BanUserCard")); +const InvitationsListCard = lazy(() => import("@/components/admin/InvitationsListCard")); +const VendorsCard = lazy(() => import("@/components/admin/VendorsCard")); + +// Loading skeleton for admin components +function AdminComponentSkeleton() { + return ( +
+ + + + + +
+ + + +
+
+
+
+ ); +} + +// Loading skeleton for management cards +function ManagementCardsSkeleton() { + return ( +
+ {[1, 2, 3, 4].map((i) => ( + + + + + + + + + ))} +
+ ); +} export default function AdminPage() { + const [activeTab, setActiveTab] = useState("analytics"); + return (
@@ -23,23 +68,27 @@ export default function AdminPage() {
- + Analytics Management - + }> + + -
- - - - -
+ }> +
+ + + + +
+
diff --git a/components/admin/OrderDetailsModal.tsx b/components/admin/OrderDetailsModal.tsx new file mode 100644 index 0000000..6b5d2bc --- /dev/null +++ b/components/admin/OrderDetailsModal.tsx @@ -0,0 +1,553 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Button } from "@/components/ui/button"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { fetchClient } from "@/lib/api-client"; +import { toast } from "sonner"; +import { Package, User, Calendar, DollarSign, MapPin, Truck, CheckCircle, XCircle, Clock, Wallet, Copy, ExternalLink } from "lucide-react"; + +interface OrderDetails { + _id: string; + orderId: number; + telegramBuyerId: string; + telegramUsername?: string; + totalPrice: number; + cryptoTotal?: number; + orderDate: string; + paidAt?: string; + status: string; + products: Array<{ + productId: string; + name: string; + quantity: number; + pricePerUnit: number; + totalItemPrice: number; + }>; + shippingMethod?: { + type?: string; + name?: string; // Legacy support + price: number; + }; + pgpAddress?: string; + trackingNumber?: string; + discountAmount?: number; + subtotalBeforeDiscount?: number; + paymentAddress?: string; + txid?: string | string[]; + vendorId?: { + username?: string; + }; + storeId?: string; +} + +interface OrderDetailsModalProps { + orderId: number | string; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +/** + * Admin-only order details modal component + * This component fetches order details from admin-only API endpoints + * and should only be used in admin contexts + */ + +// Helper function to format currency, removing unnecessary .00 +const formatCurrency = (amount: number | undefined | null): string => { + if (amount === undefined || amount === null || isNaN(amount) || amount === 0) { + return ''; + } + // If it's a whole number, don't show decimals + if (amount % 1 === 0) { + return `£${amount.toFixed(0)}`; + } + return `£${amount.toFixed(2)}`; +}; + +const getStatusConfig = (status: string) => { + switch (status) { + case 'acknowledged': + return { label: 'Acknowledged', color: 'bg-purple-500/10 text-purple-500 border-purple-500/20', icon: CheckCircle }; + case 'paid': + return { label: 'Paid', color: 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20', icon: CheckCircle }; + case 'shipped': + return { label: 'Shipped', color: 'bg-blue-500/10 text-blue-500 border-blue-500/20', icon: Truck }; + case 'completed': + return { label: 'Completed', color: 'bg-green-500/10 text-green-500 border-green-500/20', icon: CheckCircle }; + case 'cancelled': + return { label: 'Cancelled', color: 'bg-red-500/10 text-red-500 border-red-500/20', icon: XCircle }; + case 'unpaid': + return { label: 'Unpaid', color: 'bg-yellow-500/10 text-yellow-500 border-yellow-500/20', icon: Clock }; + case 'confirming': + return { label: 'Confirming', color: 'bg-orange-500/10 text-orange-500 border-orange-500/20', icon: Clock }; + default: + return { label: status, color: 'bg-gray-500/10 text-gray-500 border-gray-500/20', icon: Package }; + } +}; + +export default function OrderDetailsModal({ orderId, open, onOpenChange }: OrderDetailsModalProps) { + const [order, setOrder] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [copied, setCopied] = useState(null); + const [updatingStatus, setUpdatingStatus] = useState(false); + + const copyToClipboard = async (text: string, label: string) => { + try { + await navigator.clipboard.writeText(text); + setCopied(label); + setTimeout(() => setCopied(null), 2000); + } catch (err) { + console.error('Failed to copy:', err); + } + }; + + const handleStatusChange = async (newStatus: string) => { + if (!order || newStatus === order.status) return; + + setUpdatingStatus(true); + try { + const response = await fetchClient<{ message: string; order: { orderId: number; status: string; oldStatus: string } }>( + `/admin/orders/${orderId}/status`, + { + method: 'PUT', + body: { status: newStatus } + } + ); + + // Update local order state + setOrder({ ...order, status: newStatus }); + + toast.success(`Order status updated to ${newStatus}`); + + // Optionally refresh order details to get latest data + await fetchOrderDetails(); + } catch (err) { + console.error('Failed to update order status:', err); + const errorMessage = err instanceof Error ? err.message : 'Failed to update order status'; + toast.error(errorMessage); + } finally { + setUpdatingStatus(false); + } + }; + + useEffect(() => { + if (open && orderId) { + fetchOrderDetails(); + } else { + // Reset state when modal closes + setOrder(null); + setError(null); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, orderId]); + + const fetchOrderDetails = async () => { + setLoading(true); + setError(null); + try { + // Fetch full order details from admin endpoint + // Use /admin/orders/:orderId (fetchClient will add /api prefix and backend URL) + console.log(`Fetching order details for order #${orderId}`); + const orderData = await fetchClient(`/admin/orders/${orderId}`); + + console.log("Order data received:", orderData); + + if (!orderData) { + throw new Error("No order data received from server"); + } + + if (!orderData.orderId) { + throw new Error("Invalid order data: missing orderId"); + } + + setOrder(orderData); + } catch (err) { + console.error("Failed to fetch order details:", err); + const errorMessage = err instanceof Error + ? err.message + : typeof err === 'string' + ? err + : "Failed to load order details. Please check your connection and try again."; + setError(errorMessage); + } finally { + setLoading(false); + } + }; + + const statusConfig = order ? getStatusConfig(order.status) : null; + const StatusIcon = statusConfig?.icon || Package; + + return ( + + + + Order Details #{orderId} + + Complete order information and status + + + + {loading && ( +
+ + + +
+ )} + + {error && ( + + +
+

{error}

+

+ Please check the browser console for more details. +

+
+
+
+ )} + + {order && !loading && ( +
+ {/* Order Status */} + + +
+ Order Status + {statusConfig && ( + + + {statusConfig.label} + + )} +
+
+ +
+

Change Status:

+
+ + + + + + + +
+
+
+
+ + {/* Order Information */} + + + Order Information + + +
+
+ +
+

Customer

+

{order.telegramBuyerId}

+ {order.telegramUsername && ( +

@{order.telegramUsername}

+ )} +
+
+
+ +
+

Order Date

+

{new Date(order.orderDate).toLocaleString()}

+ {order.paidAt && ( +

+ Paid: {new Date(order.paidAt).toLocaleString()} +

+ )} +
+
+
+ + {order.vendorId?.username && ( +
+ +
+

Vendor

+

{order.vendorId.username}

+
+
+ )} +
+
+ + {/* Products */} + {order.products && order.products.length > 0 && ( + + + Products + + +
+ {order.products.map((product, index) => ( +
+
+

{product.name || `Product ${index + 1}`}

+

+ Quantity: {product.quantity} × {formatCurrency(product.pricePerUnit)} +

+
+

{formatCurrency(product.totalItemPrice)}

+
+ ))} +
+
+
+ )} + + {/* Shipping & Pricing */} + + + Pricing Details + + + {order.subtotalBeforeDiscount !== undefined && + order.subtotalBeforeDiscount !== null && + typeof order.subtotalBeforeDiscount === 'number' && + order.subtotalBeforeDiscount > 0 && + formatCurrency(order.subtotalBeforeDiscount) ? ( +
+ Subtotal + {formatCurrency(order.subtotalBeforeDiscount)} +
+ ) : null} + {order.shippingMethod && + order.shippingMethod.price !== undefined && + order.shippingMethod.price !== null && + typeof order.shippingMethod.price === 'number' && + order.shippingMethod.price > 0 && + formatCurrency(order.shippingMethod.price) ? ( +
+ + + Shipping {order.shippingMethod.type || order.shippingMethod.name ? `(${order.shippingMethod.type || order.shippingMethod.name})` : ''} + + {formatCurrency(order.shippingMethod.price)} +
+ ) : null} + {order.discountAmount !== undefined && + order.discountAmount !== null && + typeof order.discountAmount === 'number' && + order.discountAmount > 0 && + formatCurrency(order.discountAmount) ? ( +
+ Discount + -{formatCurrency(order.discountAmount)} +
+ ) : null} +
+ + + Total + + {formatCurrency(order.totalPrice)} +
+ {order.cryptoTotal && ( +
+ Crypto: {order.cryptoTotal} LTC +
+ )} +
+
+ + {/* Shipping Address */} + {order.pgpAddress && ( + + + + + Shipping Address + + + +

{order.pgpAddress}

+
+
+ )} + + {/* Crypto Payment Address */} + {order.paymentAddress && order.paymentAddress !== "NO_PAYMENT_REQUIRED" && ( + + + + + Crypto Payment Address + + + +
+

{order.paymentAddress}

+
+ + +
+
+
+
+ )} + + {/* Transaction IDs */} + {order.txid && Array.isArray(order.txid) && order.txid.length > 0 && ( + + + + + Transaction IDs + + + +
+ {order.txid.map((tx, index) => ( +
+

{tx}

+
+ + +
+
+ ))} +
+
+
+ )} + + {/* Tracking */} + {order.trackingNumber && ( + + + + + Tracking Information + + + +

{order.trackingNumber}

+
+
+ )} +
+ )} +
+
+ ); +} + diff --git a/components/admin/OrdersTable.tsx b/components/admin/OrdersTable.tsx index 2363d09..2a38a7b 100644 --- a/components/admin/OrdersTable.tsx +++ b/components/admin/OrdersTable.tsx @@ -7,6 +7,7 @@ import { Input } from "@/components/ui/input"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Search, Filter, Eye, ChevronLeft, ChevronRight } from "lucide-react"; +import OrderDetailsModal from "./OrderDetailsModal"; interface Order { orderId: string | number; @@ -23,6 +24,11 @@ interface Order { interface OrdersTableProps { orders: Order[]; + /** + * Enable order details modal (admin-only feature) + * @default true + */ + enableModal?: boolean; } const getStatusStyle = (status: string) => { @@ -46,10 +52,16 @@ const getStatusStyle = (status: string) => { } }; -export default function OrdersTable({ orders }: OrdersTableProps) { +/** + * Admin-only orders table component with order details modal + * This component should only be used in admin contexts + */ +export default function OrdersTable({ orders, enableModal = true }: OrdersTableProps) { const [currentPage, setCurrentPage] = useState(1); const [searchTerm, setSearchTerm] = useState(""); const [statusFilter, setStatusFilter] = useState("all"); + const [selectedOrderId, setSelectedOrderId] = useState(null); + const [isModalOpen, setIsModalOpen] = useState(false); const itemsPerPage = 10; // Filter orders based on search and status @@ -83,6 +95,11 @@ export default function OrdersTable({ orders }: OrdersTableProps) { setCurrentPage(1); // Reset to first page when filtering }; + const handleViewOrder = (orderId: number | string) => { + setSelectedOrderId(orderId); + setIsModalOpen(true); + }; + return ( @@ -153,9 +170,25 @@ export default function OrdersTable({ orders }: OrdersTableProps) { N/A {new Date(order.createdAt).toLocaleDateString()} - + {enableModal ? ( + + ) : ( + + )} ))} @@ -209,6 +242,15 @@ export default function OrdersTable({ orders }: OrdersTableProps) { )} + + {/* Order Details Modal - Admin only */} + {enableModal && selectedOrderId && ( + + )} ); } diff --git a/lib/api-client.ts b/lib/api-client.ts index a63bd85..c7e7781 100644 --- a/lib/api-client.ts +++ b/lib/api-client.ts @@ -282,7 +282,9 @@ export async function fetchClient( } } else { // For direct API calls, construct full URL - url = `${apiUrl}${normalizedEndpoint}`; + // Ensure /api prefix is included if apiUrl doesn't already have it + const baseUrl = apiUrl.endsWith('/api') ? apiUrl : `${apiUrl}/api`; + url = `${baseUrl}${normalizedEndpoint}`; } // Get auth token from cookies diff --git a/public/git-info.json b/public/git-info.json index 6db9608..e01af18 100644 --- a/public/git-info.json +++ b/public/git-info.json @@ -1,4 +1,4 @@ { - "commitHash": "07dcaf5", - "buildTime": "2025-12-15T17:54:38.040Z" + "commitHash": "93ec3d3", + "buildTime": "2025-12-17T23:21:50.682Z" } \ No newline at end of file