635 lines
19 KiB
TypeScript
635 lines
19 KiB
TypeScript
"use client"
|
|
|
|
import { useState, useEffect } from "react"
|
|
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 {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "@/components/ui/card"
|
|
import { Badge } from "@/components/ui/badge"
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table"
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select"
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogTrigger,
|
|
} from "@/components/ui/dialog"
|
|
import { Alert, AlertDescription } from "@/components/ui/alert"
|
|
import {
|
|
Plus,
|
|
Search,
|
|
Edit,
|
|
Trash2,
|
|
Save,
|
|
X,
|
|
AlertCircle,
|
|
CheckCircle,
|
|
DollarSign,
|
|
Package,
|
|
Image,
|
|
} from "lucide-react"
|
|
import type { Product } from "@/lib/products/types"
|
|
|
|
interface ProductFormData {
|
|
name: string
|
|
description: string
|
|
price: string
|
|
currency: string
|
|
images: string[]
|
|
metadata: Record<string, string>
|
|
}
|
|
|
|
const initialFormData: ProductFormData = {
|
|
name: "",
|
|
description: "",
|
|
price: "",
|
|
currency: "usd",
|
|
images: [],
|
|
metadata: {},
|
|
}
|
|
|
|
const currencyOptions = [
|
|
{ value: "usd", label: "USD ($)" },
|
|
{ value: "eur", label: "EUR (€)" },
|
|
{ value: "gbp", label: "GBP (£)" },
|
|
{ value: "cad", label: "CAD ($)" },
|
|
{ value: "aud", label: "AUD ($)" },
|
|
]
|
|
|
|
export function ProductAdmin() {
|
|
const [products, setProducts] = useState<Product[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [searchTerm, setSearchTerm] = useState("")
|
|
const [selectedProducts, setSelectedProducts] = useState<string[]>([])
|
|
const [showCreateDialog, setShowCreateDialog] = useState(false)
|
|
const [editingProduct, setEditingProduct] = useState<Product | null>(null)
|
|
const [isSaving, setIsSaving] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [success, setSuccess] = useState<string | null>(null)
|
|
|
|
const [formData, setFormData] = useState<ProductFormData>(initialFormData)
|
|
|
|
// Fetch products
|
|
const fetchProducts = async () => {
|
|
try {
|
|
setLoading(true)
|
|
const response = await fetch("/api/products/admin")
|
|
if (!response.ok) throw new Error("Failed to fetch products")
|
|
const data = await response.json()
|
|
setProducts(data.products)
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Failed to fetch products")
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
fetchProducts()
|
|
}, [])
|
|
|
|
// Handle form input changes
|
|
const handleInputChange = (field: keyof ProductFormData, value: any) => {
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
[field]: value,
|
|
}))
|
|
}
|
|
|
|
// Handle metadata input changes
|
|
const handleMetadataChange = (key: string, value: string) => {
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
metadata: {
|
|
...prev.metadata,
|
|
[key]: value,
|
|
},
|
|
}))
|
|
}
|
|
|
|
// Add metadata field
|
|
const addMetadataField = () => {
|
|
const emptyKey =
|
|
Object.keys(formData.metadata).length === 0
|
|
? "category"
|
|
: `key${Object.keys(formData.metadata).length + 1}`
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
metadata: {
|
|
...prev.metadata,
|
|
[emptyKey]: "",
|
|
},
|
|
}))
|
|
}
|
|
|
|
// Remove metadata field
|
|
const removeMetadataField = (key: string) => {
|
|
setFormData((prev) => {
|
|
const newMetadata = { ...prev.metadata }
|
|
delete newMetadata[key]
|
|
return {
|
|
...prev,
|
|
metadata: newMetadata,
|
|
}
|
|
})
|
|
}
|
|
|
|
// Create product
|
|
const handleCreateProduct = async () => {
|
|
try {
|
|
setIsSaving(true)
|
|
setError(null)
|
|
setSuccess(null)
|
|
|
|
// Validate form
|
|
if (!formData.name || !formData.price) {
|
|
setError("Name and price are required")
|
|
return
|
|
}
|
|
|
|
const price = parseFloat(formData.price)
|
|
if (isNaN(price) || price <= 0) {
|
|
setError("Price must be a valid positive number")
|
|
return
|
|
}
|
|
|
|
const response = await fetch("/api/products/admin", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
name: formData.name,
|
|
description: formData.description,
|
|
price,
|
|
currency: formData.currency,
|
|
images: formData.images.filter(Boolean),
|
|
metadata: formData.metadata,
|
|
}),
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json()
|
|
throw new Error(errorData.error || "Failed to create product")
|
|
}
|
|
|
|
const newProduct = await response.json()
|
|
setProducts((prev) => [newProduct.product, ...prev])
|
|
setSuccess(`Product "${newProduct.product.name}" created successfully`)
|
|
|
|
// Reset form
|
|
setFormData(initialFormData)
|
|
setShowCreateDialog(false)
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Failed to create product")
|
|
} finally {
|
|
setIsSaving(false)
|
|
}
|
|
}
|
|
|
|
// Update product (placeholder - would need implementation)
|
|
const handleUpdateProduct = async (product: Product) => {
|
|
try {
|
|
setIsSaving(true)
|
|
setError(null)
|
|
setSuccess(null)
|
|
|
|
// In a real implementation, you would call the update API
|
|
console.log("Updating product:", product)
|
|
|
|
setSuccess(`Product "${product.name}" updated successfully`)
|
|
setEditingProduct(null)
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Failed to update product")
|
|
} finally {
|
|
setIsSaving(false)
|
|
}
|
|
}
|
|
|
|
// Delete product (placeholder - would need implementation)
|
|
const handleDeleteProduct = async (productId: string) => {
|
|
try {
|
|
if (
|
|
!confirm(
|
|
"Are you sure you want to delete this product? This action cannot be undone."
|
|
)
|
|
) {
|
|
return
|
|
}
|
|
|
|
setLoading(true)
|
|
setError(null)
|
|
|
|
// In a real implementation, you would call the delete API
|
|
console.log("Deleting product:", productId)
|
|
|
|
setProducts((prev) => prev.filter((p) => p.id !== productId))
|
|
setSuccess("Product deleted successfully")
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Failed to delete product")
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
// Bulk actions
|
|
const handleBulkAction = async (action: "deactivate") => {
|
|
try {
|
|
setLoading(true)
|
|
setError(null)
|
|
|
|
const response = await fetch("/api/products/admin", {
|
|
method: "PUT",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
action,
|
|
productIds: selectedProducts,
|
|
}),
|
|
})
|
|
|
|
if (!response.ok) {
|
|
throw new Error("Failed to perform bulk action")
|
|
}
|
|
|
|
await fetchProducts()
|
|
setSelectedProducts([])
|
|
setSuccess(
|
|
`Successfully ${action}d ${selectedProducts.length} product(s)`
|
|
)
|
|
} catch (err) {
|
|
setError(
|
|
err instanceof Error ? err.message : "Failed to perform bulk action"
|
|
)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
// Filter products based on search
|
|
const filteredProducts = products.filter(
|
|
(product) =>
|
|
product.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
product.description?.toLowerCase().includes(searchTerm.toLowerCase())
|
|
)
|
|
|
|
// Clear alerts
|
|
useEffect(() => {
|
|
if (error || success) {
|
|
const timer = setTimeout(() => {
|
|
setError(null)
|
|
setSuccess(null)
|
|
}, 5000)
|
|
return () => clearTimeout(timer)
|
|
}
|
|
}, [error, success])
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Alerts */}
|
|
{error && (
|
|
<Alert variant="destructive">
|
|
<AlertCircle className="h-4 w-4" />
|
|
<AlertDescription>{error}</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
{success && (
|
|
<Alert>
|
|
<CheckCircle className="h-4 w-4" />
|
|
<AlertDescription>{success}</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
{/* Header */}
|
|
<div className="flex justify-between items-start">
|
|
<div>
|
|
<h2 className="text-2xl font-bold">Product Management</h2>
|
|
<p className="text-muted-foreground">
|
|
Manage your Stripe products and inventory
|
|
</p>
|
|
</div>
|
|
|
|
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
|
|
<DialogTrigger asChild>
|
|
<Button>
|
|
<Plus className="h-4 w-4 mr-2" />
|
|
Add Product
|
|
</Button>
|
|
</DialogTrigger>
|
|
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle>Create New Product</DialogTitle>
|
|
<DialogDescription>
|
|
Add a new product to your Stripe inventory.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-4">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="name">Product Name *</Label>
|
|
<Input
|
|
id="name"
|
|
value={formData.name}
|
|
onChange={(e) => handleInputChange("name", e.target.value)}
|
|
placeholder="Enter product name"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="price">Price *</Label>
|
|
<Input
|
|
id="price"
|
|
type="number"
|
|
step="0.01"
|
|
min="0"
|
|
value={formData.price}
|
|
onChange={(e) => handleInputChange("price", e.target.value)}
|
|
placeholder="0.00"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="description">Description</Label>
|
|
<Textarea
|
|
id="description"
|
|
value={formData.description}
|
|
onChange={(e) =>
|
|
handleInputChange("description", e.target.value)
|
|
}
|
|
placeholder="Enter product description"
|
|
rows={3}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="currency">Currency</Label>
|
|
<Select
|
|
value={formData.currency}
|
|
onValueChange={(value) =>
|
|
handleInputChange("currency", value)
|
|
}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select currency" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{currencyOptions.map((option) => (
|
|
<SelectItem key={option.value} value={option.value}>
|
|
{option.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label>Metadata</Label>
|
|
{Object.entries(formData.metadata).map(([key, value]) => (
|
|
<div key={key} className="flex gap-2">
|
|
<Input
|
|
value={key}
|
|
onChange={(e) =>
|
|
handleMetadataChange(e.target.value, value)
|
|
}
|
|
placeholder="Key"
|
|
/>
|
|
<Input
|
|
value={value}
|
|
onChange={(e) =>
|
|
handleMetadataChange(key, e.target.value)
|
|
}
|
|
placeholder="Value"
|
|
/>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => removeMetadataField(key)}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={addMetadataField}
|
|
className="w-full"
|
|
>
|
|
Add Metadata
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setShowCreateDialog(false)}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={handleCreateProduct} disabled={isSaving}>
|
|
{isSaving ? "Creating..." : "Create Product"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
|
|
{/* Bulk Actions */}
|
|
{selectedProducts.length > 0 && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">Bulk Actions</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="destructive"
|
|
size="sm"
|
|
onClick={() => handleBulkAction("deactivate")}
|
|
>
|
|
Deactivate {selectedProducts.length} Product(s)
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setSelectedProducts([])}
|
|
>
|
|
Cancel Selection
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Search and Filter */}
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<div className="flex gap-4 items-center">
|
|
<div className="relative flex-1">
|
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder="Search products..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
className="pl-10"
|
|
/>
|
|
</div>
|
|
<Badge variant="secondary">
|
|
{filteredProducts.length} product
|
|
{filteredProducts.length !== 1 ? "s" : ""}
|
|
</Badge>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Products Table */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Products</CardTitle>
|
|
<CardDescription>
|
|
View and manage all your Stripe products
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{loading ? (
|
|
<div className="flex justify-center items-center py-8">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
|
</div>
|
|
) : (
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="w-12">
|
|
<input
|
|
type="checkbox"
|
|
checked={
|
|
selectedProducts.length === products.length &&
|
|
products.length > 0
|
|
}
|
|
onChange={(e) => {
|
|
if (e.target.checked) {
|
|
setSelectedProducts(products.map((p) => p.id))
|
|
} else {
|
|
setSelectedProducts([])
|
|
}
|
|
}}
|
|
/>
|
|
</TableHead>
|
|
<TableHead>Product</TableHead>
|
|
<TableHead>Price</TableHead>
|
|
<TableHead>Status</TableHead>
|
|
<TableHead>Actions</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{filteredProducts.map((product) => (
|
|
<TableRow key={product.id}>
|
|
<TableCell>
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedProducts.includes(product.id)}
|
|
onChange={(e) => {
|
|
if (e.target.checked) {
|
|
setSelectedProducts((prev) => [...prev, product.id])
|
|
} else {
|
|
setSelectedProducts((prev) =>
|
|
prev.filter((id) => id !== product.id)
|
|
)
|
|
}
|
|
}}
|
|
/>
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="flex items-center gap-3">
|
|
{product.images?.[0] ? (
|
|
<div className="w-10 h-10 rounded-md overflow-hidden bg-muted">
|
|
<img
|
|
src={product.images[0]}
|
|
alt={product.name}
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="w-10 h-10 rounded-md bg-muted flex items-center justify-center">
|
|
<Package className="h-5 w-5 text-muted-foreground" />
|
|
</div>
|
|
)}
|
|
<div>
|
|
<div className="font-medium">{product.name}</div>
|
|
<div className="text-sm text-muted-foreground truncate max-w-[200px]">
|
|
{product.description}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="flex items-center gap-1">
|
|
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
|
{product.price.toFixed(2)}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Badge variant="default">Active</Badge>
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setEditingProduct(product)}
|
|
>
|
|
<Edit className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handleDeleteProduct(product.id)}
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
|
|
{filteredProducts.length === 0 && !loading && (
|
|
<div className="text-center py-8 text-muted-foreground">
|
|
No products found. {searchTerm && "Try adjusting your search."}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)
|
|
}
|