Enhance dashboard UI and add order timeline
All checks were successful
Build Frontend / build (push) Successful in 1m12s
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.
This commit is contained in:
@@ -39,6 +39,8 @@ import {
|
|||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import Layout from "@/components/layout/layout";
|
import Layout from "@/components/layout/layout";
|
||||||
import { cacheUtils } from '@/lib/api-client';
|
import { cacheUtils } from '@/lib/api-client';
|
||||||
|
import OrderTimeline from "@/components/orders/order-timeline";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
|
||||||
interface Order {
|
interface Order {
|
||||||
orderId: string;
|
orderId: string;
|
||||||
@@ -170,7 +172,7 @@ export default function OrderDetailsPage() {
|
|||||||
authToken: string
|
authToken: string
|
||||||
): Promise<Record<string, string>> => {
|
): Promise<Record<string, string>> => {
|
||||||
const productNamesMap: Record<string, string> = {};
|
const productNamesMap: Record<string, string> = {};
|
||||||
|
|
||||||
// Process each product ID independently
|
// Process each product ID independently
|
||||||
const fetchPromises = productIds.map(async (id) => {
|
const fetchPromises = productIds.map(async (id) => {
|
||||||
try {
|
try {
|
||||||
@@ -184,10 +186,10 @@ export default function OrderDetailsPage() {
|
|||||||
productNamesMap[id] = "Unknown Product (Deleted)";
|
productNamesMap[id] = "Unknown Product (Deleted)";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wait for all fetch operations to complete (successful or failed)
|
// Wait for all fetch operations to complete (successful or failed)
|
||||||
await Promise.all(fetchPromises);
|
await Promise.all(fetchPromises);
|
||||||
|
|
||||||
return productNamesMap;
|
return productNamesMap;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -195,38 +197,38 @@ export default function OrderDetailsPage() {
|
|||||||
try {
|
try {
|
||||||
// Add a loading state to give feedback
|
// Add a loading state to give feedback
|
||||||
const loadingToast = toast.loading("Marking order as paid...");
|
const loadingToast = toast.loading("Marking order as paid...");
|
||||||
|
|
||||||
// Log the request for debugging
|
// Log the request for debugging
|
||||||
console.log(`Sending request to /orders/${orderId}/status with clientFetch`);
|
console.log(`Sending request to /orders/${orderId}/status with clientFetch`);
|
||||||
console.log("Request payload:", { status: "paid" });
|
console.log("Request payload:", { status: "paid" });
|
||||||
|
|
||||||
// Use clientFetch which handles API URL and auth token automatically
|
// Use clientFetch which handles API URL and auth token automatically
|
||||||
const response = await clientFetch(`/orders/${orderId}/status`, {
|
const response = await clientFetch(`/orders/${orderId}/status`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify({ status: "paid" }),
|
body: JSON.stringify({ status: "paid" }),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Log the response
|
// Log the response
|
||||||
console.log("API response:", response);
|
console.log("API response:", response);
|
||||||
|
|
||||||
toast.dismiss(loadingToast);
|
toast.dismiss(loadingToast);
|
||||||
|
|
||||||
if (response && response.message === "Order status updated successfully") {
|
if (response && response.message === "Order status updated successfully") {
|
||||||
// Update both states
|
// Update both states
|
||||||
setIsPaid(true);
|
setIsPaid(true);
|
||||||
setOrder((prevOrder) => (prevOrder ? {
|
setOrder((prevOrder) => (prevOrder ? {
|
||||||
...prevOrder,
|
...prevOrder,
|
||||||
status: "paid",
|
status: "paid",
|
||||||
// Clear underpayment flags when marking as paid
|
// Clear underpayment flags when marking as paid
|
||||||
underpaid: false,
|
underpaid: false,
|
||||||
underpaymentAmount: 0
|
underpaymentAmount: 0
|
||||||
} : null));
|
} : null));
|
||||||
|
|
||||||
// Invalidate order cache to ensure fresh data everywhere
|
// Invalidate order cache to ensure fresh data everywhere
|
||||||
cacheUtils.invalidateOrderData(orderId as string);
|
cacheUtils.invalidateOrderData(orderId as string);
|
||||||
|
|
||||||
toast.success("Order marked as paid successfully");
|
toast.success("Order marked as paid successfully");
|
||||||
|
|
||||||
// Refresh order data to get latest status
|
// Refresh order data to get latest status
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setRefreshTrigger(prev => prev + 1);
|
setRefreshTrigger(prev => prev + 1);
|
||||||
@@ -240,14 +242,14 @@ export default function OrderDetailsPage() {
|
|||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("Failed to mark order as paid:", error);
|
console.error("Failed to mark order as paid:", error);
|
||||||
|
|
||||||
// More detailed error handling
|
// More detailed error handling
|
||||||
let errorMessage = "Failed to mark order as paid";
|
let errorMessage = "Failed to mark order as paid";
|
||||||
|
|
||||||
if (error.message) {
|
if (error.message) {
|
||||||
errorMessage += `: ${error.message}`;
|
errorMessage += `: ${error.message}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error.response) {
|
if (error.response) {
|
||||||
try {
|
try {
|
||||||
const errorData = await error.response.json();
|
const errorData = await error.response.json();
|
||||||
@@ -259,7 +261,7 @@ export default function OrderDetailsPage() {
|
|||||||
console.error("Could not parse error response:", e);
|
console.error("Could not parse error response:", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.error(errorMessage);
|
toast.error(errorMessage);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -317,7 +319,7 @@ export default function OrderDetailsPage() {
|
|||||||
...prevOrder,
|
...prevOrder,
|
||||||
trackingNumber: trackingNumber
|
trackingNumber: trackingNumber
|
||||||
} : null);
|
} : null);
|
||||||
|
|
||||||
toast.success("Tracking number added successfully!");
|
toast.success("Tracking number added successfully!");
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("Failed to add tracking number:", err);
|
console.error("Failed to add tracking number:", err);
|
||||||
@@ -330,7 +332,7 @@ export default function OrderDetailsPage() {
|
|||||||
const handleMarkAsAcknowledged = async () => {
|
const handleMarkAsAcknowledged = async () => {
|
||||||
try {
|
try {
|
||||||
setIsAcknowledging(true);
|
setIsAcknowledging(true);
|
||||||
|
|
||||||
// Use clientFetch which handles API URL and auth token automatically
|
// Use clientFetch which handles API URL and auth token automatically
|
||||||
const response = await clientFetch(`/orders/${orderId}/status`, {
|
const response = await clientFetch(`/orders/${orderId}/status`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
@@ -382,7 +384,7 @@ export default function OrderDetailsPage() {
|
|||||||
const handleCancelOrder = async () => {
|
const handleCancelOrder = async () => {
|
||||||
try {
|
try {
|
||||||
setIsCancelling(true);
|
setIsCancelling(true);
|
||||||
|
|
||||||
// Use clientFetch which handles API URL and auth token automatically
|
// Use clientFetch which handles API URL and auth token automatically
|
||||||
const response = await clientFetch(`/orders/${orderId}/status`, {
|
const response = await clientFetch(`/orders/${orderId}/status`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
@@ -429,10 +431,10 @@ export default function OrderDetailsPage() {
|
|||||||
const productIds = data.order.products.map((product) => product.productId);
|
const productIds = data.order.products.map((product) => product.productId);
|
||||||
const productNamesMap = await fetchProductNames(productIds, authToken);
|
const productNamesMap = await fetchProductNames(productIds, authToken);
|
||||||
setProductNames(productNamesMap);
|
setProductNames(productNamesMap);
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setProductNames(prev => {
|
setProductNames(prev => {
|
||||||
const newMap = {...prev};
|
const newMap = { ...prev };
|
||||||
productIds.forEach(id => {
|
productIds.forEach(id => {
|
||||||
if (!newMap[id] || newMap[id] === "Loading...") {
|
if (!newMap[id] || newMap[id] === "Loading...") {
|
||||||
newMap[id] = "Unknown Product (Deleted)";
|
newMap[id] = "Unknown Product (Deleted)";
|
||||||
@@ -440,7 +442,7 @@ export default function OrderDetailsPage() {
|
|||||||
});
|
});
|
||||||
return newMap;
|
return newMap;
|
||||||
});
|
});
|
||||||
}, 3000);
|
}, 3000);
|
||||||
|
|
||||||
if (data.order.status === "paid") {
|
if (data.order.status === "paid") {
|
||||||
setIsPaid(true);
|
setIsPaid(true);
|
||||||
@@ -460,7 +462,7 @@ export default function OrderDetailsPage() {
|
|||||||
const fetchAdjacentOrders = async () => {
|
const fetchAdjacentOrders = async () => {
|
||||||
try {
|
try {
|
||||||
const authToken = document.cookie.split("Authorization=")[1];
|
const authToken = document.cookie.split("Authorization=")[1];
|
||||||
|
|
||||||
if (!order?.orderId) return;
|
if (!order?.orderId) return;
|
||||||
|
|
||||||
// Get the current numerical orderId
|
// Get the current numerical orderId
|
||||||
@@ -470,7 +472,7 @@ export default function OrderDetailsPage() {
|
|||||||
// Use the new optimized backend endpoint to get adjacent orders
|
// Use the new optimized backend endpoint to get adjacent orders
|
||||||
const adjacentOrdersUrl = `${process.env.NEXT_PUBLIC_API_URL}/orders/adjacent/${currentOrderId}`;
|
const adjacentOrdersUrl = `${process.env.NEXT_PUBLIC_API_URL}/orders/adjacent/${currentOrderId}`;
|
||||||
console.log('Fetching adjacent orders:', adjacentOrdersUrl);
|
console.log('Fetching adjacent orders:', adjacentOrdersUrl);
|
||||||
|
|
||||||
const adjacentOrdersRes = await fetchData(
|
const adjacentOrdersRes = await fetchData(
|
||||||
adjacentOrdersUrl,
|
adjacentOrdersUrl,
|
||||||
{
|
{
|
||||||
@@ -488,17 +490,17 @@ export default function OrderDetailsPage() {
|
|||||||
|
|
||||||
// Set the next and previous order IDs
|
// Set the next and previous order IDs
|
||||||
const { newer, older } = adjacentOrdersRes;
|
const { newer, older } = adjacentOrdersRes;
|
||||||
|
|
||||||
// Set IDs for navigation
|
// Set IDs for navigation
|
||||||
setPrevOrderId(newer?._id || null);
|
setPrevOrderId(newer?._id || null);
|
||||||
setNextOrderId(older?._id || null);
|
setNextOrderId(older?._id || null);
|
||||||
|
|
||||||
if (newer) {
|
if (newer) {
|
||||||
console.log(`Newer order: ${newer.orderId} (ID: ${newer._id})`);
|
console.log(`Newer order: ${newer.orderId} (ID: ${newer._id})`);
|
||||||
} else {
|
} else {
|
||||||
console.log('No newer order found');
|
console.log('No newer order found');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (older) {
|
if (older) {
|
||||||
console.log(`Older order: ${older.orderId} (ID: ${older._id})`);
|
console.log(`Older order: ${older.orderId} (ID: ${older._id})`);
|
||||||
} else {
|
} else {
|
||||||
@@ -544,7 +546,7 @@ export default function OrderDetailsPage() {
|
|||||||
...prevOrder,
|
...prevOrder,
|
||||||
trackingNumber: trackingNumber
|
trackingNumber: trackingNumber
|
||||||
} : null);
|
} : null);
|
||||||
|
|
||||||
toast.success("Tracking number updated successfully!");
|
toast.success("Tracking number updated successfully!");
|
||||||
setTrackingNumber(""); // Clear the input
|
setTrackingNumber(""); // Clear the input
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -569,11 +571,11 @@ export default function OrderDetailsPage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const lines = [];
|
const lines = [];
|
||||||
|
|
||||||
// Order number
|
// Order number
|
||||||
lines.push(`Order Number: ${order.orderId}`);
|
lines.push(`Order Number: ${order.orderId}`);
|
||||||
lines.push('');
|
lines.push('');
|
||||||
|
|
||||||
// Order details
|
// Order details
|
||||||
lines.push('Order Details:');
|
lines.push('Order Details:');
|
||||||
if (order.products && order.products.length > 0) {
|
if (order.products && order.products.length > 0) {
|
||||||
@@ -582,30 +584,30 @@ export default function OrderDetailsPage() {
|
|||||||
lines.push(` - ${productName} (Qty: ${product.quantity} @ £${product.pricePerUnit.toFixed(2)} = £${product.totalItemPrice.toFixed(2)})`);
|
lines.push(` - ${productName} (Qty: ${product.quantity} @ £${product.pricePerUnit.toFixed(2)} = £${product.totalItemPrice.toFixed(2)})`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shipping
|
// Shipping
|
||||||
if (order.shippingMethod) {
|
if (order.shippingMethod) {
|
||||||
lines.push(` - Shipping: ${order.shippingMethod.type} (£${order.shippingMethod.price.toFixed(2)})`);
|
lines.push(` - Shipping: ${order.shippingMethod.type} (£${order.shippingMethod.price.toFixed(2)})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Discount
|
// Discount
|
||||||
if (order.discountAmount && order.discountAmount > 0) {
|
if (order.discountAmount && order.discountAmount > 0) {
|
||||||
lines.push(` - Discount: -£${order.discountAmount.toFixed(2)}${order.promotionCode ? ` (Promo: ${order.promotionCode})` : ''}`);
|
lines.push(` - Discount: -£${order.discountAmount.toFixed(2)}${order.promotionCode ? ` (Promo: ${order.promotionCode})` : ''}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subtotal if different from total
|
// Subtotal if different from total
|
||||||
if (order.subtotalBeforeDiscount && order.subtotalBeforeDiscount !== order.totalPrice) {
|
if (order.subtotalBeforeDiscount && order.subtotalBeforeDiscount !== order.totalPrice) {
|
||||||
lines.push(` - Subtotal: £${order.subtotalBeforeDiscount.toFixed(2)}`);
|
lines.push(` - Subtotal: £${order.subtotalBeforeDiscount.toFixed(2)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Total
|
// Total
|
||||||
lines.push(` - Total: £${order.totalPrice.toFixed(2)}`);
|
lines.push(` - Total: £${order.totalPrice.toFixed(2)}`);
|
||||||
lines.push('');
|
lines.push('');
|
||||||
|
|
||||||
// Address
|
// Address
|
||||||
lines.push('Address:');
|
lines.push('Address:');
|
||||||
lines.push(order.pgpAddress || 'N/A');
|
lines.push(order.pgpAddress || 'N/A');
|
||||||
|
|
||||||
const textToCopy = lines.join('\n');
|
const textToCopy = lines.join('\n');
|
||||||
await navigator.clipboard.writeText(textToCopy);
|
await navigator.clipboard.writeText(textToCopy);
|
||||||
toast.success("Order data copied to clipboard!");
|
toast.success("Order data copied to clipboard!");
|
||||||
@@ -618,28 +620,28 @@ export default function OrderDetailsPage() {
|
|||||||
// Helper function to check if order is underpaid
|
// Helper function to check if order is underpaid
|
||||||
const isOrderUnderpaid = (order: Order | null) => {
|
const isOrderUnderpaid = (order: Order | null) => {
|
||||||
// More robust check - only show underpaid if status is NOT paid and underpayment exists
|
// More robust check - only show underpaid if status is NOT paid and underpayment exists
|
||||||
return order?.underpaid === true &&
|
return order?.underpaid === true &&
|
||||||
order?.underpaymentAmount &&
|
order?.underpaymentAmount &&
|
||||||
order.underpaymentAmount > 0 &&
|
order.underpaymentAmount > 0 &&
|
||||||
order.status !== "paid" &&
|
order.status !== "paid" &&
|
||||||
order.status !== "completed" &&
|
order.status !== "completed" &&
|
||||||
order.status !== "shipped";
|
order.status !== "shipped";
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to get underpaid information
|
// Helper function to get underpaid information
|
||||||
const getUnderpaidInfo = (order: Order | null) => {
|
const getUnderpaidInfo = (order: Order | null) => {
|
||||||
if (!isOrderUnderpaid(order)) return null;
|
if (!isOrderUnderpaid(order)) return null;
|
||||||
|
|
||||||
const received = order?.lastBalanceReceived || 0;
|
const received = order?.lastBalanceReceived || 0;
|
||||||
const required = order?.cryptoTotal || 0;
|
const required = order?.cryptoTotal || 0;
|
||||||
const missing = order?.underpaymentAmount || 0;
|
const missing = order?.underpaymentAmount || 0;
|
||||||
|
|
||||||
// Calculate LTC to GBP exchange rate from order data
|
// Calculate LTC to GBP exchange rate from order data
|
||||||
const ltcToGbpRate = required > 0 ? (order?.totalPrice || 0) / required : 0;
|
const ltcToGbpRate = required > 0 ? (order?.totalPrice || 0) / required : 0;
|
||||||
const receivedGbp = received * ltcToGbpRate;
|
const receivedGbp = received * ltcToGbpRate;
|
||||||
const requiredGbp = order?.totalPrice || 0;
|
const requiredGbp = order?.totalPrice || 0;
|
||||||
const missingGbp = missing * ltcToGbpRate;
|
const missingGbp = missing * ltcToGbpRate;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
received,
|
received,
|
||||||
required,
|
required,
|
||||||
@@ -772,7 +774,7 @@ export default function OrderDetailsPage() {
|
|||||||
<p className="font-semibold">{underpaidInfo.percentage}% paid</p>
|
<p className="font-semibold">{underpaidInfo.percentage}% paid</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{order?.paymentAddress && (
|
{order?.paymentAddress && (
|
||||||
<div className="pt-3 border-t border-red-200 dark:border-red-800">
|
<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>
|
<p className="text-sm text-muted-foreground mb-2">Payment Address:</p>
|
||||||
@@ -791,10 +793,26 @@ export default function OrderDetailsPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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 }}
|
||||||
<div className="grid grid-cols-3 gap-6">
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.4 }}
|
||||||
|
className="grid grid-cols-3 gap-6"
|
||||||
|
>
|
||||||
{/* Left Column - Order Details */}
|
{/* Left Column - Order Details */}
|
||||||
<div className="col-span-2 space-y-6">
|
<div className="col-span-2 space-y-6">
|
||||||
{/* Products Card */}
|
{/* Products Card */}
|
||||||
@@ -929,7 +947,7 @@ export default function OrderDetailsPage() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-zinc-400">Customer Since</span>
|
<span className="text-zinc-400">Customer Since</span>
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{customerInsights.firstOrder ?
|
{customerInsights.firstOrder ?
|
||||||
new Date(customerInsights.firstOrder).toLocaleDateString('en-GB', {
|
new Date(customerInsights.firstOrder).toLocaleDateString('en-GB', {
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
@@ -1092,34 +1110,34 @@ export default function OrderDetailsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Cancel Order Button */}
|
{/* Cancel Order Button */}
|
||||||
{order?.status !== "cancelled" &&
|
{order?.status !== "cancelled" &&
|
||||||
order?.status !== "completed" &&
|
order?.status !== "completed" &&
|
||||||
order?.status !== "shipped" && (
|
order?.status !== "shipped" && (
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button variant="destructive" className="w-full">
|
<Button variant="destructive" className="w-full">
|
||||||
Cancel Order
|
Cancel Order
|
||||||
</Button>
|
</Button>
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Cancel Order</AlertDialogTitle>
|
<AlertDialogTitle>Cancel Order</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
Are you sure you want to cancel this order? This action cannot be undone.
|
Are you sure you want to cancel this order? This action cannot be undone.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
onClick={handleCancelOrder}
|
onClick={handleCancelOrder}
|
||||||
className="bg-red-500 hover:bg-red-600"
|
className="bg-red-500 hover:bg-red-600"
|
||||||
>
|
>
|
||||||
Confirm Cancel
|
Confirm Cancel
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* No Actions Available Message */}
|
{/* No Actions Available Message */}
|
||||||
{(order?.status === "completed" || order?.status === "cancelled") && (
|
{(order?.status === "completed" || order?.status === "cancelled") && (
|
||||||
@@ -1168,11 +1186,10 @@ export default function OrderDetailsPage() {
|
|||||||
{[...Array(5)].map((_, i) => (
|
{[...Array(5)].map((_, i) => (
|
||||||
<svg
|
<svg
|
||||||
key={i}
|
key={i}
|
||||||
className={`w-4 h-4 ${
|
className={`w-4 h-4 ${i < (order?.review?.stars || 0)
|
||||||
i < (order?.review?.stars || 0)
|
|
||||||
? "text-yellow-400"
|
? "text-yellow-400"
|
||||||
: "text-zinc-600"
|
: "text-zinc-600"
|
||||||
}`}
|
}`}
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
viewBox="0 0 20 20"
|
viewBox="0 0 20 20"
|
||||||
>
|
>
|
||||||
@@ -1198,10 +1215,10 @@ export default function OrderDetailsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Shipping Dialog removed; use inline tracking input above */}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Shipping Dialog removed; use inline tracking input above */}
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,27 +30,32 @@ import {
|
|||||||
DialogFooter,
|
DialogFooter,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Loader2,
|
Loader2,
|
||||||
Users,
|
Users,
|
||||||
ArrowUpDown,
|
ArrowUpDown,
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Search,
|
Search,
|
||||||
X
|
X,
|
||||||
|
CreditCard,
|
||||||
|
Calendar,
|
||||||
|
ShoppingBag
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
|
||||||
export default function CustomerManagementPage() {
|
export default function CustomerManagementPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -71,32 +76,32 @@ export default function CustomerManagementPage() {
|
|||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const response = await getCustomers(page, itemsPerPage);
|
const response = await getCustomers(page, itemsPerPage);
|
||||||
|
|
||||||
// Sort customers based on current sort config
|
// Sort customers based on current sort config
|
||||||
let sortedCustomers = [...response.customers];
|
let sortedCustomers = [...response.customers];
|
||||||
sortedCustomers.sort((a, b) => {
|
sortedCustomers.sort((a, b) => {
|
||||||
if (sortConfig.column === "totalOrders") {
|
if (sortConfig.column === "totalOrders") {
|
||||||
return sortConfig.direction === "asc"
|
return sortConfig.direction === "asc"
|
||||||
? a.totalOrders - b.totalOrders
|
? a.totalOrders - b.totalOrders
|
||||||
: b.totalOrders - a.totalOrders;
|
: b.totalOrders - a.totalOrders;
|
||||||
} else if (sortConfig.column === "totalSpent") {
|
} else if (sortConfig.column === "totalSpent") {
|
||||||
return sortConfig.direction === "asc"
|
return sortConfig.direction === "asc"
|
||||||
? a.totalSpent - b.totalSpent
|
? a.totalSpent - b.totalSpent
|
||||||
: b.totalSpent - a.totalSpent;
|
: b.totalSpent - a.totalSpent;
|
||||||
} else if (sortConfig.column === "lastOrderDate") {
|
} else if (sortConfig.column === "lastOrderDate") {
|
||||||
// Handle null lastOrderDate values
|
// Handle null lastOrderDate values
|
||||||
if (!a.lastOrderDate && !b.lastOrderDate) return 0;
|
if (!a.lastOrderDate && !b.lastOrderDate) return 0;
|
||||||
if (!a.lastOrderDate) return sortConfig.direction === "asc" ? -1 : 1;
|
if (!a.lastOrderDate) return sortConfig.direction === "asc" ? -1 : 1;
|
||||||
if (!b.lastOrderDate) return sortConfig.direction === "asc" ? 1 : -1;
|
if (!b.lastOrderDate) return sortConfig.direction === "asc" ? 1 : -1;
|
||||||
|
|
||||||
// Both have valid dates
|
// Both have valid dates
|
||||||
return sortConfig.direction === "asc"
|
return sortConfig.direction === "asc"
|
||||||
? new Date(a.lastOrderDate).getTime() - new Date(b.lastOrderDate).getTime()
|
? new Date(a.lastOrderDate).getTime() - new Date(b.lastOrderDate).getTime()
|
||||||
: new Date(b.lastOrderDate).getTime() - new Date(a.lastOrderDate).getTime();
|
: new Date(b.lastOrderDate).getTime() - new Date(a.lastOrderDate).getTime();
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
setCustomers(sortedCustomers);
|
setCustomers(sortedCustomers);
|
||||||
setFilteredCustomers(sortedCustomers);
|
setFilteredCustomers(sortedCustomers);
|
||||||
setTotalPages(Math.ceil(response.total / itemsPerPage));
|
setTotalPages(Math.ceil(response.total / itemsPerPage));
|
||||||
@@ -138,424 +143,444 @@ export default function CustomerManagementPage() {
|
|||||||
}
|
}
|
||||||
}, [searchQuery, customers]);
|
}, [searchQuery, customers]);
|
||||||
|
|
||||||
const handlePageChange = (newPage: number) => {
|
const handlePageChange = (newPage: number) => {
|
||||||
setPage(newPage);
|
setPage(newPage);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleItemsPerPageChange = (value: string) => {
|
const handleItemsPerPageChange = (value: string) => {
|
||||||
setItemsPerPage(parseInt(value, 10));
|
setItemsPerPage(parseInt(value, 10));
|
||||||
setPage(1);
|
setPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSort = (column: "totalOrders" | "totalSpent" | "lastOrderDate") => {
|
const handleSort = (column: "totalOrders" | "totalSpent" | "lastOrderDate") => {
|
||||||
setSortConfig(prev => ({
|
setSortConfig(prev => ({
|
||||||
column,
|
column,
|
||||||
direction: prev.column === column && prev.direction === "asc" ? "desc" : "asc"
|
direction: prev.column === column && prev.direction === "asc" ? "desc" : "asc"
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearSearch = () => {
|
const clearSearch = () => {
|
||||||
setSearchQuery("");
|
setSearchQuery("");
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (dateString: string | null | undefined) => {
|
const formatDate = (dateString: string | null | undefined) => {
|
||||||
if (!dateString) return "N/A";
|
if (!dateString) return "N/A";
|
||||||
try {
|
try {
|
||||||
const date = new Date(dateString);
|
const date = new Date(dateString);
|
||||||
return new Intl.DateTimeFormat('en-GB', {
|
return new Intl.DateTimeFormat('en-GB', {
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit'
|
minute: '2-digit'
|
||||||
}).format(date);
|
}).format(date);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return "N/A";
|
return "N/A";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-semibold text-white flex items-center">
|
<h1 className="text-2xl font-semibold text-white flex items-center">
|
||||||
<Users className="mr-2 h-6 w-6" />
|
<Users className="mr-2 h-6 w-6" />
|
||||||
Customer Management
|
Customer Management
|
||||||
</h1>
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden">
|
||||||
|
<div className="p-4 border-b border-border/50 bg-muted/30 flex flex-col md:flex-row md:justify-between md:items-center gap-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="text-sm font-medium text-muted-foreground">Show:</div>
|
||||||
|
<Select value={itemsPerPage.toString()} onValueChange={handleItemsPerPageChange}>
|
||||||
|
<SelectTrigger className="w-[70px] bg-background/50 border-border/50">
|
||||||
|
<SelectValue placeholder="25" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{[5, 10, 25, 50, 100].map(size => (
|
||||||
|
<SelectItem key={size} value={size.toString()}>{size}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-black/40 border border-zinc-800 rounded-md overflow-hidden">
|
<div className="relative flex-1 max-w-md">
|
||||||
<div className="p-4 border-b border-zinc-800 bg-black/60 flex flex-col md:flex-row md:justify-between md:items-center gap-4">
|
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||||
<div className="flex items-center gap-4">
|
<Search className="h-4 w-4 text-muted-foreground" />
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="text-sm font-medium text-gray-400">Show:</div>
|
|
||||||
<Select value={itemsPerPage.toString()} onValueChange={handleItemsPerPageChange}>
|
|
||||||
<SelectTrigger className="w-[70px]">
|
|
||||||
<SelectValue placeholder="25" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{[5, 10, 25, 50, 100].map(size => (
|
|
||||||
<SelectItem key={size} value={size.toString()}>{size}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative flex-1 max-w-md">
|
|
||||||
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
|
||||||
<Search className="h-4 w-4 text-gray-400" />
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search by username or Telegram ID..."
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
className="pl-10 pr-10 py-2 w-full bg-black/40 border-zinc-700 text-white"
|
|
||||||
/>
|
|
||||||
{searchQuery && (
|
|
||||||
<button
|
|
||||||
className="absolute inset-y-0 right-0 flex items-center pr-3"
|
|
||||||
onClick={clearSearch}
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4 text-gray-400 hover:text-gray-200" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-sm text-gray-400 whitespace-nowrap">
|
|
||||||
{loading
|
|
||||||
? "Loading..."
|
|
||||||
: searchQuery
|
|
||||||
? `Found ${filteredCustomers.length} matching customers`
|
|
||||||
: `Showing ${filteredCustomers.length} of ${totalPages * itemsPerPage} customers`}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search by username or Telegram ID..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-10 pr-10 py-2 w-full bg-background/50 border-border/50 focus:ring-primary/20 transition-all duration-300"
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<button
|
||||||
|
className="absolute inset-y-0 right-0 flex items-center pr-3"
|
||||||
|
onClick={clearSearch}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4 text-muted-foreground hover:text-foreground" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
<div className="text-sm text-muted-foreground whitespace-nowrap">
|
||||||
<div className="p-8 bg-black/60">
|
{loading
|
||||||
{/* Loading indicator */}
|
? "Loading..."
|
||||||
<div className="absolute top-0 left-0 right-0 h-1 bg-muted overflow-hidden">
|
: searchQuery
|
||||||
<div className="h-full bg-primary w-1/3"
|
? `Found ${filteredCustomers.length} matching customers`
|
||||||
style={{
|
: `Showing ${filteredCustomers.length} of ${totalPages * itemsPerPage} customers`}
|
||||||
background: 'linear-gradient(90deg, transparent, hsl(var(--primary)), transparent)',
|
</div>
|
||||||
backgroundSize: '200% 100%',
|
</div>
|
||||||
animation: 'shimmer 2s ease-in-out infinite',
|
|
||||||
}}
|
<CardContent className="p-0">
|
||||||
/>
|
{loading ? (
|
||||||
</div>
|
<div className="p-8">
|
||||||
|
{/* Loading indicator */}
|
||||||
{/* Table skeleton */}
|
<div className="absolute top-[69px] left-0 right-0 h-0.5 bg-muted overflow-hidden">
|
||||||
<div className="space-y-4">
|
<div className="h-full bg-primary w-1/3 animate-shimmer"
|
||||||
<div className="flex items-center gap-4 pb-4 border-b border-zinc-800">
|
style={{
|
||||||
{['Customer', 'Orders', 'Total Spent', 'Last Order', 'Status'].map((header, i) => (
|
background: 'linear-gradient(90deg, transparent, hsl(var(--primary)), transparent)',
|
||||||
<Skeleton
|
backgroundSize: '200% 100%',
|
||||||
key={i}
|
}}
|
||||||
className="h-4 w-20 flex-1 animate-in fade-in"
|
/>
|
||||||
style={{
|
</div>
|
||||||
animationDelay: `${i * 50}ms`,
|
|
||||||
animationDuration: '300ms',
|
<div className="space-y-4">
|
||||||
animationFillMode: 'both',
|
<div className="flex items-center gap-4 pb-4 border-b border-border/50">
|
||||||
}}
|
{['Customer', 'Orders', 'Total Spent', 'Last Order', 'Status'].map((header, i) => (
|
||||||
/>
|
<Skeleton
|
||||||
))}
|
key={i}
|
||||||
</div>
|
className="h-4 w-20 flex-1"
|
||||||
|
/>
|
||||||
{[...Array(5)].map((_, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className="flex items-center gap-4 pb-4 border-b border-zinc-800 last:border-b-0 animate-in fade-in"
|
|
||||||
style={{
|
|
||||||
animationDelay: `${250 + i * 50}ms`,
|
|
||||||
animationDuration: '300ms',
|
|
||||||
animationFillMode: 'both',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3 flex-1">
|
|
||||||
<Skeleton className="h-10 w-10 rounded-full" />
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Skeleton className="h-4 w-32" />
|
|
||||||
<Skeleton className="h-3 w-24" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Skeleton className="h-6 w-12 flex-1 rounded-full" />
|
|
||||||
<Skeleton className="h-4 w-20 flex-1" />
|
|
||||||
<Skeleton className="h-4 w-24 flex-1" />
|
|
||||||
<Skeleton className="h-6 w-24 flex-1 rounded-full" />
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{[...Array(5)].map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex items-center gap-4 pb-4 border-b border-border/50 last:border-b-0"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 flex-1">
|
||||||
|
<Skeleton className="h-10 w-10 rounded-full" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Skeleton className="h-4 w-32" />
|
||||||
|
<Skeleton className="h-3 w-24" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-6 w-12 flex-1 rounded-full" />
|
||||||
|
<Skeleton className="h-4 w-20 flex-1" />
|
||||||
|
<Skeleton className="h-4 w-24 flex-1" />
|
||||||
|
<Skeleton className="h-6 w-24 flex-1 rounded-full" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : filteredCustomers.length === 0 ? (
|
</div>
|
||||||
<div className="p-8 text-center bg-black/60">
|
) : filteredCustomers.length === 0 ? (
|
||||||
<Users className="h-12 w-12 mx-auto text-gray-500 mb-4" />
|
<div className="p-12 text-center">
|
||||||
<h3 className="text-lg font-medium mb-2 text-white">
|
<div className="w-16 h-16 bg-muted/50 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
{searchQuery ? "No customers matching your search" : "No customers found"}
|
<Users className="h-8 w-8 text-muted-foreground" />
|
||||||
</h3>
|
|
||||||
<p className="text-gray-500">
|
|
||||||
{searchQuery
|
|
||||||
? "Try a different search term or clear the search"
|
|
||||||
: "Once you have customers placing orders, they will appear here."}
|
|
||||||
</p>
|
|
||||||
{searchQuery && (
|
|
||||||
<Button variant="outline" size="sm" onClick={clearSearch} className="mt-4">
|
|
||||||
Clear search
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
<h3 className="text-lg font-medium mb-2 text-foreground">
|
||||||
<div className="overflow-x-auto">
|
{searchQuery ? "No matching customers" : "No customers yet"}
|
||||||
<Table className="[&_tr]:border-b [&_tr]:border-zinc-800 [&_tr:last-child]:border-b-0 [&_td]:border-r [&_td]:border-zinc-800 [&_td:last-child]:border-r-0 [&_th]:border-r [&_th]:border-zinc-800 [&_th:last-child]:border-r-0 [&_tr:hover]:bg-zinc-900/70">
|
</h3>
|
||||||
<TableHeader className="bg-black/60 sticky top-0 z-10">
|
<p className="text-muted-foreground max-w-sm mx-auto mb-6">
|
||||||
<TableRow>
|
{searchQuery
|
||||||
<TableHead className="w-[180px] text-gray-300">Customer</TableHead>
|
? "We couldn't find any customers matching your search criteria."
|
||||||
<TableHead
|
: "Once you have customers placing orders, they will appear here."}
|
||||||
className="cursor-pointer w-[100px] text-gray-300 text-center"
|
</p>
|
||||||
onClick={() => handleSort("totalOrders")}
|
{searchQuery && (
|
||||||
>
|
<Button variant="outline" size="sm" onClick={clearSearch}>
|
||||||
<div className="flex items-center justify-center">
|
Clear Search
|
||||||
Orders
|
</Button>
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TableHead>
|
) : (
|
||||||
<TableHead
|
<div className="overflow-x-auto">
|
||||||
className="cursor-pointer w-[150px] text-gray-300 text-center"
|
<Table>
|
||||||
onClick={() => handleSort("totalSpent")}
|
<TableHeader className="bg-muted/50">
|
||||||
>
|
<TableRow className="hover:bg-transparent border-border/50">
|
||||||
<div className="flex items-center justify-center">
|
<TableHead className="w-[200px]">Customer</TableHead>
|
||||||
Total Spent
|
<TableHead
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
className="cursor-pointer w-[100px] text-center hover:text-primary transition-colors"
|
||||||
</div>
|
onClick={() => handleSort("totalOrders")}
|
||||||
</TableHead>
|
>
|
||||||
<TableHead
|
<div className="flex items-center justify-center gap-1">
|
||||||
className="cursor-pointer w-[180px] text-gray-300 text-center"
|
Orders
|
||||||
onClick={() => handleSort("lastOrderDate")}
|
<ArrowUpDown className="h-3 w-3" />
|
||||||
>
|
</div>
|
||||||
<div className="flex items-center justify-center">
|
</TableHead>
|
||||||
Last Order
|
<TableHead
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
className="cursor-pointer w-[150px] text-center hover:text-primary transition-colors"
|
||||||
</div>
|
onClick={() => handleSort("totalSpent")}
|
||||||
</TableHead>
|
>
|
||||||
<TableHead className="w-[250px] text-gray-300 text-center">Status</TableHead>
|
<div className="flex items-center justify-center gap-1">
|
||||||
</TableRow>
|
Total Spent
|
||||||
</TableHeader>
|
<ArrowUpDown className="h-3 w-3" />
|
||||||
<TableBody>
|
</div>
|
||||||
{filteredCustomers.map((customer) => (
|
</TableHead>
|
||||||
<TableRow
|
<TableHead
|
||||||
|
className="cursor-pointer w-[180px] text-center hover:text-primary transition-colors"
|
||||||
|
onClick={() => handleSort("lastOrderDate")}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
Last Order
|
||||||
|
<ArrowUpDown className="h-3 w-3" />
|
||||||
|
</div>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[250px] text-center">Status</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
<AnimatePresence mode="popLayout">
|
||||||
|
{filteredCustomers.map((customer, index) => (
|
||||||
|
<motion.tr
|
||||||
key={customer.userId}
|
key={customer.userId}
|
||||||
className={`cursor-pointer ${!customer.hasOrders ? "bg-black/30" : ""}`}
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2, delay: index * 0.03 }}
|
||||||
|
className={`cursor-pointer group hover:bg-muted/50 border-b border-border/50 transition-colors ${!customer.hasOrders ? "bg-primary/5 hover:bg-primary/10" : ""}`}
|
||||||
onClick={() => setSelectedCustomer(customer)}
|
onClick={() => setSelectedCustomer(customer)}
|
||||||
>
|
>
|
||||||
<TableCell>
|
<TableCell className="py-3">
|
||||||
<div className="font-medium text-gray-100">
|
<div className="flex items-center gap-3">
|
||||||
@{customer.telegramUsername || "Unknown"}
|
<div className={`h-9 w-9 rounded-full flex items-center justify-center text-xs font-medium ${!customer.hasOrders ? 'bg-primary/20 text-primary' : 'bg-muted text-muted-foreground'
|
||||||
{!customer.hasOrders && (
|
}`}>
|
||||||
<Badge variant="outline" className="ml-2 bg-purple-900/30 text-purple-300 border-purple-700">
|
{customer.telegramUsername ? customer.telegramUsername.substring(0, 2).toUpperCase() : 'ID'}
|
||||||
<UserPlus className="h-3 w-3 mr-1" />
|
</div>
|
||||||
New
|
<div>
|
||||||
</Badge>
|
<div className="font-medium flex items-center gap-2">
|
||||||
)}
|
@{customer.telegramUsername || "Unknown"}
|
||||||
|
{!customer.hasOrders && (
|
||||||
|
<Badge variant="outline" className="h-5 px-1.5 text-[10px] bg-primary/10 text-primary border-primary/20">
|
||||||
|
New
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground flex items-center font-mono mt-0.5">
|
||||||
|
<span className="opacity-50 select-none">ID:</span>
|
||||||
|
<span className="ml-1">{customer.telegramUserId}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-400">ID: {customer.telegramUserId}</div>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-center">
|
<TableCell className="text-center">
|
||||||
<Badge className="bg-gray-700 text-white hover:bg-gray-600">{customer.totalOrders}</Badge>
|
<Badge variant="secondary" className="font-mono font-normal">
|
||||||
|
{customer.totalOrders}
|
||||||
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="font-medium text-gray-100 text-center">
|
<TableCell className="text-center font-mono text-sm">
|
||||||
{formatCurrency(customer.totalSpent)}
|
{formatCurrency(customer.totalSpent)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-sm text-gray-100 text-center">
|
<TableCell className="text-center text-sm text-muted-foreground">
|
||||||
{customer.lastOrderDate ? formatDate(customer.lastOrderDate) : "N/A"}
|
{customer.lastOrderDate ? (
|
||||||
|
<div className="flex items-center justify-center gap-1.5">
|
||||||
|
<Calendar className="h-3 w-3 opacity-70" />
|
||||||
|
{formatDate(customer.lastOrderDate).split(",")[0]}
|
||||||
|
</div>
|
||||||
|
) : "Never"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-center">
|
<TableCell className="text-center">
|
||||||
{customer.hasOrders ? (
|
{customer.hasOrders ? (
|
||||||
<div className="flex justify-center space-x-1">
|
<div className="flex justify-center flex-wrap gap-1">
|
||||||
<Badge className="bg-blue-500 text-white hover:bg-blue-600">
|
{customer.ordersByStatus.paid > 0 && (
|
||||||
{customer.ordersByStatus.paid} Paid
|
<Badge className="bg-blue-500/15 text-blue-500 hover:bg-blue-500/25 border-blue-500/20 h-5 px-1.5 text-[10px]">
|
||||||
</Badge>
|
{customer.ordersByStatus.paid} Paid
|
||||||
<Badge className="bg-green-500 text-white hover:bg-green-600">
|
</Badge>
|
||||||
{customer.ordersByStatus.completed} Completed
|
)}
|
||||||
</Badge>
|
{customer.ordersByStatus.completed > 0 && (
|
||||||
<Badge className="bg-amber-500 text-white hover:bg-amber-600">
|
<Badge className="bg-emerald-500/15 text-emerald-500 hover:bg-emerald-500/25 border-emerald-500/20 h-5 px-1.5 text-[10px]">
|
||||||
{customer.ordersByStatus.shipped} Shipped
|
{customer.ordersByStatus.completed} Done
|
||||||
</Badge>
|
</Badge>
|
||||||
|
)}
|
||||||
|
{customer.ordersByStatus.shipped > 0 && (
|
||||||
|
<Badge className="bg-amber-500/15 text-amber-500 hover:bg-amber-500/25 border-amber-500/20 h-5 px-1.5 text-[10px]">
|
||||||
|
{customer.ordersByStatus.shipped} Ship
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Badge variant="outline" className="bg-gray-800 text-gray-300 border-gray-700">
|
<span className="text-xs text-muted-foreground italic">No activity</span>
|
||||||
No orders yet
|
|
||||||
</Badge>
|
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</motion.tr>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</AnimatePresence>
|
||||||
</Table>
|
</TableBody>
|
||||||
</div>
|
</Table>
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
<div className="p-4 border-t border-zinc-800 bg-black/40 flex justify-between items-center">
|
<div className="p-4 border-t border-border/50 bg-background/50 flex justify-between items-center">
|
||||||
<div className="text-sm text-gray-400">
|
<div className="text-sm text-muted-foreground">
|
||||||
Page {page} of {totalPages}
|
Page {page} of {totalPages}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handlePageChange(Math.max(1, page - 1))}
|
||||||
|
disabled={page === 1 || loading}
|
||||||
|
className="h-8"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-3 w-3 mr-1" />
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{totalPages > 2 ? (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm" className="h-8 px-2">
|
||||||
|
<MoreHorizontal className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="center" className="max-h-60 overflow-y-auto">
|
||||||
|
{Array.from({ length: totalPages }, (_, i) => i + 1).map((pageNum) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={pageNum}
|
||||||
|
onClick={() => handlePageChange(pageNum)}
|
||||||
|
className={pageNum === page ? 'bg-primary/10 text-primary' : ''}
|
||||||
|
>
|
||||||
|
Page {pageNum}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handlePageChange(Math.min(totalPages, page + 1))}
|
||||||
|
disabled={page === totalPages || loading}
|
||||||
|
className="h-8"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="h-3 w-3 ml-1" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Customer Details Dialog */}
|
||||||
|
{selectedCustomer && (
|
||||||
|
<Dialog open={!!selectedCustomer} onOpenChange={(open) => !open && setSelectedCustomer(null)}>
|
||||||
|
<DialogContent className="max-w-[95vw] sm:max-w-2xl w-full overflow-y-auto max-h-[90vh] z-[80]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-base">
|
||||||
|
Customer Details
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 py-4">
|
||||||
|
{/* Customer Information */}
|
||||||
|
<div>
|
||||||
|
<div className="mb-4">
|
||||||
|
<h3 className="text-sm font-medium mb-2">Customer Information</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex justify-between items-center text-sm">
|
||||||
|
<div className="text-muted-foreground">Username:</div>
|
||||||
|
<div className="font-medium">@{selectedCustomer.telegramUsername || "Unknown"}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center text-sm">
|
||||||
|
<div className="text-muted-foreground">Telegram ID:</div>
|
||||||
|
<div className="font-medium">{selectedCustomer.telegramUserId}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center text-sm">
|
||||||
|
<div className="text-muted-foreground">Chat ID:</div>
|
||||||
|
<div className="font-medium">{selectedCustomer.chatId}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => {
|
||||||
|
window.open(`https://t.me/${selectedCustomer.telegramUsername || selectedCustomer.telegramUserId}`, '_blank');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MessageCircle className="h-4 w-4 mr-2" />
|
||||||
|
Open Telegram Chat
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
{/* Order Statistics */}
|
||||||
variant="outline"
|
<div>
|
||||||
size="sm"
|
<h3 className="text-sm font-medium mb-2">Order Statistics</h3>
|
||||||
onClick={() => handlePageChange(Math.max(1, page - 1))}
|
<div className="bg-background rounded-lg border border-border p-4 space-y-3">
|
||||||
disabled={page === 1 || loading}
|
<div className="flex justify-between items-center text-sm">
|
||||||
>
|
<div className="text-muted-foreground">Total Orders:</div>
|
||||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
<div className="font-medium">{selectedCustomer.totalOrders}</div>
|
||||||
Previous
|
</div>
|
||||||
</Button>
|
<div className="flex justify-between items-center text-sm">
|
||||||
|
<div className="text-muted-foreground">Total Spent:</div>
|
||||||
{totalPages > 2 && (
|
<div className="font-medium">{formatCurrency(selectedCustomer.totalSpent)}</div>
|
||||||
<DropdownMenu>
|
</div>
|
||||||
<DropdownMenuTrigger asChild>
|
<div className="flex justify-between items-center text-sm">
|
||||||
<Button variant="outline" size="sm">
|
<div className="text-muted-foreground">First Order:</div>
|
||||||
<span className="sr-only">Go to page</span>
|
<div className="font-medium">
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
{formatDate(selectedCustomer.firstOrderDate)}
|
||||||
</Button>
|
</div>
|
||||||
</DropdownMenuTrigger>
|
</div>
|
||||||
<DropdownMenuContent align="center" className="bg-black/90 border-zinc-800 max-h-60 overflow-y-auto">
|
<div className="flex justify-between items-center text-sm">
|
||||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((pageNum) => (
|
<div className="text-muted-foreground">Last Order:</div>
|
||||||
<DropdownMenuItem
|
<div className="font-medium">
|
||||||
key={pageNum}
|
{formatDate(selectedCustomer.lastOrderDate)}
|
||||||
onClick={() => handlePageChange(pageNum)}
|
</div>
|
||||||
className={`cursor-pointer ${pageNum === page ? 'bg-zinc-800 text-white' : 'text-gray-300'}`}
|
</div>
|
||||||
>
|
</div>
|
||||||
Page {pageNum}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handlePageChange(Math.min(totalPages, page + 1))}
|
|
||||||
disabled={page === totalPages || loading}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
<ChevronRight className="h-4 w-4 ml-1" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Customer Details Dialog */}
|
{/* Order Status Breakdown */}
|
||||||
{selectedCustomer && (
|
<div className="mb-4">
|
||||||
<Dialog open={!!selectedCustomer} onOpenChange={(open) => !open && setSelectedCustomer(null)}>
|
<h3 className="text-sm font-medium mb-2">Order Status Breakdown</h3>
|
||||||
<DialogContent className="max-w-[95vw] sm:max-w-2xl w-full overflow-y-auto max-h-[90vh] z-[80]">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||||
<DialogHeader>
|
<div className="bg-blue-500/10 rounded-lg border border-blue-500/20 p-3">
|
||||||
<DialogTitle className="text-base">
|
<p className="text-sm text-muted-foreground">Paid</p>
|
||||||
Customer Details
|
<p className="text-xl font-semibold">{selectedCustomer.ordersByStatus.paid}</p>
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 py-4">
|
|
||||||
{/* Customer Information */}
|
|
||||||
<div>
|
|
||||||
<div className="mb-4">
|
|
||||||
<h3 className="text-sm font-medium mb-2">Customer Information</h3>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex justify-between items-center text-sm">
|
|
||||||
<div className="text-muted-foreground">Username:</div>
|
|
||||||
<div className="font-medium">@{selectedCustomer.telegramUsername || "Unknown"}</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between items-center text-sm">
|
|
||||||
<div className="text-muted-foreground">Telegram ID:</div>
|
|
||||||
<div className="font-medium">{selectedCustomer.telegramUserId}</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between items-center text-sm">
|
|
||||||
<div className="text-muted-foreground">Chat ID:</div>
|
|
||||||
<div className="font-medium">{selectedCustomer.chatId}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="w-full"
|
|
||||||
onClick={() => {
|
|
||||||
window.open(`https://t.me/${selectedCustomer.telegramUsername || selectedCustomer.telegramUserId}`, '_blank');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MessageCircle className="h-4 w-4 mr-2" />
|
|
||||||
Open Telegram Chat
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Order Statistics */}
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium mb-2">Order Statistics</h3>
|
|
||||||
<div className="bg-background rounded-lg border border-border p-4 space-y-3">
|
|
||||||
<div className="flex justify-between items-center text-sm">
|
|
||||||
<div className="text-muted-foreground">Total Orders:</div>
|
|
||||||
<div className="font-medium">{selectedCustomer.totalOrders}</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between items-center text-sm">
|
|
||||||
<div className="text-muted-foreground">Total Spent:</div>
|
|
||||||
<div className="font-medium">{formatCurrency(selectedCustomer.totalSpent)}</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between items-center text-sm">
|
|
||||||
<div className="text-muted-foreground">First Order:</div>
|
|
||||||
<div className="font-medium">
|
|
||||||
{formatDate(selectedCustomer.firstOrderDate)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between items-center text-sm">
|
|
||||||
<div className="text-muted-foreground">Last Order:</div>
|
|
||||||
<div className="font-medium">
|
|
||||||
{formatDate(selectedCustomer.lastOrderDate)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="bg-purple-500/10 rounded-lg border border-purple-500/20 p-3">
|
||||||
{/* Order Status Breakdown */}
|
<p className="text-sm text-muted-foreground">Acknowledged</p>
|
||||||
<div className="mb-4">
|
<p className="text-xl font-semibold">{selectedCustomer.ordersByStatus.acknowledged}</p>
|
||||||
<h3 className="text-sm font-medium mb-2">Order Status Breakdown</h3>
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
|
||||||
<div className="bg-blue-500/10 rounded-lg border border-blue-500/20 p-3">
|
|
||||||
<p className="text-sm text-muted-foreground">Paid</p>
|
|
||||||
<p className="text-xl font-semibold">{selectedCustomer.ordersByStatus.paid}</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-purple-500/10 rounded-lg border border-purple-500/20 p-3">
|
|
||||||
<p className="text-sm text-muted-foreground">Acknowledged</p>
|
|
||||||
<p className="text-xl font-semibold">{selectedCustomer.ordersByStatus.acknowledged}</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-amber-500/10 rounded-lg border border-amber-500/20 p-3">
|
|
||||||
<p className="text-sm text-muted-foreground">Shipped</p>
|
|
||||||
<p className="text-xl font-semibold">{selectedCustomer.ordersByStatus.shipped}</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-green-500/10 rounded-lg border border-green-500/20 p-3">
|
|
||||||
<p className="text-sm text-muted-foreground">Completed</p>
|
|
||||||
<p className="text-xl font-semibold">{selectedCustomer.ordersByStatus.completed}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="bg-amber-500/10 rounded-lg border border-amber-500/20 p-3">
|
||||||
|
<p className="text-sm text-muted-foreground">Shipped</p>
|
||||||
|
<p className="text-xl font-semibold">{selectedCustomer.ordersByStatus.shipped}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-green-500/10 rounded-lg border border-green-500/20 p-3">
|
||||||
|
<p className="text-sm text-muted-foreground">Completed</p>
|
||||||
|
<p className="text-xl font-semibold">{selectedCustomer.ordersByStatus.completed}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setSelectedCustomer(null)}
|
onClick={() => setSelectedCustomer(null)}
|
||||||
>
|
>
|
||||||
Close
|
Close
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => router.push(`/dashboard/storefront/chat/new?userId=${selectedCustomer.telegramUserId}`)}
|
onClick={() => router.push(`/dashboard/storefront/chat/new?userId=${selectedCustomer.telegramUserId}`)}
|
||||||
>
|
>
|
||||||
<MessageCircle className="h-4 w-4 mr-2" />
|
<MessageCircle className="h-4 w-4 mr-2" />
|
||||||
Start Chat
|
Start Chat
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -6,11 +6,15 @@ import Layout from "@/components/layout/layout";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Save, Send, Key, MessageSquare, Shield, Globe, Wallet } from "lucide-react";
|
import { Save, Send, Key, MessageSquare, Shield, Globe, Wallet, RefreshCw } from "lucide-react";
|
||||||
import { apiRequest } from "@/lib/api";
|
import { apiRequest } from "@/lib/api";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import BroadcastDialog from "@/components/modals/broadcast-dialog";
|
import BroadcastDialog from "@/components/modals/broadcast-dialog";
|
||||||
import Dashboard from "@/components/dashboard/dashboard";
|
import Dashboard from "@/components/dashboard/dashboard";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from "@/components/ui/card";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -166,251 +170,298 @@ export default function StorefrontPage() {
|
|||||||
return (
|
return (
|
||||||
<Dashboard>
|
<Dashboard>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between mb-8">
|
||||||
<div className="flex items-center gap-6">
|
<div className="flex items-center gap-4">
|
||||||
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white flex items-center">
|
<div className="p-3 rounded-xl bg-primary/10 text-primary">
|
||||||
<Globe className="mr-2 h-6 w-6" />
|
<Globe className="h-8 w-8" />
|
||||||
Storefront Settings
|
</div>
|
||||||
</h1>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<h1 className="text-3xl font-bold tracking-tight text-foreground">
|
||||||
<TooltipProvider>
|
Storefront Settings
|
||||||
<Tooltip>
|
</h1>
|
||||||
<TooltipTrigger asChild>
|
<p className="text-muted-foreground">
|
||||||
<div className="flex items-center gap-2">
|
Manage your shop's appearance, policies, and configuration
|
||||||
<Switch
|
</p>
|
||||||
checked={storefront.isEnabled}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
setStorefront((prev) => ({
|
|
||||||
...prev,
|
|
||||||
isEnabled: checked,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<span className={`text-sm font-medium ${storefront.isEnabled ? 'text-emerald-400' : 'text-zinc-400'}`}>
|
|
||||||
{storefront.isEnabled ? 'Store Open' : 'Store Closed'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>{storefront.isEnabled ? 'Click to close store' : 'Click to open store'}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-3">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setBroadcastOpen(true)}
|
onClick={() => setBroadcastOpen(true)}
|
||||||
className="gap-2"
|
className="gap-2 h-10"
|
||||||
size="sm"
|
|
||||||
>
|
>
|
||||||
<Send className="h-4 w-4" />
|
<Send className="h-4 w-4" />
|
||||||
Broadcast
|
Broadcast
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={saveStorefront}
|
onClick={saveStorefront}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="gap-2"
|
className="gap-2 h-10 min-w-[120px]"
|
||||||
size="sm"
|
|
||||||
>
|
>
|
||||||
<Save className="h-4 w-4" />
|
{saving ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||||
{saving ? "Saving..." : "Save Changes"}
|
{saving ? "Saving..." : "Save Changes"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4 lg:gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
{/* Security Settings */}
|
{/* Main Column */}
|
||||||
<div className="space-y-3">
|
<div className="lg:col-span-2 space-y-6">
|
||||||
<div className="bg-[#0F0F12] rounded-lg p-4 border border-zinc-800">
|
|
||||||
<div className="flex items-center gap-2 mb-3">
|
{/* Store Status Card */}
|
||||||
<Shield className="h-4 w-4 text-purple-400" />
|
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }}>
|
||||||
<h2 className="text-base font-medium text-zinc-100">
|
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden relative">
|
||||||
Security
|
<div className={`absolute top-0 left-0 w-1 h-full ${storefront.isEnabled ? 'bg-emerald-500' : 'bg-destructive'}`} />
|
||||||
</h2>
|
<CardHeader className="pb-3">
|
||||||
</div>
|
<div className="flex items-center justify-between">
|
||||||
<div className="space-y-3">
|
<div className="space-y-1">
|
||||||
<div>
|
<CardTitle>Store Status</CardTitle>
|
||||||
<label className="text-xs font-medium mb-1 block text-zinc-400">PGP Public Key</label>
|
<CardDescription>Control your store's visibility to customers</CardDescription>
|
||||||
<Textarea
|
</div>
|
||||||
value={storefront.pgpKey}
|
<Badge variant={storefront.isEnabled ? "default" : "destructive"} className="h-6">
|
||||||
onChange={(e) => setStorefront(prev => ({ ...prev, pgpKey: e.target.value }))}
|
{storefront.isEnabled ? "Open for Business" : "Store Closed"}
|
||||||
placeholder="Enter your PGP public key"
|
</Badge>
|
||||||
className="font-mono text-sm h-24 bg-[#1C1C1C] border-zinc-800 resize-none"
|
</div>
|
||||||
/>
|
</CardHeader>
|
||||||
</div>
|
<CardContent className="pb-6">
|
||||||
<div>
|
<div className="flex items-center gap-4 p-4 rounded-lg bg-card border border-border/50">
|
||||||
<label className="text-xs font-medium mb-1 block text-zinc-400">Telegram Bot Token</label>
|
<Switch
|
||||||
<Input
|
checked={storefront.isEnabled}
|
||||||
type="password"
|
onCheckedChange={(checked) =>
|
||||||
value={storefront.telegramToken}
|
setStorefront((prev) => ({
|
||||||
onChange={(e) => setStorefront(prev => ({ ...prev, telegramToken: e.target.value }))}
|
...prev,
|
||||||
placeholder="Enter your Telegram bot token"
|
isEnabled: checked,
|
||||||
className="bg-[#1C1C1C] border-zinc-800 h-8 text-sm"
|
}))
|
||||||
/>
|
}
|
||||||
</div>
|
className="data-[state=checked]:bg-emerald-500"
|
||||||
</div>
|
/>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-sm">
|
||||||
|
{storefront.isEnabled ? 'Your store is currently online' : 'Your store is currently offline'}
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{storefront.isEnabled
|
||||||
|
? 'Customers can browse listings and place orders normally.'
|
||||||
|
: 'Customers will see a maintenance page. No new orders can be placed.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Welcome & Policy */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.1 }}>
|
||||||
|
<Card className="h-full border-border/40 bg-background/50 backdrop-blur-sm shadow-sm">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-lg">
|
||||||
|
<MessageSquare className="h-4 w-4 text-primary" />
|
||||||
|
Welcome Message
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Textarea
|
||||||
|
value={storefront.welcomeMessage}
|
||||||
|
onChange={(e) => setStorefront(prev => ({ ...prev, welcomeMessage: e.target.value }))}
|
||||||
|
placeholder="Enter the welcome message for new customers..."
|
||||||
|
className="min-h-[180px] bg-background/50 border-border/50 resize-none focus:ring-primary/20"
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.2 }}>
|
||||||
|
<Card className="h-full border-border/40 bg-background/50 backdrop-blur-sm shadow-sm">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-lg">
|
||||||
|
<Shield className="h-4 w-4 text-orange-400" />
|
||||||
|
Store Policy
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Textarea
|
||||||
|
value={storefront.storePolicy}
|
||||||
|
onChange={(e) => setStorefront(prev => ({ ...prev, storePolicy: e.target.value }))}
|
||||||
|
placeholder="Enter your store's policies, terms, and conditions..."
|
||||||
|
className="min-h-[180px] bg-background/50 border-border/50 resize-none focus:ring-primary/20"
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Shipping Settings */}
|
{/* Security Settings */}
|
||||||
<div className="bg-[#0F0F12] rounded-lg p-4 border border-zinc-800">
|
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.3 }}>
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm">
|
||||||
<Globe className="h-4 w-4 text-blue-400" />
|
<CardHeader>
|
||||||
<h2 className="text-base font-medium text-zinc-100">
|
<CardTitle className="flex items-center gap-2">
|
||||||
Shipping
|
<Key className="h-5 w-5 text-purple-400" />
|
||||||
</h2>
|
Security Configuration
|
||||||
</div>
|
</CardTitle>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<CardDescription>Manage keys and access tokens for your store security</CardDescription>
|
||||||
<div>
|
</CardHeader>
|
||||||
<label className="text-xs font-medium mb-1 block text-zinc-400">Ships From</label>
|
<CardContent className="space-y-6">
|
||||||
<Select
|
<div className="grid grid-cols-1 gap-4">
|
||||||
value={storefront.shipsFrom}
|
<div className="space-y-2">
|
||||||
onValueChange={(value) =>
|
<Label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">PGP Public Key</Label>
|
||||||
setStorefront((prev) => ({ ...prev, shipsFrom: value as any }))
|
<Textarea
|
||||||
}
|
value={storefront.pgpKey}
|
||||||
>
|
onChange={(e) => setStorefront(prev => ({ ...prev, pgpKey: e.target.value }))}
|
||||||
<SelectTrigger className="bg-[#1C1C1C] border-zinc-800 h-8 text-sm">
|
placeholder="-----BEGIN PGP PUBLIC KEY BLOCK-----..."
|
||||||
<SelectValue />
|
className="font-mono text-xs h-32 bg-zinc-950/50 border-zinc-800/50 resize-none"
|
||||||
</SelectTrigger>
|
/>
|
||||||
<SelectContent>
|
</div>
|
||||||
{SHIPPING_REGIONS.map((region) => (
|
<div className="space-y-2">
|
||||||
<SelectItem key={region.value} value={region.value}>
|
<Label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Telegram Bot Token</Label>
|
||||||
{region.emoji} {region.label}
|
<div className="relative">
|
||||||
</SelectItem>
|
<Input
|
||||||
))}
|
type="password"
|
||||||
</SelectContent>
|
value={storefront.telegramToken}
|
||||||
</Select>
|
onChange={(e) => setStorefront(prev => ({ ...prev, telegramToken: e.target.value }))}
|
||||||
</div>
|
placeholder="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"
|
||||||
<div>
|
className="bg-background/50 border-border/50 font-mono text-sm pl-10"
|
||||||
<label className="text-xs font-medium mb-1 block text-zinc-400">Ships To</label>
|
/>
|
||||||
<Select
|
<div className="absolute left-3 top-2.5 text-muted-foreground">
|
||||||
value={storefront.shipsTo}
|
<Shield className="h-4 w-4" />
|
||||||
onValueChange={(value) =>
|
</div>
|
||||||
setStorefront((prev) => ({ ...prev, shipsTo: value as any }))
|
</div>
|
||||||
}
|
<p className="text-[10px] text-muted-foreground">Used for notifications and bot integration.</p>
|
||||||
>
|
</div>
|
||||||
<SelectTrigger className="bg-[#1C1C1C] border-zinc-800 h-8 text-sm">
|
</div>
|
||||||
<SelectValue />
|
</CardContent>
|
||||||
</SelectTrigger>
|
</Card>
|
||||||
<SelectContent>
|
</motion.div>
|
||||||
{SHIPPING_REGIONS.map((region) => (
|
|
||||||
<SelectItem key={region.value} value={region.value}>
|
|
||||||
{region.emoji} {region.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Messaging and Payments */}
|
{/* Sidebar Column */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-6">
|
||||||
<div className="bg-[#0F0F12] rounded-lg p-4 border border-zinc-800">
|
{/* Shipping Settings */}
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<motion.div initial={{ opacity: 0, x: 10 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: 0.4 }}>
|
||||||
<MessageSquare className="h-4 w-4 text-emerald-400" />
|
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm">
|
||||||
<h2 className="text-base font-medium text-zinc-100">
|
<CardHeader>
|
||||||
Welcome Message
|
<CardTitle className="flex items-center gap-2 text-lg">
|
||||||
</h2>
|
<Globe className="h-4 w-4 text-blue-400" />
|
||||||
</div>
|
Shipping & Logistics
|
||||||
<Textarea
|
</CardTitle>
|
||||||
value={storefront.welcomeMessage}
|
</CardHeader>
|
||||||
onChange={(e) => setStorefront(prev => ({ ...prev, welcomeMessage: e.target.value }))}
|
<CardContent className="space-y-4">
|
||||||
placeholder="Enter the welcome message for new customers"
|
<div className="space-y-2">
|
||||||
className="h-36 bg-[#1C1C1C] border-zinc-800 text-sm resize-none"
|
<Label>Ships From</Label>
|
||||||
/>
|
<Select
|
||||||
</div>
|
value={storefront.shipsFrom}
|
||||||
|
onValueChange={(value) =>
|
||||||
<div className="bg-[#0F0F12] rounded-lg p-4 border border-zinc-800">
|
setStorefront((prev) => ({ ...prev, shipsFrom: value as any }))
|
||||||
<div className="flex items-center gap-2 mb-3">
|
}
|
||||||
<Shield className="h-4 w-4 text-orange-400" />
|
>
|
||||||
<h2 className="text-base font-medium text-zinc-100">
|
<SelectTrigger className="bg-background/50 border-border/50">
|
||||||
Store Policy
|
<SelectValue />
|
||||||
</h2>
|
</SelectTrigger>
|
||||||
</div>
|
<SelectContent>
|
||||||
<Textarea
|
{SHIPPING_REGIONS.map((region) => (
|
||||||
value={storefront.storePolicy}
|
<SelectItem key={region.value} value={region.value}>
|
||||||
onChange={(e) => setStorefront(prev => ({ ...prev, storePolicy: e.target.value }))}
|
<span className="flex items-center gap-2">
|
||||||
placeholder="Enter your store's policies, terms, and conditions"
|
<span className="text-lg">{region.emoji}</span>
|
||||||
className="h-48 bg-[#1C1C1C] border-zinc-800 text-sm resize-none"
|
{region.label}
|
||||||
/>
|
</span>
|
||||||
</div>
|
</SelectItem>
|
||||||
|
))}
|
||||||
<div className="bg-[#0F0F12] rounded-lg p-4 border border-zinc-800">
|
</SelectContent>
|
||||||
<div className="flex items-center gap-2 mb-3">
|
</Select>
|
||||||
<Wallet className="h-4 w-4 text-yellow-400" />
|
|
||||||
<h2 className="text-base font-medium text-zinc-100">
|
|
||||||
Payment Methods
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{WALLET_OPTIONS.map((wallet) => (
|
|
||||||
<div key={wallet.id} className="space-y-1.5">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<label className="text-xs font-medium flex items-center gap-2 text-zinc-400">
|
|
||||||
<span>{wallet.emoji}</span>
|
|
||||||
{wallet.name}
|
|
||||||
{wallet.comingSoon && (
|
|
||||||
<span className="text-[10px] bg-purple-900/50 text-purple-400 px-1.5 py-0.5 rounded">
|
|
||||||
Coming Soon
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<div>
|
|
||||||
<Switch
|
|
||||||
checked={storefront.enabledWallets[wallet.id]}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
setStorefront((prev) => ({
|
|
||||||
...prev,
|
|
||||||
enabledWallets: {
|
|
||||||
...prev.enabledWallets,
|
|
||||||
[wallet.id]: checked,
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
disabled={wallet.disabled}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
{wallet.disabled && (
|
|
||||||
<TooltipContent>
|
|
||||||
<p>Coming soon</p>
|
|
||||||
</TooltipContent>
|
|
||||||
)}
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
</div>
|
|
||||||
{storefront.enabledWallets[wallet.id] && !wallet.disabled && (
|
|
||||||
<Input
|
|
||||||
value={storefront.wallets[wallet.id]}
|
|
||||||
onChange={(e) =>
|
|
||||||
setStorefront((prev) => ({
|
|
||||||
...prev,
|
|
||||||
wallets: {
|
|
||||||
...prev.wallets,
|
|
||||||
[wallet.id]: e.target.value,
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
placeholder={wallet.placeholder}
|
|
||||||
className="font-mono text-sm h-8 bg-[#1C1C1C] border-zinc-800"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
<div className="space-y-2">
|
||||||
</div>
|
<Label>Ships To</Label>
|
||||||
</div>
|
<Select
|
||||||
|
value={storefront.shipsTo}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setStorefront((prev) => ({ ...prev, shipsTo: value as any }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="bg-background/50 border-border/50">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{SHIPPING_REGIONS.map((region) => (
|
||||||
|
<SelectItem key={region.value} value={region.value}>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="text-lg">{region.emoji}</span>
|
||||||
|
{region.label}
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Payment Methods */}
|
||||||
|
<motion.div initial={{ opacity: 0, x: 10 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: 0.5 }}>
|
||||||
|
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-lg">
|
||||||
|
<Wallet className="h-4 w-4 text-yellow-500" />
|
||||||
|
Crypto Wallets
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{WALLET_OPTIONS.map((wallet) => (
|
||||||
|
<div key={wallet.id} className="p-3 rounded-lg border border-border/50 bg-card/30 hover:bg-card/50 transition-colors">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<label className="text-sm font-medium flex items-center gap-2">
|
||||||
|
<span className="text-lg">{wallet.emoji}</span>
|
||||||
|
{wallet.name}
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{wallet.comingSoon && (
|
||||||
|
<Badge variant="secondary" className="text-[10px] h-5">Soon</Badge>
|
||||||
|
)}
|
||||||
|
<Switch
|
||||||
|
checked={storefront.enabledWallets[wallet.id]}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setStorefront((prev) => ({
|
||||||
|
...prev,
|
||||||
|
enabledWallets: {
|
||||||
|
...prev.enabledWallets,
|
||||||
|
[wallet.id]: checked,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
disabled={wallet.disabled}
|
||||||
|
className="scale-90"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{storefront.enabledWallets[wallet.id] && !wallet.disabled && (
|
||||||
|
<motion.div initial={{ height: 0, opacity: 0 }} animate={{ height: 'auto', opacity: 1 }}>
|
||||||
|
<Input
|
||||||
|
value={storefront.wallets[wallet.id]}
|
||||||
|
onChange={(e) =>
|
||||||
|
setStorefront((prev) => ({
|
||||||
|
...prev,
|
||||||
|
wallets: {
|
||||||
|
...prev.wallets,
|
||||||
|
[wallet.id]: e.target.value,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder={wallet.placeholder}
|
||||||
|
className="font-mono text-xs h-9 bg-background/50"
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<BroadcastDialog open={broadcastOpen} setOpen={setBroadcastOpen} />
|
<BroadcastDialog open={broadcastOpen} setOpen={setBroadcastOpen} />
|
||||||
|
|
||||||
</Dashboard>
|
</Dashboard >
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,41 +46,40 @@ const getFileNameFromUrl = (url: string): string => {
|
|||||||
// Try to extract filename from the URL path
|
// Try to extract filename from the URL path
|
||||||
const pathParts = url.split('/');
|
const pathParts = url.split('/');
|
||||||
const lastPart = pathParts[pathParts.length - 1];
|
const lastPart = pathParts[pathParts.length - 1];
|
||||||
|
|
||||||
// Remove query parameters if any
|
// Remove query parameters if any
|
||||||
const fileNameParts = lastPart.split('?');
|
const fileNameParts = lastPart.split('?');
|
||||||
let fileName = fileNameParts[0];
|
let fileName = fileNameParts[0];
|
||||||
|
|
||||||
// If filename is too long or not found, create a generic name
|
// If filename is too long or not found, create a generic name
|
||||||
if (!fileName || fileName.length > 30) {
|
if (!fileName || fileName.length > 30) {
|
||||||
return 'attachment';
|
return 'attachment';
|
||||||
}
|
}
|
||||||
|
|
||||||
// URL decode the filename (handle spaces and special characters)
|
|
||||||
try {
|
try {
|
||||||
fileName = decodeURIComponent(fileName);
|
fileName = decodeURIComponent(fileName);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// If decoding fails, use the original
|
// If decoding fails, use the original
|
||||||
}
|
}
|
||||||
|
|
||||||
return fileName;
|
return fileName;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to get file icon based on extension or URL pattern
|
// Helper function to get file icon based on extension or URL pattern
|
||||||
const getFileIcon = (url: string): React.ReactNode => {
|
const getFileIcon = (url: string): React.ReactNode => {
|
||||||
const fileName = url.toLowerCase();
|
const fileName = url.toLowerCase();
|
||||||
|
|
||||||
// Image files
|
// Image files
|
||||||
if (/\.(jpg|jpeg|png|gif|webp|svg|bmp|tiff)($|\?)/i.test(fileName) ||
|
if (/\.(jpg|jpeg|png|gif|webp|svg|bmp|tiff)($|\?)/i.test(fileName) ||
|
||||||
url.includes('/photos/') || url.includes('/photo/')) {
|
url.includes('/photos/') || url.includes('/photo/')) {
|
||||||
return <ImageIcon className="h-5 w-5" />;
|
return <ImageIcon className="h-5 w-5" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Document files
|
// Document files
|
||||||
if (/\.(pdf|doc|docx|xls|xlsx|ppt|pptx|txt|rtf|csv)($|\?)/i.test(fileName)) {
|
if (/\.(pdf|doc|docx|xls|xlsx|ppt|pptx|txt|rtf|csv)($|\?)/i.test(fileName)) {
|
||||||
return <FileText className="h-5 w-5" />;
|
return <FileText className="h-5 w-5" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default file icon
|
// Default file icon
|
||||||
return <File className="h-5 w-5" />;
|
return <File className="h-5 w-5" />;
|
||||||
};
|
};
|
||||||
@@ -107,7 +106,7 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
|
|||||||
const { scrollToBottom, scrollToBottomInstant } = useSmoothScrollToBottom();
|
const { scrollToBottom, scrollToBottomInstant } = useSmoothScrollToBottom();
|
||||||
useChromebookKeyboard();
|
useChromebookKeyboard();
|
||||||
const { focusMessageInput, focusNextMessage, focusPreviousMessage } = useChatFocus();
|
const { focusMessageInput, focusNextMessage, focusPreviousMessage } = useChatFocus();
|
||||||
|
|
||||||
// Scroll to bottom utility functions
|
// Scroll to bottom utility functions
|
||||||
const scrollToBottomHandler = () => {
|
const scrollToBottomHandler = () => {
|
||||||
if (scrollContainerRef.current) {
|
if (scrollContainerRef.current) {
|
||||||
@@ -116,40 +115,40 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
|
|||||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isNearBottom = () => {
|
const isNearBottom = () => {
|
||||||
if (!messagesEndRef.current) return true;
|
if (!messagesEndRef.current) return true;
|
||||||
|
|
||||||
const container = messagesEndRef.current.parentElement;
|
const container = messagesEndRef.current.parentElement;
|
||||||
if (!container) return true;
|
if (!container) return true;
|
||||||
|
|
||||||
const { scrollTop, scrollHeight, clientHeight } = container;
|
const { scrollTop, scrollHeight, clientHeight } = container;
|
||||||
// Consider "near bottom" if within 100px of the bottom
|
// Consider "near bottom" if within 100px of the bottom
|
||||||
return scrollHeight - (scrollTop + clientHeight) < 100;
|
return scrollHeight - (scrollTop + clientHeight) < 100;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Initialize audio element
|
// Initialize audio element
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Create audio element for notification sound
|
// Create audio element for notification sound
|
||||||
audioRef.current = new Audio('/notification.mp3');
|
audioRef.current = new Audio('/notification.mp3');
|
||||||
|
|
||||||
// Fallback if notification.mp3 doesn't exist - use browser API for a simple beep
|
// Fallback if notification.mp3 doesn't exist - use browser API for a simple beep
|
||||||
audioRef.current.addEventListener('error', () => {
|
audioRef.current.addEventListener('error', () => {
|
||||||
audioRef.current = null;
|
audioRef.current = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (audioRef.current) {
|
if (audioRef.current) {
|
||||||
audioRef.current = null;
|
audioRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear any pending timeouts when component unmounts
|
// Clear any pending timeouts when component unmounts
|
||||||
if (markReadTimeoutRef.current) {
|
if (markReadTimeoutRef.current) {
|
||||||
clearTimeout(markReadTimeoutRef.current);
|
clearTimeout(markReadTimeoutRef.current);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Function to play notification sound
|
// Function to play notification sound
|
||||||
const playNotificationSound = () => {
|
const playNotificationSound = () => {
|
||||||
if (audioRef.current) {
|
if (audioRef.current) {
|
||||||
@@ -187,7 +186,7 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Function to mark messages as read
|
// Function to mark messages as read
|
||||||
const markMessagesAsRead = async () => {
|
const markMessagesAsRead = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -200,7 +199,7 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
|
|||||||
console.error("Error marking messages as read:", error);
|
console.error("Error marking messages as read:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Loading effect
|
// Loading effect
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (chatId) {
|
if (chatId) {
|
||||||
@@ -212,7 +211,7 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
|
|||||||
const getAuthAxios = () => {
|
const getAuthAxios = () => {
|
||||||
const authToken = getCookie("Authorization");
|
const authToken = getCookie("Authorization");
|
||||||
if (!authToken) return null;
|
if (!authToken) return null;
|
||||||
|
|
||||||
return axios.create({
|
return axios.create({
|
||||||
baseURL: process.env.NEXT_PUBLIC_API_URL,
|
baseURL: process.env.NEXT_PUBLIC_API_URL,
|
||||||
headers: {
|
headers: {
|
||||||
@@ -221,22 +220,22 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fetch chat information and messages
|
// Fetch chat information and messages
|
||||||
const fetchChatData = async () => {
|
const fetchChatData = async () => {
|
||||||
if (!chatId) return;
|
if (!chatId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
// Use clientFetch to load chat data
|
// Use clientFetch to load chat data
|
||||||
// For now, we're only loading chat data, but this could be extended
|
// For now, we're only loading chat data, but this could be extended
|
||||||
// to load additional data in parallel (user profiles, order details, etc.)
|
// to load additional data in parallel (user profiles, order details, etc.)
|
||||||
const response = await clientFetch(`/chats/${chatId}`);
|
const response = await clientFetch(`/chats/${chatId}`);
|
||||||
|
|
||||||
setChatData(response);
|
setChatData(response);
|
||||||
setChat(response); // Set chat data to maintain compatibility
|
setChat(response); // Set chat data to maintain compatibility
|
||||||
|
|
||||||
// Set messages with a transition effect
|
// Set messages with a transition effect
|
||||||
// If we already have messages, append new ones to avoid jumpiness
|
// If we already have messages, append new ones to avoid jumpiness
|
||||||
if (messages.length > 0) {
|
if (messages.length > 0) {
|
||||||
@@ -244,10 +243,10 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
|
|||||||
const newMessages = response.messages.filter(
|
const newMessages = response.messages.filter(
|
||||||
(msg: Message) => !existingMessageIds.has(msg._id)
|
(msg: Message) => !existingMessageIds.has(msg._id)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (newMessages.length > 0) {
|
if (newMessages.length > 0) {
|
||||||
setMessages(prev => [...prev, ...newMessages]);
|
setMessages(prev => [...prev, ...newMessages]);
|
||||||
|
|
||||||
// Mark all these messages as seen to avoid notification sounds
|
// Mark all these messages as seen to avoid notification sounds
|
||||||
newMessages.forEach((msg: Message) => {
|
newMessages.forEach((msg: Message) => {
|
||||||
seenMessageIdsRef.current.add(msg._id);
|
seenMessageIdsRef.current.add(msg._id);
|
||||||
@@ -255,7 +254,7 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
|
|||||||
} else {
|
} else {
|
||||||
// If we need to replace all messages (e.g., first load or refresh)
|
// If we need to replace all messages (e.g., first load or refresh)
|
||||||
setMessages(Array.isArray(response.messages) ? response.messages : []);
|
setMessages(Array.isArray(response.messages) ? response.messages : []);
|
||||||
|
|
||||||
// Mark all messages as seen
|
// Mark all messages as seen
|
||||||
if (Array.isArray(response.messages)) {
|
if (Array.isArray(response.messages)) {
|
||||||
response.messages.forEach((msg: Message) => {
|
response.messages.forEach((msg: Message) => {
|
||||||
@@ -267,20 +266,20 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
|
|||||||
// Initial load
|
// Initial load
|
||||||
const initialMessages = Array.isArray(response.messages) ? response.messages : [];
|
const initialMessages = Array.isArray(response.messages) ? response.messages : [];
|
||||||
setMessages(initialMessages);
|
setMessages(initialMessages);
|
||||||
|
|
||||||
// Mark all initial messages as seen
|
// Mark all initial messages as seen
|
||||||
initialMessages.forEach((msg: Message) => {
|
initialMessages.forEach((msg: Message) => {
|
||||||
seenMessageIdsRef.current.add(msg._id);
|
seenMessageIdsRef.current.add(msg._id);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scroll to bottom on initial load
|
// Scroll to bottom on initial load
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
scrollToBottomHandler();
|
scrollToBottomHandler();
|
||||||
}, 100);
|
}, 100);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("Error fetching chat data:", error);
|
console.error("Error fetching chat data:", error);
|
||||||
|
|
||||||
// Don't redirect on auth errors - let the middleware handle it
|
// Don't redirect on auth errors - let the middleware handle it
|
||||||
// Only show error toast for non-auth errors
|
// Only show error toast for non-auth errors
|
||||||
if (error?.message?.includes('401') || error?.message?.includes('403')) {
|
if (error?.message?.includes('401') || error?.message?.includes('403')) {
|
||||||
@@ -293,7 +292,7 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Setup polling for new messages
|
// Setup polling for new messages
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Set up a polling interval to check for new messages
|
// Set up a polling interval to check for new messages
|
||||||
@@ -302,60 +301,60 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
|
|||||||
pollNewMessages();
|
pollNewMessages();
|
||||||
}
|
}
|
||||||
}, 3000); // Poll every 3 seconds
|
}, 3000); // Poll every 3 seconds
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearInterval(pollInterval);
|
clearInterval(pollInterval);
|
||||||
};
|
};
|
||||||
}, [chatId]);
|
}, [chatId]);
|
||||||
|
|
||||||
// Poll for new messages without replacing existing ones
|
// Poll for new messages without replacing existing ones
|
||||||
const pollNewMessages = async () => {
|
const pollNewMessages = async () => {
|
||||||
if (!chatId || isPollingRef.current) return;
|
if (!chatId || isPollingRef.current) return;
|
||||||
|
|
||||||
isPollingRef.current = true;
|
isPollingRef.current = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await clientFetch(`/chats/${chatId}`);
|
const response = await clientFetch(`/chats/${chatId}`);
|
||||||
|
|
||||||
// Update chat metadata
|
// Update chat metadata
|
||||||
setChatData(response);
|
setChatData(response);
|
||||||
setChat(response);
|
setChat(response);
|
||||||
|
|
||||||
// Check if there are new messages
|
// Check if there are new messages
|
||||||
if (Array.isArray(response.messages) && response.messages.length > 0) {
|
if (Array.isArray(response.messages) && response.messages.length > 0) {
|
||||||
// Get existing message IDs to avoid duplicates
|
// Get existing message IDs to avoid duplicates
|
||||||
const existingIds = new Set(messages.map(m => m._id));
|
const existingIds = new Set(messages.map(m => m._id));
|
||||||
const newMessages = response.messages.filter((msg: Message) => !existingIds.has(msg._id));
|
const newMessages = response.messages.filter((msg: Message) => !existingIds.has(msg._id));
|
||||||
|
|
||||||
if (newMessages.length > 0) {
|
if (newMessages.length > 0) {
|
||||||
// Add only new messages to avoid re-rendering all messages
|
// Add only new messages to avoid re-rendering all messages
|
||||||
setMessages(prev => [...prev, ...newMessages]);
|
setMessages(prev => [...prev, ...newMessages]);
|
||||||
|
|
||||||
// Play notification sound only for new buyer messages we haven't seen before
|
// Play notification sound only for new buyer messages we haven't seen before
|
||||||
const unseenBuyerMessages = newMessages.filter((msg: Message) =>
|
const unseenBuyerMessages = newMessages.filter((msg: Message) =>
|
||||||
msg.sender === 'buyer' && !seenMessageIdsRef.current.has(msg._id)
|
msg.sender === 'buyer' && !seenMessageIdsRef.current.has(msg._id)
|
||||||
);
|
);
|
||||||
|
|
||||||
// If we have unseen buyer messages, play sound and mark them as seen
|
// If we have unseen buyer messages, play sound and mark them as seen
|
||||||
if (unseenBuyerMessages.length > 0) {
|
if (unseenBuyerMessages.length > 0) {
|
||||||
playNotificationSound();
|
playNotificationSound();
|
||||||
|
|
||||||
// Add these messages to our seen set
|
// Add these messages to our seen set
|
||||||
unseenBuyerMessages.forEach((msg: Message) => {
|
unseenBuyerMessages.forEach((msg: Message) => {
|
||||||
seenMessageIdsRef.current.add(msg._id);
|
seenMessageIdsRef.current.add(msg._id);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// If near bottom, scroll to new messages
|
// If near bottom, scroll to new messages
|
||||||
if (isNearBottom()) {
|
if (isNearBottom()) {
|
||||||
setTimeout(scrollToBottom, 50);
|
setTimeout(scrollToBottom, 50);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set timeout to mark new messages as read
|
// Set timeout to mark new messages as read
|
||||||
if (markReadTimeoutRef.current) {
|
if (markReadTimeoutRef.current) {
|
||||||
clearTimeout(markReadTimeoutRef.current);
|
clearTimeout(markReadTimeoutRef.current);
|
||||||
}
|
}
|
||||||
|
|
||||||
markReadTimeoutRef.current = setTimeout(() => {
|
markReadTimeoutRef.current = setTimeout(() => {
|
||||||
markMessagesAsRead();
|
markMessagesAsRead();
|
||||||
}, 1000);
|
}, 1000);
|
||||||
@@ -363,7 +362,7 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
|
|||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("Error polling new messages:", error);
|
console.error("Error polling new messages:", error);
|
||||||
|
|
||||||
// Silently fail on auth errors during polling - don't disrupt the user
|
// Silently fail on auth errors during polling - don't disrupt the user
|
||||||
if (error?.message?.includes('401') || error?.message?.includes('403')) {
|
if (error?.message?.includes('401') || error?.message?.includes('403')) {
|
||||||
console.log("Auth error during polling, stopping poll");
|
console.log("Auth error during polling, stopping poll");
|
||||||
@@ -411,12 +410,12 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
|
|||||||
const focusableElements = document.querySelectorAll(
|
const focusableElements = document.querySelectorAll(
|
||||||
'button:not([disabled]), input:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
'button:not([disabled]), input:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||||
) as NodeListOf<HTMLElement>;
|
) as NodeListOf<HTMLElement>;
|
||||||
|
|
||||||
const currentIndex = Array.from(focusableElements).indexOf(document.activeElement as HTMLElement);
|
const currentIndex = Array.from(focusableElements).indexOf(document.activeElement as HTMLElement);
|
||||||
const nextIndex = e.shiftKey
|
const nextIndex = e.shiftKey
|
||||||
? (currentIndex > 0 ? currentIndex - 1 : focusableElements.length - 1)
|
? (currentIndex > 0 ? currentIndex - 1 : focusableElements.length - 1)
|
||||||
: (currentIndex < focusableElements.length - 1 ? currentIndex + 1 : 0);
|
: (currentIndex < focusableElements.length - 1 ? currentIndex + 1 : 0);
|
||||||
|
|
||||||
focusableElements[nextIndex]?.focus();
|
focusableElements[nextIndex]?.focus();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -425,9 +424,9 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
|
|||||||
const sendMessage = async (newMessage: string, file?: File | null) => {
|
const sendMessage = async (newMessage: string, file?: File | null) => {
|
||||||
// Don't send empty messages
|
// Don't send empty messages
|
||||||
if (!newMessage.trim() && !file) return;
|
if (!newMessage.trim() && !file) return;
|
||||||
|
|
||||||
if (!chatId || !chatData) return;
|
if (!chatId || !chatData) return;
|
||||||
|
|
||||||
// Create a temporary message with a unique temporary ID
|
// Create a temporary message with a unique temporary ID
|
||||||
const tempId = `temp-${Date.now()}`;
|
const tempId = `temp-${Date.now()}`;
|
||||||
const tempMessage: Message = {
|
const tempMessage: Message = {
|
||||||
@@ -440,27 +439,27 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
|
|||||||
buyerId: chatData.buyerId || '',
|
buyerId: chatData.buyerId || '',
|
||||||
vendorId: chatData.vendorId || ''
|
vendorId: chatData.vendorId || ''
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add the temp message ID to seen messages
|
// Add the temp message ID to seen messages
|
||||||
seenMessageIdsRef.current.add(tempId);
|
seenMessageIdsRef.current.add(tempId);
|
||||||
|
|
||||||
// Optimistically add the temp message to the UI
|
// Optimistically add the temp message to the UI
|
||||||
setMessages(prev => [...prev, tempMessage]);
|
setMessages(prev => [...prev, tempMessage]);
|
||||||
|
|
||||||
// Scroll to bottom to show the new message
|
// Scroll to bottom to show the new message
|
||||||
setTimeout(scrollToBottom, 50);
|
setTimeout(scrollToBottom, 50);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setSending(true);
|
setSending(true);
|
||||||
|
|
||||||
let response;
|
let response;
|
||||||
|
|
||||||
if (file) {
|
if (file) {
|
||||||
// Use FormData for file uploads
|
// Use FormData for file uploads
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('content', newMessage);
|
formData.append('content', newMessage);
|
||||||
formData.append('attachment', file);
|
formData.append('attachment', file);
|
||||||
|
|
||||||
response = await clientFetch(`/chats/${chatId}/message`, {
|
response = await clientFetch(`/chats/${chatId}/message`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData,
|
body: formData,
|
||||||
@@ -470,35 +469,35 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
|
|||||||
// Use JSON for text-only messages
|
// Use JSON for text-only messages
|
||||||
response = await clientFetch(`/chats/${chatId}/message`, {
|
response = await clientFetch(`/chats/${chatId}/message`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
content: newMessage
|
content: newMessage
|
||||||
}),
|
}),
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json; charset=utf-8'
|
'Content-Type': 'application/json; charset=utf-8'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replace the temporary message with the real one from the server
|
// Replace the temporary message with the real one from the server
|
||||||
setMessages(prev => prev.map(msg =>
|
setMessages(prev => prev.map(msg =>
|
||||||
msg._id === tempId ? response : msg
|
msg._id === tempId ? response : msg
|
||||||
));
|
));
|
||||||
|
|
||||||
// Add the real message ID to seen messages
|
// Add the real message ID to seen messages
|
||||||
if (response && response._id) {
|
if (response && response._id) {
|
||||||
seenMessageIdsRef.current.add(response._id);
|
seenMessageIdsRef.current.add(response._id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the textarea value to empty
|
// Update the textarea value to empty
|
||||||
setMessage('');
|
setMessage('');
|
||||||
|
|
||||||
// Clear the file if there was one
|
// Clear the file if there was one
|
||||||
if (file) {
|
if (file) {
|
||||||
setSelectedImage(null);
|
setSelectedImage(null);
|
||||||
setSelectedMessageIndex(null);
|
setSelectedMessageIndex(null);
|
||||||
setSelectedAttachmentIndex(null);
|
setSelectedAttachmentIndex(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the chat's last message
|
// Update the chat's last message
|
||||||
if (chatData) {
|
if (chatData) {
|
||||||
setChatData({
|
setChatData({
|
||||||
@@ -506,34 +505,34 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
|
|||||||
lastUpdated: new Date().toISOString()
|
lastUpdated: new Date().toISOString()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error sending message:', error);
|
console.error('Error sending message:', error);
|
||||||
toast.error('Failed to send message');
|
toast.error('Failed to send message');
|
||||||
|
|
||||||
// Remove the temporary message if sending failed
|
// Remove the temporary message if sending failed
|
||||||
setMessages(prev => prev.filter(msg => msg._id !== tempId));
|
setMessages(prev => prev.filter(msg => msg._id !== tempId));
|
||||||
} finally {
|
} finally {
|
||||||
setSending(false);
|
setSending(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBackClick = () => {
|
const handleBackClick = () => {
|
||||||
router.push("/dashboard/chats");
|
router.push("/dashboard/chats");
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add function to handle image navigation
|
// Add function to handle image navigation
|
||||||
const handleImageNavigation = (direction: 'prev' | 'next') => {
|
const handleImageNavigation = (direction: 'prev' | 'next') => {
|
||||||
if (!chat || selectedMessageIndex === null || selectedAttachmentIndex === null) return;
|
if (!chat || selectedMessageIndex === null || selectedAttachmentIndex === null) return;
|
||||||
|
|
||||||
// Get all images from all messages
|
// Get all images from all messages
|
||||||
const allImages: { messageIndex: number; attachmentIndex: number; url: string }[] = [];
|
const allImages: { messageIndex: number; attachmentIndex: number; url: string }[] = [];
|
||||||
|
|
||||||
chat.messages.forEach((msg, msgIndex) => {
|
chat.messages.forEach((msg, msgIndex) => {
|
||||||
msg.attachments.forEach((att, attIndex) => {
|
msg.attachments.forEach((att, attIndex) => {
|
||||||
if (/\.(jpg|jpeg|png|gif|webp|svg|bmp|tiff)($|\?)/i.test(att) ||
|
if (/\.(jpg|jpeg|png|gif|webp|svg|bmp|tiff)($|\?)/i.test(att) ||
|
||||||
att.includes('/photos/') ||
|
att.includes('/photos/') ||
|
||||||
att.includes('/photo/')) {
|
att.includes('/photo/')) {
|
||||||
allImages.push({ messageIndex: msgIndex, attachmentIndex: attIndex, url: att });
|
allImages.push({ messageIndex: msgIndex, attachmentIndex: attIndex, url: att });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -542,8 +541,8 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
|
|||||||
if (allImages.length === 0) return;
|
if (allImages.length === 0) return;
|
||||||
|
|
||||||
// Find current image index
|
// Find current image index
|
||||||
const currentIndex = allImages.findIndex(img =>
|
const currentIndex = allImages.findIndex(img =>
|
||||||
img.messageIndex === selectedMessageIndex &&
|
img.messageIndex === selectedMessageIndex &&
|
||||||
img.attachmentIndex === selectedAttachmentIndex
|
img.attachmentIndex === selectedAttachmentIndex
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -570,7 +569,7 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
|
|||||||
setSelectedMessageIndex(messageIndex);
|
setSelectedMessageIndex(messageIndex);
|
||||||
setSelectedAttachmentIndex(attachmentIndex);
|
setSelectedAttachmentIndex(attachmentIndex);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-screen w-full relative">
|
<div className="flex flex-col h-screen w-full relative">
|
||||||
@@ -586,7 +585,7 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!chat) {
|
if (!chat) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-screen w-full relative">
|
<div className="flex flex-col h-screen w-full relative">
|
||||||
@@ -605,17 +604,17 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-screen w-full relative">
|
<div className="flex flex-col h-screen w-full relative">
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"border-b bg-card z-10 flex items-center justify-between",
|
"border-b bg-background/80 backdrop-blur-md z-10 flex items-center justify-between sticky top-0",
|
||||||
isTouchDevice ? "h-20 px-3" : "h-16 px-4"
|
isTouchDevice ? "h-16 px-4" : "h-16 px-6"
|
||||||
)}>
|
)}>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={handleBackClick}
|
onClick={handleBackClick}
|
||||||
className={cn(
|
className={cn(
|
||||||
"transition-all duration-200",
|
"transition-all duration-200",
|
||||||
@@ -644,15 +643,15 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<BuyerOrderInfo buyerId={chat.buyerId} chatId={chatId} />
|
<BuyerOrderInfo buyerId={chat.buyerId} chatId={chatId} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref={scrollContainerRef}
|
ref={scrollContainerRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
isTouchDevice
|
isTouchDevice
|
||||||
? "flex-1 overflow-y-auto space-y-2 p-3 pb-[calc(112px+env(safe-area-inset-bottom))]"
|
? "flex-1 overflow-y-auto space-y-2 p-3 pb-[calc(112px+env(safe-area-inset-bottom))]"
|
||||||
: "flex-1 overflow-y-auto space-y-2 p-2 pb-[calc(88px+env(safe-area-inset-bottom))]"
|
: "flex-1 overflow-y-auto space-y-2 p-2 pb-[calc(88px+env(safe-area-inset-bottom))]"
|
||||||
)}
|
)}
|
||||||
role="log"
|
role="log"
|
||||||
@@ -681,11 +680,10 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"max-w-[90%] rounded-lg chat-message",
|
"max-w-[85%] rounded-2xl p-4 shadow-sm",
|
||||||
isTouchDevice ? "p-4" : "p-3",
|
|
||||||
msg.sender === "vendor"
|
msg.sender === "vendor"
|
||||||
? "bg-primary text-primary-foreground"
|
? "bg-primary text-primary-foreground rounded-tr-none"
|
||||||
: "bg-muted"
|
: "bg-muted text-muted-foreground rounded-tl-none border border-border/50"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center space-x-2 mb-1">
|
<div className="flex items-center space-x-2 mb-1">
|
||||||
@@ -705,15 +703,15 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
|
|||||||
{msg.attachments && msg.attachments.length > 0 && (
|
{msg.attachments && msg.attachments.length > 0 && (
|
||||||
<div className="mt-2 space-y-2">
|
<div className="mt-2 space-y-2">
|
||||||
{msg.attachments.map((attachment, attachmentIndex) => {
|
{msg.attachments.map((attachment, attachmentIndex) => {
|
||||||
const isImage = /\.(jpg|jpeg|png|gif|webp|svg|bmp|tiff)($|\?)/i.test(attachment) ||
|
const isImage = /\.(jpg|jpeg|png|gif|webp|svg|bmp|tiff)($|\?)/i.test(attachment) ||
|
||||||
attachment.includes('/photos/') ||
|
attachment.includes('/photos/') ||
|
||||||
attachment.includes('/photo/');
|
attachment.includes('/photo/');
|
||||||
|
|
||||||
const fileName = getFileNameFromUrl(attachment);
|
const fileName = getFileNameFromUrl(attachment);
|
||||||
|
|
||||||
return isImage ? (
|
return isImage ? (
|
||||||
<div
|
<div
|
||||||
key={`attachment-${attachmentIndex}`}
|
key={`attachment-${attachmentIndex}`}
|
||||||
className="rounded-md overflow-hidden bg-background/20 p-1"
|
className="rounded-md overflow-hidden bg-background/20 p-1"
|
||||||
onClick={() => handleImageClick(attachment, messageIndex, attachmentIndex)}
|
onClick={() => handleImageClick(attachment, messageIndex, attachmentIndex)}
|
||||||
role="button"
|
role="button"
|
||||||
@@ -731,10 +729,10 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
|
|||||||
<ImageIcon className="h-3 w-3 mr-1" aria-hidden="true" />
|
<ImageIcon className="h-3 w-3 mr-1" aria-hidden="true" />
|
||||||
{fileName}
|
{fileName}
|
||||||
</span>
|
</span>
|
||||||
<a
|
<a
|
||||||
href={attachment}
|
href={attachment}
|
||||||
download={fileName}
|
download={fileName}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-xs opacity-70 hover:opacity-100"
|
className="text-xs opacity-70 hover:opacity-100"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
@@ -773,8 +771,8 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
|
|||||||
{fileName}
|
{fileName}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<a
|
<a
|
||||||
href={attachment}
|
href={attachment}
|
||||||
download={fileName}
|
download={fileName}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
@@ -794,49 +792,38 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
|
|||||||
)}
|
)}
|
||||||
<div ref={messagesEndRef} />
|
<div ref={messagesEndRef} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"absolute bottom-0 left-0 right-0 border-t border-border bg-background",
|
"absolute bottom-0 left-0 right-0 px-4 pt-10 bg-gradient-to-t from-background via-background/95 to-transparent",
|
||||||
isTouchDevice ? "p-3" : "p-4",
|
"pb-[calc(1.5rem+env(safe-area-inset-bottom))]"
|
||||||
"pb-[env(safe-area-inset-bottom)]"
|
|
||||||
)}>
|
)}>
|
||||||
<form onSubmit={handleSendMessage} className="flex space-x-2">
|
<form onSubmit={handleSendMessage} className="flex space-x-2 max-w-4xl mx-auto items-end">
|
||||||
<Input
|
<div className="relative flex-1">
|
||||||
value={message}
|
<Input
|
||||||
onChange={(e) => setMessage(e.target.value)}
|
value={message}
|
||||||
placeholder="Type your message..."
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
disabled={sending}
|
placeholder="Type your message..."
|
||||||
className={cn(
|
disabled={sending}
|
||||||
"flex-1 text-base transition-all duration-200 form-input",
|
className={cn(
|
||||||
isTouchDevice ? "min-h-[52px] text-lg" : "min-h-[48px]"
|
"w-full pl-4 pr-12 py-3 bg-background/50 border-border/50 backdrop-blur-sm shadow-sm focus:ring-primary/20 transition-all duration-200 rounded-full",
|
||||||
)}
|
isTouchDevice ? "min-h-[52px] text-lg" : "min-h-[48px] text-base"
|
||||||
onKeyDown={handleKeyDown}
|
)}
|
||||||
autoFocus
|
onKeyDown={handleKeyDown}
|
||||||
aria-label="Message input"
|
autoFocus
|
||||||
aria-describedby="message-help"
|
aria-label="Message input"
|
||||||
role="textbox"
|
role="textbox"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
spellCheck="true"
|
/>
|
||||||
maxLength={2000}
|
</div>
|
||||||
style={{
|
<Button
|
||||||
WebkitAppearance: 'none',
|
type="submit"
|
||||||
borderRadius: '0.5rem'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={sending || !message.trim()}
|
disabled={sending || !message.trim()}
|
||||||
aria-label={sending ? "Sending message" : "Send message"}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"transition-all duration-200 btn-chromebook",
|
"rounded-full shadow-md transition-all duration-200 bg-primary hover:bg-primary/90 text-primary-foreground",
|
||||||
isTouchDevice ? "min-h-[52px] min-w-[52px]" : "min-h-[48px] min-w-[48px]"
|
isTouchDevice ? "h-[52px] w-[52px]" : "h-[48px] w-[48px]"
|
||||||
)}
|
)}
|
||||||
style={{
|
|
||||||
WebkitAppearance: 'none',
|
|
||||||
touchAction: 'manipulation'
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{sending ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
{sending ? <RefreshCw className="h-5 w-5 animate-spin" /> : <Send className="h-5 w-5 ml-0.5" />}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
<div id="message-help" className="sr-only">
|
<div id="message-help" className="sr-only">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -10,14 +11,15 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
import { formatDistanceToNow } from "date-fns";
|
import { formatDistanceToNow } from "date-fns";
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
Loader2,
|
Loader2,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Eye,
|
Eye,
|
||||||
User,
|
User,
|
||||||
@@ -30,7 +32,8 @@ import {
|
|||||||
CheckCheck,
|
CheckCheck,
|
||||||
Search,
|
Search,
|
||||||
Volume2,
|
Volume2,
|
||||||
VolumeX
|
VolumeX,
|
||||||
|
MoreHorizontal
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -78,7 +81,7 @@ export default function ChatTable() {
|
|||||||
const [totalChats, setTotalChats] = useState(0);
|
const [totalChats, setTotalChats] = useState(0);
|
||||||
const [itemsPerPage, setItemsPerPage] = useState<number>(10);
|
const [itemsPerPage, setItemsPerPage] = useState<number>(10);
|
||||||
const isManualRefresh = useRef(false);
|
const isManualRefresh = useRef(false);
|
||||||
|
|
||||||
// Initialize audio element for notifications
|
// Initialize audio element for notifications
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
audioRef.current = new Audio('/notification.mp3');
|
audioRef.current = new Audio('/notification.mp3');
|
||||||
@@ -88,7 +91,7 @@ export default function ChatTable() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Play notification sound
|
// Play notification sound
|
||||||
const playNotificationSound = () => {
|
const playNotificationSound = () => {
|
||||||
if (audioRef.current) {
|
if (audioRef.current) {
|
||||||
@@ -100,30 +103,30 @@ export default function ChatTable() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get vendor ID from JWT token
|
// Get vendor ID from JWT token
|
||||||
const getVendorIdFromToken = () => {
|
const getVendorIdFromToken = () => {
|
||||||
const authToken = getCookie("Authorization") || "";
|
const authToken = getCookie("Authorization") || "";
|
||||||
|
|
||||||
if (!authToken) {
|
if (!authToken) {
|
||||||
throw new Error("No auth token found");
|
throw new Error("No auth token found");
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokenParts = authToken.split(".");
|
const tokenParts = authToken.split(".");
|
||||||
if (tokenParts.length !== 3) {
|
if (tokenParts.length !== 3) {
|
||||||
throw new Error("Invalid token format");
|
throw new Error("Invalid token format");
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = JSON.parse(atob(tokenParts[1]));
|
const payload = JSON.parse(atob(tokenParts[1]));
|
||||||
const vendorId = payload.id;
|
const vendorId = payload.id;
|
||||||
|
|
||||||
if (!vendorId) {
|
if (!vendorId) {
|
||||||
throw new Error("Vendor ID not found in token");
|
throw new Error("Vendor ID not found in token");
|
||||||
}
|
}
|
||||||
|
|
||||||
return { vendorId, authToken };
|
return { vendorId, authToken };
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fetch chats when component mounts or page/limit changes
|
// Fetch chats when component mounts or page/limit changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Skip fetch if this effect was triggered by a manual refresh
|
// Skip fetch if this effect was triggered by a manual refresh
|
||||||
@@ -131,57 +134,57 @@ export default function ChatTable() {
|
|||||||
if (!isManualRefresh.current) {
|
if (!isManualRefresh.current) {
|
||||||
fetchChats();
|
fetchChats();
|
||||||
}
|
}
|
||||||
|
|
||||||
isManualRefresh.current = false;
|
isManualRefresh.current = false;
|
||||||
|
|
||||||
// Set up polling for unread messages
|
// Set up polling for unread messages
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
fetchUnreadCounts();
|
fetchUnreadCounts();
|
||||||
}, 30000); // Check every 30 seconds
|
}, 30000); // Check every 30 seconds
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [currentPage, itemsPerPage]);
|
}, [currentPage, itemsPerPage]);
|
||||||
|
|
||||||
// Handle refresh button click
|
// Handle refresh button click
|
||||||
const handleRefresh = () => {
|
const handleRefresh = () => {
|
||||||
isManualRefresh.current = true;
|
isManualRefresh.current = true;
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
fetchChats();
|
fetchChats();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fetch unread counts
|
// Fetch unread counts
|
||||||
const fetchUnreadCounts = async () => {
|
const fetchUnreadCounts = async () => {
|
||||||
try {
|
try {
|
||||||
// Get the vendor ID from the auth token
|
// Get the vendor ID from the auth token
|
||||||
const { vendorId } = getVendorIdFromToken();
|
const { vendorId } = getVendorIdFromToken();
|
||||||
|
|
||||||
// Fetch unread counts for this vendor using clientFetch
|
// Fetch unread counts for this vendor using clientFetch
|
||||||
const response = await clientFetch(`/chats/vendor/${vendorId}/unread`);
|
const response = await clientFetch(`/chats/vendor/${vendorId}/unread`);
|
||||||
|
|
||||||
const newUnreadCounts = response;
|
const newUnreadCounts = response;
|
||||||
|
|
||||||
// Play sound if there are new messages
|
// Play sound if there are new messages
|
||||||
if (newUnreadCounts.totalUnread > unreadCounts.totalUnread) {
|
if (newUnreadCounts.totalUnread > unreadCounts.totalUnread) {
|
||||||
//playNotificationSound();
|
//playNotificationSound();
|
||||||
}
|
}
|
||||||
|
|
||||||
setUnreadCounts(newUnreadCounts);
|
setUnreadCounts(newUnreadCounts);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch unread counts:", error);
|
console.error("Failed to fetch unread counts:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fetch chats with pagination
|
// Fetch chats with pagination
|
||||||
const fetchChats = async (page = currentPage, limit = itemsPerPage) => {
|
const fetchChats = async (page = currentPage, limit = itemsPerPage) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get the vendor ID from the auth token
|
// Get the vendor ID from the auth token
|
||||||
const { vendorId } = getVendorIdFromToken();
|
const { vendorId } = getVendorIdFromToken();
|
||||||
|
|
||||||
// Use the optimized batch endpoint that fetches chats and unread counts together
|
// Use the optimized batch endpoint that fetches chats and unread counts together
|
||||||
const batchResponse = await clientFetch(`/chats/vendor/${vendorId}/batch?page=${page}&limit=${limit}`);
|
const batchResponse = await clientFetch(`/chats/vendor/${vendorId}/batch?page=${page}&limit=${limit}`);
|
||||||
|
|
||||||
// Handle batch response (contains both chats and unread counts)
|
// Handle batch response (contains both chats and unread counts)
|
||||||
if (Array.isArray(batchResponse)) {
|
if (Array.isArray(batchResponse)) {
|
||||||
// Fallback to old API response format (backward compatibility)
|
// Fallback to old API response format (backward compatibility)
|
||||||
@@ -201,15 +204,15 @@ export default function ChatTable() {
|
|||||||
setTotalPages(batchResponse.totalPages || 1);
|
setTotalPages(batchResponse.totalPages || 1);
|
||||||
setCurrentPage(batchResponse.page || 1);
|
setCurrentPage(batchResponse.page || 1);
|
||||||
setTotalChats(batchResponse.totalChats || 0);
|
setTotalChats(batchResponse.totalChats || 0);
|
||||||
|
|
||||||
// Handle unread counts from batch response
|
// Handle unread counts from batch response
|
||||||
const newUnreadCounts = batchResponse.unreadCounts || { totalUnread: 0, chatCounts: {} };
|
const newUnreadCounts = batchResponse.unreadCounts || { totalUnread: 0, chatCounts: {} };
|
||||||
|
|
||||||
// Play sound if there are new messages
|
// Play sound if there are new messages
|
||||||
if (newUnreadCounts.totalUnread > unreadCounts.totalUnread) {
|
if (newUnreadCounts.totalUnread > unreadCounts.totalUnread) {
|
||||||
//playNotificationSound();
|
//playNotificationSound();
|
||||||
}
|
}
|
||||||
|
|
||||||
setUnreadCounts(newUnreadCounts);
|
setUnreadCounts(newUnreadCounts);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -220,12 +223,12 @@ export default function ChatTable() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Navigate to chat detail page
|
// Navigate to chat detail page
|
||||||
const handleChatClick = (chatId: string) => {
|
const handleChatClick = (chatId: string) => {
|
||||||
router.push(`/dashboard/chats/${chatId}`);
|
router.push(`/dashboard/chats/${chatId}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create new chat
|
// Create new chat
|
||||||
const handleCreateChat = () => {
|
const handleCreateChat = () => {
|
||||||
router.push("/dashboard/chats/new");
|
router.push("/dashboard/chats/new");
|
||||||
@@ -261,163 +264,213 @@ export default function ChatTable() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-end">
|
||||||
<Button
|
<div>
|
||||||
variant="outline"
|
<h2 className="text-2xl font-bold tracking-tight">Messages</h2>
|
||||||
size="sm"
|
<p className="text-muted-foreground">Manage your customer conversations</p>
|
||||||
onClick={handleRefresh}
|
</div>
|
||||||
disabled={loading}
|
<div className="flex gap-2">
|
||||||
>
|
<Button
|
||||||
{loading ? (
|
variant="outline"
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
size="sm"
|
||||||
) : (
|
onClick={handleRefresh}
|
||||||
<RefreshCw className="h-4 w-4" />
|
disabled={loading}
|
||||||
)}
|
className="h-9"
|
||||||
<span className="ml-2">Refresh</span>
|
>
|
||||||
</Button>
|
{loading ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||||
<Button onClick={handleCreateChat} size="sm">
|
) : (
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
New Chat
|
)}
|
||||||
</Button>
|
Refresh
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button onClick={handleCreateChat} size="sm" className="h-9">
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
New Chat
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-md border">
|
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden">
|
||||||
<Table>
|
<CardContent className="p-0">
|
||||||
<TableHeader>
|
<Table>
|
||||||
<TableRow>
|
<TableHeader className="bg-muted/50">
|
||||||
<TableHead className="w-[200px]">Customer</TableHead>
|
<TableRow className="hover:bg-transparent">
|
||||||
<TableHead>Last Activity</TableHead>
|
<TableHead className="w-[300px] pl-6">Customer</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>Last Activity</TableHead>
|
||||||
<TableHead className="text-right">Actions</TableHead>
|
<TableHead>Status</TableHead>
|
||||||
</TableRow>
|
<TableHead className="text-right pr-6">Actions</TableHead>
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{loading ? (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={4} className="h-24 text-center">
|
|
||||||
<Loader2 className="h-6 w-6 animate-spin mx-auto" />
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : chats.length === 0 ? (
|
</TableHeader>
|
||||||
<TableRow>
|
<TableBody>
|
||||||
<TableCell colSpan={4} className="h-24 text-center">
|
<AnimatePresence mode="popLayout">
|
||||||
<div className="flex flex-col items-center justify-center">
|
{loading ? (
|
||||||
<MessageCircle className="h-8 w-8 text-muted-foreground mb-2" />
|
<TableRow>
|
||||||
<p className="text-muted-foreground">No chats found</p>
|
<TableCell colSpan={4} className="h-32 text-center">
|
||||||
</div>
|
<div className="flex flex-col items-center justify-center gap-2">
|
||||||
</TableCell>
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
</TableRow>
|
<span className="text-sm text-muted-foreground">Loading conversations...</span>
|
||||||
) : (
|
</div>
|
||||||
chats.map((chat) => (
|
</TableCell>
|
||||||
<TableRow
|
</TableRow>
|
||||||
key={chat._id}
|
) : chats.length === 0 ? (
|
||||||
className="cursor-pointer hover:bg-muted/50"
|
<TableRow>
|
||||||
onClick={() => handleChatClick(chat._id)}
|
<TableCell colSpan={4} className="h-32 text-center">
|
||||||
>
|
<div className="flex flex-col items-center justify-center">
|
||||||
<TableCell>
|
<MessageCircle className="h-10 w-10 text-muted-foreground/50 mb-3" />
|
||||||
<div className="flex items-center space-x-3">
|
<p className="text-muted-foreground font-medium">No chats found</p>
|
||||||
<Avatar>
|
<p className="text-xs text-muted-foreground mt-1">Start a new conversation to communicate with customers</p>
|
||||||
<AvatarFallback>
|
</div>
|
||||||
<User className="h-4t w-4" />
|
</TableCell>
|
||||||
</AvatarFallback>
|
</TableRow>
|
||||||
</Avatar>
|
) : (
|
||||||
<div>
|
chats.map((chat, index) => (
|
||||||
<div className="font-medium">
|
<motion.tr
|
||||||
{chat.telegramUsername ? `@${chat.telegramUsername}` : 'Customer'}
|
key={chat._id}
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2, delay: index * 0.05 }}
|
||||||
|
className="group cursor-pointer hover:bg-muted/30 transition-colors border-b border-border/50 last:border-0"
|
||||||
|
onClick={() => handleChatClick(chat._id)}
|
||||||
|
style={{ display: 'table-row' }} // Essential for table layout
|
||||||
|
>
|
||||||
|
<TableCell className="pl-6 py-4">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="relative">
|
||||||
|
<Avatar className="h-10 w-10 border-2 border-background shadow-sm group-hover:scale-105 transition-transform duration-200">
|
||||||
|
<AvatarFallback className={cn(
|
||||||
|
"font-medium text-xs",
|
||||||
|
unreadCounts.chatCounts[chat._id] > 0 ? "bg-primary/20 text-primary" : "bg-muted text-muted-foreground"
|
||||||
|
)}>
|
||||||
|
{chat.buyerId.slice(0, 2).toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
{unreadCounts.chatCounts[chat._id] > 0 && (
|
||||||
|
<span className="absolute -top-1 -right-1 h-3 w-3 bg-primary rounded-full ring-2 ring-background animate-pulse" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold text-sm flex items-center gap-2">
|
||||||
|
{chat.telegramUsername ? (
|
||||||
|
<span className="text-blue-400">@{chat.telegramUsername}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-foreground">Customer {chat.buyerId.slice(0, 6)}...</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground mt-0.5 font-mono">
|
||||||
|
ID: {chat.buyerId}
|
||||||
|
</div>
|
||||||
|
{chat.orderId && (
|
||||||
|
<div className="text-[10px] text-muted-foreground mt-1 flex items-center gap-1 bg-muted/50 px-1.5 py-0.5 rounded w-fit">
|
||||||
|
<span className="w-1 h-1 rounded-full bg-zinc-400" />
|
||||||
|
Order #{chat.orderId}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground">
|
</TableCell>
|
||||||
ID: {chat.buyerId}
|
<TableCell className="py-4">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{formatDistanceToNow(new Date(chat.lastUpdated), { addSuffix: true })}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{new Date(chat.lastUpdated).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{chat.orderId && (
|
</TableCell>
|
||||||
<div className="text-xs text-muted-foreground">
|
<TableCell className="py-4">
|
||||||
Order #{chat.orderId}
|
{unreadCounts.chatCounts[chat._id] > 0 ? (
|
||||||
|
<div className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full bg-primary/10 text-primary text-xs font-medium border border-primary/20 shadow-[0_0_10px_rgba(var(--primary),0.1)]">
|
||||||
|
<span className="relative flex h-2 w-2">
|
||||||
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75"></span>
|
||||||
|
<span className="relative inline-flex rounded-full h-2 w-2 bg-primary"></span>
|
||||||
|
</span>
|
||||||
|
{unreadCounts.chatCounts[chat._id]} new message{unreadCounts.chatCounts[chat._id] !== 1 ? 's' : ''}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full bg-muted text-muted-foreground text-xs font-medium border border-border">
|
||||||
|
<CheckCheck className="h-3 w-3" />
|
||||||
|
All caught up
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</TableCell>
|
||||||
</div>
|
<TableCell className="text-right pr-6 py-4">
|
||||||
</TableCell>
|
<div className="flex justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||||
<TableCell>
|
<Button
|
||||||
{formatDistanceToNow(new Date(chat.lastUpdated), { addSuffix: true })}
|
variant="secondary"
|
||||||
</TableCell>
|
size="sm"
|
||||||
<TableCell>
|
className="h-8 w-8 p-0 rounded-full"
|
||||||
{unreadCounts.chatCounts[chat._id] > 0 ? (
|
onClick={(e) => {
|
||||||
<Badge variant="destructive" className="ml-1">
|
e.stopPropagation();
|
||||||
{unreadCounts.chatCounts[chat._id]} new
|
handleChatClick(chat._id);
|
||||||
</Badge>
|
}}
|
||||||
) : (
|
>
|
||||||
<Badge variant="outline">Read</Badge>
|
<ArrowRightCircle className="h-4 w-4" />
|
||||||
)}
|
<span className="sr-only">View</span>
|
||||||
</TableCell>
|
</Button>
|
||||||
<TableCell className="text-right">
|
</div>
|
||||||
<div className="flex justify-end space-x-2">
|
</TableCell>
|
||||||
<Button
|
</motion.tr>
|
||||||
variant="ghost"
|
))
|
||||||
size="icon"
|
)}
|
||||||
onClick={(e) => {
|
</AnimatePresence>
|
||||||
e.stopPropagation();
|
</TableBody>
|
||||||
handleChatClick(chat._id);
|
</Table>
|
||||||
}}
|
</CardContent>
|
||||||
>
|
</Card>
|
||||||
<Eye className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pagination controls */}
|
{/* Pagination controls */}
|
||||||
{!loading && chats.length > 0 && (
|
{
|
||||||
<div className="flex items-center justify-between">
|
!loading && chats.length > 0 && (
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="flex items-center justify-between">
|
||||||
Showing {chats.length} of {totalChats} chats
|
<div className="text-sm text-muted-foreground">
|
||||||
</div>
|
Showing {chats.length} of {totalChats} chats
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<span className="text-sm text-muted-foreground">Rows per page:</span>
|
|
||||||
<Select
|
|
||||||
value={itemsPerPage.toString()}
|
|
||||||
onValueChange={handleItemsPerPageChange}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 w-[70px]">
|
|
||||||
<SelectValue placeholder={itemsPerPage.toString()} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="5">5</SelectItem>
|
|
||||||
<SelectItem value="10">10</SelectItem>
|
|
||||||
<SelectItem value="20">20</SelectItem>
|
|
||||||
<SelectItem value="50">50</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-4">
|
||||||
<Button
|
<div className="flex items-center space-x-2">
|
||||||
variant="outline"
|
<span className="text-sm text-muted-foreground">Rows per page:</span>
|
||||||
size="sm"
|
<Select
|
||||||
onClick={goToPrevPage}
|
value={itemsPerPage.toString()}
|
||||||
disabled={currentPage <= 1 || loading}
|
onValueChange={handleItemsPerPageChange}
|
||||||
>
|
>
|
||||||
<ChevronLeft className="h-4 w-4" />
|
<SelectTrigger className="h-8 w-[70px]">
|
||||||
</Button>
|
<SelectValue placeholder={itemsPerPage.toString()} />
|
||||||
<div className="text-sm">
|
</SelectTrigger>
|
||||||
Page {currentPage} of {totalPages}
|
<SelectContent>
|
||||||
|
<SelectItem value="5">5</SelectItem>
|
||||||
|
<SelectItem value="10">10</SelectItem>
|
||||||
|
<SelectItem value="20">20</SelectItem>
|
||||||
|
<SelectItem value="50">50</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={goToPrevPage}
|
||||||
|
disabled={currentPage <= 1 || loading}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<div className="text-sm">
|
||||||
|
Page {currentPage} of {totalPages}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={goToNextPage}
|
||||||
|
disabled={currentPage >= totalPages || loading}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={goToNextPage}
|
|
||||||
disabled={currentPage >= totalPages || loading}
|
|
||||||
>
|
|
||||||
<ChevronRight className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,16 +2,20 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect } from "react"
|
||||||
import OrderStats from "./order-stats"
|
import OrderStats from "./order-stats"
|
||||||
|
import QuickActions from "./quick-actions"
|
||||||
|
import RecentActivity from "./recent-activity"
|
||||||
import { getGreeting } from "@/lib/utils/general"
|
import { getGreeting } from "@/lib/utils/general"
|
||||||
import { statsConfig } from "@/config/dashboard"
|
import { statsConfig } from "@/config/dashboard"
|
||||||
import { getRandomQuote } from "@/config/quotes"
|
import { getRandomQuote } from "@/config/quotes"
|
||||||
import type { OrderStatsData } from "@/lib/types"
|
import type { OrderStatsData } from "@/lib/types"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { ShoppingCart, RefreshCcw } from "lucide-react"
|
import { ShoppingCart, RefreshCcw, ArrowRight } from "lucide-react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { useToast } from "@/components/ui/use-toast"
|
import { useToast } from "@/components/ui/use-toast"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { clientFetch } from "@/lib/api"
|
import { clientFetch } from "@/lib/api"
|
||||||
|
import { motion } from "framer-motion"
|
||||||
|
import Link from "next/link"
|
||||||
|
|
||||||
interface ContentProps {
|
interface ContentProps {
|
||||||
username: string
|
username: string
|
||||||
@@ -33,146 +37,166 @@ export default function Content({ username, orderStats }: ContentProps) {
|
|||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
// Initialize with a random quote from the quotes config
|
|
||||||
const [randomQuote, setRandomQuote] = useState(getRandomQuote());
|
const [randomQuote, setRandomQuote] = useState(getRandomQuote());
|
||||||
|
|
||||||
// Fetch top-selling products data
|
|
||||||
const fetchTopProducts = async () => {
|
const fetchTopProducts = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
const data = await clientFetch('/orders/top-products');
|
const data = await clientFetch('/orders/top-products');
|
||||||
setTopProducts(data);
|
setTopProducts(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error fetching top products:", err);
|
console.error("Error fetching top products:", err);
|
||||||
setError(err instanceof Error ? err.message : "Failed to fetch top products");
|
setError(err instanceof Error ? err.message : "Failed to fetch top products");
|
||||||
toast({
|
|
||||||
title: "Error loading top products",
|
|
||||||
description: "Please try refreshing the page",
|
|
||||||
variant: "destructive"
|
|
||||||
});
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Initialize greeting and fetch data on component mount
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setGreeting(getGreeting());
|
setGreeting(getGreeting());
|
||||||
fetchTopProducts();
|
fetchTopProducts();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Retry fetching top products data
|
|
||||||
const handleRetry = () => {
|
const handleRetry = () => {
|
||||||
fetchTopProducts();
|
fetchTopProducts();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-10 pb-10">
|
||||||
<div>
|
<motion.div
|
||||||
<h1 className="text-2xl font-semibold text-foreground">
|
initial={{ opacity: 0, y: -20 }}
|
||||||
{greeting}, {username}!
|
animate={{ opacity: 1, y: 0 }}
|
||||||
</h1>
|
className="flex flex-col md:flex-row md:items-end md:justify-between gap-4"
|
||||||
<p className="text-muted-foreground mt-1 italic text-sm">
|
>
|
||||||
"{randomQuote.text}" — <span className="font-medium">{randomQuote.author}</span>
|
<div>
|
||||||
</p>
|
<h1 className="text-4xl font-bold tracking-tight text-foreground">
|
||||||
</div>
|
{greeting}, <span className="text-primary">{username}</span>!
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground mt-2 max-w-2xl text-lg">
|
||||||
|
"{randomQuote.text}" — <span className="font-medium">{randomQuote.author}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Quick ActionsSection */}
|
||||||
|
<section className="space-y-4">
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground ml-1">Quick Actions</h2>
|
||||||
|
<QuickActions />
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* Order Statistics */}
|
{/* Order Statistics */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 lg:gap-6">
|
<section className="space-y-4">
|
||||||
{statsConfig.map((stat) => (
|
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground ml-1">Overview</h2>
|
||||||
<OrderStats
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
key={stat.title}
|
{statsConfig.map((stat, index) => (
|
||||||
title={stat.title}
|
<OrderStats
|
||||||
value={orderStats[stat.key as keyof OrderStatsData].toLocaleString()}
|
key={stat.title}
|
||||||
icon={stat.icon}
|
title={stat.title}
|
||||||
/>
|
value={orderStats[stat.key as keyof OrderStatsData]?.toLocaleString() || "0"}
|
||||||
))}
|
icon={stat.icon}
|
||||||
</div>
|
index={index}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* Best Selling Products Section */}
|
<div className="grid grid-cols-1 xl:grid-cols-3 gap-8">
|
||||||
<div className="mt-8">
|
{/* Recent Activity Section */}
|
||||||
<Card>
|
<div className="xl:col-span-1">
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
<RecentActivity />
|
||||||
<div>
|
</div>
|
||||||
<CardTitle>Your Best Selling Products</CardTitle>
|
|
||||||
<CardDescription>Products with the highest sales from your store</CardDescription>
|
{/* Best Selling Products Section */}
|
||||||
</div>
|
<div className="xl:col-span-2">
|
||||||
{error && (
|
<Card className="h-full border-border/40 bg-background/50 backdrop-blur-sm">
|
||||||
<Button
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
variant="outline"
|
<div>
|
||||||
size="sm"
|
<CardTitle>Top Performing Listings</CardTitle>
|
||||||
onClick={handleRetry}
|
<CardDescription>Your products with the highest sales volume</CardDescription>
|
||||||
className="flex items-center gap-1"
|
</div>
|
||||||
>
|
{error && (
|
||||||
<RefreshCcw className="h-3 w-3" />
|
<Button
|
||||||
<span>Retry</span>
|
variant="outline"
|
||||||
</Button>
|
size="sm"
|
||||||
)}
|
onClick={handleRetry}
|
||||||
</CardHeader>
|
className="flex items-center gap-1"
|
||||||
|
>
|
||||||
<CardContent>
|
<RefreshCcw className="h-3 w-3" />
|
||||||
{isLoading ? (
|
<span>Retry</span>
|
||||||
// Loading skeleton
|
</Button>
|
||||||
<div className="space-y-4">
|
)}
|
||||||
{[...Array(5)].map((_, i) => (
|
</CardHeader>
|
||||||
<div key={i} className="flex items-center gap-4">
|
|
||||||
<Skeleton className="h-12 w-12 rounded-md" />
|
<CardContent>
|
||||||
<div className="space-y-2">
|
{isLoading ? (
|
||||||
<Skeleton className="h-4 w-40" />
|
<div className="space-y-4">
|
||||||
<Skeleton className="h-4 w-20" />
|
{[...Array(5)].map((_, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-4">
|
||||||
|
<Skeleton className="h-14 w-14 rounded-xl" />
|
||||||
|
<div className="space-y-2 flex-1">
|
||||||
|
<Skeleton className="h-4 w-1/2" />
|
||||||
|
<Skeleton className="h-3 w-1/4" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-4 w-16" />
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-auto text-right">
|
))}
|
||||||
<Skeleton className="h-4 w-16 ml-auto" />
|
</div>
|
||||||
<Skeleton className="h-4 w-16 ml-auto mt-2" />
|
) : error ? (
|
||||||
</div>
|
<div className="py-12 text-center">
|
||||||
</div>
|
<div className="text-muted-foreground mb-4">Failed to load product insights</div>
|
||||||
))}
|
</div>
|
||||||
</div>
|
) : topProducts.length === 0 ? (
|
||||||
) : error ? (
|
<div className="py-12 text-center">
|
||||||
// Error state
|
<ShoppingCart className="h-12 w-12 mx-auto text-muted-foreground/50 mb-4" />
|
||||||
<div className="py-8 text-center">
|
<h3 className="text-lg font-medium mb-1">Begin your sales journey</h3>
|
||||||
<div className="text-muted-foreground mb-4">Failed to load products</div>
|
<p className="text-muted-foreground text-sm max-w-xs mx-auto">
|
||||||
</div>
|
Your top performing listings will materialize here as you receive orders.
|
||||||
) : topProducts.length === 0 ? (
|
</p>
|
||||||
// Empty state
|
</div>
|
||||||
<div className="py-8 text-center">
|
) : (
|
||||||
<ShoppingCart className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
|
<div className="space-y-1">
|
||||||
<h3 className="text-lg font-medium mb-2">No products sold yet</h3>
|
{topProducts.map((product, index) => (
|
||||||
<p className="text-muted-foreground">
|
<motion.div
|
||||||
Your best-selling products will appear here after you make some sales.
|
key={product.id}
|
||||||
</p>
|
initial={{ opacity: 0, y: 10 }}
|
||||||
</div>
|
animate={{ opacity: 1, y: 0 }}
|
||||||
) : (
|
transition={{ delay: 0.1 + index * 0.05 }}
|
||||||
// Data view
|
className="flex items-center gap-4 p-3 rounded-xl hover:bg-muted/50 transition-colors group"
|
||||||
<div className="space-y-4">
|
|
||||||
{topProducts.map((product) => (
|
|
||||||
<div key={product.id} className="flex items-center gap-4 py-2 border-b last:border-0">
|
|
||||||
<div
|
|
||||||
className="h-12 w-12 bg-cover bg-center rounded-md border flex-shrink-0 flex items-center justify-center overflow-hidden"
|
|
||||||
style={{
|
|
||||||
backgroundImage: product.image
|
|
||||||
? `url(/api/products/${product.id}/image)`
|
|
||||||
: 'none'
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{!product.image && (
|
<div
|
||||||
<ShoppingCart className="h-6 w-6 text-muted-foreground" />
|
className="h-14 w-14 bg-muted bg-cover bg-center rounded-xl border flex-shrink-0 flex items-center justify-center overflow-hidden group-hover:scale-105 transition-transform"
|
||||||
)}
|
style={{
|
||||||
</div>
|
backgroundImage: product.image
|
||||||
<div className="flex-grow min-w-0">
|
? `url(/api/products/${product.id}/image)`
|
||||||
<h4 className="font-medium truncate">{product.name}</h4>
|
: 'none'
|
||||||
</div>
|
}}
|
||||||
<div className="text-right">
|
>
|
||||||
<div className="font-medium">{product.count} sold</div>
|
{!product.image && (
|
||||||
</div>
|
<ShoppingCart className="h-6 w-6 text-muted-foreground/50" />
|
||||||
</div>
|
)}
|
||||||
))}
|
</div>
|
||||||
</div>
|
<div className="flex-grow min-w-0">
|
||||||
)}
|
<h4 className="font-semibold text-lg truncate group-hover:text-primary transition-colors">{product.name}</h4>
|
||||||
</CardContent>
|
<div className="flex items-center gap-3 mt-0.5">
|
||||||
</Card>
|
<span className="text-sm text-muted-foreground font-medium">£{product.price.toFixed(2)}</span>
|
||||||
|
<span className="h-1 w-1 rounded-full bg-muted-foreground/30" />
|
||||||
|
<span className="text-xs text-muted-foreground">ID: {product.id.slice(-6)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-xl font-bold">{product.count}</div>
|
||||||
|
<div className="text-xs text-muted-foreground font-medium uppercase tracking-tighter">Units Sold</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,23 +1,37 @@
|
|||||||
import type { LucideIcon } from "lucide-react"
|
import type { LucideIcon } from "lucide-react"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { motion } from "framer-motion"
|
||||||
|
|
||||||
interface OrderStatsProps {
|
interface OrderStatsProps {
|
||||||
title: string
|
title: string
|
||||||
value: string
|
value: string
|
||||||
icon: LucideIcon
|
icon: LucideIcon
|
||||||
|
index?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function OrderStats({ title, value, icon: Icon }: OrderStatsProps) {
|
export default function OrderStats({ title, value, icon: Icon, index = 0 }: OrderStatsProps) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<motion.div
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
<CardTitle className="text-sm font-medium">{title}</CardTitle>
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
transition={{ delay: index * 0.05 }}
|
||||||
</CardHeader>
|
>
|
||||||
<CardContent>
|
<Card className="relative overflow-hidden group border-border/40 bg-background/50 backdrop-blur-sm hover:bg-background/80 transition-all duration-300">
|
||||||
<div className="text-2xl font-bold">{value}</div>
|
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||||
</CardContent>
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 relative z-10">
|
||||||
</Card>
|
<CardTitle className="text-sm font-medium tracking-tight text-muted-foreground group-hover:text-foreground transition-colors">
|
||||||
|
{title}
|
||||||
|
</CardTitle>
|
||||||
|
<div className="p-2 rounded-lg bg-muted group-hover:bg-primary/10 group-hover:text-primary transition-all duration-300">
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="relative z-10">
|
||||||
|
<div className="text-3xl font-bold tracking-tight">{value}</div>
|
||||||
|
<div className="mt-1 h-1 w-0 bg-primary/20 group-hover:w-full transition-all duration-500 rounded-full" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
75
components/dashboard/quick-actions.tsx
Normal file
75
components/dashboard/quick-actions.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import Link from "next/link"
|
||||||
|
import { motion } from "framer-motion"
|
||||||
|
import {
|
||||||
|
PlusCircle,
|
||||||
|
Package,
|
||||||
|
BarChart3,
|
||||||
|
Settings,
|
||||||
|
MessageSquare,
|
||||||
|
Truck,
|
||||||
|
Tag,
|
||||||
|
Users
|
||||||
|
} from "lucide-react"
|
||||||
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
|
|
||||||
|
const actions = [
|
||||||
|
{
|
||||||
|
title: "Add Product",
|
||||||
|
icon: PlusCircle,
|
||||||
|
href: "/dashboard/products/new",
|
||||||
|
color: "bg-blue-500/10 text-blue-500",
|
||||||
|
description: "Create a new listing"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Process Orders",
|
||||||
|
icon: Truck,
|
||||||
|
href: "/dashboard/orders?status=paid",
|
||||||
|
color: "bg-emerald-500/10 text-emerald-500",
|
||||||
|
description: "Ship pending orders"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Analytics",
|
||||||
|
icon: BarChart3,
|
||||||
|
href: "/dashboard/analytics",
|
||||||
|
color: "bg-purple-500/10 text-purple-500",
|
||||||
|
description: "View sales performance"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Messages",
|
||||||
|
icon: MessageSquare,
|
||||||
|
href: "/dashboard/chats",
|
||||||
|
color: "bg-amber-500/10 text-amber-500",
|
||||||
|
description: "Chat with customers"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function QuickActions() {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
{actions.map((action, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={action.title}
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: index * 0.1 }}
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
>
|
||||||
|
<Link href={action.href}>
|
||||||
|
<Card className="hover:border-primary/50 transition-colors cursor-pointer group h-full">
|
||||||
|
<CardContent className="p-6 flex flex-col items-center text-center">
|
||||||
|
<div className={`p-3 rounded-xl ${action.color} mb-4 group-hover:scale-110 transition-transform`}>
|
||||||
|
<action.icon className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-semibold text-lg">{action.title}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">{action.description}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
119
components/dashboard/recent-activity.tsx
Normal file
119
components/dashboard/recent-activity.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react"
|
||||||
|
import { motion } from "framer-motion"
|
||||||
|
import { ShoppingBag, CreditCard, Truck, MessageSquare, AlertCircle } from "lucide-react"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||||
|
import { clientFetch } from "@/lib/api"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { formatDistanceToNow } from "date-fns"
|
||||||
|
import Link from "next/link"
|
||||||
|
|
||||||
|
interface ActivityItem {
|
||||||
|
_id: string;
|
||||||
|
orderId: string;
|
||||||
|
status: string;
|
||||||
|
totalPrice: number;
|
||||||
|
orderDate: string;
|
||||||
|
username?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RecentActivity() {
|
||||||
|
const [activities, setActivities] = useState<ActivityItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchRecentOrders() {
|
||||||
|
try {
|
||||||
|
const data = await clientFetch("/orders?limit=5&sortBy=orderDate&sortOrder=desc");
|
||||||
|
setActivities(data.orders || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch recent activity:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchRecentOrders();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getStatusIcon = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case "paid": return <CreditCard className="h-4 w-4" />;
|
||||||
|
case "shipped": return <Truck className="h-4 w-4" />;
|
||||||
|
case "unpaid": return <ShoppingBag className="h-4 w-4" />;
|
||||||
|
default: return <AlertCircle className="h-4 w-4" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case "paid": return "bg-emerald-500/10 text-emerald-500";
|
||||||
|
case "shipped": return "bg-blue-500/10 text-blue-500";
|
||||||
|
case "unpaid": return "bg-amber-500/10 text-amber-500";
|
||||||
|
default: return "bg-gray-500/10 text-gray-500";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="h-full">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Recent Activity</CardTitle>
|
||||||
|
<CardDescription>Latest updates from your store</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loading ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
|
<div key={i} className="flex items-center gap-4">
|
||||||
|
<Skeleton className="h-8 w-8 rounded-full" />
|
||||||
|
<div className="space-y-2 flex-1">
|
||||||
|
<Skeleton className="h-4 w-3/4" />
|
||||||
|
<Skeleton className="h-3 w-1/4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : activities.length === 0 ? (
|
||||||
|
<div className="py-8 text-center text-muted-foreground">
|
||||||
|
No recent activity
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{activities.map((item, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={item._id}
|
||||||
|
initial={{ opacity: 0, x: -20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ delay: index * 0.1 }}
|
||||||
|
className="flex items-start gap-4 relative"
|
||||||
|
>
|
||||||
|
{index !== activities.length - 1 && (
|
||||||
|
<div className="absolute left-[15px] top-8 bottom-[-24px] w-[2px] bg-border/50" />
|
||||||
|
)}
|
||||||
|
<div className={`mt-1 p-2 rounded-full z-10 ${getStatusColor(item.status)}`}>
|
||||||
|
{getStatusIcon(item.status)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 space-y-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Link href={`/dashboard/orders/${item._id}`} className="font-medium hover:underline">
|
||||||
|
Order #{item.orderId}
|
||||||
|
</Link>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{formatDistanceToNow(new Date(item.orderDate), { addSuffix: true })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{item.status === "paid" ? "Payment received" :
|
||||||
|
item.status === "shipped" ? "Order marked as shipped" :
|
||||||
|
`Order status: ${item.status}`} for £{item.totalPrice.toFixed(2)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
86
components/orders/order-timeline.tsx
Normal file
86
components/orders/order-timeline.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { CheckCircle2, Circle, Clock, Package, Truck, Flag } from "lucide-react"
|
||||||
|
import { motion } from "framer-motion"
|
||||||
|
|
||||||
|
interface OrderTimelineProps {
|
||||||
|
status: string;
|
||||||
|
orderDate: Date | string;
|
||||||
|
paidAt?: Date | string;
|
||||||
|
completedAt?: Date | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{ status: "unpaid", label: "Ordered", icon: Clock },
|
||||||
|
{ status: "paid", label: "Paid", icon: CheckCircle2 },
|
||||||
|
{ status: "acknowledged", label: "Processing", icon: Package },
|
||||||
|
{ status: "shipped", label: "Shipped", icon: Truck },
|
||||||
|
{ status: "completed", label: "Completed", icon: Flag },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function OrderTimeline({ status, orderDate, paidAt }: OrderTimelineProps) {
|
||||||
|
const currentStatusIndex = steps.findIndex(step =>
|
||||||
|
step.status === status ||
|
||||||
|
(status === "confirming" && step.status === "unpaid") ||
|
||||||
|
(status === "acknowledged" && step.status === "paid") // Processed is after paid
|
||||||
|
);
|
||||||
|
|
||||||
|
// If status is "confirming", it's basically "unpaid" for the timeline
|
||||||
|
// If status is "acknowledged", it's "Processing"
|
||||||
|
|
||||||
|
const getStepStatus = (index: number) => {
|
||||||
|
// Basic logic to determine if a step is completed, current, or pending
|
||||||
|
let effectiveIndex = currentStatusIndex;
|
||||||
|
if (status === "confirming") effectiveIndex = 0;
|
||||||
|
if (status === "paid") effectiveIndex = 1;
|
||||||
|
if (status === "acknowledged") effectiveIndex = 2;
|
||||||
|
if (status === "shipped") effectiveIndex = 3;
|
||||||
|
if (status === "completed") effectiveIndex = 4;
|
||||||
|
|
||||||
|
if (index < effectiveIndex) return "completed";
|
||||||
|
if (index === effectiveIndex) return "current";
|
||||||
|
return "pending";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex justify-between items-center w-full px-4 py-8">
|
||||||
|
{/* Connector Line */}
|
||||||
|
<div className="absolute left-10 right-10 top-1/2 h-0.5 bg-muted -translate-y-1/2 z-0">
|
||||||
|
<motion.div
|
||||||
|
className="h-full bg-primary"
|
||||||
|
initial={{ width: "0%" }}
|
||||||
|
animate={{ width: `${(Math.max(0, steps.findIndex(s => s.status === status)) / (steps.length - 1)) * 100}%` }}
|
||||||
|
transition={{ duration: 1, ease: "easeInOut" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{steps.map((step, index) => {
|
||||||
|
const stepStatus = getStepStatus(index);
|
||||||
|
const Icon = step.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={step.label} className="relative flex flex-col items-center z-10">
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.8, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
transition={{ delay: index * 0.1 }}
|
||||||
|
className={`flex items-center justify-center w-10 h-10 rounded-full border-2 transition-colors duration-500 ${stepStatus === "completed"
|
||||||
|
? "bg-primary border-primary text-primary-foreground"
|
||||||
|
: stepStatus === "current"
|
||||||
|
? "bg-background border-primary text-primary ring-4 ring-primary/10"
|
||||||
|
: "bg-background border-muted text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="h-5 w-5" />
|
||||||
|
</motion.div>
|
||||||
|
<div className="absolute top-12 whitespace-nowrap text-xs font-medium tracking-tight">
|
||||||
|
<p className={stepStatus === "pending" ? "text-muted-foreground" : "text-foreground"}>
|
||||||
|
{step.label}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -11,6 +12,7 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -167,7 +169,7 @@ export default function OrderTable() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const data = await clientFetch(`/orders?${queryParams}`);
|
const data = await clientFetch(`/orders?${queryParams}`);
|
||||||
|
|
||||||
console.log("Fetched orders with fresh data:", data.orders?.length || 0);
|
console.log("Fetched orders with fresh data:", data.orders?.length || 0);
|
||||||
setOrders(data.orders || []);
|
setOrders(data.orders || []);
|
||||||
setTotalPages(data.totalPages || 1);
|
setTotalPages(data.totalPages || 1);
|
||||||
@@ -244,11 +246,11 @@ export default function OrderTable() {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ orderIds: Array.from(selectedOrders) })
|
body: JSON.stringify({ orderIds: Array.from(selectedOrders) })
|
||||||
});
|
});
|
||||||
|
|
||||||
// Only update orders that were successfully marked as shipped
|
// Only update orders that were successfully marked as shipped
|
||||||
if (response.success && response.success.orders) {
|
if (response.success && response.success.orders) {
|
||||||
const successfulOrderIds = new Set(response.success.orders.map((o: any) => o.id));
|
const successfulOrderIds = new Set(response.success.orders.map((o: any) => o.id));
|
||||||
|
|
||||||
setOrders(prev =>
|
setOrders(prev =>
|
||||||
prev.map(order =>
|
prev.map(order =>
|
||||||
successfulOrderIds.has(order._id)
|
successfulOrderIds.has(order._id)
|
||||||
@@ -256,16 +258,16 @@ export default function OrderTable() {
|
|||||||
: order
|
: order
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.failed && response.failed.count > 0) {
|
if (response.failed && response.failed.count > 0) {
|
||||||
toast.warning(`${response.failed.count} orders could not be marked as shipped`);
|
toast.warning(`${response.failed.count} orders could not be marked as shipped`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.success.count > 0) {
|
if (response.success.count > 0) {
|
||||||
toast.success(`${response.success.count} orders marked as shipped`);
|
toast.success(`${response.success.count} orders marked as shipped`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setSelectedOrders(new Set());
|
setSelectedOrders(new Set());
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error("Failed to update orders");
|
toast.error("Failed to update orders");
|
||||||
@@ -276,68 +278,69 @@ export default function OrderTable() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const statusConfig: Record<OrderStatus, StatusConfig> = {
|
const statusConfig: Record<OrderStatus, StatusConfig> = {
|
||||||
acknowledged: {
|
acknowledged: {
|
||||||
icon: CheckCircle2,
|
icon: CheckCircle2,
|
||||||
color: "text-white",
|
color: "text-purple-100",
|
||||||
bgColor: "bg-purple-600"
|
bgColor: "bg-purple-600/90 shadow-[0_0_10px_rgba(147,51,234,0.3)]"
|
||||||
},
|
},
|
||||||
paid: {
|
paid: {
|
||||||
icon: CheckCircle2,
|
icon: CheckCircle2,
|
||||||
color: "text-white",
|
color: "text-emerald-100",
|
||||||
bgColor: "bg-emerald-600"
|
bgColor: "bg-emerald-600/90 shadow-[0_0_10px_rgba(16,185,129,0.3)]",
|
||||||
|
animate: "animate-pulse"
|
||||||
},
|
},
|
||||||
unpaid: {
|
unpaid: {
|
||||||
icon: XCircle,
|
icon: XCircle,
|
||||||
color: "text-white",
|
color: "text-amber-100",
|
||||||
bgColor: "bg-red-500"
|
bgColor: "bg-amber-500/90"
|
||||||
},
|
},
|
||||||
confirming: {
|
confirming: {
|
||||||
icon: Loader2,
|
icon: Loader2,
|
||||||
color: "text-white",
|
color: "text-blue-100",
|
||||||
bgColor: "bg-yellow-500",
|
bgColor: "bg-blue-500/90",
|
||||||
animate: "animate-spin"
|
animate: "animate-spin"
|
||||||
},
|
},
|
||||||
shipped: {
|
shipped: {
|
||||||
icon: Truck,
|
icon: Truck,
|
||||||
color: "text-white",
|
color: "text-indigo-100",
|
||||||
bgColor: "bg-blue-600"
|
bgColor: "bg-indigo-600/90 shadow-[0_0_10px_rgba(79,70,229,0.3)]"
|
||||||
},
|
},
|
||||||
completed: {
|
completed: {
|
||||||
icon: CheckCircle2,
|
icon: CheckCircle2,
|
||||||
color: "text-white",
|
color: "text-green-100",
|
||||||
bgColor: "bg-green-600"
|
bgColor: "bg-green-600/90"
|
||||||
},
|
},
|
||||||
cancelled: {
|
cancelled: {
|
||||||
icon: XCircle,
|
icon: XCircle,
|
||||||
color: "text-white",
|
color: "text-gray-100",
|
||||||
bgColor: "bg-gray-500"
|
bgColor: "bg-gray-600/90"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to determine if order is underpaid
|
// Helper function to determine if order is underpaid
|
||||||
const isOrderUnderpaid = (order: Order) => {
|
const isOrderUnderpaid = (order: Order) => {
|
||||||
// More robust check - only show underpaid if status allows it and underpayment exists
|
// More robust check - only show underpaid if status allows it and underpayment exists
|
||||||
return order.underpaid === true &&
|
return order.underpaid === true &&
|
||||||
order.underpaymentAmount &&
|
order.underpaymentAmount &&
|
||||||
order.underpaymentAmount > 0 &&
|
order.underpaymentAmount > 0 &&
|
||||||
order.status !== "paid" &&
|
order.status !== "paid" &&
|
||||||
order.status !== "completed" &&
|
order.status !== "completed" &&
|
||||||
order.status !== "shipped" &&
|
order.status !== "shipped" &&
|
||||||
order.status !== "cancelled";
|
order.status !== "cancelled";
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to get underpaid display info
|
// Helper function to get underpaid display info
|
||||||
const getUnderpaidInfo = (order: Order) => {
|
const getUnderpaidInfo = (order: Order) => {
|
||||||
if (!isOrderUnderpaid(order)) return null;
|
if (!isOrderUnderpaid(order)) return null;
|
||||||
|
|
||||||
const received = order.lastBalanceReceived || 0;
|
const received = order.lastBalanceReceived || 0;
|
||||||
const required = order.cryptoTotal || 0;
|
const required = order.cryptoTotal || 0;
|
||||||
const missing = order.underpaymentAmount || 0;
|
const missing = order.underpaymentAmount || 0;
|
||||||
|
|
||||||
// Calculate LTC to GBP exchange rate from order data
|
// Calculate LTC to GBP exchange rate from order data
|
||||||
const ltcToGbpRate = required > 0 ? order.totalPrice / required : 0;
|
const ltcToGbpRate = required > 0 ? order.totalPrice / required : 0;
|
||||||
const missingGbp = missing * ltcToGbpRate;
|
const missingGbp = missing * ltcToGbpRate;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
received,
|
received,
|
||||||
required,
|
required,
|
||||||
@@ -377,7 +380,7 @@ export default function OrderTable() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Check if we have any underpaid orders
|
// Check if we have any underpaid orders
|
||||||
const hasUnderpaidOrders = orders.some(order => isOrderUnderpaid(order));
|
const hasUnderpaidOrders = orders.some(order => isOrderUnderpaid(order));
|
||||||
|
|
||||||
if (hasUnderpaidOrders) {
|
if (hasUnderpaidOrders) {
|
||||||
console.log("Found underpaid orders, setting up refresh interval");
|
console.log("Found underpaid orders, setting up refresh interval");
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
@@ -391,16 +394,16 @@ export default function OrderTable() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="border border-zinc-800 rounded-md bg-black/40 overflow-hidden">
|
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden">
|
||||||
{/* Filters header */}
|
{/* Filters header */}
|
||||||
<div className="p-4 border-b border-zinc-800 bg-black/60">
|
<div className="p-4 border-b border-border/50 bg-muted/30">
|
||||||
<div className="flex flex-col lg:flex-row justify-between items-start lg:items-center gap-4">
|
<div className="flex flex-col lg:flex-row justify-between items-start lg:items-center gap-4">
|
||||||
<div className="flex flex-col sm:flex-row gap-2 sm:items-center w-full lg:w-auto">
|
<div className="flex flex-col sm:flex-row gap-2 sm:items-center w-full lg:w-auto">
|
||||||
<StatusFilter
|
<StatusFilter
|
||||||
currentStatus={statusFilter}
|
currentStatus={statusFilter}
|
||||||
onChange={setStatusFilter}
|
onChange={setStatusFilter}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PageSizeSelector
|
<PageSizeSelector
|
||||||
currentSize={itemsPerPage}
|
currentSize={itemsPerPage}
|
||||||
onChange={(value) => handleItemsPerPageChange({ target: { value } } as React.ChangeEvent<HTMLSelectElement>)}
|
onChange={(value) => handleItemsPerPageChange({ target: { value } } as React.ChangeEvent<HTMLSelectElement>)}
|
||||||
@@ -413,6 +416,7 @@ export default function OrderTable() {
|
|||||||
disabled={exporting}
|
disabled={exporting}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
className="bg-background/50 border-border/50"
|
||||||
>
|
>
|
||||||
{exporting ? (
|
{exporting ? (
|
||||||
<>
|
<>
|
||||||
@@ -428,12 +432,12 @@ export default function OrderTable() {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 self-end lg:self-auto">
|
<div className="flex items-center gap-2 self-end lg:self-auto">
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button disabled={selectedOrders.size === 0 || isShipping}>
|
<Button disabled={selectedOrders.size === 0 || isShipping} className="shadow-md">
|
||||||
<Truck className="mr-2 h-5 w-5" />
|
<Truck className="mr-2 h-4 w-4" />
|
||||||
{isShipping ? (
|
{isShipping ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
@@ -445,7 +449,7 @@ export default function OrderTable() {
|
|||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Mark Orders as Shipped</AlertDialogTitle>
|
<AlertDialogTitle>Mark Orders as Shipped</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
Are you sure you want to mark {selectedOrders.size} order{selectedOrders.size !== 1 ? 's' : ''} as shipped?
|
Are you sure you want to mark {selectedOrders.size} order{selectedOrders.size !== 1 ? 's' : ''} as shipped?
|
||||||
This action cannot be undone.
|
This action cannot be undone.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
@@ -462,163 +466,168 @@ export default function OrderTable() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
<div className="relative">
|
<CardContent className="p-0 relative min-h-[400px]">
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="absolute inset-0 bg-background/50 flex items-center justify-center">
|
<div className="absolute inset-0 bg-background/50 backdrop-blur-[1px] flex items-center justify-center z-50">
|
||||||
<Loader2 className="h-6 w-6 animate-spin" />
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="max-h-[calc(100vh-300px)] overflow-auto">
|
<div className="max-h-[calc(100vh-350px)] overflow-auto">
|
||||||
<Table className="[&_tr]:border-b [&_tr]:border-zinc-800 [&_tr:last-child]:border-b-0 [&_td]:border-r [&_td]:border-zinc-800 [&_td:last-child]:border-r-0 [&_th]:border-r [&_th]:border-zinc-800 [&_th:last-child]:border-r-0 [&_tr:hover]:bg-zinc-900/70">
|
<Table>
|
||||||
<TableHeader className="bg-black/60 sticky top-0 z-10">
|
<TableHeader className="bg-muted/50 sticky top-0 z-20">
|
||||||
<TableRow>
|
<TableRow className="hover:bg-transparent border-border/50">
|
||||||
<TableHead className="w-12">
|
<TableHead className="w-12">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selectedOrders.size === paginatedOrders.length && paginatedOrders.length > 0}
|
checked={selectedOrders.size === paginatedOrders.length && paginatedOrders.length > 0}
|
||||||
onCheckedChange={toggleAll}
|
onCheckedChange={toggleAll}
|
||||||
/>
|
/>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="cursor-pointer" onClick={() => handleSort("orderId")}>
|
<TableHead className="cursor-pointer hover:text-primary transition-colors" onClick={() => handleSort("orderId")}>
|
||||||
Order ID <ArrowUpDown className="ml-2 inline h-4 w-4" />
|
Order ID <ArrowUpDown className="ml-2 inline h-3 w-3" />
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="cursor-pointer" onClick={() => handleSort("totalPrice")}>
|
<TableHead className="cursor-pointer hover:text-primary transition-colors" onClick={() => handleSort("totalPrice")}>
|
||||||
Total <ArrowUpDown className="ml-2 inline h-4 w-4" />
|
Total <ArrowUpDown className="ml-2 inline h-3 w-3" />
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="hidden lg:table-cell">Promotion</TableHead>
|
<TableHead className="hidden lg:table-cell">Promotion</TableHead>
|
||||||
<TableHead className="cursor-pointer" onClick={() => handleSort("status")}>
|
<TableHead className="cursor-pointer hover:text-primary transition-colors" onClick={() => handleSort("status")}>
|
||||||
Status <ArrowUpDown className="ml-2 inline h-4 w-4" />
|
Status <ArrowUpDown className="ml-2 inline h-3 w-3" />
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="hidden md:table-cell cursor-pointer" onClick={() => handleSort("orderDate")}>
|
<TableHead className="hidden md:table-cell cursor-pointer hover:text-primary transition-colors" onClick={() => handleSort("orderDate")}>
|
||||||
Order Date <ArrowUpDown className="ml-2 inline h-4 w-4" />
|
Date <ArrowUpDown className="ml-2 inline h-3 w-3" />
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="hidden xl:table-cell cursor-pointer" onClick={() => handleSort("paidAt")}>
|
<TableHead className="hidden xl:table-cell cursor-pointer hover:text-primary transition-colors" onClick={() => handleSort("paidAt")}>
|
||||||
Paid At <ArrowUpDown className="ml-2 inline h-4 w-4" />
|
Paid At <ArrowUpDown className="ml-2 inline h-3 w-3" />
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="hidden lg:table-cell">Buyer</TableHead>
|
<TableHead className="hidden lg:table-cell">Buyer</TableHead>
|
||||||
<TableHead className="w-24 text-center">Actions</TableHead>
|
<TableHead className="w-24 text-center">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{paginatedOrders.map((order) => {
|
<AnimatePresence mode="popLayout">
|
||||||
const StatusIcon = statusConfig[order.status as keyof typeof statusConfig]?.icon || XCircle;
|
{paginatedOrders.map((order, index) => {
|
||||||
const underpaidInfo = getUnderpaidInfo(order);
|
const StatusIcon = statusConfig[order.status as keyof typeof statusConfig]?.icon || XCircle;
|
||||||
|
const underpaidInfo = getUnderpaidInfo(order);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow key={order._id}>
|
<motion.tr
|
||||||
<TableCell>
|
key={order._id}
|
||||||
<Checkbox
|
initial={{ opacity: 0, y: 10 }}
|
||||||
checked={selectedOrders.has(order._id)}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
onCheckedChange={() => toggleSelection(order._id)}
|
exit={{ opacity: 0 }}
|
||||||
disabled={order.status !== "paid" && order.status !== "acknowledged"}
|
transition={{ duration: 0.2, delay: index * 0.03 }}
|
||||||
/>
|
className="group hover:bg-muted/40 border-b border-border/50 transition-colors"
|
||||||
</TableCell>
|
>
|
||||||
<TableCell>#{order.orderId}</TableCell>
|
<TableCell>
|
||||||
<TableCell>
|
<Checkbox
|
||||||
<div className="flex flex-col">
|
checked={selectedOrders.has(order._id)}
|
||||||
<span>£{order.totalPrice.toFixed(2)}</span>
|
onCheckedChange={() => toggleSelection(order._id)}
|
||||||
{underpaidInfo && (
|
disabled={order.status !== "paid" && order.status !== "acknowledged"}
|
||||||
<span className="text-xs text-red-400">
|
/>
|
||||||
Missing: £{underpaidInfo.missingGbp.toFixed(2)} ({underpaidInfo.missing.toFixed(8)} LTC)
|
</TableCell>
|
||||||
</span>
|
<TableCell className="font-mono text-sm font-medium">#{order.orderId}</TableCell>
|
||||||
)}
|
<TableCell>
|
||||||
</div>
|
<div className="flex flex-col">
|
||||||
</TableCell>
|
<span className="font-medium">£{order.totalPrice.toFixed(2)}</span>
|
||||||
<TableCell className="hidden lg:table-cell">
|
{underpaidInfo && (
|
||||||
{order.promotionCode ? (
|
<span className="text-[10px] text-destructive flex items-center gap-1">
|
||||||
<div className="flex flex-col gap-1">
|
<AlertTriangle className="h-3 w-3" />
|
||||||
<div className="flex items-center gap-1">
|
-£{underpaidInfo.missingGbp.toFixed(2)}
|
||||||
<Tag className="h-3 w-3 text-green-500" />
|
|
||||||
<span className="text-xs font-mono bg-green-100 text-green-800 px-2 py-0.5 rounded">
|
|
||||||
{order.promotionCode}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
)}
|
||||||
<div className="flex items-center gap-1 text-xs text-green-600">
|
</div>
|
||||||
<Percent className="h-3 w-3" />
|
</TableCell>
|
||||||
<span>-£{(order.discountAmount || 0).toFixed(2)}</span>
|
<TableCell className="hidden lg:table-cell">
|
||||||
{order.subtotalBeforeDiscount && order.subtotalBeforeDiscount > 0 && (
|
{order.promotionCode ? (
|
||||||
<span className="text-muted-foreground">
|
<div className="flex flex-col gap-1">
|
||||||
(was £{order.subtotalBeforeDiscount.toFixed(2)})
|
<div className="flex items-center gap-1">
|
||||||
|
<Tag className="h-3 w-3 text-emerald-500" />
|
||||||
|
<span className="text-[10px] font-mono bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 px-1.5 py-0.5 rounded border border-emerald-500/20">
|
||||||
|
{order.promotionCode}
|
||||||
</span>
|
</span>
|
||||||
)}
|
</div>
|
||||||
</div>
|
<div className="flex items-center gap-1 text-[10px] text-emerald-600/80">
|
||||||
</div>
|
<Percent className="h-2.5 w-2.5" />
|
||||||
) : (
|
<span>-£{(order.discountAmount || 0).toFixed(2)}</span>
|
||||||
<span className="text-xs text-muted-foreground">-</span>
|
</div>
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className={`inline-flex items-center gap-2 px-3 py-1 rounded-full text-sm ${
|
|
||||||
statusConfig[order.status as OrderStatus]?.bgColor || "bg-gray-500"
|
|
||||||
} ${statusConfig[order.status as OrderStatus]?.color || "text-white"}`}>
|
|
||||||
{React.createElement(statusConfig[order.status as OrderStatus]?.icon || XCircle, {
|
|
||||||
className: `h-4 w-4 ${statusConfig[order.status as OrderStatus]?.animate || ""}`
|
|
||||||
})}
|
|
||||||
{order.status.charAt(0).toUpperCase() + order.status.slice(1)}
|
|
||||||
</div>
|
|
||||||
{isOrderUnderpaid(order) && (
|
|
||||||
<div className="flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-red-600 text-white">
|
|
||||||
<AlertTriangle className="h-3 w-3" />
|
|
||||||
{underpaidInfo?.percentage}%
|
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">-</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</TableCell>
|
||||||
</TableCell>
|
<TableCell>
|
||||||
<TableCell className="hidden md:table-cell">
|
<div className="flex items-center gap-2">
|
||||||
{new Date(order.orderDate).toLocaleDateString("en-GB", {
|
<div className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border shadow-sm ${statusConfig[order.status as OrderStatus]?.bgColor || "bg-muted text-muted-foreground border-border"} ${statusConfig[order.status as OrderStatus]?.color || ""}`}>
|
||||||
day: '2-digit',
|
{React.createElement(statusConfig[order.status as OrderStatus]?.icon || XCircle, {
|
||||||
month: 'short',
|
className: `h-3.5 w-3.5 ${statusConfig[order.status as OrderStatus]?.animate || ""}`
|
||||||
year: 'numeric',
|
})}
|
||||||
hour: '2-digit',
|
{order.status.charAt(0).toUpperCase() + order.status.slice(1)}
|
||||||
minute: '2-digit',
|
</div>
|
||||||
hour12: false
|
{isOrderUnderpaid(order) && (
|
||||||
})}
|
<div className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] bg-destructive/10 text-destructive border border-destructive/20 font-medium">
|
||||||
</TableCell>
|
{underpaidInfo?.percentage}%
|
||||||
<TableCell className="hidden xl:table-cell">
|
</div>
|
||||||
{order.paidAt ? new Date(order.paidAt).toLocaleDateString("en-GB", {
|
)}
|
||||||
day: '2-digit',
|
</div>
|
||||||
month: 'short',
|
</TableCell>
|
||||||
year: 'numeric',
|
<TableCell className="hidden md:table-cell text-sm text-muted-foreground">
|
||||||
hour: '2-digit',
|
{new Date(order.orderDate).toLocaleDateString("en-GB", {
|
||||||
minute: '2-digit',
|
day: '2-digit',
|
||||||
hour12: false
|
month: 'short',
|
||||||
}) : "-"}
|
year: 'numeric',
|
||||||
</TableCell>
|
})}
|
||||||
<TableCell className="hidden lg:table-cell">
|
<span className="ml-1 opacity-50 text-[10px]">
|
||||||
{order.telegramUsername ? `@${order.telegramUsername}` : "-"}
|
{new Date(order.orderDate).toLocaleTimeString("en-GB", { hour: '2-digit', minute: '2-digit' })}
|
||||||
</TableCell>
|
</span>
|
||||||
<TableCell className="text-center">
|
</TableCell>
|
||||||
<div className="flex items-center justify-center gap-1">
|
<TableCell className="hidden xl:table-cell text-sm text-muted-foreground">
|
||||||
<Button variant="ghost" size="sm" asChild>
|
{order.paidAt ? new Date(order.paidAt).toLocaleDateString("en-GB", {
|
||||||
<Link href={`/dashboard/orders/${order._id}`}>
|
day: '2-digit',
|
||||||
<Eye className="h-4 w-4" />
|
month: 'short',
|
||||||
</Link>
|
hour: '2-digit',
|
||||||
</Button>
|
minute: '2-digit'
|
||||||
|
}) : "-"}
|
||||||
{(order.telegramBuyerId || order.telegramUsername) && (
|
</TableCell>
|
||||||
<Button
|
<TableCell className="hidden lg:table-cell">
|
||||||
variant="ghost"
|
{order.telegramUsername ? (
|
||||||
size="sm"
|
<span className="text-sm font-medium text-primary">@{order.telegramUsername}</span>
|
||||||
asChild
|
) : (
|
||||||
title={`Chat with customer${order.telegramUsername ? ` @${order.telegramUsername}` : ''}`}
|
<span className="text-xs text-muted-foreground italic">Guest</span>
|
||||||
>
|
)}
|
||||||
<Link href={`/dashboard/chats/new?buyerId=${order.telegramBuyerId || order.telegramUsername}`}>
|
</TableCell>
|
||||||
<MessageCircle className="h-4 w-4 text-primary" />
|
<TableCell className="text-center">
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-foreground" asChild>
|
||||||
|
<Link href={`/dashboard/orders/${order._id}`}>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
|
||||||
</div>
|
{(order.telegramBuyerId || order.telegramUsername) && (
|
||||||
</TableCell>
|
<Button
|
||||||
</TableRow>
|
variant="ghost"
|
||||||
);
|
size="icon"
|
||||||
})}
|
className="h-8 w-8 text-muted-foreground hover:text-primary"
|
||||||
|
asChild
|
||||||
|
title={`Chat with customer${order.telegramUsername ? ` @${order.telegramUsername}` : ''}`}
|
||||||
|
>
|
||||||
|
<Link href={`/dashboard/chats/new?buyerId=${order.telegramBuyerId || order.telegramUsername}`}>
|
||||||
|
<MessageCircle className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</motion.tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</AnimatePresence>
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardContent>
|
||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
<div className="flex items-center justify-between px-4 py-4 border-t border-zinc-800 bg-black/40">
|
<div className="flex items-center justify-between px-4 py-4 border-t border-border/50 bg-background/50">
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
Page {currentPage} of {totalPages} ({totalOrders} total)
|
Page {currentPage} of {totalPages} ({totalOrders} total)
|
||||||
</div>
|
</div>
|
||||||
@@ -628,8 +637,9 @@ export default function OrderTable() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handlePageChange(currentPage - 1)}
|
onClick={() => handlePageChange(currentPage - 1)}
|
||||||
disabled={currentPage === 1 || loading}
|
disabled={currentPage === 1 || loading}
|
||||||
|
className="h-8"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="h-4 w-4" />
|
<ChevronLeft className="h-3 w-3 mr-1" />
|
||||||
Previous
|
Previous
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -637,14 +647,15 @@ export default function OrderTable() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handlePageChange(currentPage + 1)}
|
onClick={() => handlePageChange(currentPage + 1)}
|
||||||
disabled={currentPage >= totalPages || loading}
|
disabled={currentPage >= totalPages || loading}
|
||||||
|
className="h-8"
|
||||||
>
|
>
|
||||||
Next
|
Next
|
||||||
<ChevronRight className="h-4 w-4" />
|
<ChevronRight className="h-3 w-3 ml-1" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"commitHash": "a05787a",
|
"commitHash": "7b95589",
|
||||||
"buildTime": "2026-01-12T05:47:06.100Z"
|
"buildTime": "2026-01-12T06:32:31.897Z"
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user