Spaces:
Running
Running
import { useState, useRef } from "react"; | |
import { useLocation } from "wouter"; | |
import { useAuth } from "@/hooks/use-auth"; | |
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; | |
import { sellerApi, categoriesApi, storesApi, productsApi, authApi, ordersApi } from "@/lib/api"; | |
import { useForm } from "react-hook-form"; | |
import { zodResolver } from "@hookform/resolvers/zod"; | |
import { z } from "zod"; | |
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; | |
import { Button } from "@/components/ui/button"; | |
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; | |
import { Input } from "@/components/ui/input"; | |
import { Textarea } from "@/components/ui/textarea"; | |
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; | |
import { Badge } from "@/components/ui/badge"; | |
import { Skeleton } from "@/components/ui/skeleton"; | |
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; | |
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; | |
import { useToast } from "@/hooks/use-toast"; | |
import { Store, Package, Plus, Edit, Trash2, Upload, LogOut, Loader2, ShoppingCart, Clock, CheckCircle, MapPin, ExternalLink, Camera } from "lucide-react"; | |
const sellerLoginSchema = z.object({ | |
username: z.string().min(1, "Username is required"), | |
password: z.string().min(1, "Password is required"), | |
}); | |
const storeSchema = z.object({ | |
name: z.string().min(1, "Store name is required"), | |
description: z.string().optional(), | |
}); | |
const productSchema = z.object({ | |
title: z.string().min(1, "Product title is required"), | |
description: z.string().min(1, "Product description is required"), | |
price: z.string().min(1, "Price is required"), | |
originalPrice: z.string().optional(), | |
stock: z.string().min(1, "Stock quantity is required"), | |
categoryId: z.string().min(1, "Category is required"), | |
}); | |
type SellerLoginFormData = z.infer<typeof sellerLoginSchema>; | |
type StoreFormData = z.infer<typeof storeSchema>; | |
type ProductFormData = z.infer<typeof productSchema>; | |
export default function AdminSeller() { | |
const [, setLocation] = useLocation(); | |
const { seller, userType, logout, sellerLogin } = useAuth(); | |
const { toast } = useToast(); | |
const queryClient = useQueryClient(); | |
const [isStoreDialogOpen, setIsStoreDialogOpen] = useState(false); | |
const [isProductDialogOpen, setIsProductDialogOpen] = useState(false); | |
const [selectedFiles, setSelectedFiles] = useState<FileList | null>(null); | |
const [capturedFiles, setCapturedFiles] = useState<File[]>([]); | |
const videoRef = useRef<HTMLVideoElement>(null); | |
const canvasRef = useRef<HTMLCanvasElement>(null); | |
const [isCameraOpen, setIsCameraOpen] = useState(false); | |
const [stream, setStream] = useState<MediaStream | null>(null); | |
const [isLoading, setIsLoading] = useState(false); | |
const [activeTab, setActiveTab] = useState("dashboard"); | |
// All useQuery hooks must be called at the top level - before any conditional returns | |
const { data: store, isLoading: storeLoading } = useQuery({ | |
queryKey: ['/api/seller/store'], | |
queryFn: async () => { | |
const response = await sellerApi.getStore(); | |
return response.json(); | |
}, | |
enabled: userType === 'seller' && !!seller, // Only run query when authenticated | |
}); | |
const { data: products = [], isLoading: productsLoading } = useQuery({ | |
queryKey: ['/api/seller/products'], | |
queryFn: async () => { | |
const response = await sellerApi.getProducts(); | |
return response.json(); | |
}, | |
enabled: userType === 'seller' && !!seller, // Only run query when authenticated | |
}); | |
const { data: categories = [] } = useQuery({ | |
queryKey: ['/api/categories'], | |
queryFn: () => categoriesApi.getAll(), | |
enabled: userType === 'seller' && !!seller, // Only run query when authenticated | |
}); | |
const { data: orders = [], isLoading: ordersLoading } = useQuery({ | |
queryKey: ['/api/seller/orders'], | |
queryFn: async () => { | |
const response = await sellerApi.getOrders(); | |
return response.json(); | |
}, | |
enabled: userType === 'seller' && !!seller, // Only run query when authenticated | |
}); | |
// Query to get user location data | |
const { data: users = [] } = useQuery({ | |
queryKey: ['/api/admin/users'], | |
queryFn: async () => { | |
const response = await fetch('/api/admin/users'); | |
return response.json(); | |
}, | |
enabled: userType === 'seller' && !!seller, | |
}); | |
// All useForm and useMutation hooks must also be at the top level | |
const sellerLoginForm = useForm<SellerLoginFormData>({ | |
resolver: zodResolver(sellerLoginSchema), | |
defaultValues: { | |
username: "", | |
password: "", | |
}, | |
}); | |
const storeForm = useForm<StoreFormData>({ | |
resolver: zodResolver(storeSchema), | |
defaultValues: { | |
name: store?.name || "", | |
description: store?.description || "", | |
}, | |
}); | |
const productForm = useForm<ProductFormData>({ | |
resolver: zodResolver(productSchema), | |
defaultValues: { | |
title: "", | |
description: "", | |
price: "", | |
originalPrice: "", | |
stock: "", | |
categoryId: "", | |
}, | |
}); | |
const createStoreMutation = useMutation({ | |
mutationFn: (data: StoreFormData) => { | |
const formData = new FormData(); | |
formData.append('name', data.name); | |
if (data.description) formData.append('description', data.description); | |
if (selectedFiles) { | |
Array.from(selectedFiles).forEach((file, index) => { | |
if (index === 0) formData.append('bannerImage', file); | |
if (index === 1) formData.append('faceImage', file); | |
}); | |
} | |
return storesApi.create(formData); | |
}, | |
onSuccess: () => { | |
queryClient.invalidateQueries({ queryKey: ['/api/seller/store'] }); | |
setIsStoreDialogOpen(false); | |
storeForm.reset(); | |
setSelectedFiles(null); | |
toast({ | |
title: "Store created", | |
description: "Your store has been created successfully.", | |
}); | |
}, | |
onError: () => { | |
toast({ | |
variant: "destructive", | |
title: "Error", | |
description: "Failed to create store.", | |
}); | |
}, | |
}); | |
const updateStoreMutation = useMutation({ | |
mutationFn: (data: StoreFormData) => { | |
const formData = new FormData(); | |
formData.append('name', data.name); | |
if (data.description) formData.append('description', data.description); | |
if (selectedFiles) { | |
Array.from(selectedFiles).forEach((file, index) => { | |
if (index === 0) formData.append('bannerImage', file); | |
if (index === 1) formData.append('faceImage', file); | |
}); | |
} | |
return storesApi.update(store.id, formData); | |
}, | |
onSuccess: () => { | |
queryClient.invalidateQueries({ queryKey: ['/api/seller/store'] }); | |
setIsStoreDialogOpen(false); | |
setSelectedFiles(null); | |
toast({ | |
title: "Store updated", | |
description: "Your store has been updated successfully.", | |
}); | |
}, | |
onError: () => { | |
toast({ | |
variant: "destructive", | |
title: "Error", | |
description: "Failed to update store.", | |
}); | |
}, | |
}); | |
const createProductMutation = useMutation({ | |
mutationFn: (data: ProductFormData) => { | |
const formData = new FormData(); | |
formData.append('title', data.title); | |
formData.append('description', data.description); | |
formData.append('price', data.price); | |
if (data.originalPrice) formData.append('originalPrice', data.originalPrice); | |
formData.append('stock', data.stock); | |
formData.append('categoryId', data.categoryId); | |
// Append selected files | |
if (selectedFiles) { | |
Array.from(selectedFiles).forEach((file) => { | |
formData.append('images', file); | |
}); | |
} | |
// Append captured files | |
capturedFiles.forEach((file) => { | |
formData.append('images', file); | |
}); | |
return productsApi.create(formData); | |
}, | |
onSuccess: () => { | |
queryClient.invalidateQueries({ queryKey: ['/api/seller/products'] }); | |
setIsProductDialogOpen(false); | |
productForm.reset(); | |
setSelectedFiles(null); | |
setCapturedFiles([]); | |
closeCamera(); | |
toast({ | |
title: "Product created", | |
description: "Your product has been created successfully.", | |
}); | |
}, | |
onError: () => { | |
toast({ | |
variant: "destructive", | |
title: "Error", | |
description: "Failed to create product.", | |
}); | |
}, | |
}); | |
const deleteProductMutation = useMutation({ | |
mutationFn: (id: string) => productsApi.delete(id), | |
onSuccess: () => { | |
queryClient.invalidateQueries({ queryKey: ['/api/seller/products'] }); | |
toast({ | |
title: "Product deleted", | |
description: "Product has been deleted successfully.", | |
}); | |
}, | |
onError: () => { | |
toast({ | |
variant: "destructive", | |
title: "Error", | |
description: "Failed to delete product.", | |
}); | |
}, | |
}); | |
const updateOrderMutation = useMutation({ | |
mutationFn: ({ id, data }: { id: string; data: any }) => sellerApi.updateOrder(id, data), | |
onSuccess: () => { | |
queryClient.invalidateQueries({ queryKey: ['/api/seller/orders'] }); | |
toast({ | |
title: "Order updated", | |
description: "Order status has been updated successfully.", | |
}); | |
}, | |
onError: () => { | |
toast({ | |
variant: "destructive", | |
title: "Error", | |
description: "Failed to update order status.", | |
}); | |
}, | |
}); | |
const handleUpdateOrderStatus = (id: string, data: any) => { | |
updateOrderMutation.mutate({ id, data }); | |
}; | |
const onSellerLogin = async (data: SellerLoginFormData) => { | |
setIsLoading(true); | |
try { | |
const response = await authApi.sellerLogin(data); | |
const result = await response.json(); | |
sellerLogin(result.token, result.seller); | |
toast({ | |
title: "Welcome back!", | |
description: "You have been successfully logged in as a seller.", | |
}); | |
} catch (error) { | |
toast({ | |
variant: "destructive", | |
title: "Login failed", | |
description: "Invalid credentials. Please check with the administrator.", | |
}); | |
} finally { | |
setIsLoading(false); | |
} | |
}; | |
// Show login form if not authenticated as seller | |
if (userType !== 'seller' || !seller) { | |
return ( | |
<div className="min-h-screen flex items-center justify-center page-gradient py-12 px-4 sm:px-6 lg:px-8"> | |
<Card className="w-full max-w-md glass-card border-0"> | |
<CardHeader className="text-center"> | |
<CardTitle className="text-2xl font-bold text-primary" data-testid="seller-auth-title"> | |
Shoposphere Seller Login | |
</CardTitle> | |
</CardHeader> | |
<CardContent> | |
<Form {...sellerLoginForm}> | |
<form onSubmit={sellerLoginForm.handleSubmit(onSellerLogin)} className="space-y-4"> | |
<FormField | |
control={sellerLoginForm.control} | |
name="username" | |
render={({ field }) => ( | |
<FormItem> | |
<FormLabel>Username</FormLabel> | |
<FormControl> | |
<Input {...field} placeholder="Enter your username" data-testid="input-seller-username" /> | |
</FormControl> | |
<FormMessage /> | |
</FormItem> | |
)} | |
/> | |
<FormField | |
control={sellerLoginForm.control} | |
name="password" | |
render={({ field }) => ( | |
<FormItem> | |
<FormLabel>Password</FormLabel> | |
<FormControl> | |
<Input {...field} type="password" placeholder="Enter your password" data-testid="input-seller-password" /> | |
</FormControl> | |
<FormMessage /> | |
</FormItem> | |
)} | |
/> | |
<Button | |
type="submit" | |
className="w-full" | |
disabled={isLoading} | |
data-testid="button-seller-login" | |
> | |
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} | |
Sign In | |
</Button> | |
</form> | |
</Form> | |
<p className="text-sm text-gray-600 text-center mt-4"> | |
Seller accounts are created by administrators only | |
</p> | |
</CardContent> | |
</Card> | |
</div> | |
); | |
} | |
const onStoreSubmit = (data: StoreFormData) => { | |
if (store) { | |
updateStoreMutation.mutate(data); | |
} else { | |
createStoreMutation.mutate(data); | |
} | |
}; | |
const onProductSubmit = (data: ProductFormData) => { | |
createProductMutation.mutate(data); | |
}; | |
// Camera functions | |
const openCamera = async () => { | |
try { | |
const mediaStream = await navigator.mediaDevices.getUserMedia({ | |
video: { facingMode: 'environment' }, // Use back camera on mobile | |
audio: false | |
}); | |
setStream(mediaStream); | |
setIsCameraOpen(true); | |
if (videoRef.current) { | |
videoRef.current.srcObject = mediaStream; | |
} | |
} catch (error) { | |
console.error('Error accessing camera:', error); | |
toast({ | |
variant: "destructive", | |
title: "Camera Error", | |
description: "Unable to access camera. Please check permissions.", | |
}); | |
} | |
}; | |
const closeCamera = () => { | |
if (stream) { | |
stream.getTracks().forEach(track => track.stop()); | |
setStream(null); | |
} | |
setIsCameraOpen(false); | |
}; | |
const capturePhoto = () => { | |
if (videoRef.current && canvasRef.current) { | |
const video = videoRef.current; | |
const canvas = canvasRef.current; | |
const context = canvas.getContext('2d'); | |
canvas.width = video.videoWidth; | |
canvas.height = video.videoHeight; | |
if (context) { | |
context.drawImage(video, 0, 0); | |
canvas.toBlob((blob) => { | |
if (blob) { | |
const file = new File([blob], `captured-${Date.now()}.jpg`, { type: 'image/jpeg' }); | |
setCapturedFiles(prev => [...prev, file]); | |
toast({ | |
title: "Photo captured", | |
description: "Photo added to product images.", | |
}); | |
} | |
}, 'image/jpeg', 0.8); | |
} | |
} | |
}; | |
const removeCapturedPhoto = (index: number) => { | |
setCapturedFiles(prev => prev.filter((_, i) => i !== index)); | |
}; | |
const handleLogout = () => { | |
logout(); | |
setLocation('/'); | |
}; | |
return ( | |
<div className="min-h-screen page-gradient"> | |
<header className="glass-strong border-0 shadow-2xl"> | |
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> | |
<div className="flex justify-between items-center h-16"> | |
<div> | |
<h1 className="text-2xl font-bold text-primary" data-testid="seller-title"> | |
Shoposphere Seller | |
</h1> | |
<p className="text-sm text-muted-foreground">Welcome, {seller.username}</p> | |
</div> | |
<div className="flex items-center space-x-4"> | |
<Button onClick={() => setLocation('/')} variant="outline" data-testid="back-to-site"> | |
Back to Site | |
</Button> | |
<Button onClick={handleLogout} variant="ghost" data-testid="seller-logout"> | |
<LogOut className="mr-2 h-4 w-4" /> | |
Logout | |
</Button> | |
</div> | |
</div> | |
</div> | |
</header> | |
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8" data-testid="admin-seller-page"> | |
<Tabs defaultValue="dashboard" className="space-y-6"> | |
<TabsList className="glass-card border-0 grid w-full grid-cols-4"> | |
<TabsTrigger value="dashboard" data-testid="dashboard-tab"> | |
<Package className="mr-2 h-4 w-4" /> | |
Dashboard | |
</TabsTrigger> | |
<TabsTrigger value="store" data-testid="store-tab"> | |
<Store className="mr-2 h-4 w-4" /> | |
My Store | |
</TabsTrigger> | |
<TabsTrigger value="products" data-testid="products-tab"> | |
<Package className="mr-2 h-4 w-4" /> | |
Products | |
</TabsTrigger> | |
<TabsTrigger value="orders" data-testid="orders-tab"> | |
<ShoppingCart className="mr-2 h-4 w-4" /> | |
Orders ({orders.length}) | |
</TabsTrigger> | |
</TabsList> | |
<TabsContent value="dashboard"> | |
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8"> | |
<Card className="glass-card border-0 p-6"> | |
<div className="flex items-center space-x-4"> | |
<Store className="h-8 w-8 text-blue-600" /> | |
<div> | |
<h3 className="text-2xl font-bold">{store ? 1 : 0}</h3> | |
<p className="text-gray-600">Store</p> | |
</div> | |
</div> | |
</Card> | |
<Card className="glass-card border-0 p-6"> | |
<div className="flex items-center space-x-4"> | |
<Package className="h-8 w-8 text-blue-600" /> | |
<div> | |
<h3 className="text-2xl font-bold">{products.length}</h3> | |
<p className="text-gray-600">Products Listed</p> | |
</div> | |
</div> | |
</Card> | |
<Card className="glass-card border-0 p-6"> | |
<div className="flex items-center space-x-4"> | |
<ShoppingCart className="h-8 w-8 text-green-600" /> | |
<div> | |
<h3 className="text-2xl font-bold">{orders.length}</h3> | |
<p className="text-gray-600">Total Orders</p> | |
</div> | |
</div> | |
</Card> | |
</div> | |
{/* Customer Locations */} | |
<Card className="glass-card border-0 mb-6"> | |
<CardHeader> | |
<CardTitle className="flex items-center gap-2"> | |
<MapPin className="h-5 w-5" /> | |
Customer Locations | |
</CardTitle> | |
</CardHeader> | |
<CardContent> | |
{users.length > 0 ? ( | |
<div className="space-y-4" data-testid="customer-locations"> | |
{users.slice(0, 5).map((user: any) => ( | |
<div key={user.id} className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-800 rounded-lg"> | |
<div className="flex-1"> | |
<h3 className="font-semibold" data-testid={`user-name-${user.id}`}> | |
{user.firstName} {user.lastName} | |
</h3> | |
<p className="text-sm text-gray-600 dark:text-gray-400"> | |
{user.email} • {user.phone} | |
</p> | |
{/* Location Information */} | |
<div className="mt-2"> | |
{user.locationDetectedAutomatically && user.googleMapsUrl ? ( | |
<div className="flex items-center gap-2 text-sm"> | |
<MapPin className="h-4 w-4 text-green-600" /> | |
<span className="text-green-600" data-testid={`user-location-auto-${user.id}`}> | |
Auto-detected location | |
</span> | |
<Button | |
size="sm" | |
variant="outline" | |
className="h-6 px-2 text-xs" | |
onClick={() => window.open(user.googleMapsUrl, '_blank')} | |
data-testid={`view-location-${user.id}`} | |
> | |
<ExternalLink className="mr-1 h-3 w-3" /> | |
View on Maps | |
</Button> | |
</div> | |
) : user.street && user.city ? ( | |
<div className="flex items-center gap-2 text-sm"> | |
<MapPin className="h-4 w-4 text-blue-600" /> | |
<span className="text-gray-600 dark:text-gray-400" data-testid={`user-location-manual-${user.id}`}> | |
{user.street}, {user.city}, {user.state} {user.pinCode} | |
</span> | |
</div> | |
) : ( | |
<div className="flex items-center gap-2 text-sm"> | |
<MapPin className="h-4 w-4 text-gray-400" /> | |
<span className="text-gray-400" data-testid={`user-location-none-${user.id}`}> | |
No location provided | |
</span> | |
</div> | |
)} | |
</div> | |
</div> | |
<div className="text-right"> | |
{user.latitude && user.longitude && ( | |
<p className="text-xs text-gray-500 dark:text-gray-400" data-testid={`coordinates-${user.id}`}> | |
{Number(user.latitude).toFixed(4)}, {Number(user.longitude).toFixed(4)} | |
</p> | |
)} | |
<p className="text-xs text-gray-400 mt-1"> | |
Joined: {new Date(user.createdAt).toLocaleDateString()} | |
</p> | |
</div> | |
</div> | |
))} | |
</div> | |
) : ( | |
<div className="text-center py-8" data-testid="no-customers"> | |
<MapPin className="h-12 w-12 text-gray-400 mx-auto mb-4" /> | |
<p className="text-gray-500">No customer data available yet.</p> | |
</div> | |
)} | |
</CardContent> | |
</Card> | |
{/* Recent Orders */} | |
<Card className="glass-card border-0"> | |
<CardHeader> | |
<CardTitle>Recent Orders</CardTitle> | |
</CardHeader> | |
<CardContent> | |
{ordersLoading ? ( | |
<div className="space-y-4"> | |
{[...Array(5)].map((_, i) => ( | |
<Skeleton key={i} className="h-16 w-full" /> | |
))} | |
</div> | |
) : orders.length === 0 ? ( | |
<p className="text-gray-500 text-center py-8" data-testid="no-orders"> | |
No orders yet. Start by creating products to sell! | |
</p> | |
) : ( | |
<div className="space-y-4"> | |
{orders.slice(0, 5).map((order: any) => ( | |
<div key={order.id} className="flex items-center justify-between p-4 bg-gray-50 rounded-lg"> | |
<div> | |
<p className="font-semibold">Order #{order.id.slice(-8)}</p> | |
<p className="text-sm text-gray-600">₹{parseFloat(order.total).toFixed(2)} • {order.items?.length || 0} items</p> | |
</div> | |
<div className="text-right"> | |
<Badge variant={order.status === 'delivered' ? 'default' : order.status === 'pending' ? 'outline' : 'secondary'}> | |
{order.status} | |
</Badge> | |
<p className="text-sm text-gray-500 mt-1"> | |
{new Date(order.createdAt).toLocaleDateString()} | |
</p> | |
</div> | |
</div> | |
))} | |
</div> | |
)} | |
</CardContent> | |
</Card> | |
</TabsContent> | |
<TabsContent value="store"> | |
<Card className="glass-card border-0"> | |
<CardHeader className="flex flex-row items-center justify-between"> | |
<CardTitle>Store Management</CardTitle> | |
<Dialog open={isStoreDialogOpen} onOpenChange={setIsStoreDialogOpen}> | |
<DialogTrigger asChild> | |
<Button data-testid="manage-store-button"> | |
{store ? <Edit className="mr-2 h-4 w-4" /> : <Plus className="mr-2 h-4 w-4" />} | |
{store ? 'Edit Store' : 'Create Store'} | |
</Button> | |
</DialogTrigger> | |
<DialogContent className="bg-white border border-black max-w-md"> | |
<DialogHeader> | |
<DialogTitle>{store ? 'Edit Store' : 'Create Store'}</DialogTitle> | |
</DialogHeader> | |
<Form {...storeForm}> | |
<form onSubmit={storeForm.handleSubmit(onStoreSubmit)} className="space-y-4"> | |
<FormField | |
control={storeForm.control} | |
name="name" | |
render={({ field }) => ( | |
<FormItem> | |
<FormLabel>Store Name</FormLabel> | |
<FormControl> | |
<Input {...field} data-testid="store-name-input" /> | |
</FormControl> | |
<FormMessage /> | |
</FormItem> | |
)} | |
/> | |
<FormField | |
control={storeForm.control} | |
name="description" | |
render={({ field }) => ( | |
<FormItem> | |
<FormLabel>Description</FormLabel> | |
<FormControl> | |
<Textarea {...field} data-testid="store-description-input" /> | |
</FormControl> | |
<FormMessage /> | |
</FormItem> | |
)} | |
/> | |
<div> | |
<label className="block text-sm font-medium text-gray-700 mb-2">Images</label> | |
<Input | |
type="file" | |
multiple | |
accept="image/*" | |
onChange={(e) => setSelectedFiles(e.target.files)} | |
data-testid="store-images-input" | |
/> | |
<p className="text-xs text-gray-500 mt-1"> | |
Upload up to 2 images (banner and profile) | |
</p> | |
</div> | |
<Button type="submit" className="w-full" data-testid="submit-store"> | |
{store ? 'Update Store' : 'Create Store'} | |
</Button> | |
</form> | |
</Form> | |
</DialogContent> | |
</Dialog> | |
</CardHeader> | |
<CardContent> | |
{storeLoading ? ( | |
<div className="space-y-4"> | |
<Skeleton className="h-32 w-full" /> | |
<div className="flex items-center space-x-4"> | |
<Skeleton className="w-16 h-16 rounded-full" /> | |
<div> | |
<Skeleton className="h-6 w-48 mb-2" /> | |
<Skeleton className="h-4 w-32" /> | |
</div> | |
</div> | |
</div> | |
) : store ? ( | |
<div className="space-y-6" data-testid="store-info"> | |
{/* Store Banner */} | |
<div className="relative h-32 bg-gradient-to-r from-blue-600 to-blue-700 rounded-lg overflow-hidden"> | |
{store.bannerImage ? ( | |
<img | |
src={store.bannerImage} | |
alt="Store banner" | |
className="w-full h-full object-cover" | |
data-testid="store-banner-image" | |
/> | |
) : ( | |
<div className="absolute inset-0 bg-gradient-to-r from-blue-600 to-blue-700" /> | |
)} | |
</div> | |
{/* Store Info */} | |
<div className="flex items-start space-x-6"> | |
<div className="w-16 h-16 rounded-full overflow-hidden bg-gray-100 flex-shrink-0"> | |
{store.faceImage ? ( | |
<img | |
src={store.faceImage} | |
alt="Store profile" | |
className="w-full h-full object-cover" | |
data-testid="store-face-image" | |
/> | |
) : ( | |
<div className="w-full h-full bg-gray-200 flex items-center justify-center"> | |
<Store className="h-6 w-6 text-gray-400" /> | |
</div> | |
)} | |
</div> | |
<div className="flex-1"> | |
<h3 className="text-xl font-bold text-gray-900" data-testid="store-name"> | |
{store.name} | |
</h3> | |
{store.description && ( | |
<p className="text-gray-600 mt-2" data-testid="store-description"> | |
{store.description} | |
</p> | |
)} | |
<div className="mt-4"> | |
<Button | |
variant="outline" | |
onClick={() => setLocation(`/store/${store.id}`)} | |
data-testid="view-store-public" | |
> | |
View Public Store | |
</Button> | |
</div> | |
</div> | |
</div> | |
</div> | |
) : ( | |
<div className="text-center py-12" data-testid="no-store"> | |
<Store className="h-12 w-12 text-gray-400 mx-auto mb-4" /> | |
<p className="text-gray-500 mb-4">You haven't created a store yet.</p> | |
<p className="text-sm text-gray-400">Create your store to start selling products.</p> | |
</div> | |
)} | |
</CardContent> | |
</Card> | |
</TabsContent> | |
<TabsContent value="products"> | |
<Card className="glass-card border-0"> | |
<CardHeader className="flex flex-row items-center justify-between"> | |
<CardTitle>Product Management</CardTitle> | |
<Dialog open={isProductDialogOpen} onOpenChange={setIsProductDialogOpen}> | |
<DialogTrigger asChild> | |
<Button disabled={!store} data-testid="create-product-button"> | |
<Plus className="mr-2 h-4 w-4" /> | |
Add Product | |
</Button> | |
</DialogTrigger> | |
<DialogContent className="bg-white border border-black max-w-md"> | |
<DialogHeader> | |
<DialogTitle>Add New Product</DialogTitle> | |
</DialogHeader> | |
<Form {...productForm}> | |
<form onSubmit={productForm.handleSubmit(onProductSubmit)} className="space-y-4"> | |
<FormField | |
control={productForm.control} | |
name="title" | |
render={({ field }) => ( | |
<FormItem> | |
<FormLabel>Product Title</FormLabel> | |
<FormControl> | |
<Input {...field} data-testid="product-title-input" /> | |
</FormControl> | |
<FormMessage /> | |
</FormItem> | |
)} | |
/> | |
<FormField | |
control={productForm.control} | |
name="description" | |
render={({ field }) => ( | |
<FormItem> | |
<FormLabel>Description</FormLabel> | |
<FormControl> | |
<Textarea {...field} data-testid="product-description-input" /> | |
</FormControl> | |
<FormMessage /> | |
</FormItem> | |
)} | |
/> | |
<div className="grid grid-cols-2 gap-4"> | |
<FormField | |
control={productForm.control} | |
name="price" | |
render={({ field }) => ( | |
<FormItem> | |
<FormLabel>Price</FormLabel> | |
<FormControl> | |
<Input {...field} type="number" step="0.01" data-testid="product-price-input" /> | |
</FormControl> | |
<FormMessage /> | |
</FormItem> | |
)} | |
/> | |
<FormField | |
control={productForm.control} | |
name="originalPrice" | |
render={({ field }) => ( | |
<FormItem> | |
<FormLabel>Original Price</FormLabel> | |
<FormControl> | |
<Input {...field} type="number" step="0.01" data-testid="product-original-price-input" /> | |
</FormControl> | |
<FormMessage /> | |
</FormItem> | |
)} | |
/> | |
</div> | |
<FormField | |
control={productForm.control} | |
name="stock" | |
render={({ field }) => ( | |
<FormItem> | |
<FormLabel>Stock Quantity</FormLabel> | |
<FormControl> | |
<Input {...field} type="number" data-testid="product-stock-input" /> | |
</FormControl> | |
<FormMessage /> | |
</FormItem> | |
)} | |
/> | |
<FormField | |
control={productForm.control} | |
name="categoryId" | |
render={({ field }) => ( | |
<FormItem> | |
<FormLabel>Category</FormLabel> | |
<Select onValueChange={field.onChange} value={field.value}> | |
<FormControl> | |
<SelectTrigger data-testid="product-category-select"> | |
<SelectValue placeholder="Select category" /> | |
</SelectTrigger> | |
</FormControl> | |
<SelectContent> | |
{categories.map((category: any) => ( | |
<SelectItem key={category.id} value={category.id}> | |
{category.name} | |
</SelectItem> | |
))} | |
</SelectContent> | |
</Select> | |
<FormMessage /> | |
</FormItem> | |
)} | |
/> | |
<div> | |
<label className="block text-sm font-medium text-gray-700 mb-2">Product Images</label> | |
<div className="space-y-3"> | |
<div className="flex space-x-2"> | |
<Input | |
type="file" | |
multiple | |
accept="image/*" | |
onChange={(e) => setSelectedFiles(e.target.files)} | |
data-testid="product-images-input" | |
className="flex-1" | |
/> | |
<Button | |
type="button" | |
variant="outline" | |
onClick={openCamera} | |
data-testid="camera-button" | |
className="px-3" | |
> | |
<Camera className="h-4 w-4" /> | |
</Button> | |
</div> | |
{isCameraOpen && ( | |
<div className="border rounded-lg p-4 bg-gray-50"> | |
<video | |
ref={videoRef} | |
autoPlay | |
playsInline | |
className="w-full h-48 object-cover rounded mb-2" | |
data-testid="camera-preview" | |
/> | |
<div className="flex space-x-2"> | |
<Button | |
type="button" | |
onClick={capturePhoto} | |
data-testid="capture-photo-button" | |
className="flex-1" | |
> | |
Capture Photo | |
</Button> | |
<Button | |
type="button" | |
variant="outline" | |
onClick={closeCamera} | |
data-testid="close-camera-button" | |
> | |
Close Camera | |
</Button> | |
</div> | |
</div> | |
)} | |
{capturedFiles.length > 0 && ( | |
<div className="grid grid-cols-3 gap-2"> | |
{capturedFiles.map((file, index) => ( | |
<div key={index} className="relative"> | |
<img | |
src={URL.createObjectURL(file)} | |
alt={`Captured ${index + 1}`} | |
className="w-full h-20 object-cover rounded" | |
data-testid={`captured-image-${index}`} | |
/> | |
<Button | |
type="button" | |
variant="destructive" | |
size="sm" | |
onClick={() => removeCapturedPhoto(index)} | |
className="absolute top-1 right-1 h-6 w-6 p-0" | |
data-testid={`remove-captured-${index}`} | |
> | |
× | |
</Button> | |
</div> | |
))} | |
</div> | |
)} | |
<canvas ref={canvasRef} className="hidden" /> | |
</div> | |
<p className="text-xs text-gray-500 mt-1"> | |
Upload files or take photos - up to 5 images total | |
</p> | |
</div> | |
<Button type="submit" className="w-full" data-testid="submit-product"> | |
Add Product | |
</Button> | |
</form> | |
</Form> | |
</DialogContent> | |
</Dialog> | |
</CardHeader> | |
<CardContent> | |
{!store ? ( | |
<div className="text-center py-12" data-testid="create-store-first"> | |
<Package className="h-12 w-12 text-gray-400 mx-auto mb-4" /> | |
<p className="text-gray-500 mb-4">Create your store first to add products.</p> | |
</div> | |
) : productsLoading ? ( | |
<div className="space-y-4"> | |
{[...Array(3)].map((_, i) => ( | |
<div key={i} className="flex items-center justify-between p-4 border rounded-lg"> | |
<div className="flex items-center space-x-4"> | |
<Skeleton className="w-16 h-16" /> | |
<div> | |
<Skeleton className="h-6 w-48 mb-2" /> | |
<Skeleton className="h-4 w-32" /> | |
</div> | |
</div> | |
<Skeleton className="h-8 w-20" /> | |
</div> | |
))} | |
</div> | |
) : products.length > 0 ? ( | |
<div className="space-y-4" data-testid="products-list"> | |
{products.map((product: any) => ( | |
<div key={product.id} className="flex items-center justify-between p-4 border border-gray-200 rounded-lg" data-testid={`product-${product.id}`}> | |
<div className="flex items-center space-x-4"> | |
<div className="w-16 h-16 bg-gray-100 rounded overflow-hidden"> | |
{product.images && product.images.length > 0 ? ( | |
<img | |
src={product.images[0]} | |
alt={product.title} | |
className="w-full h-full object-cover" | |
/> | |
) : ( | |
<div className="w-full h-full bg-gray-200 flex items-center justify-center"> | |
<Package className="h-6 w-6 text-gray-400" /> | |
</div> | |
)} | |
</div> | |
<div> | |
<h3 className="font-medium text-gray-900" data-testid={`product-title-${product.id}`}> | |
{product.title} | |
</h3> | |
<p className="text-sm text-gray-500" data-testid={`product-category-${product.id}`}> | |
{product.category?.name} | |
</p> | |
<div className="flex items-center space-x-2 mt-1"> | |
<span className="text-sm font-semibold text-gray-900" data-testid={`product-price-${product.id}`}> | |
₹{parseFloat(product.price).toFixed(2)} | |
</span> | |
<Badge variant={product.isActive ? 'default' : 'secondary'} data-testid={`product-status-${product.id}`}> | |
{product.isActive ? 'Active' : 'Inactive'} | |
</Badge> | |
<span className="text-xs text-gray-500" data-testid={`product-stock-${product.id}`}> | |
Stock: {product.stock} | |
</span> | |
</div> | |
</div> | |
</div> | |
<div className="flex items-center space-x-2"> | |
<Button | |
variant="outline" | |
size="sm" | |
onClick={() => setLocation(`/product/${product.id}`)} | |
data-testid={`view-product-${product.id}`} | |
> | |
View | |
</Button> | |
<Button | |
variant="destructive" | |
size="sm" | |
onClick={() => deleteProductMutation.mutate(product.id)} | |
data-testid={`delete-product-${product.id}`} | |
> | |
<Trash2 className="h-4 w-4" /> | |
</Button> | |
</div> | |
</div> | |
))} | |
</div> | |
) : ( | |
<div className="text-center py-12" data-testid="no-products"> | |
<Package className="h-12 w-12 text-gray-400 mx-auto mb-4" /> | |
<p className="text-gray-500">No products added yet.</p> | |
</div> | |
)} | |
</CardContent> | |
</Card> | |
</TabsContent> | |
<TabsContent value="orders"> | |
<Card className="glass-card border-0"> | |
<CardHeader> | |
<CardTitle>Order Management</CardTitle> | |
</CardHeader> | |
<CardContent> | |
{ordersLoading ? ( | |
<div className="space-y-4"> | |
{[...Array(5)].map((_, i) => ( | |
<Skeleton key={i} className="h-24 w-full" /> | |
))} | |
</div> | |
) : orders.length === 0 ? ( | |
<div className="text-center py-12" data-testid="no-orders-list"> | |
<ShoppingCart className="h-12 w-12 text-gray-400 mx-auto mb-4" /> | |
<p className="text-gray-500">No orders received yet.</p> | |
</div> | |
) : ( | |
<div className="space-y-4" data-testid="orders-list"> | |
{orders.map((order: any) => ( | |
<div key={order.id} className="border border-gray-200 rounded-lg p-6" data-testid={`order-${order.id}`}> | |
<div className="flex items-start justify-between mb-4"> | |
<div> | |
<h3 className="font-semibold text-lg" data-testid={`order-id-${order.id}`}> | |
Order #{order.id.slice(-8)} | |
</h3> | |
<p className="text-sm text-gray-600" data-testid={`order-date-${order.id}`}> | |
Placed on {new Date(order.createdAt).toLocaleDateString('en-US', { | |
year: 'numeric', | |
month: 'long', | |
day: 'numeric' | |
})} | |
</p> | |
</div> | |
<div className="text-right"> | |
<div className="flex items-center space-x-2 mb-2"> | |
<Badge | |
variant={ | |
order.status === 'delivered' ? 'default' : | |
order.status === 'shipped' ? 'secondary' : | |
order.status === 'pending' ? 'outline' : | |
'destructive' | |
} | |
data-testid={`order-status-${order.id}`} | |
> | |
{order.status} | |
</Badge> | |
<Badge variant={order.paymentStatus === 'paid' ? 'default' : 'destructive'}> | |
{order.paymentStatus === 'paid' ? 'Paid' : 'Unpaid'} | |
</Badge> | |
</div> | |
<p className="text-lg font-semibold mt-1" data-testid={`order-total-${order.id}`}> | |
₹{parseFloat(order.total).toFixed(2)} | |
</p> | |
</div> | |
</div> | |
{/* Customer Info */} | |
<div className="bg-gray-50 rounded-lg p-4 mb-4"> | |
<h4 className="font-medium mb-2">Customer Information</h4> | |
{order.user && ( | |
<div className="grid grid-cols-2 gap-4 mb-3"> | |
<div> | |
<p className="text-sm"><span className="font-medium">Name:</span> {order.user.firstName} {order.user.lastName}</p> | |
<p className="text-sm"><span className="font-medium">Email:</span> {order.user.email}</p> | |
</div> | |
<div> | |
<p className="text-sm"><span className="font-medium">Phone:</span> {order.user.phone}</p> | |
<p className="text-sm"><span className="font-medium">Payment:</span> {order.paymentMethod === 'cod' ? 'Cash on Delivery' : 'UPI'}</p> | |
</div> | |
</div> | |
)} | |
<p className="text-sm text-gray-600"><span className="font-medium">Address:</span> {order.shippingAddress}</p> | |
</div> | |
{/* Order Management Actions */} | |
<div className="flex items-center space-x-2 mb-4 p-3 bg-blue-50 rounded-lg"> | |
<div className="flex items-center space-x-2"> | |
<label className="text-sm font-medium">Status:</label> | |
<select | |
className="px-3 py-1 text-sm border rounded" | |
value={order.status} | |
onChange={(e) => handleUpdateOrderStatus(order.id, { status: e.target.value })} | |
> | |
<option value="pending">Pending</option> | |
<option value="processing">Processing</option> | |
<option value="shipped">Shipped</option> | |
<option value="delivered">Delivered</option> | |
<option value="cancelled">Cancelled</option> | |
</select> | |
</div> | |
<Button | |
size="sm" | |
variant={order.paymentStatus === 'paid' ? 'outline' : 'default'} | |
onClick={() => handleUpdateOrderStatus(order.id, { | |
paymentStatus: order.paymentStatus === 'paid' ? 'unpaid' : 'paid' | |
})} | |
> | |
Mark as {order.paymentStatus === 'paid' ? 'Unpaid' : 'Paid'} | |
</Button> | |
</div> | |
{/* Order Items */} | |
<div> | |
<h4 className="font-medium mb-3">Items Ordered</h4> | |
<div className="space-y-2"> | |
{order.items?.map((item: any, index: number) => ( | |
<div key={index} className="flex items-center justify-between py-2 border-b border-gray-100 last:border-0" data-testid={`order-item-${order.id}-${index}`}> | |
<div className="flex items-center space-x-3"> | |
<div className="w-12 h-12 bg-gray-100 rounded overflow-hidden"> | |
{item.product?.images?.[0] ? ( | |
<img | |
src={item.product.images[0]} | |
alt={item.product.title} | |
className="w-full h-full object-cover" | |
/> | |
) : ( | |
<div className="w-full h-full bg-gray-200 flex items-center justify-center"> | |
<Package className="h-4 w-4 text-gray-400" /> | |
</div> | |
)} | |
</div> | |
<div> | |
<p className="font-medium text-sm">{item.product?.title}</p> | |
<p className="text-xs text-gray-500">Qty: {item.quantity}</p> | |
</div> | |
</div> | |
<div className="text-right"> | |
<p className="font-medium text-sm">₹{parseFloat(item.price).toFixed(2)}</p> | |
<p className="text-xs text-gray-500">₹{(parseFloat(item.price) * item.quantity).toFixed(2)} total</p> | |
</div> | |
</div> | |
))} | |
</div> | |
</div> | |
<div className="flex justify-end mt-4 pt-4 border-t"> | |
<div className="text-right"> | |
<p className="text-sm text-gray-600">Subtotal: ₹{parseFloat(order.subtotal).toFixed(2)}</p> | |
<p className="text-sm text-gray-600">Tax: ₹{parseFloat(order.tax).toFixed(2)}</p> | |
<p className="text-sm text-gray-600">Shipping: ₹{parseFloat(order.shipping || '0').toFixed(2)}</p> | |
<p className="font-semibold text-lg">Total: ₹{parseFloat(order.total).toFixed(2)}</p> | |
</div> | |
</div> | |
</div> | |
))} | |
</div> | |
)} | |
</CardContent> | |
</Card> | |
</TabsContent> | |
</Tabs> | |
</div> | |
</div> | |
); | |
} | |