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>
577 lines
19 KiB
TypeScript
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>
|
|
)
|
|
}
|