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