Rocky_Mountain_Vending/components/product-admin.tsx
DMleadgen 46d973904b
Initial commit: Rocky Mountain Vending website
Next.js website for Rocky Mountain Vending company featuring:
- Product catalog with Stripe integration
- Service areas and parts pages
- Admin dashboard with Clerk authentication
- SEO optimized pages with JSON-LD structured data

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 16:22:15 -07:00

577 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>
)
}