diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 9b5ad3c..9e6d309 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -13,12 +13,39 @@ interface Drop { 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([]) + const [buyers, setBuyers] = useState([]) + const [sales, setSales] = useState([]) 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: '', @@ -26,10 +53,24 @@ export default function AdminPage() { 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 () => { @@ -101,8 +142,9 @@ export default function AdminPage() { item: formData.item, size: parseInt(formData.size), unit: formData.unit.trim() || 'g', - ppu: parseFloat(formData.ppu), + ppu: parseInt(formData.ppu, 10), imageUrl: imageUrl, + startTime: formData.startTime || null, }), }) @@ -115,6 +157,7 @@ export default function AdminPage() { ppu: '', imageFile: null, imagePreview: '', + startTime: '', }) // Clear file input const fileInput = document.getElementById('imageFile') as HTMLInputElement @@ -143,6 +186,188 @@ export default function AdminPage() { 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 (
@@ -203,7 +428,7 @@ export default function AdminPage() {
- + +

+ 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. +

@@ -309,13 +552,334 @@ export default function AdminPage() {
- {drop.ppu.toFixed(2)} CHF / {drop.unit} + {(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/api/buyers/[id]/route.ts b/app/api/buyers/[id]/route.ts new file mode 100644 index 0000000..1d713b1 --- /dev/null +++ b/app/api/buyers/[id]/route.ts @@ -0,0 +1,194 @@ +import { NextRequest, NextResponse } from 'next/server' +import pool from '@/lib/db' +import bcrypt from 'bcrypt' + +// GET /api/buyers/[id] - Get a specific buyer +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 buyer ID' }, + { status: 400 } + ) + } + + const [rows] = await pool.execute( + 'SELECT id, username, email, created_at FROM buyers WHERE id = ?', + [id] + ) + + const buyers = rows as any[] + if (buyers.length === 0) { + return NextResponse.json( + { error: 'Buyer not found' }, + { status: 404 } + ) + } + + return NextResponse.json(buyers[0]) + } catch (error) { + console.error('Error fetching buyer:', error) + return NextResponse.json( + { error: 'Failed to fetch buyer' }, + { status: 500 } + ) + } +} + +// PUT /api/buyers/[id] - Update a buyer +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 buyer ID' }, + { status: 400 } + ) + } + + const body = await request.json() + const { username, email, password } = body + + // Check if buyer exists + const [existingRows] = await pool.execute( + 'SELECT id FROM buyers WHERE id = ?', + [id] + ) + const existing = existingRows as any[] + if (existing.length === 0) { + return NextResponse.json( + { error: 'Buyer not found' }, + { status: 404 } + ) + } + + // Build update query dynamically based on provided fields + const updates: string[] = [] + const values: any[] = [] + + if (username !== undefined) { + // Check if username already exists (excluding current buyer) + const [usernameCheck] = await pool.execute( + 'SELECT id FROM buyers WHERE username = ? AND id != ?', + [username, id] + ) + if ((usernameCheck as any[]).length > 0) { + return NextResponse.json( + { error: 'Username already exists' }, + { status: 400 } + ) + } + updates.push('username = ?') + values.push(username) + } + + if (email !== undefined) { + // Validate email format + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + if (!emailRegex.test(email)) { + return NextResponse.json( + { error: 'Invalid email format' }, + { status: 400 } + ) + } + // Check if email already exists (excluding current buyer) + const [emailCheck] = await pool.execute( + 'SELECT id FROM buyers WHERE email = ? AND id != ?', + [email, id] + ) + if ((emailCheck as any[]).length > 0) { + return NextResponse.json( + { error: 'Email already exists' }, + { status: 400 } + ) + } + updates.push('email = ?') + values.push(email) + } + + if (password !== undefined) { + if (password.length < 6) { + return NextResponse.json( + { error: 'Password must be at least 6 characters' }, + { status: 400 } + ) + } + const hashedPassword = await bcrypt.hash(password, 10) + updates.push('password = ?') + values.push(hashedPassword) + } + + if (updates.length === 0) { + return NextResponse.json( + { error: 'No fields to update' }, + { status: 400 } + ) + } + + values.push(id) + const query = `UPDATE buyers SET ${updates.join(', ')} WHERE id = ?` + await pool.execute(query, values) + + // Fetch updated buyer + const [rows] = await pool.execute( + 'SELECT id, username, email, created_at FROM buyers WHERE id = ?', + [id] + ) + + return NextResponse.json((rows as any[])[0]) + } catch (error) { + console.error('Error updating buyer:', error) + return NextResponse.json( + { error: 'Failed to update buyer' }, + { status: 500 } + ) + } +} + +// DELETE /api/buyers/[id] - Delete a buyer +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 buyer ID' }, + { status: 400 } + ) + } + + // Check if buyer exists + const [existingRows] = await pool.execute( + 'SELECT id FROM buyers WHERE id = ?', + [id] + ) + const existing = existingRows as any[] + if (existing.length === 0) { + return NextResponse.json( + { error: 'Buyer not found' }, + { status: 404 } + ) + } + + // Delete buyer (cascade will handle related sales) + await pool.execute('DELETE FROM buyers WHERE id = ?', [id]) + + return NextResponse.json({ success: true }) + } catch (error) { + console.error('Error deleting buyer:', error) + return NextResponse.json( + { error: 'Failed to delete buyer' }, + { status: 500 } + ) + } +} + diff --git a/app/api/buyers/route.ts b/app/api/buyers/route.ts new file mode 100644 index 0000000..0d93533 --- /dev/null +++ b/app/api/buyers/route.ts @@ -0,0 +1,19 @@ +import { NextRequest, NextResponse } from 'next/server' +import pool from '@/lib/db' + +// GET /api/buyers - Get all buyers +export async function GET(request: NextRequest) { + try { + const [rows] = await pool.execute( + 'SELECT id, username, email, created_at FROM buyers ORDER BY created_at DESC' + ) + return NextResponse.json(rows) + } catch (error) { + console.error('Error fetching buyers:', error) + return NextResponse.json( + { error: 'Failed to fetch buyers' }, + { status: 500 } + ) + } +} + diff --git a/app/api/drops/active/route.ts b/app/api/drops/active/route.ts index dbd55e2..e4ce7a3 100644 --- a/app/api/drops/active/route.ts +++ b/app/api/drops/active/route.ts @@ -1,18 +1,45 @@ import { NextResponse } from 'next/server' import pool from '@/lib/db' -// GET /api/drops/active - Get the earliest unfilled drop (not sold out) +// GET /api/drops/active - Get the earliest unfilled drop (not sold out) that has started export async function GET() { try { - // Get all drops ordered by creation date + const now = new Date() + + // Get all drops ordered by start_time (or created_at if start_time is null) const [rows] = await pool.execute( - 'SELECT * FROM drops ORDER BY created_at ASC' + 'SELECT * FROM drops ORDER BY COALESCE(start_time, created_at) ASC' ) const drops = rows as any[] - // Find the first drop that's not fully sold out + // Find the first drop that's not fully sold out and has started for (const drop of drops) { + // Check if drop has started (start_time is in the past or null) + const startTime = drop.start_time ? new Date(drop.start_time) : new Date(drop.created_at) + if (startTime > now) { + // Drop hasn't started yet - return it with a flag indicating it's upcoming + // Calculate fill (will be 0 for upcoming drops) + const [salesRows] = await pool.execute( + 'SELECT COALESCE(SUM(size), 0) as total_fill FROM sales WHERE drop_id = ?', + [drop.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, + is_upcoming: true, + start_time: drop.start_time || drop.created_at, + }) + } + // Calculate fill from sales records // Sales are stored in grams, so we need to convert based on drop unit const [salesRows] = await pool.execute( @@ -34,6 +61,8 @@ export async function GET() { return NextResponse.json({ ...drop, fill: fill, + is_upcoming: false, + start_time: drop.start_time || drop.created_at, }) } } diff --git a/app/api/drops/route.ts b/app/api/drops/route.ts index 807ab30..df1c542 100644 --- a/app/api/drops/route.ts +++ b/app/api/drops/route.ts @@ -48,7 +48,7 @@ export async function GET(request: NextRequest) { export async function POST(request: NextRequest) { try { const body = await request.json() - const { item, size, unit = 'g', ppu, imageUrl } = body + const { item, size, unit = 'g', ppu, imageUrl, startTime } = body // Validate required fields if (!item || !size || !ppu) { @@ -63,8 +63,8 @@ export async function POST(request: NextRequest) { // ALTER TABLE drops ADD COLUMN image_url VARCHAR(255) DEFAULT NULL AFTER unit; // Note: fill is no longer stored, it's calculated from sales const [result] = await pool.execute( - 'INSERT INTO drops (item, size, unit, ppu, image_url) VALUES (?, ?, ?, ?, ?)', - [item, size, unit, ppu, imageUrl || null] + 'INSERT INTO drops (item, size, unit, ppu, image_url, start_time) VALUES (?, ?, ?, ?, ?, ?)', + [item, size, unit, ppu, imageUrl || null, startTime || null] ) const insertId = (result as any).insertId diff --git a/app/api/payments/create-invoice/route.ts b/app/api/payments/create-invoice/route.ts index 3de0b9e..4c77221 100644 --- a/app/api/payments/create-invoice/route.ts +++ b/app/api/payments/create-invoice/route.ts @@ -71,11 +71,13 @@ export async function POST(request: NextRequest) { } // Calculate price + // ppu is stored as integer where 1000 = $1.00, so divide by 1000 to get actual price + const pricePerUnit = drop.ppu / 1000 let priceAmount = 0 if (drop.unit === 'kg') { - priceAmount = (size / 1000) * drop.ppu + priceAmount = (size / 1000) * pricePerUnit } else { - priceAmount = size * drop.ppu + priceAmount = size * pricePerUnit } // Round to 2 decimal places diff --git a/app/api/sales/[id]/route.ts b/app/api/sales/[id]/route.ts new file mode 100644 index 0000000..bcc05ee --- /dev/null +++ b/app/api/sales/[id]/route.ts @@ -0,0 +1,219 @@ +import { NextRequest, NextResponse } from 'next/server' +import pool from '@/lib/db' + +// GET /api/sales/[id] - Get a specific sale +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 sale 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 + FROM sales s + LEFT JOIN drops d ON s.drop_id = d.id + LEFT JOIN buyers b ON s.buyer_id = b.id + WHERE s.id = ?`, + [id] + ) + + const sales = rows as any[] + if (sales.length === 0) { + return NextResponse.json( + { error: 'Sale not found' }, + { status: 404 } + ) + } + + return NextResponse.json(sales[0]) + } catch (error) { + console.error('Error fetching sale:', error) + return NextResponse.json( + { error: 'Failed to fetch sale' }, + { status: 500 } + ) + } +} + +// PUT /api/sales/[id] - Update a sale +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 sale ID' }, + { status: 400 } + ) + } + + const body = await request.json() + const { drop_id, buyer_id, size, payment_id } = body + + // Check if sale exists + const [existingRows] = await pool.execute( + 'SELECT id FROM sales WHERE id = ?', + [id] + ) + const existing = existingRows as any[] + if (existing.length === 0) { + return NextResponse.json( + { error: 'Sale not found' }, + { status: 404 } + ) + } + + // Build update query dynamically + const updates: string[] = [] + const values: any[] = [] + + if (drop_id !== undefined) { + // Verify drop exists + const [dropCheck] = await pool.execute( + 'SELECT id FROM drops WHERE id = ?', + [drop_id] + ) + if ((dropCheck as any[]).length === 0) { + return NextResponse.json( + { error: 'Drop not found' }, + { status: 400 } + ) + } + updates.push('drop_id = ?') + values.push(drop_id) + } + + if (buyer_id !== undefined) { + // Verify buyer exists + const [buyerCheck] = await pool.execute( + 'SELECT id FROM buyers WHERE id = ?', + [buyer_id] + ) + if ((buyerCheck as any[]).length === 0) { + return NextResponse.json( + { error: 'Buyer not found' }, + { status: 400 } + ) + } + updates.push('buyer_id = ?') + values.push(buyer_id) + } + + 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 (payment_id !== undefined) { + updates.push('payment_id = ?') + values.push(payment_id) + } + + if (updates.length === 0) { + return NextResponse.json( + { error: 'No fields to update' }, + { status: 400 } + ) + } + + values.push(id) + const query = `UPDATE sales SET ${updates.join(', ')} WHERE id = ?` + await pool.execute(query, values) + + // Fetch updated sale + 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 + FROM sales s + LEFT JOIN drops d ON s.drop_id = d.id + LEFT JOIN buyers b ON s.buyer_id = b.id + WHERE s.id = ?`, + [id] + ) + + return NextResponse.json((rows as any[])[0]) + } catch (error) { + console.error('Error updating sale:', error) + return NextResponse.json( + { error: 'Failed to update sale' }, + { status: 500 } + ) + } +} + +// DELETE /api/sales/[id] - Delete a sale +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 sale ID' }, + { status: 400 } + ) + } + + // Check if sale exists + const [existingRows] = await pool.execute( + 'SELECT id FROM sales WHERE id = ?', + [id] + ) + const existing = existingRows as any[] + if (existing.length === 0) { + return NextResponse.json( + { error: 'Sale not found' }, + { status: 404 } + ) + } + + // Delete sale + await pool.execute('DELETE FROM sales WHERE id = ?', [id]) + + return NextResponse.json({ success: true }) + } catch (error) { + console.error('Error deleting sale:', error) + return NextResponse.json( + { error: 'Failed to delete sale' }, + { status: 500 } + ) + } +} + diff --git a/app/api/sales/list/route.ts b/app/api/sales/list/route.ts new file mode 100644 index 0000000..68b55a7 --- /dev/null +++ b/app/api/sales/list/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from 'next/server' +import pool from '@/lib/db' + +// GET /api/sales/list - Get all sales with related data +export async function GET(request: NextRequest) { + try { + 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 + FROM sales s + LEFT JOIN drops d ON s.drop_id = d.id + LEFT JOIN buyers b ON s.buyer_id = b.id + ORDER BY s.created_at DESC` + ) + return NextResponse.json(rows) + } catch (error) { + console.error('Error fetching sales:', error) + return NextResponse.json( + { error: 'Failed to fetch sales' }, + { status: 500 } + ) + } +} + diff --git a/app/components/Drop.tsx b/app/components/Drop.tsx index 59ebb5f..936c423 100644 --- a/app/components/Drop.tsx +++ b/app/components/Drop.tsx @@ -13,6 +13,8 @@ interface DropData { ppu: number image_url: string | null created_at: string + start_time: string | null + is_upcoming?: boolean } interface User { @@ -167,10 +169,34 @@ export default function Drop() { const calculatePrice = () => { if (!drop) return 0 + // ppu is stored as integer where 1000 = $1.00, so divide by 1000 to get actual price + const pricePerUnit = drop.ppu / 1000 if (drop.unit === 'kg') { - return (selectedSize / 1000) * drop.ppu + return (selectedSize / 1000) * pricePerUnit + } + return selectedSize * pricePerUnit + } + + const getTimeUntilStart = () => { + if (!drop || !drop.is_upcoming || !drop.start_time) return null + + const startTime = new Date(drop.start_time) + const now = new Date() + const diffMs = startTime.getTime() - now.getTime() + + if (diffMs <= 0) return null + + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)) + const diffHours = Math.floor((diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)) + const diffMinutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60)) + + if (diffDays > 0) { + return `${diffDays} day${diffDays > 1 ? 's' : ''}${diffHours > 0 ? ` ${diffHours} hour${diffHours > 1 ? 's' : ''}` : ''}` + } else if (diffHours > 0) { + return `${diffHours} hour${diffHours > 1 ? 's' : ''}${diffMinutes > 0 ? ` ${diffMinutes} minute${diffMinutes > 1 ? 's' : ''}` : ''}` + } else { + return `${diffMinutes} minute${diffMinutes > 1 ? 's' : ''}` } - return selectedSize * drop.ppu } if (loading) { @@ -201,6 +227,8 @@ export default function Drop() { const progressPercentage = getProgressPercentage(drop.fill, drop.size) const availableSizes = getAvailableSizes() + const timeUntilStart = getTimeUntilStart() + const isUpcoming = drop.is_upcoming && timeUntilStart // Calculate remaining in the drop's unit const remaining = drop.size - drop.fill @@ -238,19 +266,29 @@ export default function Drop() { {formatSize(drop.size, drop.unit)} Batch
- {drop.ppu.toFixed(2)} CHF / {drop.unit} · incl. 2.5% VAT + {(drop.ppu / 1000).toFixed(2)} CHF / {drop.unit} · incl. 2.5% VAT
-
- -
-
- {drop.unit === 'kg' ? drop.fill.toFixed(2) : Math.round(drop.fill)} - {drop.unit} of {drop.size} - {drop.unit} reserved -
+ {isUpcoming ? ( +
+

+ Drop starts in {timeUntilStart} +

+
+ ) : ( + <> +
+ +
+
+ {drop.unit === 'kg' ? drop.fill.toFixed(2) : Math.round(drop.fill)} + {drop.unit} of {drop.size} + {drop.unit} reserved +
+ + )} - {hasRemaining && availableSizes.length > 0 && ( + {!isUpcoming && hasRemaining && availableSizes.length > 0 && ( <>
{availableSizes.map((size) => ( @@ -325,7 +363,7 @@ export default function Drop() { Quantity: {selectedSize}g

- Price per {drop.unit}: {drop.ppu.toFixed(2)} CHF + Price per {drop.unit}: {(drop.ppu / 1000).toFixed(2)} CHF