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";
|
||||
import Layout from "@/components/layout/layout";
|
||||
import { cacheUtils } from '@/lib/api-client';
|
||||
import OrderTimeline from "@/components/orders/order-timeline";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
|
||||
interface Order {
|
||||
orderId: string;
|
||||
@@ -170,7 +172,7 @@ export default function OrderDetailsPage() {
|
||||
authToken: string
|
||||
): Promise<Record<string, string>> => {
|
||||
const productNamesMap: Record<string, string> = {};
|
||||
|
||||
|
||||
// Process each product ID independently
|
||||
const fetchPromises = productIds.map(async (id) => {
|
||||
try {
|
||||
@@ -184,10 +186,10 @@ export default function OrderDetailsPage() {
|
||||
productNamesMap[id] = "Unknown Product (Deleted)";
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Wait for all fetch operations to complete (successful or failed)
|
||||
await Promise.all(fetchPromises);
|
||||
|
||||
|
||||
return productNamesMap;
|
||||
};
|
||||
|
||||
@@ -195,38 +197,38 @@ export default function OrderDetailsPage() {
|
||||
try {
|
||||
// Add a loading state to give feedback
|
||||
const loadingToast = toast.loading("Marking order as paid...");
|
||||
|
||||
|
||||
// Log the request for debugging
|
||||
console.log(`Sending request to /orders/${orderId}/status with clientFetch`);
|
||||
console.log("Request payload:", { status: "paid" });
|
||||
|
||||
|
||||
// Use clientFetch which handles API URL and auth token automatically
|
||||
const response = await clientFetch(`/orders/${orderId}/status`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ status: "paid" }),
|
||||
});
|
||||
|
||||
|
||||
// Log the response
|
||||
console.log("API response:", response);
|
||||
|
||||
toast.dismiss(loadingToast);
|
||||
|
||||
|
||||
if (response && response.message === "Order status updated successfully") {
|
||||
// Update both states
|
||||
setIsPaid(true);
|
||||
setOrder((prevOrder) => (prevOrder ? {
|
||||
...prevOrder,
|
||||
setOrder((prevOrder) => (prevOrder ? {
|
||||
...prevOrder,
|
||||
status: "paid",
|
||||
// Clear underpayment flags when marking as paid
|
||||
underpaid: false,
|
||||
underpaymentAmount: 0
|
||||
} : null));
|
||||
|
||||
|
||||
// Invalidate order cache to ensure fresh data everywhere
|
||||
cacheUtils.invalidateOrderData(orderId as string);
|
||||
|
||||
|
||||
toast.success("Order marked as paid successfully");
|
||||
|
||||
|
||||
// Refresh order data to get latest status
|
||||
setTimeout(() => {
|
||||
setRefreshTrigger(prev => prev + 1);
|
||||
@@ -240,14 +242,14 @@ export default function OrderDetailsPage() {
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Failed to mark order as paid:", error);
|
||||
|
||||
|
||||
// More detailed error handling
|
||||
let errorMessage = "Failed to mark order as paid";
|
||||
|
||||
|
||||
if (error.message) {
|
||||
errorMessage += `: ${error.message}`;
|
||||
}
|
||||
|
||||
|
||||
if (error.response) {
|
||||
try {
|
||||
const errorData = await error.response.json();
|
||||
@@ -259,7 +261,7 @@ export default function OrderDetailsPage() {
|
||||
console.error("Could not parse error response:", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
};
|
||||
@@ -317,7 +319,7 @@ export default function OrderDetailsPage() {
|
||||
...prevOrder,
|
||||
trackingNumber: trackingNumber
|
||||
} : null);
|
||||
|
||||
|
||||
toast.success("Tracking number added successfully!");
|
||||
} catch (err: any) {
|
||||
console.error("Failed to add tracking number:", err);
|
||||
@@ -330,7 +332,7 @@ export default function OrderDetailsPage() {
|
||||
const handleMarkAsAcknowledged = async () => {
|
||||
try {
|
||||
setIsAcknowledging(true);
|
||||
|
||||
|
||||
// Use clientFetch which handles API URL and auth token automatically
|
||||
const response = await clientFetch(`/orders/${orderId}/status`, {
|
||||
method: "PUT",
|
||||
@@ -382,7 +384,7 @@ export default function OrderDetailsPage() {
|
||||
const handleCancelOrder = async () => {
|
||||
try {
|
||||
setIsCancelling(true);
|
||||
|
||||
|
||||
// Use clientFetch which handles API URL and auth token automatically
|
||||
const response = await clientFetch(`/orders/${orderId}/status`, {
|
||||
method: "PUT",
|
||||
@@ -429,10 +431,10 @@ export default function OrderDetailsPage() {
|
||||
const productIds = data.order.products.map((product) => product.productId);
|
||||
const productNamesMap = await fetchProductNames(productIds, authToken);
|
||||
setProductNames(productNamesMap);
|
||||
|
||||
|
||||
setTimeout(() => {
|
||||
setProductNames(prev => {
|
||||
const newMap = {...prev};
|
||||
const newMap = { ...prev };
|
||||
productIds.forEach(id => {
|
||||
if (!newMap[id] || newMap[id] === "Loading...") {
|
||||
newMap[id] = "Unknown Product (Deleted)";
|
||||
@@ -440,7 +442,7 @@ export default function OrderDetailsPage() {
|
||||
});
|
||||
return newMap;
|
||||
});
|
||||
}, 3000);
|
||||
}, 3000);
|
||||
|
||||
if (data.order.status === "paid") {
|
||||
setIsPaid(true);
|
||||
@@ -460,7 +462,7 @@ export default function OrderDetailsPage() {
|
||||
const fetchAdjacentOrders = async () => {
|
||||
try {
|
||||
const authToken = document.cookie.split("Authorization=")[1];
|
||||
|
||||
|
||||
if (!order?.orderId) return;
|
||||
|
||||
// Get the current numerical orderId
|
||||
@@ -470,7 +472,7 @@ export default function OrderDetailsPage() {
|
||||
// Use the new optimized backend endpoint to get adjacent orders
|
||||
const adjacentOrdersUrl = `${process.env.NEXT_PUBLIC_API_URL}/orders/adjacent/${currentOrderId}`;
|
||||
console.log('Fetching adjacent orders:', adjacentOrdersUrl);
|
||||
|
||||
|
||||
const adjacentOrdersRes = await fetchData(
|
||||
adjacentOrdersUrl,
|
||||
{
|
||||
@@ -488,17 +490,17 @@ export default function OrderDetailsPage() {
|
||||
|
||||
// Set the next and previous order IDs
|
||||
const { newer, older } = adjacentOrdersRes;
|
||||
|
||||
|
||||
// Set IDs for navigation
|
||||
setPrevOrderId(newer?._id || null);
|
||||
setNextOrderId(older?._id || null);
|
||||
|
||||
|
||||
if (newer) {
|
||||
console.log(`Newer order: ${newer.orderId} (ID: ${newer._id})`);
|
||||
} else {
|
||||
console.log('No newer order found');
|
||||
}
|
||||
|
||||
|
||||
if (older) {
|
||||
console.log(`Older order: ${older.orderId} (ID: ${older._id})`);
|
||||
} else {
|
||||
@@ -544,7 +546,7 @@ export default function OrderDetailsPage() {
|
||||
...prevOrder,
|
||||
trackingNumber: trackingNumber
|
||||
} : null);
|
||||
|
||||
|
||||
toast.success("Tracking number updated successfully!");
|
||||
setTrackingNumber(""); // Clear the input
|
||||
} catch (err: any) {
|
||||
@@ -569,11 +571,11 @@ export default function OrderDetailsPage() {
|
||||
|
||||
try {
|
||||
const lines = [];
|
||||
|
||||
|
||||
// Order number
|
||||
lines.push(`Order Number: ${order.orderId}`);
|
||||
lines.push('');
|
||||
|
||||
|
||||
// Order details
|
||||
lines.push('Order Details:');
|
||||
if (order.products && order.products.length > 0) {
|
||||
@@ -582,30 +584,30 @@ export default function OrderDetailsPage() {
|
||||
lines.push(` - ${productName} (Qty: ${product.quantity} @ £${product.pricePerUnit.toFixed(2)} = £${product.totalItemPrice.toFixed(2)})`);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Shipping
|
||||
if (order.shippingMethod) {
|
||||
lines.push(` - Shipping: ${order.shippingMethod.type} (£${order.shippingMethod.price.toFixed(2)})`);
|
||||
}
|
||||
|
||||
|
||||
// Discount
|
||||
if (order.discountAmount && order.discountAmount > 0) {
|
||||
lines.push(` - Discount: -£${order.discountAmount.toFixed(2)}${order.promotionCode ? ` (Promo: ${order.promotionCode})` : ''}`);
|
||||
}
|
||||
|
||||
|
||||
// Subtotal if different from total
|
||||
if (order.subtotalBeforeDiscount && order.subtotalBeforeDiscount !== order.totalPrice) {
|
||||
lines.push(` - Subtotal: £${order.subtotalBeforeDiscount.toFixed(2)}`);
|
||||
}
|
||||
|
||||
|
||||
// Total
|
||||
lines.push(` - Total: £${order.totalPrice.toFixed(2)}`);
|
||||
lines.push('');
|
||||
|
||||
|
||||
// Address
|
||||
lines.push('Address:');
|
||||
lines.push(order.pgpAddress || 'N/A');
|
||||
|
||||
|
||||
const textToCopy = lines.join('\n');
|
||||
await navigator.clipboard.writeText(textToCopy);
|
||||
toast.success("Order data copied to clipboard!");
|
||||
@@ -618,28 +620,28 @@ export default function OrderDetailsPage() {
|
||||
// Helper function to check if order is underpaid
|
||||
const isOrderUnderpaid = (order: Order | null) => {
|
||||
// More robust check - only show underpaid if status is NOT paid and underpayment exists
|
||||
return order?.underpaid === true &&
|
||||
order?.underpaymentAmount &&
|
||||
order.underpaymentAmount > 0 &&
|
||||
order.status !== "paid" &&
|
||||
order.status !== "completed" &&
|
||||
order.status !== "shipped";
|
||||
return order?.underpaid === true &&
|
||||
order?.underpaymentAmount &&
|
||||
order.underpaymentAmount > 0 &&
|
||||
order.status !== "paid" &&
|
||||
order.status !== "completed" &&
|
||||
order.status !== "shipped";
|
||||
};
|
||||
|
||||
// Helper function to get underpaid information
|
||||
const getUnderpaidInfo = (order: Order | null) => {
|
||||
if (!isOrderUnderpaid(order)) return null;
|
||||
|
||||
|
||||
const received = order?.lastBalanceReceived || 0;
|
||||
const required = order?.cryptoTotal || 0;
|
||||
const missing = order?.underpaymentAmount || 0;
|
||||
|
||||
|
||||
// Calculate LTC to GBP exchange rate from order data
|
||||
const ltcToGbpRate = required > 0 ? (order?.totalPrice || 0) / required : 0;
|
||||
const receivedGbp = received * ltcToGbpRate;
|
||||
const requiredGbp = order?.totalPrice || 0;
|
||||
const missingGbp = missing * ltcToGbpRate;
|
||||
|
||||
|
||||
return {
|
||||
received,
|
||||
required,
|
||||
@@ -772,7 +774,7 @@ export default function OrderDetailsPage() {
|
||||
<p className="font-semibold">{underpaidInfo.percentage}% paid</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{order?.paymentAddress && (
|
||||
<div className="pt-3 border-t border-red-200 dark:border-red-800">
|
||||
<p className="text-sm text-muted-foreground mb-2">Payment Address:</p>
|
||||
@@ -791,10 +793,26 @@ export default function OrderDetailsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{/* Order Timeline */}
|
||||
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden">
|
||||
<CardHeader className="pb-0">
|
||||
<CardTitle className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Order Lifecycle</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<OrderTimeline
|
||||
status={order?.status || ''}
|
||||
orderDate={order?.orderDate || ''}
|
||||
paidAt={order?.paidAt}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="grid grid-cols-3 gap-6"
|
||||
>
|
||||
{/* Left Column - Order Details */}
|
||||
<div className="col-span-2 space-y-6">
|
||||
{/* Products Card */}
|
||||
@@ -929,7 +947,7 @@ export default function OrderDetailsPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-zinc-400">Customer Since</span>
|
||||
<span className="font-medium">
|
||||
{customerInsights.firstOrder ?
|
||||
{customerInsights.firstOrder ?
|
||||
new Date(customerInsights.firstOrder).toLocaleDateString('en-GB', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
@@ -1092,34 +1110,34 @@ export default function OrderDetailsPage() {
|
||||
)}
|
||||
|
||||
{/* Cancel Order Button */}
|
||||
{order?.status !== "cancelled" &&
|
||||
order?.status !== "completed" &&
|
||||
order?.status !== "shipped" && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive" className="w-full">
|
||||
Cancel Order
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Cancel Order</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to cancel this order? This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleCancelOrder}
|
||||
className="bg-red-500 hover:bg-red-600"
|
||||
>
|
||||
Confirm Cancel
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
{order?.status !== "cancelled" &&
|
||||
order?.status !== "completed" &&
|
||||
order?.status !== "shipped" && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive" className="w-full">
|
||||
Cancel Order
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Cancel Order</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to cancel this order? This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleCancelOrder}
|
||||
className="bg-red-500 hover:bg-red-600"
|
||||
>
|
||||
Confirm Cancel
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
|
||||
{/* No Actions Available Message */}
|
||||
{(order?.status === "completed" || order?.status === "cancelled") && (
|
||||
@@ -1168,11 +1186,10 @@ export default function OrderDetailsPage() {
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<svg
|
||||
key={i}
|
||||
className={`w-4 h-4 ${
|
||||
i < (order?.review?.stars || 0)
|
||||
className={`w-4 h-4 ${i < (order?.review?.stars || 0)
|
||||
? "text-yellow-400"
|
||||
: "text-zinc-600"
|
||||
}`}
|
||||
}`}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
@@ -1198,10 +1215,10 @@ export default function OrderDetailsPage() {
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Shipping Dialog removed; use inline tracking input above */}
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Shipping Dialog removed; use inline tracking input above */}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,27 +30,32 @@ import {
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
Users,
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
Users,
|
||||
ArrowUpDown,
|
||||
MessageCircle,
|
||||
UserPlus,
|
||||
MoreHorizontal,
|
||||
Search,
|
||||
X
|
||||
X,
|
||||
CreditCard,
|
||||
Calendar,
|
||||
ShoppingBag
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
export default function CustomerManagementPage() {
|
||||
const router = useRouter();
|
||||
@@ -71,32 +76,32 @@ export default function CustomerManagementPage() {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await getCustomers(page, itemsPerPage);
|
||||
|
||||
|
||||
// Sort customers based on current sort config
|
||||
let sortedCustomers = [...response.customers];
|
||||
sortedCustomers.sort((a, b) => {
|
||||
if (sortConfig.column === "totalOrders") {
|
||||
return sortConfig.direction === "asc"
|
||||
? a.totalOrders - b.totalOrders
|
||||
return sortConfig.direction === "asc"
|
||||
? a.totalOrders - b.totalOrders
|
||||
: b.totalOrders - a.totalOrders;
|
||||
} else if (sortConfig.column === "totalSpent") {
|
||||
return sortConfig.direction === "asc"
|
||||
? a.totalSpent - b.totalSpent
|
||||
return sortConfig.direction === "asc"
|
||||
? a.totalSpent - b.totalSpent
|
||||
: b.totalSpent - a.totalSpent;
|
||||
} else if (sortConfig.column === "lastOrderDate") {
|
||||
// Handle null lastOrderDate values
|
||||
if (!a.lastOrderDate && !b.lastOrderDate) return 0;
|
||||
if (!a.lastOrderDate) return sortConfig.direction === "asc" ? -1 : 1;
|
||||
if (!b.lastOrderDate) return sortConfig.direction === "asc" ? 1 : -1;
|
||||
|
||||
|
||||
// Both have valid dates
|
||||
return sortConfig.direction === "asc"
|
||||
? new Date(a.lastOrderDate).getTime() - new Date(b.lastOrderDate).getTime()
|
||||
return sortConfig.direction === "asc"
|
||||
? new Date(a.lastOrderDate).getTime() - new Date(b.lastOrderDate).getTime()
|
||||
: new Date(b.lastOrderDate).getTime() - new Date(a.lastOrderDate).getTime();
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
|
||||
setCustomers(sortedCustomers);
|
||||
setFilteredCustomers(sortedCustomers);
|
||||
setTotalPages(Math.ceil(response.total / itemsPerPage));
|
||||
@@ -138,424 +143,444 @@ export default function CustomerManagementPage() {
|
||||
}
|
||||
}, [searchQuery, customers]);
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setPage(newPage);
|
||||
};
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setPage(newPage);
|
||||
};
|
||||
|
||||
const handleItemsPerPageChange = (value: string) => {
|
||||
setItemsPerPage(parseInt(value, 10));
|
||||
setPage(1);
|
||||
};
|
||||
const handleItemsPerPageChange = (value: string) => {
|
||||
setItemsPerPage(parseInt(value, 10));
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleSort = (column: "totalOrders" | "totalSpent" | "lastOrderDate") => {
|
||||
setSortConfig(prev => ({
|
||||
column,
|
||||
direction: prev.column === column && prev.direction === "asc" ? "desc" : "asc"
|
||||
}));
|
||||
};
|
||||
const handleSort = (column: "totalOrders" | "totalSpent" | "lastOrderDate") => {
|
||||
setSortConfig(prev => ({
|
||||
column,
|
||||
direction: prev.column === column && prev.direction === "asc" ? "desc" : "asc"
|
||||
}));
|
||||
};
|
||||
|
||||
const clearSearch = () => {
|
||||
setSearchQuery("");
|
||||
};
|
||||
const clearSearch = () => {
|
||||
setSearchQuery("");
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string | null | undefined) => {
|
||||
if (!dateString) return "N/A";
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}).format(date);
|
||||
} catch (error) {
|
||||
return "N/A";
|
||||
}
|
||||
};
|
||||
const formatDate = (dateString: string | null | undefined) => {
|
||||
if (!dateString) return "N/A";
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}).format(date);
|
||||
} catch (error) {
|
||||
return "N/A";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold text-white flex items-center">
|
||||
<Users className="mr-2 h-6 w-6" />
|
||||
Customer Management
|
||||
</h1>
|
||||
return (
|
||||
<Layout>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold text-white flex items-center">
|
||||
<Users className="mr-2 h-6 w-6" />
|
||||
Customer Management
|
||||
</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 className="bg-black/40 border border-zinc-800 rounded-md overflow-hidden">
|
||||
<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="flex items-center gap-4">
|
||||
<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 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-muted-foreground" />
|
||||
</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="p-8 bg-black/60">
|
||||
{/* Loading indicator */}
|
||||
<div className="absolute top-0 left-0 right-0 h-1 bg-muted overflow-hidden">
|
||||
<div className="h-full bg-primary w-1/3"
|
||||
style={{
|
||||
background: 'linear-gradient(90deg, transparent, hsl(var(--primary)), transparent)',
|
||||
backgroundSize: '200% 100%',
|
||||
animation: 'shimmer 2s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Table skeleton */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-4 pb-4 border-b border-zinc-800">
|
||||
{['Customer', 'Orders', 'Total Spent', 'Last Order', 'Status'].map((header, i) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
className="h-4 w-20 flex-1 animate-in fade-in"
|
||||
style={{
|
||||
animationDelay: `${i * 50}ms`,
|
||||
animationDuration: '300ms',
|
||||
animationFillMode: 'both',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{[...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 className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
{loading
|
||||
? "Loading..."
|
||||
: searchQuery
|
||||
? `Found ${filteredCustomers.length} matching customers`
|
||||
: `Showing ${filteredCustomers.length} of ${totalPages * itemsPerPage} customers`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CardContent className="p-0">
|
||||
{loading ? (
|
||||
<div className="p-8">
|
||||
{/* Loading indicator */}
|
||||
<div className="absolute top-[69px] left-0 right-0 h-0.5 bg-muted overflow-hidden">
|
||||
<div className="h-full bg-primary w-1/3 animate-shimmer"
|
||||
style={{
|
||||
background: 'linear-gradient(90deg, transparent, hsl(var(--primary)), transparent)',
|
||||
backgroundSize: '200% 100%',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<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}
|
||||
className="h-4 w-20 flex-1"
|
||||
/>
|
||||
))}
|
||||
</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>
|
||||
) : filteredCustomers.length === 0 ? (
|
||||
<div className="p-8 text-center bg-black/60">
|
||||
<Users className="h-12 w-12 mx-auto text-gray-500 mb-4" />
|
||||
<h3 className="text-lg font-medium mb-2 text-white">
|
||||
{searchQuery ? "No customers matching your search" : "No customers found"}
|
||||
</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>
|
||||
) : filteredCustomers.length === 0 ? (
|
||||
<div className="p-12 text-center">
|
||||
<div className="w-16 h-16 bg-muted/50 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Users className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-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">
|
||||
<TableHeader className="bg-black/60 sticky top-0 z-10">
|
||||
<TableRow>
|
||||
<TableHead className="w-[180px] text-gray-300">Customer</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer w-[100px] text-gray-300 text-center"
|
||||
onClick={() => handleSort("totalOrders")}
|
||||
>
|
||||
<div className="flex items-center justify-center">
|
||||
Orders
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer w-[150px] text-gray-300 text-center"
|
||||
onClick={() => handleSort("totalSpent")}
|
||||
>
|
||||
<div className="flex items-center justify-center">
|
||||
Total Spent
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer w-[180px] text-gray-300 text-center"
|
||||
onClick={() => handleSort("lastOrderDate")}
|
||||
>
|
||||
<div className="flex items-center justify-center">
|
||||
Last Order
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="w-[250px] text-gray-300 text-center">Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredCustomers.map((customer) => (
|
||||
<TableRow
|
||||
<h3 className="text-lg font-medium mb-2 text-foreground">
|
||||
{searchQuery ? "No matching customers" : "No customers yet"}
|
||||
</h3>
|
||||
<p className="text-muted-foreground max-w-sm mx-auto mb-6">
|
||||
{searchQuery
|
||||
? "We couldn't find any customers matching your search criteria."
|
||||
: "Once you have customers placing orders, they will appear here."}
|
||||
</p>
|
||||
{searchQuery && (
|
||||
<Button variant="outline" size="sm" onClick={clearSearch}>
|
||||
Clear Search
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/50">
|
||||
<TableRow className="hover:bg-transparent border-border/50">
|
||||
<TableHead className="w-[200px]">Customer</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer w-[100px] text-center hover:text-primary transition-colors"
|
||||
onClick={() => handleSort("totalOrders")}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
Orders
|
||||
<ArrowUpDown className="h-3 w-3" />
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer w-[150px] text-center hover:text-primary transition-colors"
|
||||
onClick={() => handleSort("totalSpent")}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
Total Spent
|
||||
<ArrowUpDown className="h-3 w-3" />
|
||||
</div>
|
||||
</TableHead>
|
||||
<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}
|
||||
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)}
|
||||
>
|
||||
<TableCell>
|
||||
<div className="font-medium text-gray-100">
|
||||
@{customer.telegramUsername || "Unknown"}
|
||||
{!customer.hasOrders && (
|
||||
<Badge variant="outline" className="ml-2 bg-purple-900/30 text-purple-300 border-purple-700">
|
||||
<UserPlus className="h-3 w-3 mr-1" />
|
||||
New
|
||||
</Badge>
|
||||
)}
|
||||
<TableCell className="py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<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.telegramUsername ? customer.telegramUsername.substring(0, 2).toUpperCase() : 'ID'}
|
||||
</div>
|
||||
<div>
|
||||
<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 className="text-sm text-gray-400">ID: {customer.telegramUserId}</div>
|
||||
</TableCell>
|
||||
<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 className="font-medium text-gray-100 text-center">
|
||||
<TableCell className="text-center font-mono text-sm">
|
||||
{formatCurrency(customer.totalSpent)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-gray-100 text-center">
|
||||
{customer.lastOrderDate ? formatDate(customer.lastOrderDate) : "N/A"}
|
||||
<TableCell className="text-center text-sm text-muted-foreground">
|
||||
{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 className="text-center">
|
||||
{customer.hasOrders ? (
|
||||
<div className="flex justify-center space-x-1">
|
||||
<Badge className="bg-blue-500 text-white hover:bg-blue-600">
|
||||
{customer.ordersByStatus.paid} Paid
|
||||
</Badge>
|
||||
<Badge className="bg-green-500 text-white hover:bg-green-600">
|
||||
{customer.ordersByStatus.completed} Completed
|
||||
</Badge>
|
||||
<Badge className="bg-amber-500 text-white hover:bg-amber-600">
|
||||
{customer.ordersByStatus.shipped} Shipped
|
||||
</Badge>
|
||||
<div className="flex justify-center flex-wrap gap-1">
|
||||
{customer.ordersByStatus.paid > 0 && (
|
||||
<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]">
|
||||
{customer.ordersByStatus.paid} Paid
|
||||
</Badge>
|
||||
)}
|
||||
{customer.ordersByStatus.completed > 0 && (
|
||||
<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.completed} Done
|
||||
</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>
|
||||
) : (
|
||||
<Badge variant="outline" className="bg-gray-800 text-gray-300 border-gray-700">
|
||||
No orders yet
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground italic">No activity</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</motion.tr>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<div className="p-4 border-t border-zinc-800 bg-black/40 flex justify-between items-center">
|
||||
<div className="text-sm text-gray-400">
|
||||
Page {page} of {totalPages}
|
||||
<div className="p-4 border-t border-border/50 bg-background/50 flex justify-between items-center">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
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 className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePageChange(Math.max(1, page - 1))}
|
||||
disabled={page === 1 || loading}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
Previous
|
||||
</Button>
|
||||
|
||||
{totalPages > 2 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<span className="sr-only">Go to page</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" className="bg-black/90 border-zinc-800 max-h-60 overflow-y-auto">
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((pageNum) => (
|
||||
<DropdownMenuItem
|
||||
key={pageNum}
|
||||
onClick={() => handlePageChange(pageNum)}
|
||||
className={`cursor-pointer ${pageNum === page ? 'bg-zinc-800 text-white' : 'text-gray-300'}`}
|
||||
>
|
||||
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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
{/* Order Status Breakdown */}
|
||||
<div className="mb-4">
|
||||
<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>
|
||||
|
||||
{/* Order Status Breakdown */}
|
||||
<div className="mb-4">
|
||||
<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 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>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setSelectedCustomer(null)}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => router.push(`/dashboard/storefront/chat/new?userId=${selectedCustomer.telegramUserId}`)}
|
||||
>
|
||||
<MessageCircle className="h-4 w-4 mr-2" />
|
||||
Start Chat
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setSelectedCustomer(null)}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => router.push(`/dashboard/storefront/chat/new?userId=${selectedCustomer.telegramUserId}`)}
|
||||
>
|
||||
<MessageCircle className="h-4 w-4 mr-2" />
|
||||
Start Chat
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -6,11 +6,15 @@ import Layout from "@/components/layout/layout";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
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 { toast } from "sonner";
|
||||
import BroadcastDialog from "@/components/modals/broadcast-dialog";
|
||||
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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -166,251 +170,298 @@ export default function StorefrontPage() {
|
||||
return (
|
||||
<Dashboard>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-6">
|
||||
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white flex items-center">
|
||||
<Globe className="mr-2 h-6 w-6" />
|
||||
Storefront Settings
|
||||
</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
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 className="flex items-center justify-between mb-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 rounded-xl bg-primary/10 text-primary">
|
||||
<Globe className="h-8 w-8" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-foreground">
|
||||
Storefront Settings
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage your shop's appearance, policies, and configuration
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setBroadcastOpen(true)}
|
||||
className="gap-2"
|
||||
size="sm"
|
||||
className="gap-2 h-10"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
Broadcast
|
||||
</Button>
|
||||
<Button
|
||||
onClick={saveStorefront}
|
||||
<Button
|
||||
onClick={saveStorefront}
|
||||
disabled={saving}
|
||||
className="gap-2"
|
||||
size="sm"
|
||||
className="gap-2 h-10 min-w-[120px]"
|
||||
>
|
||||
<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"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4 lg:gap-6">
|
||||
{/* Security Settings */}
|
||||
<div className="space-y-3">
|
||||
<div className="bg-[#0F0F12] rounded-lg p-4 border border-zinc-800">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Shield className="h-4 w-4 text-purple-400" />
|
||||
<h2 className="text-base font-medium text-zinc-100">
|
||||
Security
|
||||
</h2>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs font-medium mb-1 block text-zinc-400">PGP Public Key</label>
|
||||
<Textarea
|
||||
value={storefront.pgpKey}
|
||||
onChange={(e) => setStorefront(prev => ({ ...prev, pgpKey: e.target.value }))}
|
||||
placeholder="Enter your PGP public key"
|
||||
className="font-mono text-sm h-24 bg-[#1C1C1C] border-zinc-800 resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium mb-1 block text-zinc-400">Telegram Bot Token</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={storefront.telegramToken}
|
||||
onChange={(e) => setStorefront(prev => ({ ...prev, telegramToken: e.target.value }))}
|
||||
placeholder="Enter your Telegram bot token"
|
||||
className="bg-[#1C1C1C] border-zinc-800 h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Main Column */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
|
||||
{/* Store Status Card */}
|
||||
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }}>
|
||||
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden relative">
|
||||
<div className={`absolute top-0 left-0 w-1 h-full ${storefront.isEnabled ? 'bg-emerald-500' : 'bg-destructive'}`} />
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<CardTitle>Store Status</CardTitle>
|
||||
<CardDescription>Control your store's visibility to customers</CardDescription>
|
||||
</div>
|
||||
<Badge variant={storefront.isEnabled ? "default" : "destructive"} className="h-6">
|
||||
{storefront.isEnabled ? "Open for Business" : "Store Closed"}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pb-6">
|
||||
<div className="flex items-center gap-4 p-4 rounded-lg bg-card border border-border/50">
|
||||
<Switch
|
||||
checked={storefront.isEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setStorefront((prev) => ({
|
||||
...prev,
|
||||
isEnabled: checked,
|
||||
}))
|
||||
}
|
||||
className="data-[state=checked]:bg-emerald-500"
|
||||
/>
|
||||
<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>
|
||||
|
||||
{/* Shipping Settings */}
|
||||
<div className="bg-[#0F0F12] rounded-lg p-4 border border-zinc-800">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Globe className="h-4 w-4 text-blue-400" />
|
||||
<h2 className="text-base font-medium text-zinc-100">
|
||||
Shipping
|
||||
</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs font-medium mb-1 block text-zinc-400">Ships From</label>
|
||||
<Select
|
||||
value={storefront.shipsFrom}
|
||||
onValueChange={(value) =>
|
||||
setStorefront((prev) => ({ ...prev, shipsFrom: value as any }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="bg-[#1C1C1C] border-zinc-800 h-8 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SHIPPING_REGIONS.map((region) => (
|
||||
<SelectItem key={region.value} value={region.value}>
|
||||
{region.emoji} {region.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium mb-1 block text-zinc-400">Ships To</label>
|
||||
<Select
|
||||
value={storefront.shipsTo}
|
||||
onValueChange={(value) =>
|
||||
setStorefront((prev) => ({ ...prev, shipsTo: value as any }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="bg-[#1C1C1C] border-zinc-800 h-8 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SHIPPING_REGIONS.map((region) => (
|
||||
<SelectItem key={region.value} value={region.value}>
|
||||
{region.emoji} {region.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Security Settings */}
|
||||
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.3 }}>
|
||||
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Key className="h-5 w-5 text-purple-400" />
|
||||
Security Configuration
|
||||
</CardTitle>
|
||||
<CardDescription>Manage keys and access tokens for your store security</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">PGP Public Key</Label>
|
||||
<Textarea
|
||||
value={storefront.pgpKey}
|
||||
onChange={(e) => setStorefront(prev => ({ ...prev, pgpKey: e.target.value }))}
|
||||
placeholder="-----BEGIN PGP PUBLIC KEY BLOCK-----..."
|
||||
className="font-mono text-xs h-32 bg-zinc-950/50 border-zinc-800/50 resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Telegram Bot Token</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="password"
|
||||
value={storefront.telegramToken}
|
||||
onChange={(e) => setStorefront(prev => ({ ...prev, telegramToken: e.target.value }))}
|
||||
placeholder="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"
|
||||
className="bg-background/50 border-border/50 font-mono text-sm pl-10"
|
||||
/>
|
||||
<div className="absolute left-3 top-2.5 text-muted-foreground">
|
||||
<Shield className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">Used for notifications and bot integration.</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Messaging and Payments */}
|
||||
<div className="space-y-3">
|
||||
<div className="bg-[#0F0F12] rounded-lg p-4 border border-zinc-800">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<MessageSquare className="h-4 w-4 text-emerald-400" />
|
||||
<h2 className="text-base font-medium text-zinc-100">
|
||||
Welcome Message
|
||||
</h2>
|
||||
</div>
|
||||
<Textarea
|
||||
value={storefront.welcomeMessage}
|
||||
onChange={(e) => setStorefront(prev => ({ ...prev, welcomeMessage: e.target.value }))}
|
||||
placeholder="Enter the welcome message for new customers"
|
||||
className="h-36 bg-[#1C1C1C] border-zinc-800 text-sm resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#0F0F12] rounded-lg p-4 border border-zinc-800">
|
||||
<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">
|
||||
Store Policy
|
||||
</h2>
|
||||
</div>
|
||||
<Textarea
|
||||
value={storefront.storePolicy}
|
||||
onChange={(e) => setStorefront(prev => ({ ...prev, storePolicy: e.target.value }))}
|
||||
placeholder="Enter your store's policies, terms, and conditions"
|
||||
className="h-48 bg-[#1C1C1C] border-zinc-800 text-sm resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#0F0F12] rounded-lg p-4 border border-zinc-800">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<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"
|
||||
/>
|
||||
)}
|
||||
{/* Sidebar Column */}
|
||||
<div className="space-y-6">
|
||||
{/* Shipping Settings */}
|
||||
<motion.div initial={{ opacity: 0, x: 10 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: 0.4 }}>
|
||||
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Globe className="h-4 w-4 text-blue-400" />
|
||||
Shipping & Logistics
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Ships From</Label>
|
||||
<Select
|
||||
value={storefront.shipsFrom}
|
||||
onValueChange={(value) =>
|
||||
setStorefront((prev) => ({ ...prev, shipsFrom: 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>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Ships To</Label>
|
||||
<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>
|
||||
|
||||
<BroadcastDialog open={broadcastOpen} setOpen={setBroadcastOpen} />
|
||||
|
||||
</Dashboard>
|
||||
|
||||
</Dashboard >
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user