diff --git a/README.md b/README.md index ff2c843..bf5e048 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,22 @@ DB_PORT=3306 DB_USER=root DB_PASSWORD=your_password DB_NAME=cbd420 + +# NOWPayments Configuration +# For testnet/sandbox testing: +NOWPAYMENTS_TESTNET=true +NOWPAYMENTS_SANDBOX_API_KEY=your_sandbox_api_key_here +NOWPAYMENTS_CURRENCY=usd # Sandbox doesn't support CHF, use USD or other supported currency +# For production: +# NOWPAYMENTS_TESTNET=false +# NOWPAYMENTS_API_KEY=your_production_api_key_here +# NOWPAYMENTS_CURRENCY=chf # Default is CHF for production + +# IPN Callback URL (your external Node.js service that handles IPN callbacks) +IPN_CALLBACK_URL=http://your-ipn-service.com/api/payments/ipn-callback + +# Base URL for success/cancel redirects (use your domain in production) +NEXT_PUBLIC_BASE_URL=http://localhost:3420 ``` ### Installation @@ -57,11 +73,52 @@ Access the admin panel at `/admin` to: - **Product Image**: Optional product image upload (JPEG, PNG, WebP, max 5MB) 3. Click "Create Drop" +## Payment Integration (NOWPayments) + +### Testnet/Sandbox Setup + +1. **Create a Sandbox Account**: Register at [https://sandbox.nowpayments.io/](https://sandbox.nowpayments.io/) + +2. **Generate Sandbox API Key**: + - Log in to your sandbox dashboard + - Navigate to **Settings** > **Payments** > **API keys** + - Generate a test API key + +3. **Configure Environment Variables**: + ```env + NOWPAYMENTS_TESTNET=true + NOWPAYMENTS_SANDBOX_API_KEY=your_sandbox_api_key_here + ``` + +4. **Run Pending Orders Migration**: + ```bash + mysql -u root -p cbd420 < migrations/create_pending_orders.sql + ``` + +5. **Test Payments**: + - Create test payments through the application + - Payments will use the sandbox environment + - No real money will be charged + +### Production Setup + +1. **Get Production API Key** from [NOWPayments Dashboard](https://nowpayments.io/) + +2. **Update Environment Variables**: + ```env + NOWPAYMENTS_TESTNET=false + NOWPAYMENTS_API_KEY=your_production_api_key_here + NEXT_PUBLIC_BASE_URL=https://yourdomain.com + ``` + ## Project Structure - `app/` - Next.js app directory - `api/drops/` - API routes for drop management + - `api/payments/` - Payment integration endpoints - `admin/` - Admin panel page - `components/` - React components - `lib/db.ts` - Database connection pool +- `lib/nowpayments.ts` - NOWPayments API configuration - `cbd420.sql` - Database schema +- `migrations/` - Database migration files diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts new file mode 100644 index 0000000..556928a --- /dev/null +++ b/app/api/auth/login/route.ts @@ -0,0 +1,74 @@ +import { NextRequest, NextResponse } from 'next/server' +import pool from '@/lib/db' +import bcrypt from 'bcrypt' + +// POST /api/auth/login - Login with username and password +export async function POST(request: NextRequest) { + try { + const body = await request.json() + const { username, password } = body + + // Validate required fields + if (!username || !password) { + return NextResponse.json( + { error: 'Username and password are required' }, + { status: 400 } + ) + } + + // Find user by username + const [rows] = await pool.execute( + 'SELECT * FROM buyers WHERE username = ?', + [username] + ) + + const buyers = rows as any[] + if (buyers.length === 0) { + return NextResponse.json( + { error: 'Invalid username or password' }, + { status: 401 } + ) + } + + const buyer = buyers[0] + + // Verify password + const isValidPassword = await bcrypt.compare(password, buyer.password) + if (!isValidPassword) { + return NextResponse.json( + { error: 'Invalid username or password' }, + { status: 401 } + ) + } + + // Create session cookie + const response = NextResponse.json( + { + user: { + id: buyer.id, + username: buyer.username, + email: buyer.email, + }, + }, + { status: 200 } + ) + + // Set secure cookie with buyer_id + response.cookies.set('buyer_id', buyer.id.toString(), { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 60 * 60 * 24 * 7, // 7 days + path: '/', + }) + + return response + } catch (error) { + console.error('Error during login:', error) + return NextResponse.json( + { error: 'Failed to login' }, + { status: 500 } + ) + } +} + diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts new file mode 100644 index 0000000..5b36c21 --- /dev/null +++ b/app/api/auth/logout/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from 'next/server' + +// POST /api/auth/logout - Logout and clear session +export async function POST() { + const response = NextResponse.json({ success: true }, { status: 200 }) + + // Clear the session cookie + response.cookies.set('buyer_id', '', { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 0, + path: '/', + }) + + return response +} + diff --git a/app/api/auth/register/route.ts b/app/api/auth/register/route.ts new file mode 100644 index 0000000..2105cd0 --- /dev/null +++ b/app/api/auth/register/route.ts @@ -0,0 +1,117 @@ +import { NextRequest, NextResponse } from 'next/server' +import pool from '@/lib/db' +import bcrypt from 'bcrypt' + +// POST /api/auth/register - Register a new buyer +export async function POST(request: NextRequest) { + try { + const body = await request.json() + const { username, password, email } = body + + // Validate required fields + if (!username || !password || !email) { + return NextResponse.json( + { error: 'Username, password, and email are required' }, + { status: 400 } + ) + } + + // Validate username length + if (username.length < 3) { + return NextResponse.json( + { error: 'Username must be at least 3 characters' }, + { status: 400 } + ) + } + + // Validate password length + if (password.length < 6) { + return NextResponse.json( + { error: 'Password must be at least 6 characters' }, + { status: 400 } + ) + } + + // Validate email format + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + if (!emailRegex.test(email)) { + return NextResponse.json( + { error: 'Invalid email format' }, + { status: 400 } + ) + } + + // Check if username already exists + const [existingUsername] = await pool.execute( + 'SELECT id FROM buyers WHERE username = ?', + [username] + ) + if ((existingUsername as any[]).length > 0) { + return NextResponse.json( + { error: 'Username already exists' }, + { status: 400 } + ) + } + + // Check if email already exists + const [existingEmail] = await pool.execute( + 'SELECT id FROM buyers WHERE email = ?', + [email] + ) + if ((existingEmail as any[]).length > 0) { + return NextResponse.json( + { error: 'Email already exists' }, + { status: 400 } + ) + } + + // Hash password + const hashedPassword = await bcrypt.hash(password, 10) + + // Insert new buyer + const [result] = await pool.execute( + 'INSERT INTO buyers (username, password, email) VALUES (?, ?, ?)', + [username, hashedPassword, email] + ) + + const insertId = (result as any).insertId + + // Fetch the created buyer (without password) + const [rows] = await pool.execute( + 'SELECT id, username, email FROM buyers WHERE id = ?', + [insertId] + ) + + const buyer = (rows as any[])[0] + + // Create session cookie + const response = NextResponse.json( + { + user: { + id: buyer.id, + username: buyer.username, + email: buyer.email, + }, + }, + { status: 201 } + ) + + // Set secure cookie with buyer_id + response.cookies.set('buyer_id', buyer.id.toString(), { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 60 * 60 * 24 * 7, // 7 days + path: '/', + }) + + return response + } catch (error) { + console.error('Error during registration:', error) + return NextResponse.json( + { error: 'Failed to register' }, + { status: 500 } + ) + } +} + diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts new file mode 100644 index 0000000..cc9a1d3 --- /dev/null +++ b/app/api/auth/session/route.ts @@ -0,0 +1,22 @@ +import { NextResponse } from 'next/server' +import { getCurrentUser } from '@/lib/auth' + +// GET /api/auth/session - Get current session/user +export async function GET() { + try { + const user = await getCurrentUser() + + if (!user) { + return NextResponse.json({ user: null }, { status: 200 }) + } + + return NextResponse.json({ user }, { status: 200 }) + } catch (error) { + console.error('Error getting session:', error) + return NextResponse.json( + { error: 'Failed to get session' }, + { status: 500 } + ) + } +} + diff --git a/app/api/drops/active/route.ts b/app/api/drops/active/route.ts index 286c48b..dbd55e2 100644 --- a/app/api/drops/active/route.ts +++ b/app/api/drops/active/route.ts @@ -4,12 +4,42 @@ import pool from '@/lib/db' // GET /api/drops/active - Get the earliest unfilled drop (not sold out) export async function GET() { try { + // Get all drops ordered by creation date const [rows] = await pool.execute( - 'SELECT * FROM drops WHERE fill < size ORDER BY created_at ASC LIMIT 1' + 'SELECT * FROM drops ORDER BY created_at ASC' ) const drops = rows as any[] - return NextResponse.json(drops[0] || null) + + // Find the first drop that's not fully sold out + for (const drop of drops) { + // Calculate fill from sales records + // Sales are stored in grams, so we need to convert based on drop unit + 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 + + // Convert fill to drop's unit for comparison + let fill = totalFillInGrams + if (drop.unit === 'kg') { + fill = totalFillInGrams / 1000 + } + + // Check if drop is not fully sold out + if (fill < drop.size) { + // Return drop with calculated fill + return NextResponse.json({ + ...drop, + fill: fill, + }) + } + } + + // No active drops found + return NextResponse.json(null) } catch (error) { console.error('Error fetching active drop:', error) return NextResponse.json( diff --git a/app/api/drops/past/route.ts b/app/api/drops/past/route.ts new file mode 100644 index 0000000..a32cfba --- /dev/null +++ b/app/api/drops/past/route.ts @@ -0,0 +1,65 @@ +import { NextResponse } from 'next/server' +import pool from '@/lib/db' + +// GET /api/drops/past - Get all sold-out drops +export async function GET() { + try { + // Get all drops + const [rows] = await pool.execute( + 'SELECT * FROM drops ORDER BY created_at DESC' + ) + const drops = rows as any[] + + // Calculate fill from sales for each drop and filter sold-out ones + const soldOutDrops = [] + + for (const drop of drops) { + // Calculate fill from sales records + 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 + + // Convert fill to drop's unit for comparison + let fill = totalFillInGrams + if (drop.unit === 'kg') { + fill = totalFillInGrams / 1000 + } + + // Check if drop is sold out (fill >= size) + if (fill >= drop.size) { + // Get the timestamp of the last sale to calculate "sold out in X hours" + const [lastSaleRows] = await pool.execute( + 'SELECT created_at FROM sales WHERE drop_id = ? ORDER BY created_at DESC LIMIT 1', + [drop.id] + ) + const lastSaleData = lastSaleRows as any[] + const lastSaleDate = lastSaleData[0]?.created_at || drop.created_at + + // Calculate hours between drop creation and last sale + const dropDate = new Date(drop.created_at) + const soldOutDate = new Date(lastSaleDate) + const hoursDiff = Math.round( + (soldOutDate.getTime() - dropDate.getTime()) / (1000 * 60 * 60) + ) + + soldOutDrops.push({ + ...drop, + fill: fill, + soldOutInHours: hoursDiff, + }) + } + } + + return NextResponse.json(soldOutDrops) + } catch (error) { + console.error('Error fetching past drops:', error) + return NextResponse.json( + { error: 'Failed to fetch past drops' }, + { status: 500 } + ) + } +} + diff --git a/app/api/drops/route.ts b/app/api/drops/route.ts index e8ed42c..807ab30 100644 --- a/app/api/drops/route.ts +++ b/app/api/drops/route.ts @@ -7,7 +7,34 @@ export async function GET(request: NextRequest) { const [rows] = await pool.execute( 'SELECT * FROM drops ORDER BY created_at DESC' ) - return NextResponse.json(rows) + const drops = rows as any[] + + // Calculate fill from sales for each drop + const dropsWithFill = await Promise.all( + drops.map(async (drop) => { + // Calculate fill from sales records + // Sales are stored in grams, so we need to convert based on drop unit + 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 + + // Convert fill to drop's unit for comparison + let fill = totalFillInGrams + if (drop.unit === 'kg') { + fill = totalFillInGrams / 1000 + } + + return { + ...drop, + fill: fill, + } + }) + ) + + return NextResponse.json(dropsWithFill) } catch (error) { console.error('Error fetching drops:', error) return NextResponse.json( @@ -34,8 +61,9 @@ export async function POST(request: NextRequest) { // Insert new drop // Note: If imageUrl column doesn't exist in database, add it with: // 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, fill, image_url) VALUES (?, ?, ?, ?, 0, ?)', + 'INSERT INTO drops (item, size, unit, ppu, image_url) VALUES (?, ?, ?, ?, ?)', [item, size, unit, ppu, imageUrl || null] ) @@ -46,7 +74,13 @@ export async function POST(request: NextRequest) { insertId, ]) - return NextResponse.json(rows[0], { status: 201 }) + const drop = rows[0] as any + + // Return drop with calculated fill (will be 0 for new drop) + return NextResponse.json({ + ...drop, + fill: 0, + }, { status: 201 }) } catch (error) { console.error('Error creating drop:', error) return NextResponse.json( diff --git a/app/api/payments/check-status/route.ts b/app/api/payments/check-status/route.ts new file mode 100644 index 0000000..f556de9 --- /dev/null +++ b/app/api/payments/check-status/route.ts @@ -0,0 +1,94 @@ +import { NextRequest, NextResponse } from 'next/server' +import { cookies } from 'next/headers' +import pool from '@/lib/db' +import { getNowPaymentsConfig } from '@/lib/nowpayments' + +// GET /api/payments/check-status?payment_id=xxx - Check payment status manually +export async function GET(request: NextRequest) { + try { + // Get buyer_id from session cookie + const cookieStore = await cookies() + const buyerIdCookie = cookieStore.get('buyer_id')?.value + + if (!buyerIdCookie) { + return NextResponse.json( + { error: 'Authentication required' }, + { status: 401 } + ) + } + + const buyer_id = parseInt(buyerIdCookie, 10) + + const { searchParams } = new URL(request.url) + const payment_id = searchParams.get('payment_id') + + if (!payment_id) { + return NextResponse.json( + { error: 'payment_id is required' }, + { status: 400 } + ) + } + + // Check if it's a pending order or completed sale + const [pendingRows] = await pool.execute( + 'SELECT * FROM pending_orders WHERE payment_id = ? AND buyer_id = ?', + [payment_id, buyer_id] + ) + + const [salesRows] = await pool.execute( + 'SELECT * FROM sales WHERE payment_id = ? AND buyer_id = ?', + [payment_id, buyer_id] + ) + + const pendingOrders = pendingRows as any[] + const sales = salesRows as any[] + + if (pendingOrders.length === 0 && sales.length === 0) { + return NextResponse.json( + { error: 'Payment not found' }, + { status: 404 } + ) + } + + // Get NOWPayments config (testnet or production) + const nowPaymentsConfig = getNowPaymentsConfig() + + // Check payment status with NOWPayments + const nowPaymentsResponse = await fetch( + `${nowPaymentsConfig.baseUrl}/v1/payment/${payment_id}`, + { + method: 'GET', + headers: { + 'x-api-key': nowPaymentsConfig.apiKey, + }, + } + ) + + if (!nowPaymentsResponse.ok) { + const error = await nowPaymentsResponse.json() + return NextResponse.json( + { error: 'Failed to check payment status', details: error }, + { status: 500 } + ) + } + + const paymentStatus = await nowPaymentsResponse.json() + + return NextResponse.json({ + payment_id, + status: paymentStatus.payment_status, + payment_status: paymentStatus.payment_status, + pay_amount: paymentStatus.pay_amount, + pay_currency: paymentStatus.pay_currency, + price_amount: paymentStatus.price_amount, + price_currency: paymentStatus.price_currency, + }) + } catch (error) { + console.error('Error checking payment status:', error) + return NextResponse.json( + { error: 'Failed to check payment status' }, + { status: 500 } + ) + } +} + diff --git a/app/api/payments/create-invoice/route.ts b/app/api/payments/create-invoice/route.ts new file mode 100644 index 0000000..3de0b9e --- /dev/null +++ b/app/api/payments/create-invoice/route.ts @@ -0,0 +1,153 @@ +import { NextRequest, NextResponse } from 'next/server' +import { cookies } from 'next/headers' +import pool from '@/lib/db' +import { getNowPaymentsConfig } from '@/lib/nowpayments' + +// POST /api/payments/create-invoice - Create a NOWPayments invoice +export async function POST(request: NextRequest) { + try { + // Get buyer_id from session cookie + const cookieStore = await cookies() + const buyerIdCookie = cookieStore.get('buyer_id')?.value + + if (!buyerIdCookie) { + return NextResponse.json( + { error: 'Authentication required' }, + { status: 401 } + ) + } + + const buyer_id = parseInt(buyerIdCookie, 10) + + const body = await request.json() + const { drop_id, size } = body + + // Validate required fields + if (!drop_id || !size) { + return NextResponse.json( + { error: 'Missing required fields: drop_id, size' }, + { status: 400 } + ) + } + + // Get drop details + const [dropRows] = await pool.execute( + 'SELECT * FROM drops WHERE id = ?', + [drop_id] + ) + const drops = dropRows as any[] + if (drops.length === 0) { + return NextResponse.json( + { error: 'Drop not found' }, + { status: 404 } + ) + } + + const drop = drops[0] + + // Check inventory availability (but don't reserve yet - will reserve when payment confirmed) + 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 currentFill = salesData[0]?.total_fill || 0 + + // Convert fill to the drop's unit for comparison + let currentFillInDropUnit = currentFill + let sizeInDropUnit = size + if (drop.unit === 'kg') { + currentFillInDropUnit = currentFill / 1000 + sizeInDropUnit = size / 1000 + } + + // Check if there's enough remaining inventory + const remaining = drop.size - currentFillInDropUnit + if (sizeInDropUnit > remaining) { + return NextResponse.json( + { error: 'Not enough inventory remaining' }, + { status: 400 } + ) + } + + // Calculate price + let priceAmount = 0 + if (drop.unit === 'kg') { + priceAmount = (size / 1000) * drop.ppu + } else { + priceAmount = size * drop.ppu + } + + // Round to 2 decimal places + priceAmount = Math.round(priceAmount * 100) / 100 + + // Generate order ID + const orderId = `SALE-${Date.now()}-${drop_id}-${buyer_id}` + + // Get base URL for success/cancel redirects + const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || + request.headers.get('origin') || + 'http://localhost:3420' + + // Get IPN callback URL from environment variable + const ipnCallbackUrl = process.env.IPN_CALLBACK_URL + if (!ipnCallbackUrl) { + return NextResponse.json( + { error: 'IPN_CALLBACK_URL environment variable is required' }, + { status: 500 } + ) + } + + // Get NOWPayments config (testnet or production) + const nowPaymentsConfig = getNowPaymentsConfig() + + // Create NOWPayments invoice + const nowPaymentsResponse = await fetch(`${nowPaymentsConfig.baseUrl}/v1/invoice`, { + method: 'POST', + headers: { + 'x-api-key': nowPaymentsConfig.apiKey, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + price_amount: priceAmount, + price_currency: nowPaymentsConfig.currency, + order_id: orderId, + order_description: `${drop.item} - ${size}g`, + ipn_callback_url: ipnCallbackUrl, + success_url: `${baseUrl}/?payment=success&order_id=${orderId}`, + cancel_url: `${baseUrl}/?payment=cancelled&order_id=${orderId}`, + }), + }) + + if (!nowPaymentsResponse.ok) { + const error = await nowPaymentsResponse.json() + console.error('NOWPayments error:', error) + return NextResponse.json( + { error: 'Failed to create payment invoice', details: error }, + { status: 500 } + ) + } + + const invoice = await nowPaymentsResponse.json() + + // Store pending order (will create sale when payment is confirmed) + const [result] = await pool.execute( + 'INSERT INTO pending_orders (payment_id, order_id, drop_id, buyer_id, size, price_amount, price_currency) VALUES (?, ?, ?, ?, ?, ?, ?)', + [invoice.id, orderId, drop_id, buyer_id, size, priceAmount, nowPaymentsConfig.currency] + ) + + // Return invoice URL - sale will be created when payment is confirmed via IPN + return NextResponse.json({ + invoice_url: invoice.invoice_url, + payment_id: invoice.id, + order_id: orderId, + }, { status: 201 }) + } catch (error) { + console.error('Error creating invoice:', error) + return NextResponse.json( + { error: 'Failed to create invoice' }, + { status: 500 } + ) + } +} + diff --git a/app/api/payments/ipn-callback/route.ts b/app/api/payments/ipn-callback/route.ts new file mode 100644 index 0000000..ec1392d --- /dev/null +++ b/app/api/payments/ipn-callback/route.ts @@ -0,0 +1,132 @@ +import { NextRequest, NextResponse } from 'next/server' +import pool from '@/lib/db' + +// POST /api/payments/ipn-callback - Handle NOWPayments IPN callbacks +export async function POST(request: NextRequest) { + try { + const body = await request.json() + + // NOWPayments IPN callback structure + // You may need to adjust based on actual NOWPayments IPN format + const { + payment_id, + invoice_id, + order_id, + payment_status, + pay_amount, + pay_currency, + price_amount, + price_currency, + } = body + + console.log('IPN Callback received:', { + payment_id, + invoice_id, + order_id, + payment_status, + }) + + // Find pending order by payment_id or invoice_id + const paymentIdToFind = invoice_id || payment_id + const [pendingRows] = await pool.execute( + 'SELECT * FROM pending_orders WHERE payment_id = ?', + [paymentIdToFind] + ) + + const pendingOrders = pendingRows as any[] + if (pendingOrders.length === 0) { + // Check if sale already exists (idempotency) + const [existingSales] = await pool.execute( + 'SELECT * FROM sales WHERE payment_id = ?', + [paymentIdToFind] + ) + const existing = existingSales as any[] + if (existing.length > 0) { + // Sale already created, just return success + console.log('Sale already exists for payment_id:', paymentIdToFind) + return NextResponse.json({ status: 'ok' }, { status: 200 }) + } + + console.error('Pending order not found for payment_id:', paymentIdToFind) + return NextResponse.json( + { error: 'Pending order not found' }, + { status: 404 } + ) + } + + const pendingOrder = pendingOrders[0] + + // Update payment status based on payment_status + // NOWPayments statuses: waiting, confirming, confirmed, sending, partially_paid, finished, failed, refunded, expired + if (payment_status === 'finished' || payment_status === 'confirmed') { + // Payment successful - create sale record + try { + // Check inventory again before creating sale + const [dropRows] = await pool.execute( + 'SELECT * FROM drops WHERE id = ?', + [pendingOrder.drop_id] + ) + const drops = dropRows as any[] + if (drops.length === 0) { + console.error('Drop not found for pending order:', pendingOrder.id) + return NextResponse.json({ status: 'error', message: 'Drop not found' }, { status: 200 }) + } + + const drop = drops[0] + + // Calculate current fill from sales + const [salesRows] = await pool.execute( + 'SELECT COALESCE(SUM(size), 0) as total_fill FROM sales WHERE drop_id = ?', + [pendingOrder.drop_id] + ) + const salesData = salesRows as any[] + const currentFill = salesData[0]?.total_fill || 0 + + // Convert fill to the drop's unit for comparison + let currentFillInDropUnit = currentFill + let sizeInDropUnit = pendingOrder.size + if (drop.unit === 'kg') { + currentFillInDropUnit = currentFill / 1000 + sizeInDropUnit = pendingOrder.size / 1000 + } + + // Check if there's still enough inventory + const remaining = drop.size - currentFillInDropUnit + if (sizeInDropUnit > remaining) { + console.error('Not enough inventory for pending order:', pendingOrder.id) + // Delete pending order since inventory is no longer available + await pool.execute('DELETE FROM pending_orders WHERE id = ?', [pendingOrder.id]) + return NextResponse.json({ status: 'error', message: 'Inventory no longer available' }, { status: 200 }) + } + + // Create sale record + const [result] = await pool.execute( + 'INSERT INTO sales (drop_id, buyer_id, size, payment_id) VALUES (?, ?, ?, ?)', + [pendingOrder.drop_id, pendingOrder.buyer_id, pendingOrder.size, pendingOrder.payment_id] + ) + + const saleId = (result as any).insertId + + // Delete pending order since sale is created + await pool.execute('DELETE FROM pending_orders WHERE id = ?', [pendingOrder.id]) + + console.log(`Payment confirmed - Sale ${saleId} created from pending order ${pendingOrder.id}`) + } catch (error) { + console.error('Error creating sale from pending order:', error) + return NextResponse.json({ status: 'error' }, { status: 200 }) + } + } else if (payment_status === 'failed' || payment_status === 'expired') { + // Payment failed - delete pending order + await pool.execute('DELETE FROM pending_orders WHERE id = ?', [pendingOrder.id]) + console.log(`Payment failed - Pending order ${pendingOrder.id} deleted`) + } + + // Return success to NOWPayments + return NextResponse.json({ status: 'ok' }, { status: 200 }) + } catch (error) { + console.error('Error processing IPN callback:', error) + // Still return 200 to prevent NOWPayments from retrying + return NextResponse.json({ status: 'error' }, { status: 200 }) + } +} + diff --git a/app/api/sales/route.ts b/app/api/sales/route.ts new file mode 100644 index 0000000..bc06518 --- /dev/null +++ b/app/api/sales/route.ts @@ -0,0 +1,104 @@ +import { NextRequest, NextResponse } from 'next/server' +import { cookies } from 'next/headers' +import pool from '@/lib/db' + +// POST /api/sales - Create a new sale +export async function POST(request: NextRequest) { + try { + // Get buyer_id from session cookie + const cookieStore = await cookies() + const buyerIdCookie = cookieStore.get('buyer_id')?.value + + if (!buyerIdCookie) { + return NextResponse.json( + { error: 'Authentication required. Please log in to make a purchase.' }, + { status: 401 } + ) + } + + const buyer_id = parseInt(buyerIdCookie, 10) + + const body = await request.json() + const { drop_id, size } = body + + // Validate required fields + if (!drop_id || !size) { + return NextResponse.json( + { error: 'Missing required fields: drop_id, size' }, + { status: 400 } + ) + } + + // Validate size is positive + if (size <= 0) { + return NextResponse.json( + { error: 'Size must be greater than 0' }, + { status: 400 } + ) + } + + // Check if drop exists and get its details + const [dropRows] = await pool.execute( + 'SELECT * FROM drops WHERE id = ?', + [drop_id] + ) + const drops = dropRows as any[] + if (drops.length === 0) { + return NextResponse.json( + { error: 'Drop not found' }, + { status: 404 } + ) + } + + const drop = drops[0] + + // Calculate current fill from sales + 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 currentFill = salesData[0]?.total_fill || 0 + + // Convert fill to the drop's unit for comparison + let currentFillInDropUnit = currentFill + let sizeInDropUnit = size + if (drop.unit === 'kg') { + // If drop unit is kg, convert sales (in grams) to kg + currentFillInDropUnit = currentFill / 1000 + sizeInDropUnit = size / 1000 + } + + // Check if there's enough remaining inventory + const remaining = drop.size - currentFillInDropUnit + if (sizeInDropUnit > remaining) { + return NextResponse.json( + { error: 'Not enough inventory remaining' }, + { status: 400 } + ) + } + + // Insert new sale + const [result] = await pool.execute( + 'INSERT INTO sales (drop_id, buyer_id, size) VALUES (?, ?, ?)', + [drop_id, buyer_id, size] + ) + + const insertId = (result as any).insertId + + // Fetch the created sale + const [rows] = await pool.execute('SELECT * FROM sales WHERE id = ?', [ + insertId, + ]) + + const sales = rows as any[] + return NextResponse.json(sales[0], { status: 201 }) + } catch (error) { + console.error('Error creating sale:', error) + return NextResponse.json( + { error: 'Failed to create sale' }, + { status: 500 } + ) + } +} + diff --git a/app/components/AuthModal.tsx b/app/components/AuthModal.tsx new file mode 100644 index 0000000..462e425 --- /dev/null +++ b/app/components/AuthModal.tsx @@ -0,0 +1,316 @@ +'use client' + +import { useState, useEffect } from 'react' + +interface User { + id: number + username: string + email: string +} + +interface AuthModalProps { + isOpen: boolean + onClose: () => void + onLogin: (user: User) => void +} + +export default function AuthModal({ isOpen, onClose, onLogin }: AuthModalProps) { + const [isLogin, setIsLogin] = useState(true) + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [email, setEmail] = useState('') + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + + useEffect(() => { + if (isOpen) { + // Reset form when modal opens + setUsername('') + setPassword('') + setEmail('') + setError('') + setIsLogin(true) + } + }, [isOpen]) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError('') + setLoading(true) + + try { + const endpoint = isLogin ? '/api/auth/login' : '/api/auth/register' + const body = isLogin + ? { username, password } + : { username, password, email } + + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + credentials: 'include', // Important for cookies + }) + + const data = await response.json() + + if (!response.ok) { + setError(data.error || 'An error occurred') + setLoading(false) + return + } + + // Success - call onLogin callback and close modal + onLogin(data.user) + onClose() + } catch (error) { + console.error('Auth error:', error) + setError('An unexpected error occurred') + } finally { + setLoading(false) + } + } + + if (!isOpen) return null + + return ( +
+ Item: {drop.item} +
++ Quantity: {selectedSize}g +
++ Price per {drop.unit}: {drop.ppu.toFixed(2)} CHF +
++ Total: {calculatePrice().toFixed(2)} CHF +
++ incl. 2.5% VAT +
+Loading past drops...
++ No past drops yet. Check back soon! +
+