first commit
This commit is contained in:
88
components/admin/AdminHeader.tsx
Normal file
88
components/admin/AdminHeader.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { signOut } from 'next-auth/react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { Bell, Settings, LogOut, Eye } from 'lucide-react'
|
||||
|
||||
interface AdminHeaderProps {
|
||||
user: {
|
||||
name?: string | null
|
||||
email?: string | null
|
||||
image?: string | null
|
||||
}
|
||||
}
|
||||
|
||||
export function AdminHeader({ user }: AdminHeaderProps) {
|
||||
return (
|
||||
<header className="bg-white border-b border-gray-200">
|
||||
<div className="flex h-16 items-center justify-between px-6">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Link href="/admin" className="flex items-center space-x-2">
|
||||
<div className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center">
|
||||
<span className="text-white font-bold text-lg">M</span>
|
||||
</div>
|
||||
<span className="text-xl font-bold text-gray-900">Padmaaja Rasooi Admin</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href="/" className="flex items-center">
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
View Site
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button variant="ghost" size="sm">
|
||||
<Bell className="h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="relative h-8 w-8 rounded-full">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src={user.image || ''} alt={user.name || ''} />
|
||||
<AvatarFallback>
|
||||
{user.name?.[0] || user.email?.[0] || 'A'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56" align="end" forceMount>
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-sm font-medium leading-none">{user.name}</p>
|
||||
<p className="text-xs leading-none text-muted-foreground">
|
||||
{user.email}
|
||||
</p>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/admin/settings" className="flex items-center">
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Settings
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => signOut()}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Log out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
90
components/admin/AdminSidebar.tsx
Normal file
90
components/admin/AdminSidebar.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Users,
|
||||
Package,
|
||||
ShoppingCart,
|
||||
DollarSign,
|
||||
Settings,
|
||||
BarChart3,
|
||||
CreditCard,
|
||||
Tag,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Mail,
|
||||
Star
|
||||
} from 'lucide-react'
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Dashboard', href: '/admin', icon: LayoutDashboard },
|
||||
{ name: 'Users', href: '/admin/users', icon: Users },
|
||||
{ name: 'Products', href: '/admin/products', icon: Package },
|
||||
{ name: 'Categories', href: '/admin/categories', icon: Tag },
|
||||
{ name: 'Orders', href: '/admin/orders', icon: ShoppingCart },
|
||||
{ name: 'Reviews', href: '/admin/reviews', icon: Star },
|
||||
{ name: 'Form Responses', href: '/admin/forms', icon: Mail },
|
||||
{ name: 'Commissions', href: '/admin/commissions', icon: DollarSign },
|
||||
{ name: 'Payouts', href: '/admin/payouts', icon: CreditCard },
|
||||
{ name: 'Analytics', href: '/admin/analytics', icon: BarChart3 },
|
||||
{ name: 'Settings', href: '/admin/settings', icon: Settings },
|
||||
]
|
||||
|
||||
export function AdminSidebar() {
|
||||
const pathname = usePathname()
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
"bg-white border-r border-gray-200 transition-all duration-300",
|
||||
collapsed ? "w-16" : "w-64"
|
||||
)}>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
{!collapsed && (
|
||||
<h2 className="text-lg font-semibold text-gray-900">Admin Panel</h2>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
{collapsed ? (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 space-y-1 p-4">
|
||||
{navigation.map((item) => {
|
||||
const isActive = pathname === item.href
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex items-center rounded-lg px-3 py-2 text-sm font-medium transition-colors",
|
||||
isActive
|
||||
? "bg-blue-50 text-blue-700"
|
||||
: "text-gray-600 hover:bg-gray-50 hover:text-gray-900"
|
||||
)}
|
||||
title={collapsed ? item.name : undefined}
|
||||
>
|
||||
<item.icon className={cn("h-5 w-5", collapsed ? "mx-auto" : "mr-3")} />
|
||||
{!collapsed && <span>{item.name}</span>}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
243
components/admin/CategoryFormDialog.tsx
Normal file
243
components/admin/CategoryFormDialog.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Save, Upload, X, Plus, Edit } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import Image from 'next/image'
|
||||
|
||||
interface Category {
|
||||
id?: string
|
||||
name: string
|
||||
description: string
|
||||
image: string | null
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
interface CategoryFormDialogProps {
|
||||
category?: Category
|
||||
onSuccess?: () => void
|
||||
trigger?: React.ReactNode
|
||||
mode?: 'create' | 'edit'
|
||||
}
|
||||
|
||||
export function CategoryFormDialog({
|
||||
category,
|
||||
onSuccess,
|
||||
trigger,
|
||||
mode = category ? 'edit' : 'create'
|
||||
}: CategoryFormDialogProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [formData, setFormData] = useState<Category>({
|
||||
name: '',
|
||||
description: '',
|
||||
image: null,
|
||||
isActive: true,
|
||||
...category
|
||||
})
|
||||
|
||||
const handleInputChange = (field: keyof Category, value: any) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }))
|
||||
}
|
||||
|
||||
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
// Mock image upload - replace with actual upload logic
|
||||
const imageUrl = URL.createObjectURL(file)
|
||||
setFormData(prev => ({ ...prev, image: imageUrl }))
|
||||
}
|
||||
|
||||
const removeImage = () => {
|
||||
setFormData(prev => ({ ...prev, image: null }))
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
name: '',
|
||||
description: '',
|
||||
image: null,
|
||||
isActive: true,
|
||||
...category
|
||||
})
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const url = mode === 'edit' && category?.id
|
||||
? `/api/admin/categories/${category.id}`
|
||||
: '/api/admin/categories'
|
||||
|
||||
const method = mode === 'edit' ? 'PATCH' : 'POST'
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(formData)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Failed to save category')
|
||||
}
|
||||
|
||||
toast.success(`Category ${mode === 'edit' ? 'updated' : 'created'} successfully`)
|
||||
setOpen(false)
|
||||
resetForm()
|
||||
onSuccess?.()
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || `Failed to ${mode === 'edit' ? 'update' : 'create'} category`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const defaultTrigger = mode === 'edit' ? (
|
||||
<Button variant="ghost" size="sm">
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
Edit
|
||||
</Button>
|
||||
) : (
|
||||
<Button>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Category
|
||||
</Button>
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger || defaultTrigger}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{mode === 'edit' ? 'Edit Category' : 'Create New Category'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Category Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="name">Category Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => handleInputChange('name', e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) => handleInputChange('description', e.target.value)}
|
||||
rows={4}
|
||||
placeholder="Describe this category..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="isActive">Active Status</Label>
|
||||
<Switch
|
||||
id="isActive"
|
||||
checked={formData.isActive}
|
||||
onCheckedChange={(checked) => handleInputChange('isActive', checked)}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">
|
||||
{formData.isActive ? 'Category is visible to customers' : 'Category is hidden from customers'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Category Image</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Label htmlFor="image" className="cursor-pointer">
|
||||
<div className="flex items-center space-x-2 px-4 py-2 border border-dashed border-gray-300 rounded-lg hover:border-gray-400">
|
||||
<Upload className="h-4 w-4" />
|
||||
<span>Upload Image</span>
|
||||
</div>
|
||||
</Label>
|
||||
<Input
|
||||
id="image"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleImageUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{formData.image && (
|
||||
<div className="relative group">
|
||||
<Image
|
||||
src={formData.image}
|
||||
alt="Category image"
|
||||
width={200}
|
||||
height={200}
|
||||
className="rounded-lg object-cover w-full h-48"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={removeImage}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setOpen(false)}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
{loading ? 'Saving...' : mode === 'edit' ? 'Update Category' : 'Create Category'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
537
components/admin/MediaSelector.tsx
Normal file
537
components/admin/MediaSelector.tsx
Normal file
@@ -0,0 +1,537 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import {
|
||||
Search,
|
||||
Upload,
|
||||
Image as ImageIcon,
|
||||
Video,
|
||||
Music,
|
||||
File,
|
||||
FolderPlus,
|
||||
Loader2,
|
||||
Check,
|
||||
X,
|
||||
Eye
|
||||
} from 'lucide-react'
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
import { toast } from 'sonner'
|
||||
import Image from 'next/image'
|
||||
|
||||
// Helper function to determine content type from URL
|
||||
const getContentTypeFromUrl = (url: string): string | null => {
|
||||
// Try to extract extension from the pathname, looking for common image extensions
|
||||
// even if they're not at the very end (due to timestamps or other suffixes)
|
||||
const extensionMatch = url.match(/\.(jpg|jpeg|png|gif|webp|svg|mp4|webm|mp3|wav|pdf|doc|docx|txt)(?:-\d+)?(?:\?.*)?$/i)
|
||||
const extension = extensionMatch ? extensionMatch[1].toLowerCase() : null
|
||||
|
||||
if (!extension) {
|
||||
// Fallback: check if the URL contains any image extension
|
||||
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg']
|
||||
for (const ext of imageExtensions) {
|
||||
if (url.toLowerCase().includes(`.${ext}`)) {
|
||||
return getExtensionMimeType(ext)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
return getExtensionMimeType(extension)
|
||||
}
|
||||
|
||||
// Helper function to map extensions to MIME types
|
||||
const getExtensionMimeType = (extension: string): string | null => {
|
||||
const extensionMap: Record<string, string> = {
|
||||
'jpg': 'image/jpeg',
|
||||
'jpeg': 'image/jpeg',
|
||||
'png': 'image/png',
|
||||
'gif': 'image/gif',
|
||||
'webp': 'image/webp',
|
||||
'svg': 'image/svg+xml',
|
||||
'mp4': 'video/mp4',
|
||||
'webm': 'video/webm',
|
||||
'mp3': 'audio/mpeg',
|
||||
'wav': 'audio/wav',
|
||||
'pdf': 'application/pdf',
|
||||
'doc': 'application/msword',
|
||||
'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'txt': 'text/plain'
|
||||
}
|
||||
return extensionMap[extension] || null
|
||||
}
|
||||
|
||||
export interface MediaFile {
|
||||
id: string
|
||||
name: string
|
||||
type: 'file' | 'folder'
|
||||
size?: number
|
||||
mimeType?: string
|
||||
url?: string
|
||||
path: string
|
||||
createdAt: string
|
||||
uploadedBy: {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
}
|
||||
|
||||
interface MediaSelectorProps {
|
||||
onSelect: (file: MediaFile) => void
|
||||
selectedUrl?: string
|
||||
allowedTypes?: string[] // e.g., ['image/*', 'video/*']
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export default function MediaSelector({
|
||||
onSelect,
|
||||
selectedUrl,
|
||||
allowedTypes = ['image/*', 'video/*', 'audio/*'],
|
||||
children
|
||||
}: MediaSelectorProps) {
|
||||
console.log('MediaSelector component rendered')
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [files, setFiles] = useState<MediaFile[]>([])
|
||||
const [currentPath, setCurrentPath] = useState('/')
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [selectedFile, setSelectedFile] = useState<MediaFile | null>(null)
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
|
||||
|
||||
const fetchFiles = useCallback(async () => {
|
||||
console.log('fetchFiles called, isOpen:', isOpen, 'currentPath:', currentPath)
|
||||
|
||||
if (!isOpen) {
|
||||
console.log('Dialog not open, skipping fetch')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
// Use our blob storage API to list files
|
||||
const searchParams = new URLSearchParams()
|
||||
|
||||
// Use 'media' prefix for all requests for consistency with upload folder structure
|
||||
const prefix = 'media'
|
||||
console.log('Fetching files with prefix:', prefix)
|
||||
|
||||
searchParams.set('prefix', prefix)
|
||||
searchParams.set('limit', '100')
|
||||
|
||||
const apiUrl = `/api/upload/files?${searchParams.toString()}`
|
||||
console.log('Making API request to:', apiUrl)
|
||||
|
||||
const response = await fetch(apiUrl)
|
||||
console.log('API response status:', response.status)
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
console.log('MediaSelector API response:', data) // Debug log
|
||||
if (data.success) {
|
||||
// Transform blob storage response to MediaFile format
|
||||
const blobs = data.data?.blobs || []
|
||||
console.log('Blobs found:', blobs) // Debug log
|
||||
const transformedFiles: MediaFile[] = blobs.map((blob: any) => {
|
||||
const mimeType = getContentTypeFromUrl(blob.url) || 'application/octet-stream'
|
||||
console.log('Transforming blob:', {
|
||||
url: blob.url,
|
||||
pathname: blob.pathname,
|
||||
detectedMimeType: mimeType
|
||||
})
|
||||
return {
|
||||
id: blob.pathname,
|
||||
name: blob.pathname.split('/').pop() || blob.pathname,
|
||||
type: 'file' as const,
|
||||
size: blob.size,
|
||||
mimeType,
|
||||
url: blob.url,
|
||||
path: blob.pathname,
|
||||
createdAt: blob.uploadedAt,
|
||||
uploadedBy: {
|
||||
id: 'system',
|
||||
name: 'System'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
console.log('Transformed files:', transformedFiles)
|
||||
console.log('Allowed types:', allowedTypes)
|
||||
|
||||
// Filter files based on allowed types
|
||||
const filteredFiles = transformedFiles.filter((file: MediaFile) => {
|
||||
if (!file.mimeType) {
|
||||
console.log('File rejected - no mimeType:', file.name)
|
||||
return false
|
||||
}
|
||||
|
||||
const isAllowed = allowedTypes.some(type => {
|
||||
if (type.endsWith('/*')) {
|
||||
const matches = file.mimeType!.startsWith(type.replace('/*', '/'))
|
||||
console.log(`Checking ${file.mimeType} against ${type}: ${matches}`)
|
||||
return matches
|
||||
}
|
||||
const matches = file.mimeType === type
|
||||
console.log(`Checking ${file.mimeType} against ${type}: ${matches}`)
|
||||
return matches
|
||||
})
|
||||
|
||||
console.log(`File ${file.name} allowed: ${isAllowed}`)
|
||||
return isAllowed
|
||||
})
|
||||
|
||||
console.log('Filtered files:', filteredFiles)
|
||||
setFiles(filteredFiles)
|
||||
} else {
|
||||
console.error('API request failed:', data)
|
||||
setFiles([])
|
||||
}
|
||||
} else {
|
||||
console.error('Failed to fetch files, status:', response.status)
|
||||
setFiles([])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching files:', error)
|
||||
toast.error('Failed to load files')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [currentPath, isOpen, allowedTypes])
|
||||
|
||||
useEffect(() => {
|
||||
console.log('MediaSelector mounted, isOpen:', isOpen)
|
||||
fetchFiles()
|
||||
}, [fetchFiles, isOpen])
|
||||
|
||||
const onDrop = useCallback(async (acceptedFiles: File[]) => {
|
||||
setUploading(true)
|
||||
const formData = new FormData()
|
||||
|
||||
acceptedFiles.forEach((file) => {
|
||||
formData.append('files', file)
|
||||
})
|
||||
|
||||
// Set upload type and folder based on current path
|
||||
formData.append('type', 'general')
|
||||
if (currentPath !== '/') {
|
||||
formData.append('folder', currentPath.substring(1)) // Remove leading slash
|
||||
} else {
|
||||
formData.append('folder', 'media')
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/upload/files', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
if (data.success) {
|
||||
toast.success(`Uploaded ${data.data.length} file(s) successfully`)
|
||||
fetchFiles()
|
||||
} else {
|
||||
console.error('Upload failed with data:', data)
|
||||
throw new Error(data.message || 'Upload failed')
|
||||
}
|
||||
} else {
|
||||
const errorData = await response.text()
|
||||
console.error('Upload failed with status:', response.status, 'Response:', errorData)
|
||||
throw new Error(`Upload failed with status ${response.status}: ${errorData}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error)
|
||||
toast.error(`Failed to upload files: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}, [currentPath, fetchFiles])
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
multiple: true,
|
||||
accept: allowedTypes.reduce((acc, type) => {
|
||||
acc[type] = []
|
||||
return acc
|
||||
}, {} as Record<string, string[]>)
|
||||
})
|
||||
|
||||
const handleFileSelect = (file: MediaFile) => {
|
||||
if (file.type === 'folder') {
|
||||
setCurrentPath(currentPath === '/' ? `/${file.name}` : `${currentPath}/${file.name}`)
|
||||
setSelectedFile(null)
|
||||
setPreviewUrl(null)
|
||||
} else {
|
||||
setSelectedFile(file)
|
||||
setPreviewUrl(file.url || null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleConfirmSelection = () => {
|
||||
if (selectedFile) {
|
||||
onSelect(selectedFile)
|
||||
setIsOpen(false)
|
||||
setSelectedFile(null)
|
||||
setPreviewUrl(null)
|
||||
}
|
||||
}
|
||||
|
||||
const navigateUp = () => {
|
||||
const pathParts = currentPath.split('/').filter(p => p)
|
||||
if (pathParts.length > 0) {
|
||||
pathParts.pop()
|
||||
setCurrentPath(pathParts.length === 0 ? '/' : '/' + pathParts.join('/'))
|
||||
}
|
||||
}
|
||||
|
||||
const getFileIcon = (file: MediaFile) => {
|
||||
if (file.type === 'folder') return <FolderPlus className="h-8 w-8 text-blue-500" />
|
||||
|
||||
if (!file.mimeType) return <File className="h-8 w-8 text-gray-500" />
|
||||
|
||||
if (file.mimeType.startsWith('image/')) return <ImageIcon className="h-8 w-8 text-green-500" />
|
||||
if (file.mimeType.startsWith('video/')) return <Video className="h-8 w-8 text-red-500" />
|
||||
if (file.mimeType.startsWith('audio/')) return <Music className="h-8 w-8 text-purple-500" />
|
||||
|
||||
return <File className="h-8 w-8 text-gray-500" />
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes === 0) return '0 Bytes'
|
||||
const k = 1024
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
const filteredFiles = files.filter(file =>
|
||||
file.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => {
|
||||
console.log('Dialog onOpenChange called with:', open)
|
||||
setIsOpen(open)
|
||||
}}>
|
||||
<DialogTrigger asChild>
|
||||
{children}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-7xl max-h-[90vh] w-[95vw] p-0">
|
||||
<DialogHeader className="p-4 sm:p-6 pb-2 sm:pb-4">
|
||||
<DialogTitle className="text-lg sm:text-xl">Select Media</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col lg:grid lg:grid-cols-3 gap-4 p-4 sm:p-6 pt-0 h-[80vh] lg:h-[600px]">
|
||||
{/* File Browser */}
|
||||
<div className="lg:col-span-2 lg:border-r lg:pr-4 flex-1 lg:flex-none">
|
||||
<Tabs defaultValue="browse" className="h-full flex flex-col">
|
||||
<TabsList className="grid w-full grid-cols-2 mb-4">
|
||||
<TabsTrigger value="browse" className="text-sm">Browse Files</TabsTrigger>
|
||||
<TabsTrigger value="upload" className="text-sm">Upload New</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="browse" className="flex-1 flex flex-col space-y-3 sm:space-y-4">
|
||||
{/* Navigation */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center space-y-2 sm:space-y-0 sm:space-x-2">
|
||||
{currentPath !== '/' && (
|
||||
<Button variant="outline" size="sm" onClick={navigateUp} className="w-fit">
|
||||
← Back
|
||||
</Button>
|
||||
)}
|
||||
<div className="text-xs sm:text-sm text-gray-600 break-all">
|
||||
Path: {currentPath}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="Search files..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Files Grid */}
|
||||
<div className="flex-1 overflow-y-auto h-auto max-h-[400px]">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-3 xl:grid-cols-4 gap-2 sm:gap-3">
|
||||
{filteredFiles.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className={`relative p-2 sm:p-3 border rounded-lg cursor-pointer transition-all hover:shadow-md touch-manipulation ${
|
||||
selectedFile?.id === file.id
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-200'
|
||||
}`}
|
||||
onClick={() => handleFileSelect(file)}
|
||||
>
|
||||
<div className="aspect-square bg-gray-100 rounded-md flex items-center justify-center mb-2 overflow-hidden">
|
||||
{file.mimeType?.startsWith('image/') && file.url ? (
|
||||
<Image
|
||||
src={file.url}
|
||||
alt={file.name}
|
||||
width={80}
|
||||
height={80}
|
||||
className="object-cover w-full h-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center w-full h-full">
|
||||
{getFileIcon(file)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-xs">
|
||||
<p className="font-medium truncate" title={file.name}>
|
||||
{file.name}
|
||||
</p>
|
||||
{file.size && (
|
||||
<p className="text-gray-500 text-xs">
|
||||
{formatFileSize(file.size)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedFile?.id === file.id && (
|
||||
<div className="absolute top-1 right-1 sm:top-2 sm:right-2">
|
||||
<div className="bg-blue-500 text-white rounded-full p-1">
|
||||
<Check className="h-3 w-3" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="upload" className="flex-1">
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={`h-full min-h-[200px] border-2 border-dashed rounded-lg flex flex-col items-center justify-center transition-colors p-4 ${
|
||||
isDragActive ? 'border-blue-500 bg-blue-50' : 'border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
{uploading ? (
|
||||
<div className="text-center">
|
||||
<Loader2 className="h-8 w-8 sm:h-12 sm:w-12 animate-spin text-blue-500 mx-auto mb-4" />
|
||||
<p className="text-blue-600 text-sm sm:text-base">Uploading files...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center">
|
||||
<Upload className="h-8 w-8 sm:h-12 sm:w-12 text-gray-400 mx-auto mb-4" />
|
||||
<p className="text-sm sm:text-lg font-medium text-gray-900 mb-2">
|
||||
Drop files here or click to browse
|
||||
</p>
|
||||
<p className="text-xs sm:text-sm text-gray-500">
|
||||
Supported: {allowedTypes.join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* Preview Panel */}
|
||||
<div className="flex flex-col min-h-[300px] lg:min-h-0">
|
||||
<h3 className="font-semibold mb-4 text-sm sm:text-base">Preview</h3>
|
||||
|
||||
{selectedFile && previewUrl ? (
|
||||
<div className="flex-1 flex flex-col space-y-3 sm:space-y-4">
|
||||
<div className="aspect-4/3 bg-gray-100 rounded-lg overflow-hidden flex items-center justify-center">
|
||||
{selectedFile.mimeType?.startsWith('image/') ? (
|
||||
<Image
|
||||
src={previewUrl}
|
||||
alt={selectedFile.name}
|
||||
width={200}
|
||||
height={200}
|
||||
className="object-contain w-full h-full"
|
||||
/>
|
||||
) : selectedFile.mimeType?.startsWith('video/') ? (
|
||||
<video controls className="w-full h-full max-h-full">
|
||||
<source src={previewUrl} type={selectedFile.mimeType} />
|
||||
</video>
|
||||
) : selectedFile.mimeType?.startsWith('audio/') ? (
|
||||
<div className="w-full p-4">
|
||||
<div className="flex items-center justify-center mb-4">
|
||||
<Music className="h-12 w-12 sm:h-16 sm:w-16 text-purple-500" />
|
||||
</div>
|
||||
<audio controls className="w-full">
|
||||
<source src={previewUrl} type={selectedFile.mimeType} />
|
||||
</audio>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center p-4">
|
||||
{getFileIcon(selectedFile)}
|
||||
<p className="text-xs sm:text-sm text-gray-500 mt-2">No preview available</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium text-sm break-all" title={selectedFile.name}>
|
||||
{selectedFile.name}
|
||||
</p>
|
||||
{selectedFile.size && (
|
||||
<p className="text-xs text-gray-500">
|
||||
{formatFileSize(selectedFile.size)}
|
||||
</p>
|
||||
)}
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{selectedFile.mimeType}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 mt-auto flex items-center justify-between gap-2">
|
||||
<Button
|
||||
onClick={handleConfirmSelection}
|
||||
className="w-full text-sm"
|
||||
disabled={!selectedFile}
|
||||
>
|
||||
<Check className="h-4 w-4 mr-2" />
|
||||
Select This File
|
||||
</Button>
|
||||
{selectedUrl && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
onSelect({ url: '', name: '', id: '', type: 'file', path: '', createdAt: '', uploadedBy: { id: '', name: '' } })
|
||||
setIsOpen(false)
|
||||
}}
|
||||
className="w-full text-sm !m-0"
|
||||
>
|
||||
<X className="h-4 w-4 mr-2" />
|
||||
Remove Current
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-gray-500">
|
||||
<div className="text-center">
|
||||
<Eye className="h-8 w-8 sm:h-12 sm:w-12 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm sm:text-base">Select a file to preview</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
419
components/admin/ProductForm.tsx
Normal file
419
components/admin/ProductForm.tsx
Normal file
@@ -0,0 +1,419 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { ArrowLeft, Save, Upload, X, ImageIcon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import MediaSelector, { MediaFile } from '@/components/admin/MediaSelector'
|
||||
|
||||
interface Product {
|
||||
id?: string
|
||||
name: string
|
||||
description: string
|
||||
price: number
|
||||
discount: number
|
||||
images: string[]
|
||||
stock: number
|
||||
manageStock: boolean
|
||||
sku: string
|
||||
isActive: boolean
|
||||
categoryId: string
|
||||
}
|
||||
|
||||
interface Category {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface ProductFormProps {
|
||||
product?: Product
|
||||
}
|
||||
|
||||
export function ProductForm({ product }: ProductFormProps) {
|
||||
const router = useRouter()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [categoriesLoading, setCategoriesLoading] = useState(true)
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [imageUrl, setImageUrl] = useState('')
|
||||
const [formData, setFormData] = useState<Product>({
|
||||
name: '',
|
||||
description: '',
|
||||
price: 0,
|
||||
discount: 0,
|
||||
images: [],
|
||||
stock: 0,
|
||||
manageStock: true,
|
||||
sku: '',
|
||||
isActive: true,
|
||||
categoryId: '',
|
||||
...product
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
fetchCategories()
|
||||
}, [])
|
||||
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
setCategoriesLoading(true)
|
||||
const response = await fetch('/api/categories')
|
||||
const data = await response.json()
|
||||
// Handle the new API response structure
|
||||
setCategories(data.categories || [])
|
||||
} catch (error) {
|
||||
console.error('Error fetching categories:', error)
|
||||
setCategories([]) // Fallback to empty array
|
||||
} finally {
|
||||
setCategoriesLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleInputChange = (field: keyof Product, value: any) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }))
|
||||
}
|
||||
|
||||
const handleAddImageUrl = () => {
|
||||
if (!imageUrl.trim()) {
|
||||
toast.error('Please enter a valid image URL')
|
||||
return
|
||||
}
|
||||
|
||||
// Basic URL validation
|
||||
try {
|
||||
new URL(imageUrl)
|
||||
} catch {
|
||||
toast.error('Please enter a valid URL')
|
||||
return
|
||||
}
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
images: [...prev.images, imageUrl.trim()]
|
||||
}))
|
||||
|
||||
setImageUrl('')
|
||||
toast.success('Image URL added')
|
||||
}
|
||||
|
||||
const removeImage = (index: number) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
images: prev.images.filter((_, i) => i !== index)
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const url = product
|
||||
? `/api/admin/products/${product.id}`
|
||||
: '/api/admin/products'
|
||||
|
||||
const method = product ? 'PUT' : 'POST'
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(formData)
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Failed to save product')
|
||||
|
||||
toast.success(`Product ${product ? 'updated' : 'created'} successfully`)
|
||||
router.push('/admin/products')
|
||||
} catch (error) {
|
||||
toast.error(`Failed to ${product ? 'update' : 'create'} product`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="ghost" asChild>
|
||||
<Link href="/admin/products">
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to Products
|
||||
</Link>
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
{loading ? 'Saving...' : 'Save Product'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Product Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="name">Product Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => handleInputChange('name', e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) => handleInputChange('description', e.target.value)}
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="sku">SKU</Label>
|
||||
<Input
|
||||
id="sku"
|
||||
value={formData.sku}
|
||||
onChange={(e) => handleInputChange('sku', e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="category">Category</Label>
|
||||
<Select
|
||||
value={formData.categoryId}
|
||||
onValueChange={(value) => handleInputChange('categoryId', value)}
|
||||
disabled={categoriesLoading}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={
|
||||
categoriesLoading
|
||||
? "Loading categories..."
|
||||
: categories.length === 0
|
||||
? "No categories available"
|
||||
: "Select category"
|
||||
} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{!categoriesLoading && categories.length > 0 ? (
|
||||
categories.map((category) => (
|
||||
<SelectItem key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</SelectItem>
|
||||
))
|
||||
) : null}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Product Images</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
{/* Media Selector Section */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Product Images</Label>
|
||||
<MediaSelector
|
||||
allowedTypes={['image/*']}
|
||||
onSelect={(file: MediaFile) => {
|
||||
const imageUrl = file.url
|
||||
if (imageUrl && typeof imageUrl === 'string' && imageUrl.trim()) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
images: [...prev.images, imageUrl]
|
||||
}))
|
||||
toast.success('Image added successfully')
|
||||
} else {
|
||||
toast.error('Invalid file selected - no valid URL found')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center space-x-2 px-4 py-2 border border-dashed border-gray-300 rounded-lg hover:border-gray-400 cursor-pointer">
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
<span>Browse & Upload Images</span>
|
||||
</div>
|
||||
</MediaSelector>
|
||||
<p className="text-xs text-gray-500">
|
||||
Browse existing images or upload new ones. Supports JPG, PNG, WebP formats.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* URL Input Section (Optional) */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Add Image URL (Optional)</Label>
|
||||
<div className="flex space-x-2">
|
||||
<Input
|
||||
placeholder="https://example.com/image.jpg"
|
||||
value={imageUrl}
|
||||
onChange={(e) => setImageUrl(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleAddImageUrl()}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleAddImageUrl}
|
||||
disabled={!imageUrl.trim()}
|
||||
>
|
||||
Add URL
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
Add images from external URLs (e.g., Unsplash, your CDN, etc.)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Image Preview Grid */}
|
||||
{formData.images.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Image Preview</Label>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{formData.images.map((image, index) => (
|
||||
<div key={index} className="relative group">
|
||||
<Image
|
||||
src={image}
|
||||
alt={`Product image ${index + 1}`}
|
||||
width={150}
|
||||
height={150}
|
||||
className="rounded-lg object-cover w-full h-32 border"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.src = '/placeholder-image.jpg'; // fallback image
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity h-6 w-6 p-0"
|
||||
onClick={() => removeImage(index)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-black/50 text-white text-xs p-1 rounded-b-lg truncate">
|
||||
{image.startsWith('/products/') ? 'Uploaded' : 'External URL'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formData.images.length === 0 && (
|
||||
<div className="text-center py-8 border-2 border-dashed border-gray-200 rounded-lg">
|
||||
<Upload className="h-8 w-8 mx-auto text-gray-400 mb-2" />
|
||||
<p className="text-sm text-gray-500">No images added yet</p>
|
||||
<p className="text-xs text-gray-400">Upload files or add URLs to get started</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Pricing & Inventory</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="price">Price (₹)</Label>
|
||||
<Input
|
||||
id="price"
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={formData.price}
|
||||
onChange={(e) => handleInputChange('price', parseFloat(e.target.value) || 0)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="discount">Discount (%)</Label>
|
||||
<Input
|
||||
id="discount"
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
value={formData.discount}
|
||||
onChange={(e) => handleInputChange('discount', parseInt(e.target.value) || 0)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<Label htmlFor="manageStock">Manage Stock</Label>
|
||||
<Switch
|
||||
id="manageStock"
|
||||
checked={formData.manageStock}
|
||||
onCheckedChange={(checked) => handleInputChange('manageStock', checked)}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
Enable this to track inventory levels for this product
|
||||
</p>
|
||||
|
||||
{formData.manageStock && (
|
||||
<div>
|
||||
<Label htmlFor="stock">Stock Quantity</Label>
|
||||
<Input
|
||||
id="stock"
|
||||
type="number"
|
||||
min="0"
|
||||
value={formData.stock}
|
||||
onChange={(e) => handleInputChange('stock', parseInt(e.target.value) || 0)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Status</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="isActive">Active Status</Label>
|
||||
<Switch
|
||||
id="isActive"
|
||||
checked={formData.isActive}
|
||||
onCheckedChange={(checked) => handleInputChange('isActive', checked)}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
{formData.isActive ? 'Product is visible to customers' : 'Product is hidden from customers'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user