JUNOHUB-test/src/app/dashboard/orders/page.tsx
2026-02-14 16:02:43 +05:00

355 lines
18 KiB
TypeScript

"use client"
import { useEffect, useState, useRef } from "react"
import { useRouter } from "next/navigation"
import toast from "react-hot-toast"
import {
ShoppingCart,
Search,
Filter,
Calendar,
Download,
Loader2,
AlertCircle,
ShoppingBag,
User,
CheckCircle2,
Clock,
MoreHorizontal,
Eye,
ExternalLink,
Copy,
ChevronLeft,
ChevronRight
} from "lucide-react"
interface ShopifyOrder {
id: number
name: string
email: string
created_at: string
total_price: string
currency: string
financial_status: string
fulfillment_status: string | null
customer: {
first_name: string
last_name: string
email: string
} | null
line_items: unknown[]
}
export default function OrdersPage() {
const router = useRouter()
const [orders, setOrders] = useState<ShopifyOrder[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState("")
const [searchTerm, setSearchTerm] = useState("")
const [openMenuId, setOpenMenuId] = useState<number | null>(null)
const [storeDomain, setStoreDomain] = useState<string | null>(null)
const [statusFilter, setStatusFilter] = useState<string>("all")
const menuRefs = useRef<{ [key: number]: HTMLDivElement | null }>({})
useEffect(() => {
const fetchStoreInfo = async () => {
try {
const res = await fetch("/api/stores")
if (res.ok) {
const data = await res.json()
const activeStore = data.stores?.find((s: { isActive: boolean }) => s.isActive)
if (activeStore?.shopifyDomain) {
const cleanDomain = activeStore.shopifyDomain.replace(/^https?:\/\//, "").replace(/\/$/, "")
setStoreDomain(cleanDomain)
}
}
} catch {
// Silent fail
}
}
const fetchOrders = async () => {
try {
const res = await fetch("/api/shopify/orders")
if (!res.ok) throw new Error("Failed to fetch orders")
const data = await res.json()
setOrders(data.orders || [])
setError("")
} catch {
setError("Could not load orders. Please ensure your Shopify store is connected.")
} finally {
setLoading(false)
}
}
// Fetch store info and orders
fetchStoreInfo()
fetchOrders()
// Auto-refresh every 15 seconds to catch webhook updates
const interval = setInterval(() => {
fetchOrders()
}, 15000) // 15 seconds
return () => clearInterval(interval)
}, [])
// Close menu when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (openMenuId !== null) {
const menuElement = menuRefs.current[openMenuId]
if (menuElement && !menuElement.contains(event.target as Node)) {
setOpenMenuId(null)
}
}
}
if (openMenuId !== null) {
document.addEventListener("mousedown", handleClickOutside)
return () => document.removeEventListener("mousedown", handleClickOutside)
}
}, [openMenuId])
const handleMenuToggle = (orderId: number) => {
setOpenMenuId(openMenuId === orderId ? null : orderId)
}
const handleViewDetails = (order: ShopifyOrder) => {
router.push(`/dashboard/orders/${order.id}`)
setOpenMenuId(null)
}
const handleViewInShopify = (orderId: number) => {
if (storeDomain) {
window.open(`https://${storeDomain}/admin/orders/${orderId}`, "_blank")
} else {
toast.error("Store domain not found. Please check your store configuration.")
}
setOpenMenuId(null)
}
const handleCopyOrderId = (orderId: number) => {
navigator.clipboard.writeText(orderId.toString())
toast.success("Order ID copied to clipboard")
setOpenMenuId(null)
}
const handleExport = () => {
const csvHeaders = ["Order #", "Customer", "Email", "Date", "Total", "Currency", "Payment Status", "Fulfillment Status"]
const csvRows = filteredOrders.map(o => [
o.name,
o.customer ? `"${o.customer.first_name} ${o.customer.last_name}"` : "",
o.email || "",
new Date(o.created_at).toLocaleDateString(),
o.total_price,
o.currency,
o.financial_status,
o.fulfillment_status || "unfulfilled"
])
const csvContent = [
csvHeaders.join(","),
...csvRows.map(row => row.join(","))
].join("\n")
const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" })
const link = document.createElement("a")
const url = URL.createObjectURL(blob)
link.setAttribute("href", url)
link.setAttribute("download", `orders-${new Date().toISOString().split("T")[0]}.csv`)
link.style.visibility = "hidden"
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
toast.success("Orders exported successfully")
}
const filteredOrders = orders.filter(o => {
const matchesSearch = o.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
(o.customer && `${o.customer.first_name} ${o.customer.last_name}`.toLowerCase().includes(searchTerm.toLowerCase()))
const matchesStatus = statusFilter === "all" ||
(statusFilter === "fulfilled" && o.fulfillment_status === "fulfilled") ||
(statusFilter === "unfulfilled" && (!o.fulfillment_status || o.fulfillment_status !== "fulfilled")) ||
(statusFilter === "paid" && o.financial_status === "paid") ||
(statusFilter === "unpaid" && o.financial_status !== "paid")
return matchesSearch && matchesStatus
})
const getStatusStyles = (status: string | null) => {
switch (status) {
case "fulfilled": return "bg-emerald-500/20 text-emerald-400"
case "partial": return "bg-yellow-500/20 text-yellow-400"
default: return "bg-blue-500/20 text-blue-400"
}
}
return (
<div className="h-full flex flex-col space-y-4 md:space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 flex-shrink-0">
<div>
<h1 className="text-2xl md:text-3xl font-extrabold text-white tracking-tight">Orders</h1>
<p className="text-sm md:text-base text-slate-400 mt-1">Track and manage your Shopify orders for fulfillment.</p>
</div>
<div className="flex gap-2 md:gap-3">
<button className="h-10 md:h-11 px-3 md:px-6 bg-white/10 border border-white/10 rounded-xl font-bold text-white hover:bg-white/15 backdrop-blur-sm transition-all flex items-center gap-2 text-sm">
<Calendar className="w-4 h-4" /> <span className="hidden sm:inline">This Month</span>
</button>
<button
onClick={handleExport}
className="h-10 md:h-11 px-4 md:px-6 bg-indigo-500 text-white rounded-xl font-bold flex items-center gap-2 hover:bg-indigo-600 border border-indigo-400/20 transition-all text-sm"
>
<Download className="w-4 h-4" /> <span className="hidden sm:inline">Export CSV</span>
</button>
</div>
</div>
{/* Search and Filter Controls */}
<div className="flex flex-col sm:flex-row gap-3 md:gap-4 flex-shrink-0">
<div className="relative flex-1 min-w-0">
<Search className="absolute left-3 md:left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
<input
type="text"
placeholder="Search order #, customer name..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full h-10 md:h-11 pl-10 md:pl-11 pr-4 bg-white/5 border border-white/10 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50 text-white placeholder:text-slate-500 text-sm font-medium transition-all"
/>
</div>
<div className="flex gap-2">
<select
value={statusFilter}
onChange={(e) => {
setStatusFilter(e.target.value)
}}
className="h-10 md:h-11 px-4 bg-white/10 border border-white/10 rounded-xl font-bold text-slate-300 hover:bg-white/15 transition-all text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500/30"
>
<option value="all">All Statuses</option>
<option value="fulfilled">Fulfilled</option>
<option value="unfulfilled">Unfulfilled</option>
<option value="paid">Paid</option>
<option value="unpaid">Unpaid</option>
</select>
</div>
</div>
{/* Orders Grid */}
<div className="flex-1 overflow-y-auto overflow-x-hidden min-h-0">
{loading ? (
<div className="h-full flex flex-col items-center justify-center text-slate-400 gap-3">
<Loader2 className="w-8 h-8 animate-spin text-indigo-400" />
<p className="font-medium animate-pulse">Fetching latest orders...</p>
</div>
) : error ? (
<div className="h-full flex flex-col items-center justify-center text-red-400 gap-4 p-8 text-center">
<div className="w-12 h-12 bg-red-500/10 border border-red-500/20 rounded-2xl flex items-center justify-center">
<AlertCircle className="w-6 h-6" />
</div>
<p className="font-bold">{error}</p>
</div>
) : filteredOrders.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-slate-400 gap-3">
<ShoppingCart className="w-10 h-10 opacity-20" />
<p className="font-medium">No orders found.</p>
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4 md:gap-6 h-full content-start">
{filteredOrders.map((order) => (
<div
key={order.id}
className="bg-white/5 rounded-xl border border-white/10 p-5 hover:bg-white/10 hover:border-indigo-500/30 transition-all cursor-pointer group relative"
onClick={() => handleViewDetails(order)}
>
{/* Order Header */}
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="w-10 h-10 rounded-lg bg-indigo-500/20 border border-indigo-400/20 flex items-center justify-center text-indigo-400 group-hover:bg-indigo-500/30 transition-all shrink-0">
<ShoppingBag className="w-5 h-5" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-black text-base text-white truncate">{order.name}</h3>
<p className="text-xs text-slate-400 mt-0.5">
{new Date(order.created_at).toLocaleDateString()} • {new Date(order.created_at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
</p>
</div>
</div>
<div className="relative shrink-0" onClick={(e) => e.stopPropagation()} ref={(el) => { menuRefs.current[order.id] = el }}>
<button
onClick={() => handleMenuToggle(order.id)}
className="p-2 hover:bg-white/10 rounded-lg text-slate-400 hover:text-indigo-400 transition-all"
>
<MoreHorizontal className="w-4 h-4" />
</button>
{openMenuId === order.id && (
<div className="absolute right-0 top-full mt-1 bg-slate-800/95 backdrop-blur-xl border border-white/10 rounded-xl shadow-xl overflow-hidden z-[200] min-w-[180px] whitespace-nowrap">
<button
onClick={() => handleViewDetails(order)}
className="w-full px-4 py-2.5 text-left text-sm font-medium text-slate-200 hover:bg-indigo-500 hover:text-white transition-colors flex items-center gap-2"
>
<Eye className="w-4 h-4" />
Details
</button>
<button
onClick={() => handleViewInShopify(order.id)}
className="w-full px-4 py-2.5 text-left text-sm font-medium text-slate-200 hover:bg-indigo-500 hover:text-white transition-colors flex items-center gap-2"
>
<ExternalLink className="w-4 h-4" />
Show on Shopify
</button>
<button
onClick={() => handleCopyOrderId(order.id)}
className="w-full px-4 py-2.5 text-left text-sm font-medium text-slate-200 hover:bg-indigo-500 hover:text-white transition-colors flex items-center gap-2"
>
<Copy className="w-4 h-4" />
Copy Order ID
</button>
</div>
)}
</div>
</div>
{/* Customer Info */}
{order.customer && (
<div className="flex items-center gap-2 mb-4">
<div className="w-7 h-7 rounded-full bg-white/10 flex items-center justify-center shrink-0">
<User className="w-3.5 h-3.5 text-slate-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-bold text-white truncate">
{order.customer.first_name} {order.customer.last_name}
</p>
<p className="text-xs text-slate-400 truncate">{order.email}</p>
</div>
</div>
)}
{/* Order Details */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Total</span>
<span className="text-lg font-black text-white">{order.total_price} {order.currency}</span>
</div>
<div className="flex items-center gap-2 flex-wrap">
<span className={`px-2.5 py-1 rounded-full text-[10px] font-black uppercase tracking-wider inline-flex items-center gap-1.5 ${order.financial_status === "paid"
? "bg-emerald-500/20 text-emerald-400 border border-emerald-500/30"
: "bg-red-500/20 text-red-400 border border-red-500/30"
}`}>
{order.financial_status === "paid" && <CheckCircle2 className="w-3 h-3" />}
Payment: {order.financial_status}
</span>
<span className={`px-2.5 py-1 rounded-full text-[10px] font-black uppercase tracking-wider inline-flex items-center gap-1.5 ${getStatusStyles(order.fulfillment_status)}`}>
{order.fulfillment_status === "fulfilled" ? <CheckCircle2 className="w-3 h-3" /> : <Clock className="w-3 h-3" />}
{order.fulfillment_status || "Unfulfilled"}
</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
)
}