From 5e65144934077c30fdcb837ca524e299b719e885 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 21 Dec 2025 11:39:41 +0100 Subject: [PATCH] notification + admin panel --- app/admin/buyers/page.tsx | 321 ++++++++ app/admin/drops/page.tsx | 846 +++++++++++++++++++ app/admin/login/page.tsx | 116 +++ app/admin/page.tsx | 980 +++-------------------- app/admin/sales/page.tsx | 384 +++++++++ app/api/admin/check/route.ts | 12 + app/api/admin/login/route.ts | 33 + app/api/admin/logout/route.ts | 16 + app/api/drops/[id]/route.ts | 215 +++++ app/api/notifications/subscribe/route.ts | 76 ++ app/api/sales/drop/[dropId]/route.ts | 51 ++ app/api/sales/list/route.ts | 6 +- app/components/Signup.tsx | 149 +++- cbd420.sql | 43 +- lib/admin-auth.ts | 58 ++ 15 files changed, 2423 insertions(+), 883 deletions(-) create mode 100644 app/admin/buyers/page.tsx create mode 100644 app/admin/drops/page.tsx create mode 100644 app/admin/login/page.tsx create mode 100644 app/admin/sales/page.tsx create mode 100644 app/api/admin/check/route.ts create mode 100644 app/api/admin/login/route.ts create mode 100644 app/api/admin/logout/route.ts create mode 100644 app/api/drops/[id]/route.ts create mode 100644 app/api/notifications/subscribe/route.ts create mode 100644 app/api/sales/drop/[dropId]/route.ts create mode 100644 lib/admin-auth.ts diff --git a/app/admin/buyers/page.tsx b/app/admin/buyers/page.tsx new file mode 100644 index 0000000..13c75ac --- /dev/null +++ b/app/admin/buyers/page.tsx @@ -0,0 +1,321 @@ +'use client' + +import { useState, useEffect } from 'react' +import { useRouter } from 'next/navigation' + +interface Buyer { + id: number + username: string + email: string + created_at?: string +} + +export default function BuyersManagementPage() { + const router = useRouter() + const [buyers, setBuyers] = useState([]) + const [loading, setLoading] = useState(true) + const [authenticated, setAuthenticated] = useState(false) + const [editingBuyer, setEditingBuyer] = useState(null) + const [formData, setFormData] = useState({ + username: '', + email: '', + password: '', + }) + + useEffect(() => { + // Check authentication + fetch('/api/admin/check') + .then((res) => res.json()) + .then((data) => { + if (data.authenticated) { + setAuthenticated(true) + fetchBuyers() + } else { + router.push('/admin/login') + } + }) + .catch(() => { + router.push('/admin/login') + }) + }, [router]) + + const fetchBuyers = async () => { + 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) + } finally { + setLoading(false) + } + } + + const handleEdit = (buyer: Buyer) => { + setEditingBuyer(buyer) + setFormData({ + username: buyer.username, + email: buyer.email, + password: '', + }) + } + + const handleSave = async () => { + if (!editingBuyer) return + + try { + const updateData: any = {} + if (formData.username !== editingBuyer.username) { + updateData.username = formData.username + } + if (formData.email !== editingBuyer.email) { + updateData.email = formData.email + } + if (formData.password) { + updateData.password = formData.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 handleDelete = 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') + } + } + + if (loading) { + return ( +
+

Loading...

+
+ ) + } + + if (!authenticated) { + return null + } + + return ( +
+
+
+

Buyer Management

+ +
+ + {buyers.length === 0 ? ( +

No buyers found

+ ) : ( +
+ {buyers.map((buyer) => ( +
+ {editingBuyer?.id === buyer.id ? ( +
+
+
+ + setFormData({ ...formData, username: e.target.value })} + style={{ + width: '100%', + padding: '8px', + borderRadius: '8px', + border: '1px solid var(--border)', + background: 'var(--bg-soft)', + color: 'var(--text)' + }} + /> +
+
+ + setFormData({ ...formData, email: e.target.value })} + style={{ + width: '100%', + padding: '8px', + borderRadius: '8px', + border: '1px solid var(--border)', + background: 'var(--bg-soft)', + color: 'var(--text)' + }} + /> +
+
+ + setFormData({ ...formData, password: e.target.value })} + style={{ + width: '100%', + padding: '8px', + borderRadius: '8px', + border: '1px solid var(--border)', + background: 'var(--bg-soft)', + color: 'var(--text)' + }} + /> +
+
+
+ + +
+
+ ) : ( +
+
+

{buyer.username}

+

+ Email: {buyer.email} +

+

+ ID: {buyer.id} +

+ {buyer.created_at && ( +

+ Created: {new Date(buyer.created_at).toLocaleString()} +

+ )} +
+
+ + +
+
+ )} +
+ ))} +
+ )} +
+
+ ) +} + diff --git a/app/admin/drops/page.tsx b/app/admin/drops/page.tsx new file mode 100644 index 0000000..3bd1cc5 --- /dev/null +++ b/app/admin/drops/page.tsx @@ -0,0 +1,846 @@ +'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 + image_url: string | null + created_at: string + start_time: string | null +} + +interface Sale { + id: number + drop_id: number + buyer_id: number + size: number + payment_id: string | null + created_at: string + buyer_username?: string + buyer_email?: string + buyer_fullname?: string + buyer_address?: string + buyer_phone?: string +} + +export default function DropsManagementPage() { + const router = useRouter() + const [drops, setDrops] = useState([]) + const [loading, setLoading] = useState(true) + const [authenticated, setAuthenticated] = useState(false) + const [editingDrop, setEditingDrop] = useState(null) + const [creatingDrop, setCreatingDrop] = useState(false) + const [showSalesPopup, setShowSalesPopup] = useState(false) + const [salesForDrop, setSalesForDrop] = useState([]) + const [selectedDropId, setSelectedDropId] = useState(null) + const [formData, setFormData] = useState({ + item: '', + size: '', + unit: 'g', + ppu: '', + imageUrl: '', + startTime: '', + }) + const [imageFile, setImageFile] = useState(null) + const [imagePreview, setImagePreview] = useState('') + const [uploadingImage, setUploadingImage] = useState(false) + + useEffect(() => { + // Check authentication + fetch('/api/admin/check') + .then((res) => res.json()) + .then((data) => { + if (data.authenticated) { + setAuthenticated(true) + fetchDrops() + } else { + router.push('/admin/login') + } + }) + .catch(() => { + router.push('/admin/login') + }) + }, [router]) + + const fetchDrops = async () => { + try { + const response = await fetch('/api/drops') + if (response.ok) { + const data = await response.json() + setDrops(Array.isArray(data) ? data : []) + } + } catch (error) { + console.error('Error fetching drops:', error) + } finally { + setLoading(false) + } + } + + const handleEdit = (drop: Drop) => { + setEditingDrop(drop) + setFormData({ + item: drop.item, + size: drop.size.toString(), + unit: drop.unit, + ppu: drop.ppu.toString(), + imageUrl: drop.image_url || '', + startTime: drop.start_time ? new Date(drop.start_time).toISOString().slice(0, 16) : '', + }) + } + + const handleSave = async () => { + if (!editingDrop) return + + try { + const response = await fetch(`/api/drops/${editingDrop.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + item: formData.item, + size: parseInt(formData.size), + unit: formData.unit, + ppu: parseInt(formData.ppu), + imageUrl: formData.imageUrl || null, + startTime: formData.startTime || null, + }), + }) + + if (response.ok) { + alert('Drop updated successfully') + setEditingDrop(null) + fetchDrops() + } else { + const error = await response.json() + alert(`Error: ${error.error}`) + } + } catch (error) { + console.error('Error updating drop:', error) + alert('Failed to update drop') + } + } + + const handleDelete = async (id: number) => { + if (!confirm('Are you sure you want to delete this drop? This will also delete all related sales.')) { + return + } + + try { + const response = await fetch(`/api/drops/${id}`, { + method: 'DELETE', + }) + + if (response.ok) { + alert('Drop deleted successfully') + fetchDrops() + } else { + const error = await response.json() + alert(`Error: ${error.error}`) + } + } catch (error) { + console.error('Error deleting drop:', error) + alert('Failed to delete drop') + } + } + + const handleImageChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (file) { + setImageFile(file) + setImagePreview(URL.createObjectURL(file)) + // Clear the imageUrl field when a file is selected + setFormData({ ...formData, imageUrl: '' }) + } + } + + const handleCreate = async (e: React.FormEvent) => { + e.preventDefault() + + try { + let imageUrl = formData.imageUrl || null + + // Upload image file if provided + if (imageFile) { + setUploadingImage(true) + const uploadFormData = new FormData() + uploadFormData.append('file', 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) + return + } + + const uploadData = await uploadResponse.json() + imageUrl = uploadData.url + setUploadingImage(false) + } + + 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, + ppu: parseInt(formData.ppu), + imageUrl: imageUrl, + startTime: formData.startTime || null, + }), + }) + + if (response.ok) { + alert('Drop created successfully') + setFormData({ + item: '', + size: '', + unit: 'g', + ppu: '', + imageUrl: '', + startTime: '', + }) + setImageFile(null) + setImagePreview('') + // Clear file input + const fileInput = document.getElementById('imageFile') as HTMLInputElement + if (fileInput) fileInput.value = '' + setCreatingDrop(false) + fetchDrops() + } else { + const error = await response.json() + alert(`Error: ${error.error}`) + } + } catch (error) { + console.error('Error creating drop:', error) + alert('Failed to create drop') + } finally { + setUploadingImage(false) + } + } + + const handleViewSales = async (dropId: number) => { + try { + const response = await fetch(`/api/sales/drop/${dropId}`) + if (response.ok) { + const data = await response.json() + setSalesForDrop(Array.isArray(data) ? data : []) + setSelectedDropId(dropId) + setShowSalesPopup(true) + } else { + alert('Failed to fetch sales') + } + } catch (error) { + console.error('Error fetching sales:', error) + alert('Failed to fetch sales') + } + } + + const getProgressPercentage = (fill: number, size: number) => { + return Math.min((fill / size) * 100, 100) + } + + if (loading) { + return ( +
+

Loading...

+
+ ) + } + + if (!authenticated) { + return null + } + + return ( +
+
+
+

Drops Management

+
+ + +
+
+ + {creatingDrop && ( +
+

Create New Drop

+
+
+
+ + setFormData({ ...formData, item: e.target.value })} + required + style={{ + width: '100%', + padding: '8px', + borderRadius: '8px', + border: '1px solid var(--border)', + background: 'var(--bg-soft)', + color: 'var(--text)' + }} + /> +
+
+ + setFormData({ ...formData, size: e.target.value })} + required + min="1" + style={{ + width: '100%', + padding: '8px', + borderRadius: '8px', + border: '1px solid var(--border)', + background: 'var(--bg-soft)', + color: 'var(--text)' + }} + /> +
+
+ + setFormData({ ...formData, unit: e.target.value })} + required + maxLength={12} + placeholder="g, kg, ml, etc." + style={{ + width: '100%', + padding: '8px', + borderRadius: '8px', + border: '1px solid var(--border)', + background: 'var(--bg-soft)', + color: 'var(--text)' + }} + /> +
+
+ + setFormData({ ...formData, ppu: e.target.value })} + required + min="1" + placeholder="2500 = 2.50 CHF" + style={{ + width: '100%', + padding: '8px', + borderRadius: '8px', + border: '1px solid var(--border)', + background: 'var(--bg-soft)', + color: 'var(--text)' + }} + /> +
+
+ + + {imagePreview && ( +
+ Preview +
+ )} +

+ Max file size: 5MB. Allowed formats: JPEG, PNG, WebP +

+

+ Or enter an image URL: +

+ { + setFormData({ ...formData, imageUrl: e.target.value }) + // Clear file selection when URL is entered + if (e.target.value) { + setImageFile(null) + setImagePreview('') + const fileInput = document.getElementById('imageFile') as HTMLInputElement + if (fileInput) fileInput.value = '' + } + }} + placeholder="https://example.com/image.jpg" + style={{ + width: '100%', + padding: '8px', + borderRadius: '8px', + border: '1px solid var(--border)', + background: 'var(--bg-soft)', + color: 'var(--text)', + marginTop: '8px' + }} + /> +
+
+ + setFormData({ ...formData, startTime: e.target.value })} + style={{ + width: '100%', + padding: '8px', + borderRadius: '8px', + border: '1px solid var(--border)', + background: 'var(--bg-soft)', + color: 'var(--text)' + }} + /> +
+
+ +
+
+ )} + + {drops.length === 0 ? ( +

No drops found

+ ) : ( +
+ {drops.map((drop) => ( +
+ {editingDrop?.id === drop.id ? ( +
+
+
+ + setFormData({ ...formData, item: e.target.value })} + style={{ + width: '100%', + padding: '8px', + borderRadius: '8px', + border: '1px solid var(--border)', + background: 'var(--bg-soft)', + color: 'var(--text)' + }} + /> +
+
+ + setFormData({ ...formData, size: e.target.value })} + style={{ + width: '100%', + padding: '8px', + borderRadius: '8px', + border: '1px solid var(--border)', + background: 'var(--bg-soft)', + color: 'var(--text)' + }} + /> +
+
+ + setFormData({ ...formData, unit: e.target.value })} + maxLength={12} + style={{ + width: '100%', + padding: '8px', + borderRadius: '8px', + border: '1px solid var(--border)', + background: 'var(--bg-soft)', + color: 'var(--text)' + }} + /> +
+
+ + setFormData({ ...formData, ppu: e.target.value })} + style={{ + width: '100%', + padding: '8px', + borderRadius: '8px', + border: '1px solid var(--border)', + background: 'var(--bg-soft)', + color: 'var(--text)' + }} + /> +
+
+ + setFormData({ ...formData, imageUrl: e.target.value })} + style={{ + width: '100%', + padding: '8px', + borderRadius: '8px', + border: '1px solid var(--border)', + background: 'var(--bg-soft)', + color: 'var(--text)' + }} + /> +
+
+ + setFormData({ ...formData, startTime: e.target.value })} + style={{ + width: '100%', + padding: '8px', + borderRadius: '8px', + border: '1px solid var(--border)', + background: 'var(--bg-soft)', + color: 'var(--text)' + }} + /> +
+
+
+ + +
+
+ ) : ( +
+
+
+

{drop.item}

+

+ ID: {drop.id} · Size: {drop.size}{drop.unit} · Price: {(drop.ppu / 1000).toFixed(2)} CHF / {drop.unit} +

+

+ Created: {new Date(drop.created_at).toLocaleString()} + {drop.start_time && ` · Start: ${new Date(drop.start_time).toLocaleString()}`} +

+ + {/* Fill Bar */} +
+ +
+

+ {drop.unit === 'kg' ? drop.fill.toFixed(2) : Math.round(drop.fill)}{drop.unit} of {drop.size}{drop.unit} reserved +

+ + {drop.image_url && ( + {drop.item} + )} +
+
+ + + +
+
+
+ )} +
+ ))} +
+ )} + + {/* Sales Popup */} + {showSalesPopup && ( +
setShowSalesPopup(false)} + > +
e.stopPropagation()} + > +
+

Sales for Drop #{selectedDropId}

+ +
+ + {salesForDrop.length === 0 ? ( +

+ No sales found for this drop +

+ ) : ( +
+ {salesForDrop.map((sale) => ( +
+
+
+

+ Sale #{sale.id} +

+

+ Buyer: {sale.buyer_username || `#${sale.buyer_id}`} ({sale.buyer_email || 'N/A'}) +

+

+ Size: {sale.size}g +

+ {(sale.buyer_fullname || sale.buyer_address || sale.buyer_phone) && ( +
+

+ Delivery Information: +

+ {sale.buyer_fullname && ( +

+ Name: {sale.buyer_fullname} +

+ )} + {sale.buyer_address && ( +

+ Address: {sale.buyer_address} +

+ )} + {sale.buyer_phone && ( +

+ Phone: {sale.buyer_phone} +

+ )} +
+ )} +

+ {new Date(sale.created_at).toLocaleString()} + {sale.payment_id && ` · Payment ID: ${sale.payment_id}`} +

+
+
+
+ ))} +
+ )} +
+
+ )} +
+
+ ) +} + diff --git a/app/admin/login/page.tsx b/app/admin/login/page.tsx new file mode 100644 index 0000000..80f5ed9 --- /dev/null +++ b/app/admin/login/page.tsx @@ -0,0 +1,116 @@ +'use client' + +import { useState, useEffect } from 'react' +import { useRouter } from 'next/navigation' + +export default function AdminLoginPage() { + const router = useRouter() + const [password, setPassword] = useState('') + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + + useEffect(() => { + // Check if already authenticated + fetch('/api/admin/check') + .then((res) => res.json()) + .then((data) => { + if (data.authenticated) { + router.push('/admin') + } + }) + }, [router]) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError('') + setLoading(true) + + try { + const response = await fetch('/api/admin/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ password }), + }) + + const data = await response.json() + + if (response.ok) { + router.push('/admin') + } else { + setError(data.error || 'Invalid password') + } + } catch (error) { + setError('Failed to login. Please try again.') + } finally { + setLoading(false) + } + } + + return ( +
+
+

Admin Login

+
+
+ + setPassword(e.target.value)} + required + style={{ + width: '100%', + padding: '12px', + borderRadius: '8px', + border: '1px solid var(--border)', + background: 'var(--bg-soft)', + color: 'var(--text)', + fontSize: '16px' + }} + /> +
+ {error && ( +
+ {error} +
+ )} + +
+
+
+ ) +} + diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 9e6d309..7020a81 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -3,886 +3,150 @@ 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() { +export default function AdminDashboardPage() { const router = useRouter() - const [drops, setDrops] = useState([]) - const [buyers, setBuyers] = useState([]) - const [sales, setSales] = useState([]) + const [authenticated, setAuthenticated] = useState(false) 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(null) - const [editingSale, setEditingSale] = useState(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) => { - 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 + // Check authentication + fetch('/api/admin/check') + .then((res) => res.json()) + .then((data) => { + if (data.authenticated) { + setAuthenticated(true) + } else { + router.push('/admin/login') } - - 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), + .catch(() => { + router.push('/admin/login') }) - - 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', + .finally(() => { + setLoading(false) }) + }, [router]) - 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 handleLogout = async () => { + await fetch('/api/admin/logout', { method: 'POST' }) + router.push('/admin/login') } - 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 || '', - }) + if (loading) { + return ( +
+

Loading...

+
+ ) } - 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') - } + if (!authenticated) { + return null } return ( -
-
-
-

Admin Panel

- +
+
+
+

Admin Dashboard

+
+ + +
-
- {/* Create Drop Form */} -
-

Create New Drop

-
-
- - - setFormData({ ...formData, item: e.target.value }) - } - required - placeholder="e.g. Harlequin – Collective Drop" - /> -
+
+ -
-
- - - setFormData({ ...formData, size: e.target.value }) - } - required - placeholder="1000" - min="1" - /> -
+ -
- - - setFormData({ ...formData, unit: e.target.value }) - } - placeholder="g, kg, ml, etc." - required - maxLength={12} - /> -
-
- -
- - - setFormData({ ...formData, ppu: e.target.value }) - } - required - placeholder="2500" - step="1" - min="1" - /> -

- Enter price in smallest currency unit. 1000 = 1.00 CHF, 2500 = 2.50 CHF, 10 = 0.01 CHF -

-
- -
- - - setFormData({ ...formData, startTime: e.target.value }) - } - /> -

- When should this drop become available? Leave empty to make it available immediately. -

-
- -
- - - {formData.imagePreview && ( -
- Preview -
- )} -

- Max file size: 5MB. Allowed formats: JPEG, PNG, WebP -

-
- - - -
- - {/* Drops List */} -
-

All Drops

- {loading ? ( -

Loading...

- ) : !Array.isArray(drops) || drops.length === 0 ? ( -

No drops yet

- ) : ( -
- {drops.map((drop) => ( -
-
-
-

{drop.item}

-

- ID: {drop.id} · Created:{' '} - {new Date(drop.created_at).toLocaleDateString()} -

-
- {isSoldOut(drop.fill, drop.size) && ( - Sold Out - )} -
- -
-
- - {drop.fill} - {drop.unit} / {drop.size} - {drop.unit} - - - {getProgressPercentage(drop.fill, drop.size)}% - -
-
- -
-
- -
- {(drop.ppu / 1000).toFixed(2)} CHF / {drop.unit} -
-
- ))} -
- )} -
- - {/* Buyers Section */} -
-

Buyers

- {buyersLoading ? ( -

Loading...

- ) : buyers.length === 0 ? ( -

No buyers yet

- ) : ( -
- {buyers.map((buyer) => ( -
- {editingBuyer?.id === buyer.id ? ( -
-
- - - 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)', - }} - /> -
-
- - - 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)', - }} - /> -
-
- - - 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)', - }} - /> -
-
- - -
-
- ) : ( -
-
-

{buyer.username}

-

- {buyer.email} · ID: {buyer.id} -

- {buyer.created_at && ( -

- Joined: {new Date(buyer.created_at).toLocaleDateString()} -

- )} -
-
- - -
-
- )} -
- ))} -
- )} -
- - {/* Sales Section */} -
-

Sales

- {salesLoading ? ( -

Loading...

- ) : sales.length === 0 ? ( -

No sales yet

- ) : ( -
- {sales.map((sale) => ( -
- {editingSale?.id === sale.id ? ( -
-
- - - 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)', - }} - /> -
-
- - - 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)', - }} - /> -
-
- - - 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)', - }} - /> -
-
- - - 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)', - }} - /> -
-
- - -
-
- ) : ( -
-
-

Sale #{sale.id}

-

- {sale.drop_item || `Drop #${sale.drop_id}`} · {sale.size}g -

-

- Buyer: {sale.buyer_username || `#${sale.buyer_id}`} ({sale.buyer_email || 'N/A'}) -

- {sale.drop_ppu && ( -

- Price: {((sale.drop_ppu / 1000) * sale.size).toFixed(2)} CHF -

- )} -

- {new Date(sale.created_at).toLocaleString()} - {sale.payment_id && ` · Payment: ${sale.payment_id}`} -

-
-
- - -
-
- )} -
- ))} -
- )} -
+
) } - diff --git a/app/admin/sales/page.tsx b/app/admin/sales/page.tsx new file mode 100644 index 0000000..7fd37f5 --- /dev/null +++ b/app/admin/sales/page.tsx @@ -0,0 +1,384 @@ +'use client' + +import { useState, useEffect } from 'react' +import { useRouter } from 'next/navigation' + +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 + buyer_fullname?: string + buyer_address?: string + buyer_phone?: string +} + +export default function SalesManagementPage() { + const router = useRouter() + const [sales, setSales] = useState([]) + const [loading, setLoading] = useState(true) + const [authenticated, setAuthenticated] = useState(false) + const [editingSale, setEditingSale] = useState(null) + const [formData, setFormData] = useState({ + drop_id: '', + buyer_id: '', + size: '', + payment_id: '', + }) + + useEffect(() => { + // Check authentication + fetch('/api/admin/check') + .then((res) => res.json()) + .then((data) => { + if (data.authenticated) { + setAuthenticated(true) + fetchSales() + } else { + router.push('/admin/login') + } + }) + .catch(() => { + router.push('/admin/login') + }) + }, [router]) + + const fetchSales = async () => { + 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) + } finally { + setLoading(false) + } + } + + const handleEdit = (sale: Sale) => { + setEditingSale(sale) + setFormData({ + drop_id: sale.drop_id.toString(), + buyer_id: sale.buyer_id.toString(), + size: sale.size.toString(), + payment_id: sale.payment_id || '', + }) + } + + const handleSave = async () => { + if (!editingSale) return + + try { + const updateData: any = {} + if (parseInt(formData.drop_id) !== editingSale.drop_id) { + updateData.drop_id = parseInt(formData.drop_id) + } + if (parseInt(formData.buyer_id) !== editingSale.buyer_id) { + updateData.buyer_id = parseInt(formData.buyer_id) + } + if (parseInt(formData.size) !== editingSale.size) { + updateData.size = parseInt(formData.size) + } + if (formData.payment_id !== (editingSale.payment_id || '')) { + updateData.payment_id = formData.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() + } else { + const error = await response.json() + alert(`Error: ${error.error}`) + } + } catch (error) { + console.error('Error updating sale:', error) + alert('Failed to update sale') + } + } + + const handleDelete = 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() + } else { + const error = await response.json() + alert(`Error: ${error.error}`) + } + } catch (error) { + console.error('Error deleting sale:', error) + alert('Failed to delete sale') + } + } + + if (loading) { + return ( +
+

Loading...

+
+ ) + } + + if (!authenticated) { + return null + } + + return ( +
+
+
+

Sales Management

+ +
+ + {sales.length === 0 ? ( +

No sales found

+ ) : ( +
+ {sales.map((sale) => ( +
+ {editingSale?.id === sale.id ? ( +
+
+
+ + setFormData({ ...formData, drop_id: e.target.value })} + style={{ + width: '100%', + padding: '8px', + borderRadius: '8px', + border: '1px solid var(--border)', + background: 'var(--bg-soft)', + color: 'var(--text)' + }} + /> +
+
+ + setFormData({ ...formData, buyer_id: e.target.value })} + style={{ + width: '100%', + padding: '8px', + borderRadius: '8px', + border: '1px solid var(--border)', + background: 'var(--bg-soft)', + color: 'var(--text)' + }} + /> +
+
+ + setFormData({ ...formData, size: e.target.value })} + style={{ + width: '100%', + padding: '8px', + borderRadius: '8px', + border: '1px solid var(--border)', + background: 'var(--bg-soft)', + color: 'var(--text)' + }} + /> +
+
+ + setFormData({ ...formData, payment_id: e.target.value })} + style={{ + width: '100%', + padding: '8px', + borderRadius: '8px', + border: '1px solid var(--border)', + background: 'var(--bg-soft)', + color: 'var(--text)' + }} + /> +
+
+
+ + +
+
+ ) : ( +
+
+

Sale #{sale.id}

+

+ Drop: {sale.drop_item || `#${sale.drop_id}`} · Size: {sale.size}g +

+

+ Buyer: {sale.buyer_username || `#${sale.buyer_id}`} ({sale.buyer_email || 'N/A'}) +

+ {(sale.buyer_fullname || sale.buyer_address || sale.buyer_phone) && ( +
+

+ Delivery Information: +

+ {sale.buyer_fullname && ( +

+ Name: {sale.buyer_fullname} +

+ )} + {sale.buyer_address && ( +

+ Address: {sale.buyer_address} +

+ )} + {sale.buyer_phone && ( +

+ Phone: {sale.buyer_phone} +

+ )} +
+ )} + {sale.drop_ppu && ( +

+ Price: {((sale.drop_ppu / 1000) * sale.size).toFixed(2)} CHF +

+ )} +

+ Created: {new Date(sale.created_at).toLocaleString()} + {sale.payment_id && ` · Payment: ${sale.payment_id}`} +

+
+
+ + +
+
+ )} +
+ ))} +
+ )} +
+
+ ) +} + diff --git a/app/api/admin/check/route.ts b/app/api/admin/check/route.ts new file mode 100644 index 0000000..c61d0e8 --- /dev/null +++ b/app/api/admin/check/route.ts @@ -0,0 +1,12 @@ +import { NextRequest, NextResponse } from 'next/server' +import { isAdminAuthenticated } from '@/lib/admin-auth' + +export async function GET(request: NextRequest) { + try { + const authenticated = await isAdminAuthenticated() + return NextResponse.json({ authenticated }) + } catch (error) { + return NextResponse.json({ authenticated: false }) + } +} + diff --git a/app/api/admin/login/route.ts b/app/api/admin/login/route.ts new file mode 100644 index 0000000..e05f9c6 --- /dev/null +++ b/app/api/admin/login/route.ts @@ -0,0 +1,33 @@ +import { NextRequest, NextResponse } from 'next/server' +import { verifyAdminPassword, setAdminSession } from '@/lib/admin-auth' + +export async function POST(request: NextRequest) { + try { + const body = await request.json() + const { password } = body + + if (!password) { + return NextResponse.json( + { error: 'Password is required' }, + { status: 400 } + ) + } + + if (verifyAdminPassword(password)) { + await setAdminSession() + return NextResponse.json({ success: true }) + } else { + return NextResponse.json( + { error: 'Invalid password' }, + { status: 401 } + ) + } + } catch (error) { + console.error('Error during admin login:', error) + return NextResponse.json( + { error: 'Failed to process login' }, + { status: 500 } + ) + } +} + diff --git a/app/api/admin/logout/route.ts b/app/api/admin/logout/route.ts new file mode 100644 index 0000000..0be6cb7 --- /dev/null +++ b/app/api/admin/logout/route.ts @@ -0,0 +1,16 @@ +import { NextRequest, NextResponse } from 'next/server' +import { clearAdminSession } from '@/lib/admin-auth' + +export async function POST(request: NextRequest) { + try { + await clearAdminSession() + return NextResponse.json({ success: true }) + } catch (error) { + console.error('Error during admin logout:', error) + return NextResponse.json( + { error: 'Failed to process logout' }, + { status: 500 } + ) + } +} + diff --git a/app/api/drops/[id]/route.ts b/app/api/drops/[id]/route.ts new file mode 100644 index 0000000..75e2c4a --- /dev/null +++ b/app/api/drops/[id]/route.ts @@ -0,0 +1,215 @@ +import { NextRequest, NextResponse } from 'next/server' +import pool from '@/lib/db' + +// GET /api/drops/[id] - Get a specific drop +export async function GET( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const id = parseInt(params.id, 10) + if (isNaN(id)) { + return NextResponse.json( + { error: 'Invalid drop ID' }, + { status: 400 } + ) + } + + const [rows] = await pool.execute( + 'SELECT * FROM drops WHERE id = ?', + [id] + ) + + const drops = rows as any[] + if (drops.length === 0) { + return NextResponse.json( + { error: 'Drop not found' }, + { status: 404 } + ) + } + + const drop = drops[0] + + // Calculate fill from sales + const [salesRows] = await pool.execute( + 'SELECT COALESCE(SUM(size), 0) as total_fill FROM sales WHERE drop_id = ?', + [id] + ) + const salesData = salesRows as any[] + const totalFillInGrams = salesData[0]?.total_fill || 0 + + let fill = totalFillInGrams + if (drop.unit === 'kg') { + fill = totalFillInGrams / 1000 + } + + return NextResponse.json({ + ...drop, + fill: fill, + }) + } catch (error) { + console.error('Error fetching drop:', error) + return NextResponse.json( + { error: 'Failed to fetch drop' }, + { status: 500 } + ) + } +} + +// PUT /api/drops/[id] - Update a drop +export async function PUT( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const id = parseInt(params.id, 10) + if (isNaN(id)) { + return NextResponse.json( + { error: 'Invalid drop ID' }, + { status: 400 } + ) + } + + const body = await request.json() + const { item, size, unit, ppu, imageUrl, startTime } = body + + // Check if drop exists + const [existingRows] = await pool.execute( + 'SELECT id FROM drops WHERE id = ?', + [id] + ) + const existing = existingRows as any[] + if (existing.length === 0) { + return NextResponse.json( + { error: 'Drop not found' }, + { status: 404 } + ) + } + + // Build update query dynamically + const updates: string[] = [] + const values: any[] = [] + + if (item !== undefined) { + updates.push('item = ?') + values.push(item) + } + + if (size !== undefined) { + if (size <= 0) { + return NextResponse.json( + { error: 'Size must be greater than 0' }, + { status: 400 } + ) + } + updates.push('size = ?') + values.push(size) + } + + if (unit !== undefined) { + updates.push('unit = ?') + values.push(unit) + } + + if (ppu !== undefined) { + if (ppu <= 0) { + return NextResponse.json( + { error: 'Price per unit must be greater than 0' }, + { status: 400 } + ) + } + updates.push('ppu = ?') + values.push(ppu) + } + + if (imageUrl !== undefined) { + updates.push('image_url = ?') + values.push(imageUrl || null) + } + + if (startTime !== undefined) { + updates.push('start_time = ?') + values.push(startTime || null) + } + + if (updates.length === 0) { + return NextResponse.json( + { error: 'No fields to update' }, + { status: 400 } + ) + } + + values.push(id) + const query = `UPDATE drops SET ${updates.join(', ')} WHERE id = ?` + await pool.execute(query, values) + + // Fetch updated drop + const [rows] = await pool.execute('SELECT * FROM drops WHERE id = ?', [id]) + const drop = rows[0] as any + + // Calculate fill + const [salesRows] = await pool.execute( + 'SELECT COALESCE(SUM(size), 0) as total_fill FROM sales WHERE drop_id = ?', + [id] + ) + const salesData = salesRows as any[] + const totalFillInGrams = salesData[0]?.total_fill || 0 + + let fill = totalFillInGrams + if (drop.unit === 'kg') { + fill = totalFillInGrams / 1000 + } + + return NextResponse.json({ + ...drop, + fill: fill, + }) + } catch (error) { + console.error('Error updating drop:', error) + return NextResponse.json( + { error: 'Failed to update drop' }, + { status: 500 } + ) + } +} + +// DELETE /api/drops/[id] - Delete a drop +export async function DELETE( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const id = parseInt(params.id, 10) + if (isNaN(id)) { + return NextResponse.json( + { error: 'Invalid drop ID' }, + { status: 400 } + ) + } + + // Check if drop exists + const [existingRows] = await pool.execute( + 'SELECT id FROM drops WHERE id = ?', + [id] + ) + const existing = existingRows as any[] + if (existing.length === 0) { + return NextResponse.json( + { error: 'Drop not found' }, + { status: 404 } + ) + } + + // Delete drop (cascade will handle related sales) + await pool.execute('DELETE FROM drops WHERE id = ?', [id]) + + return NextResponse.json({ success: true }) + } catch (error) { + console.error('Error deleting drop:', error) + return NextResponse.json( + { error: 'Failed to delete drop' }, + { status: 500 } + ) + } +} + diff --git a/app/api/notifications/subscribe/route.ts b/app/api/notifications/subscribe/route.ts new file mode 100644 index 0000000..9edbab2 --- /dev/null +++ b/app/api/notifications/subscribe/route.ts @@ -0,0 +1,76 @@ +import { NextRequest, NextResponse } from 'next/server' +import { cookies } from 'next/headers' +import pool from '@/lib/db' + +// POST /api/notifications/subscribe - Subscribe to notifications +export async function POST(request: NextRequest) { + try { + // Get buyer_id from session cookie if logged in + const cookieStore = await cookies() + const buyerIdCookie = cookieStore.get('buyer_id')?.value + const buyer_id = buyerIdCookie ? parseInt(buyerIdCookie, 10) : null + + const body = await request.json() + const { email, phone } = body + + // Validate that at least one field is provided + if (!email && !phone) { + return NextResponse.json( + { error: 'Email or phone number is required' }, + { status: 400 } + ) + } + + // Validate email format if provided + if (email) { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + if (!emailRegex.test(email)) { + return NextResponse.json( + { error: 'Invalid email format' }, + { status: 400 } + ) + } + } + + // Validate phone format if provided (basic validation) + if (phone) { + const phoneRegex = /^[+]?[\d\s\-()]{10,15}$/ + if (!phoneRegex.test(phone)) { + return NextResponse.json( + { error: 'Invalid phone number format' }, + { status: 400 } + ) + } + } + + // Insert email subscription if provided + // Using INSERT IGNORE to handle duplicate addresses (address is now primary key) + if (email) { + await pool.execute( + 'INSERT IGNORE INTO notification_subscribers (buyer_id, type, address) VALUES (?, ?, ?)', + [buyer_id, 'email', email.trim()] + ) + } + + // Insert phone subscription if provided + // Using INSERT IGNORE to handle duplicate addresses (address is now primary key) + if (phone) { + await pool.execute( + 'INSERT IGNORE INTO notification_subscribers (buyer_id, type, address) VALUES (?, ?, ?)', + [buyer_id, 'phone', phone.trim()] + ) + } + + return NextResponse.json( + { success: true, message: 'Successfully subscribed to notifications' }, + { status: 200 } + ) + } catch (error) { + console.error('Error subscribing to notifications:', error) + return NextResponse.json( + { error: 'Failed to subscribe to notifications' }, + { status: 500 } + ) + } +} + diff --git a/app/api/sales/drop/[dropId]/route.ts b/app/api/sales/drop/[dropId]/route.ts new file mode 100644 index 0000000..5e892a3 --- /dev/null +++ b/app/api/sales/drop/[dropId]/route.ts @@ -0,0 +1,51 @@ +import { NextRequest, NextResponse } from 'next/server' +import pool from '@/lib/db' + +// GET /api/sales/drop/[dropId] - Get all sales for a specific drop +export async function GET( + request: NextRequest, + { params }: { params: { dropId: string } } +) { + try { + const dropId = parseInt(params.dropId, 10) + if (isNaN(dropId)) { + return NextResponse.json( + { error: 'Invalid drop ID' }, + { status: 400 } + ) + } + + const [rows] = await pool.execute( + `SELECT + s.id, + s.drop_id, + s.buyer_id, + s.size, + s.payment_id, + s.created_at, + d.item as drop_item, + d.unit as drop_unit, + d.ppu as drop_ppu, + b.username as buyer_username, + b.email as buyer_email, + bd.fullname as buyer_fullname, + bd.address as buyer_address, + bd.phone as buyer_phone + FROM sales s + LEFT JOIN drops d ON s.drop_id = d.id + LEFT JOIN buyers b ON s.buyer_id = b.id + LEFT JOIN buyer_data bd ON s.buyer_data_id = bd.id + WHERE s.drop_id = ? + ORDER BY s.created_at DESC`, + [dropId] + ) + return NextResponse.json(rows) + } catch (error) { + console.error('Error fetching sales for drop:', error) + return NextResponse.json( + { error: 'Failed to fetch sales' }, + { status: 500 } + ) + } +} + diff --git a/app/api/sales/list/route.ts b/app/api/sales/list/route.ts index 68b55a7..3ccf70c 100644 --- a/app/api/sales/list/route.ts +++ b/app/api/sales/list/route.ts @@ -16,10 +16,14 @@ export async function GET(request: NextRequest) { d.unit as drop_unit, d.ppu as drop_ppu, b.username as buyer_username, - b.email as buyer_email + b.email as buyer_email, + bd.fullname as buyer_fullname, + bd.address as buyer_address, + bd.phone as buyer_phone FROM sales s LEFT JOIN drops d ON s.drop_id = d.id LEFT JOIN buyers b ON s.buyer_id = b.id + LEFT JOIN buyer_data bd ON s.buyer_data_id = bd.id ORDER BY s.created_at DESC` ) return NextResponse.json(rows) diff --git a/app/components/Signup.tsx b/app/components/Signup.tsx index 8dee68f..bd6036f 100644 --- a/app/components/Signup.tsx +++ b/app/components/Signup.tsx @@ -5,34 +5,139 @@ import { useState } from 'react' export default function Signup() { const [email, setEmail] = useState('') const [whatsapp, setWhatsapp] = useState('') + const [loading, setLoading] = useState(false) + const [showPopup, setShowPopup] = useState(false) + const [error, setError] = useState('') - const handleSubmit = (e: React.FormEvent) => { + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() - // Handle form submission - console.log('Form submitted', { email, whatsapp }) + setError('') + + // Validate that at least one field is filled + if (!email.trim() && !whatsapp.trim()) { + setError('Please enter at least an email or phone number') + return + } + + setLoading(true) + + try { + const response = await fetch('/api/notifications/subscribe', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + email: email.trim(), + phone: whatsapp.trim(), + }), + }) + + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to subscribe') + } + + // Show success popup + setShowPopup(true) + + // Clear form + setEmail('') + setWhatsapp('') + + // Hide popup after 5 seconds + setTimeout(() => { + setShowPopup(false) + }, 5000) + } catch (err) { + setError(err instanceof Error ? err.message : 'An error occurred') + } finally { + setLoading(false) + } } return ( -
-

Drop Notifications

-

Receive updates about new drops via email or WhatsApp.

-
- setEmail(e.target.value)} + <> +
+

Drop Notifications

+

Receive updates about new drops via email or WhatsApp.

+ + setEmail(e.target.value)} + disabled={loading} + /> + setWhatsapp(e.target.value)} + disabled={loading} + /> +
+ {error &&
{error}
} + + +
+ + {showPopup && ( +
+

+ You will receive a notification as soon as a new drop drops. +

+ +
+ )} + + {showPopup && ( +
setShowPopup(false)} /> - setWhatsapp(e.target.value)} - /> -
- - -
+ )} + ) } diff --git a/cbd420.sql b/cbd420.sql index 229bd16..6546b2a 100644 --- a/cbd420.sql +++ b/cbd420.sql @@ -3,7 +3,7 @@ -- https://www.phpmyadmin.net/ -- -- Host: localhost:3306 --- Generation Time: Dec 21, 2025 at 09:44 AM +-- Generation Time: Dec 21, 2025 at 10:29 AM -- Server version: 10.11.14-MariaDB-0+deb12u2 -- PHP Version: 8.2.29 @@ -82,6 +82,19 @@ CREATE TABLE `drops` ( -- -------------------------------------------------------- +-- +-- Table structure for table `notification_subscribers` +-- + +CREATE TABLE `notification_subscribers` ( + `id` int(11) NOT NULL, + `buyer_id` int(11) DEFAULT NULL, + `type` text NOT NULL DEFAULT 'email', + `address` text NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +-- -------------------------------------------------------- + -- -- Table structure for table `pending_orders` -- @@ -142,7 +155,8 @@ ALTER TABLE `buyers` -- Indexes for table `buyer_data` -- ALTER TABLE `buyer_data` - ADD PRIMARY KEY (`id`); + ADD PRIMARY KEY (`id`), + ADD KEY `buyer_id` (`buyer_id`); -- -- Indexes for table `deliveries` @@ -157,6 +171,13 @@ ALTER TABLE `deliveries` ALTER TABLE `drops` ADD PRIMARY KEY (`id`); +-- +-- Indexes for table `notification_subscribers` +-- +ALTER TABLE `notification_subscribers` + ADD PRIMARY KEY (`id`), + ADD KEY `buyer_id` (`buyer_id`); + -- -- Indexes for table `pending_orders` -- @@ -214,6 +235,12 @@ ALTER TABLE `deliveries` ALTER TABLE `drops` MODIFY `id` int(11) NOT NULL AUTO_INCREMENT; +-- +-- AUTO_INCREMENT for table `notification_subscribers` +-- +ALTER TABLE `notification_subscribers` + MODIFY `id` int(11) NOT NULL AUTO_INCREMENT; + -- -- AUTO_INCREMENT for table `pending_orders` -- @@ -236,12 +263,24 @@ ALTER TABLE `sales` -- Constraints for dumped tables -- +-- +-- Constraints for table `buyer_data` +-- +ALTER TABLE `buyer_data` + ADD CONSTRAINT `buyer_data_ibfk_1` FOREIGN KEY (`buyer_id`) REFERENCES `buyers` (`id`); + -- -- Constraints for table `deliveries` -- ALTER TABLE `deliveries` ADD CONSTRAINT `deliveries_ibfk_1` FOREIGN KEY (`sale_id`) REFERENCES `sales` (`id`) ON DELETE CASCADE ON UPDATE CASCADE; +-- +-- Constraints for table `notification_subscribers` +-- +ALTER TABLE `notification_subscribers` + ADD CONSTRAINT `notification_subscribers_ibfk_1` FOREIGN KEY (`buyer_id`) REFERENCES `buyers` (`id`); + -- -- Constraints for table `pending_orders` -- diff --git a/lib/admin-auth.ts b/lib/admin-auth.ts new file mode 100644 index 0000000..4f06f8e --- /dev/null +++ b/lib/admin-auth.ts @@ -0,0 +1,58 @@ +import { cookies } from 'next/headers' + +const ADMIN_PASSWORD = 'HelloWorld' +const ADMIN_SESSION_COOKIE = 'admin_session' + +// Check if admin is authenticated +export async function isAdminAuthenticated(): Promise { + try { + const cookieStore = await cookies() + const session = cookieStore.get(ADMIN_SESSION_COOKIE)?.value + return session === 'authenticated' + } catch (error) { + return false + } +} + +// Verify admin password +export function verifyAdminPassword(password: string): boolean { + return password === ADMIN_PASSWORD +} + +// Set admin session +export async function setAdminSession(): Promise { + const cookieStore = await cookies() + cookieStore.set(ADMIN_SESSION_COOKIE, 'authenticated', { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 60 * 60 * 24, // 24 hours + }) +} + +// Clear admin session +export async function clearAdminSession(): Promise { + const cookieStore = await cookies() + cookieStore.delete(ADMIN_SESSION_COOKIE) +} + +// Get admin session from request (for API routes) +export function getAdminSessionFromRequest(request: Request): boolean { + try { + const cookieHeader = request.headers.get('cookie') + if (!cookieHeader) { + return false + } + + const cookies = cookieHeader.split(';').reduce((acc, cookie) => { + const [key, value] = cookie.trim().split('=') + acc[key] = value + return acc + }, {} as Record) + + return cookies[ADMIN_SESSION_COOKIE] === 'authenticated' + } catch (error) { + return false + } +} +