Files
cbd420/app/admin/page.tsx
2025-12-20 22:05:21 +01:00

889 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
interface Drop {
id: number
item: string
size: number
fill: number
unit: string
ppu: number
created_at: string
}
interface Buyer {
id: number
username: string
email: string
created_at?: string
}
interface Sale {
id: number
drop_id: number
buyer_id: number
size: number
payment_id: string | null
created_at: string
drop_item?: string
drop_unit?: string
drop_ppu?: number
buyer_username?: string
buyer_email?: string
}
export default function AdminPage() {
const router = useRouter()
const [drops, setDrops] = useState<Drop[]>([])
const [buyers, setBuyers] = useState<Buyer[]>([])
const [sales, setSales] = useState<Sale[]>([])
const [loading, setLoading] = useState(true)
const [buyersLoading, setBuyersLoading] = useState(false)
const [salesLoading, setSalesLoading] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [uploadingImage, setUploadingImage] = useState(false)
const [editingBuyer, setEditingBuyer] = useState<Buyer | null>(null)
const [editingSale, setEditingSale] = useState<Sale | null>(null)
const [formData, setFormData] = useState({
item: '',
size: '',
unit: 'g',
ppu: '',
imageFile: null as File | null,
imagePreview: '',
startTime: '',
})
const [buyerFormData, setBuyerFormData] = useState({
username: '',
email: '',
password: '',
})
const [saleFormData, setSaleFormData] = useState({
drop_id: '',
buyer_id: '',
size: '',
payment_id: '',
})
useEffect(() => {
fetchDrops()
fetchBuyers()
fetchSales()
}, [])
const fetchDrops = async () => {
try {
const response = await fetch('/api/drops')
if (!response.ok) {
throw new Error('Failed to fetch drops')
}
const data = await response.json()
// Ensure data is always an array
setDrops(Array.isArray(data) ? data : [])
} catch (error) {
console.error('Error fetching drops:', error)
setDrops([]) // Set to empty array on error
} finally {
setLoading(false)
}
}
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
setFormData({
...formData,
imageFile: file,
imagePreview: URL.createObjectURL(file),
})
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setSubmitting(true)
try {
let imageUrl = ''
// Upload image if provided
if (formData.imageFile) {
setUploadingImage(true)
const uploadFormData = new FormData()
uploadFormData.append('file', formData.imageFile)
const uploadResponse = await fetch('/api/upload', {
method: 'POST',
body: uploadFormData,
})
if (!uploadResponse.ok) {
const error = await uploadResponse.json()
alert(`Image upload failed: ${error.error}`)
setUploadingImage(false)
setSubmitting(false)
return
}
const uploadData = await uploadResponse.json()
imageUrl = uploadData.url
setUploadingImage(false)
}
// Create drop
const response = await fetch('/api/drops', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
item: formData.item,
size: parseInt(formData.size),
unit: formData.unit.trim() || 'g',
ppu: parseInt(formData.ppu, 10),
imageUrl: imageUrl,
startTime: formData.startTime || null,
}),
})
if (response.ok) {
// Reset form
setFormData({
item: '',
size: '',
unit: 'g',
ppu: '',
imageFile: null,
imagePreview: '',
startTime: '',
})
// Clear file input
const fileInput = document.getElementById('imageFile') as HTMLInputElement
if (fileInput) fileInput.value = ''
// Refresh drops list
fetchDrops()
alert('Drop created successfully!')
} else {
const error = await response.json()
alert(`Error: ${error.error}`)
}
} catch (error) {
console.error('Error creating drop:', error)
alert('Failed to create drop')
} finally {
setSubmitting(false)
setUploadingImage(false)
}
}
const getProgressPercentage = (fill: number, size: number) => {
return Math.min((fill / size) * 100, 100).toFixed(1)
}
const isSoldOut = (fill: number, size: number) => {
return fill >= size
}
const fetchBuyers = async () => {
setBuyersLoading(true)
try {
const response = await fetch('/api/buyers')
if (response.ok) {
const data = await response.json()
setBuyers(Array.isArray(data) ? data : [])
}
} catch (error) {
console.error('Error fetching buyers:', error)
setBuyers([])
} finally {
setBuyersLoading(false)
}
}
const fetchSales = async () => {
setSalesLoading(true)
try {
const response = await fetch('/api/sales/list')
if (response.ok) {
const data = await response.json()
setSales(Array.isArray(data) ? data : [])
}
} catch (error) {
console.error('Error fetching sales:', error)
setSales([])
} finally {
setSalesLoading(false)
}
}
const handleEditBuyer = (buyer: Buyer) => {
setEditingBuyer(buyer)
setBuyerFormData({
username: buyer.username,
email: buyer.email,
password: '',
})
}
const handleSaveBuyer = async () => {
if (!editingBuyer) return
try {
const updateData: any = {}
if (buyerFormData.username !== editingBuyer.username) {
updateData.username = buyerFormData.username
}
if (buyerFormData.email !== editingBuyer.email) {
updateData.email = buyerFormData.email
}
if (buyerFormData.password) {
updateData.password = buyerFormData.password
}
if (Object.keys(updateData).length === 0) {
setEditingBuyer(null)
return
}
const response = await fetch(`/api/buyers/${editingBuyer.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updateData),
})
if (response.ok) {
alert('Buyer updated successfully')
setEditingBuyer(null)
fetchBuyers()
} else {
const error = await response.json()
alert(`Error: ${error.error}`)
}
} catch (error) {
console.error('Error updating buyer:', error)
alert('Failed to update buyer')
}
}
const handleDeleteBuyer = async (id: number) => {
if (!confirm('Are you sure you want to delete this buyer? This will also delete all their sales.')) {
return
}
try {
const response = await fetch(`/api/buyers/${id}`, {
method: 'DELETE',
})
if (response.ok) {
alert('Buyer deleted successfully')
fetchBuyers()
} else {
const error = await response.json()
alert(`Error: ${error.error}`)
}
} catch (error) {
console.error('Error deleting buyer:', error)
alert('Failed to delete buyer')
}
}
const handleEditSale = (sale: Sale) => {
setEditingSale(sale)
setSaleFormData({
drop_id: sale.drop_id.toString(),
buyer_id: sale.buyer_id.toString(),
size: sale.size.toString(),
payment_id: sale.payment_id || '',
})
}
const handleSaveSale = async () => {
if (!editingSale) return
try {
const updateData: any = {}
if (parseInt(saleFormData.drop_id) !== editingSale.drop_id) {
updateData.drop_id = parseInt(saleFormData.drop_id)
}
if (parseInt(saleFormData.buyer_id) !== editingSale.buyer_id) {
updateData.buyer_id = parseInt(saleFormData.buyer_id)
}
if (parseInt(saleFormData.size) !== editingSale.size) {
updateData.size = parseInt(saleFormData.size)
}
if (saleFormData.payment_id !== (editingSale.payment_id || '')) {
updateData.payment_id = saleFormData.payment_id || null
}
if (Object.keys(updateData).length === 0) {
setEditingSale(null)
return
}
const response = await fetch(`/api/sales/${editingSale.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updateData),
})
if (response.ok) {
alert('Sale updated successfully')
setEditingSale(null)
fetchSales()
fetchDrops() // Refresh drops to update fill
} else {
const error = await response.json()
alert(`Error: ${error.error}`)
}
} catch (error) {
console.error('Error updating sale:', error)
alert('Failed to update sale')
}
}
const handleDeleteSale = async (id: number) => {
if (!confirm('Are you sure you want to delete this sale?')) {
return
}
try {
const response = await fetch(`/api/sales/${id}`, {
method: 'DELETE',
})
if (response.ok) {
alert('Sale deleted successfully')
fetchSales()
fetchDrops() // Refresh drops to update fill
} else {
const error = await response.json()
alert(`Error: ${error.error}`)
}
} catch (error) {
console.error('Error deleting sale:', error)
alert('Failed to delete sale')
}
}
return (
<div className="admin-page">
<div className="container">
<div className="admin-header">
<h1>Admin Panel</h1>
<button onClick={() => router.push('/')}>View Site</button>
</div>
<div className="admin-grid">
{/* Create Drop Form */}
<div className="admin-section">
<h2>Create New Drop</h2>
<form onSubmit={handleSubmit} className="admin-form">
<div className="form-group">
<label htmlFor="item">Product Name</label>
<input
type="text"
id="item"
value={formData.item}
onChange={(e) =>
setFormData({ ...formData, item: e.target.value })
}
required
placeholder="e.g. Harlequin Collective Drop"
/>
</div>
<div className="form-grid">
<div className="form-group">
<label htmlFor="size">Batch Size</label>
<input
type="number"
id="size"
value={formData.size}
onChange={(e) =>
setFormData({ ...formData, size: e.target.value })
}
required
placeholder="1000"
min="1"
/>
</div>
<div className="form-group">
<label htmlFor="unit">Unit</label>
<input
type="text"
id="unit"
value={formData.unit}
onChange={(e) =>
setFormData({ ...formData, unit: e.target.value })
}
placeholder="g, kg, ml, etc."
required
maxLength={12}
/>
</div>
</div>
<div className="form-group">
<label htmlFor="ppu">Price Per Unit (in cents, e.g., 2500 = 2.50 CHF)</label>
<input
type="number"
id="ppu"
value={formData.ppu}
onChange={(e) =>
setFormData({ ...formData, ppu: e.target.value })
}
required
placeholder="2500"
step="1"
min="1"
/>
<p className="file-hint" style={{ marginTop: '8px', fontSize: '12px' }}>
Enter price in smallest currency unit. 1000 = 1.00 CHF, 2500 = 2.50 CHF, 10 = 0.01 CHF
</p>
</div>
<div className="form-group">
<label htmlFor="startTime">Start Time (optional)</label>
<input
type="datetime-local"
id="startTime"
value={formData.startTime}
onChange={(e) =>
setFormData({ ...formData, startTime: e.target.value })
}
/>
<p className="file-hint" style={{ marginTop: '8px', fontSize: '12px' }}>
When should this drop become available? Leave empty to make it available immediately.
</p>
</div>
<div className="form-group">
<label htmlFor="imageFile">Product Image (optional)</label>
<input
type="file"
id="imageFile"
accept="image/jpeg,image/jpg,image/png,image/webp"
onChange={handleImageChange}
className="file-input"
/>
{formData.imagePreview && (
<div className="image-preview">
<img src={formData.imagePreview} alt="Preview" />
</div>
)}
<p className="file-hint">
Max file size: 5MB. Allowed formats: JPEG, PNG, WebP
</p>
</div>
<button
type="submit"
disabled={submitting || uploadingImage}
className="cta"
style={{ width: '100%', marginTop: '20px' }}
>
{uploadingImage
? 'Uploading image...'
: submitting
? 'Creating...'
: 'Create Drop'}
</button>
</form>
</div>
{/* Drops List */}
<div className="admin-section">
<h2>All Drops</h2>
{loading ? (
<p style={{ color: 'var(--muted)' }}>Loading...</p>
) : !Array.isArray(drops) || drops.length === 0 ? (
<p style={{ color: 'var(--muted)' }}>No drops yet</p>
) : (
<div className="drops-list">
{drops.map((drop) => (
<div
key={drop.id}
className="drop-card"
style={{
background: isSoldOut(drop.fill, drop.size)
? 'var(--bg-soft)'
: 'var(--card)',
opacity: isSoldOut(drop.fill, drop.size) ? 0.7 : 1,
}}
>
<div className="drop-card-header">
<div className="drop-card-info">
<h3>{drop.item}</h3>
<p>
ID: {drop.id} · Created:{' '}
{new Date(drop.created_at).toLocaleDateString()}
</p>
</div>
{isSoldOut(drop.fill, drop.size) && (
<span className="sold-out-badge">Sold Out</span>
)}
</div>
<div className="drop-card-progress">
<div className="drop-card-progress-header">
<span style={{ color: 'var(--muted)' }}>
{drop.fill}
{drop.unit} / {drop.size}
{drop.unit}
</span>
<span>
{getProgressPercentage(drop.fill, drop.size)}%
</span>
</div>
<div className="progress">
<span
style={{
width: `${getProgressPercentage(
drop.fill,
drop.size
)}%`,
}}
/>
</div>
</div>
<div className="drop-card-price">
{(drop.ppu / 1000).toFixed(2)} CHF / {drop.unit}
</div>
</div>
))}
</div>
)}
</div>
{/* Buyers Section */}
<div className="admin-section">
<h2>Buyers</h2>
{buyersLoading ? (
<p style={{ color: 'var(--muted)' }}>Loading...</p>
) : buyers.length === 0 ? (
<p style={{ color: 'var(--muted)' }}>No buyers yet</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{buyers.map((buyer) => (
<div
key={buyer.id}
className="drop-card"
style={{
background: editingBuyer?.id === buyer.id ? 'var(--bg-soft)' : 'var(--card)',
}}
>
{editingBuyer?.id === buyer.id ? (
<div style={{ padding: '16px' }}>
<div className="form-group" style={{ marginBottom: '12px' }}>
<label>Username</label>
<input
type="text"
value={buyerFormData.username}
onChange={(e) =>
setBuyerFormData({ ...buyerFormData, username: e.target.value })
}
style={{
width: '100%',
padding: '8px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--bg-soft)',
color: 'var(--text)',
}}
/>
</div>
<div className="form-group" style={{ marginBottom: '12px' }}>
<label>Email</label>
<input
type="email"
value={buyerFormData.email}
onChange={(e) =>
setBuyerFormData({ ...buyerFormData, email: e.target.value })
}
style={{
width: '100%',
padding: '8px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--bg-soft)',
color: 'var(--text)',
}}
/>
</div>
<div className="form-group" style={{ marginBottom: '12px' }}>
<label>New Password (leave empty to keep current)</label>
<input
type="password"
value={buyerFormData.password}
onChange={(e) =>
setBuyerFormData({ ...buyerFormData, password: e.target.value })
}
style={{
width: '100%',
padding: '8px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--bg-soft)',
color: 'var(--text)',
}}
/>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button
onClick={handleSaveBuyer}
className="cta"
style={{ padding: '8px 16px', fontSize: '14px' }}
>
Save
</button>
<button
onClick={() => setEditingBuyer(null)}
style={{
padding: '8px 16px',
fontSize: '14px',
background: 'transparent',
border: '1px solid var(--border)',
borderRadius: '8px',
color: 'var(--text)',
cursor: 'pointer',
}}
>
Cancel
</button>
</div>
</div>
) : (
<div className="drop-card-header">
<div className="drop-card-info">
<h3>{buyer.username}</h3>
<p style={{ color: 'var(--muted)', fontSize: '14px' }}>
{buyer.email} · ID: {buyer.id}
</p>
{buyer.created_at && (
<p style={{ color: 'var(--muted)', fontSize: '12px' }}>
Joined: {new Date(buyer.created_at).toLocaleDateString()}
</p>
)}
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button
onClick={() => handleEditBuyer(buyer)}
style={{
padding: '6px 12px',
fontSize: '12px',
background: 'transparent',
border: '1px solid var(--border)',
borderRadius: '6px',
color: 'var(--text)',
cursor: 'pointer',
}}
>
Edit
</button>
<button
onClick={() => handleDeleteBuyer(buyer.id)}
style={{
padding: '6px 12px',
fontSize: '12px',
background: '#dc2626',
border: 'none',
borderRadius: '6px',
color: '#fff',
cursor: 'pointer',
}}
>
Delete
</button>
</div>
</div>
)}
</div>
))}
</div>
)}
</div>
{/* Sales Section */}
<div className="admin-section">
<h2>Sales</h2>
{salesLoading ? (
<p style={{ color: 'var(--muted)' }}>Loading...</p>
) : sales.length === 0 ? (
<p style={{ color: 'var(--muted)' }}>No sales yet</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{sales.map((sale) => (
<div
key={sale.id}
className="drop-card"
style={{
background: editingSale?.id === sale.id ? 'var(--bg-soft)' : 'var(--card)',
}}
>
{editingSale?.id === sale.id ? (
<div style={{ padding: '16px' }}>
<div className="form-group" style={{ marginBottom: '12px' }}>
<label>Drop ID</label>
<input
type="number"
value={saleFormData.drop_id}
onChange={(e) =>
setSaleFormData({ ...saleFormData, drop_id: e.target.value })
}
style={{
width: '100%',
padding: '8px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--bg-soft)',
color: 'var(--text)',
}}
/>
</div>
<div className="form-group" style={{ marginBottom: '12px' }}>
<label>Buyer ID</label>
<input
type="number"
value={saleFormData.buyer_id}
onChange={(e) =>
setSaleFormData({ ...saleFormData, buyer_id: e.target.value })
}
style={{
width: '100%',
padding: '8px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--bg-soft)',
color: 'var(--text)',
}}
/>
</div>
<div className="form-group" style={{ marginBottom: '12px' }}>
<label>Size (grams)</label>
<input
type="number"
value={saleFormData.size}
onChange={(e) =>
setSaleFormData({ ...saleFormData, size: e.target.value })
}
style={{
width: '100%',
padding: '8px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--bg-soft)',
color: 'var(--text)',
}}
/>
</div>
<div className="form-group" style={{ marginBottom: '12px' }}>
<label>Payment ID</label>
<input
type="text"
value={saleFormData.payment_id}
onChange={(e) =>
setSaleFormData({ ...saleFormData, payment_id: e.target.value })
}
style={{
width: '100%',
padding: '8px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--bg-soft)',
color: 'var(--text)',
}}
/>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button
onClick={handleSaveSale}
className="cta"
style={{ padding: '8px 16px', fontSize: '14px' }}
>
Save
</button>
<button
onClick={() => setEditingSale(null)}
style={{
padding: '8px 16px',
fontSize: '14px',
background: 'transparent',
border: '1px solid var(--border)',
borderRadius: '8px',
color: 'var(--text)',
cursor: 'pointer',
}}
>
Cancel
</button>
</div>
</div>
) : (
<div className="drop-card-header">
<div className="drop-card-info">
<h3>Sale #{sale.id}</h3>
<p style={{ color: 'var(--muted)', fontSize: '14px' }}>
{sale.drop_item || `Drop #${sale.drop_id}`} · {sale.size}g
</p>
<p style={{ color: 'var(--muted)', fontSize: '14px' }}>
Buyer: {sale.buyer_username || `#${sale.buyer_id}`} ({sale.buyer_email || 'N/A'})
</p>
{sale.drop_ppu && (
<p style={{ color: 'var(--muted)', fontSize: '14px' }}>
Price: {((sale.drop_ppu / 1000) * sale.size).toFixed(2)} CHF
</p>
)}
<p style={{ color: 'var(--muted)', fontSize: '12px' }}>
{new Date(sale.created_at).toLocaleString()}
{sale.payment_id && ` · Payment: ${sale.payment_id}`}
</p>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button
onClick={() => handleEditSale(sale)}
style={{
padding: '6px 12px',
fontSize: '12px',
background: 'transparent',
border: '1px solid var(--border)',
borderRadius: '6px',
color: 'var(--text)',
cursor: 'pointer',
}}
>
Edit
</button>
<button
onClick={() => handleDeleteSale(sale.id)}
style={{
padding: '6px 12px',
fontSize: '12px',
background: '#dc2626',
border: 'none',
borderRadius: '6px',
color: '#fff',
cursor: 'pointer',
}}
>
Delete
</button>
</div>
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
</div>
</div>
)
}