From e1a0966dee24c3e9726712bb3dd682f036b2301f Mon Sep 17 00:00:00 2001 From: root Date: Sat, 20 Dec 2025 19:00:42 +0100 Subject: [PATCH] sync --- README.md | 57 ++++ app/api/auth/login/route.ts | 74 ++++++ app/api/auth/logout/route.ts | 18 ++ app/api/auth/register/route.ts | 117 +++++++++ app/api/auth/session/route.ts | 22 ++ app/api/drops/active/route.ts | 34 ++- app/api/drops/past/route.ts | 65 +++++ app/api/drops/route.ts | 40 ++- app/api/payments/check-status/route.ts | 94 +++++++ app/api/payments/create-invoice/route.ts | 153 +++++++++++ app/api/payments/ipn-callback/route.ts | 132 ++++++++++ app/api/sales/route.ts | 104 ++++++++ app/components/AuthModal.tsx | 316 +++++++++++++++++++++++ app/components/Drop.tsx | 237 ++++++++++++++++- app/components/Nav.tsx | 123 ++++++++- app/components/PastDrops.tsx | 139 +++++++--- app/globals.css | 4 +- app/page.tsx | 28 ++ lib/auth.ts | 68 +++++ lib/nowpayments.ts | 33 +++ migrations/create_pending_orders.sql | 20 ++ package-lock.json | 46 ++++ package.json | 2 + 23 files changed, 1878 insertions(+), 48 deletions(-) create mode 100644 app/api/auth/login/route.ts create mode 100644 app/api/auth/logout/route.ts create mode 100644 app/api/auth/register/route.ts create mode 100644 app/api/auth/session/route.ts create mode 100644 app/api/drops/past/route.ts create mode 100644 app/api/payments/check-status/route.ts create mode 100644 app/api/payments/create-invoice/route.ts create mode 100644 app/api/payments/ipn-callback/route.ts create mode 100644 app/api/sales/route.ts create mode 100644 app/components/AuthModal.tsx create mode 100644 lib/auth.ts create mode 100644 lib/nowpayments.ts create mode 100644 migrations/create_pending_orders.sql 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 ( +
+
e.stopPropagation()} + > +
+

+ {isLogin ? 'Login' : 'Register'} +

+ +
+ +
+ {!isLogin && ( +
+ + setEmail(e.target.value)} + required + style={{ + width: '100%', + padding: '12px', + borderRadius: '8px', + border: '1px solid var(--border)', + background: 'var(--bg-soft)', + color: 'var(--text)', + fontSize: '14px', + }} + placeholder="your@email.com" + /> +
+ )} + +
+ + setUsername(e.target.value)} + required + minLength={isLogin ? undefined : 3} + style={{ + width: '100%', + padding: '12px', + borderRadius: '8px', + border: '1px solid var(--border)', + background: 'var(--bg-soft)', + color: 'var(--text)', + fontSize: '14px', + }} + placeholder="username" + /> +
+ +
+ + setPassword(e.target.value)} + required + minLength={isLogin ? undefined : 6} + style={{ + width: '100%', + padding: '12px', + borderRadius: '8px', + border: '1px solid var(--border)', + background: 'var(--bg-soft)', + color: 'var(--text)', + fontSize: '14px', + }} + placeholder="password" + /> +
+ + {error && ( +
+ {error} +
+ )} + + +
+ +
+ {isLogin ? ( + <> + Don't have an account?{' '} + + + ) : ( + <> + Already have an account?{' '} + + + )} +
+
+
+ ) +} + diff --git a/app/components/Drop.tsx b/app/components/Drop.tsx index a3b8cdd..59ebb5f 100644 --- a/app/components/Drop.tsx +++ b/app/components/Drop.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react' import Image from 'next/image' +import AuthModal from './AuthModal' interface DropData { id: number @@ -14,15 +15,43 @@ interface DropData { created_at: string } +interface User { + id: number + username: string + email: string +} + export default function Drop() { const [drop, setDrop] = useState(null) const [loading, setLoading] = useState(true) const [selectedSize, setSelectedSize] = useState(50) + const [showConfirmModal, setShowConfirmModal] = useState(false) + const [showAuthModal, setShowAuthModal] = useState(false) + const [processing, setProcessing] = useState(false) + const [user, setUser] = useState(null) + const [checkingAuth, setCheckingAuth] = useState(true) useEffect(() => { fetchActiveDrop() + checkAuth() }, []) + const checkAuth = async () => { + try { + const response = await fetch('/api/auth/session', { + credentials: 'include', + }) + if (response.ok) { + const data = await response.json() + setUser(data.user) + } + } catch (error) { + console.error('Error checking auth:', error) + } finally { + setCheckingAuth(false) + } + } + const fetchActiveDrop = async () => { try { const response = await fetch('/api/drops/active') @@ -65,6 +94,85 @@ export default function Drop() { return sizes.filter((size) => size <= remainingInGrams) } + const handleJoinDrop = () => { + // Check if user is logged in + if (!user) { + setShowAuthModal(true) + return + } + setShowConfirmModal(true) + } + + const handleLogin = (loggedInUser: User) => { + setUser(loggedInUser) + setShowAuthModal(false) + // After login, show the confirmation modal + setShowConfirmModal(true) + } + + const handleConfirmPurchase = async () => { + if (!drop) return + + setProcessing(true) + try { + // Create NOWPayments invoice and sale record + const response = await fetch('/api/payments/create-invoice', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', // Important for cookies + body: JSON.stringify({ + drop_id: drop.id, + size: selectedSize, // Size in grams + }), + }) + + if (!response.ok) { + const error = await response.json() + if (response.status === 401) { + // User not authenticated - show login modal + setShowConfirmModal(false) + setShowAuthModal(true) + setProcessing(false) + return + } + alert(`Error: ${error.error || 'Failed to create payment invoice'}`) + setProcessing(false) + return + } + + const data = await response.json() + + // Close modal + setShowConfirmModal(false) + + // Redirect to NOWPayments invoice + if (data.invoice_url) { + window.location.href = data.invoice_url + } else { + alert('Payment invoice created but no redirect URL received') + await fetchActiveDrop() + } + } catch (error) { + console.error('Error creating payment invoice:', error) + alert('Failed to create payment invoice. Please try again.') + setProcessing(false) + } + } + + const handleCancelPurchase = () => { + setShowConfirmModal(false) + } + + const calculatePrice = () => { + if (!drop) return 0 + if (drop.unit === 'kg') { + return (selectedSize / 1000) * drop.ppu + } + return selectedSize * drop.ppu + } + if (loading) { return (
@@ -137,7 +245,7 @@ export default function Drop() {
- {drop.fill} + {drop.unit === 'kg' ? drop.fill.toFixed(2) : Math.round(drop.fill)} {drop.unit} of {drop.size} {drop.unit} reserved
@@ -156,7 +264,9 @@ export default function Drop() { ))} - + )} @@ -174,6 +284,129 @@ export default function Drop() { )} + + {/* Confirmation Modal */} + {showConfirmModal && drop && ( +
+
e.stopPropagation()} + > +

+ Confirm Purchase +

+
+

+ Item: {drop.item} +

+

+ Quantity: {selectedSize}g +

+

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

+
+

+ Total: {calculatePrice().toFixed(2)} CHF +

+

+ incl. 2.5% VAT +

+
+
+
+ + +
+
+
+ )} + + {/* Auth Modal */} + setShowAuthModal(false)} + onLogin={handleLogin} + /> ) } diff --git a/app/components/Nav.tsx b/app/components/Nav.tsx index ab30887..f44f13e 100644 --- a/app/components/Nav.tsx +++ b/app/components/Nav.tsx @@ -1,13 +1,120 @@ +'use client' + +import { useState, useEffect } from 'react' +import AuthModal from './AuthModal' + +interface User { + id: number + username: string + email: string +} + export default function Nav() { + const [user, setUser] = useState(null) + const [showAuthModal, setShowAuthModal] = useState(false) + const [loading, setLoading] = useState(true) + + useEffect(() => { + checkAuth() + }, []) + + const checkAuth = async () => { + try { + const response = await fetch('/api/auth/session', { + credentials: 'include', + }) + if (response.ok) { + const data = await response.json() + setUser(data.user) + } + } catch (error) { + console.error('Error checking auth:', error) + } finally { + setLoading(false) + } + } + + const handleLogin = (loggedInUser: User) => { + setUser(loggedInUser) + setShowAuthModal(false) + } + + const handleLogout = async () => { + try { + await fetch('/api/auth/logout', { + method: 'POST', + credentials: 'include', + }) + setUser(null) + } catch (error) { + console.error('Error logging out:', error) + } + } + return ( - + <> + + + setShowAuthModal(false)} + onLogin={handleLogin} + /> + ) } diff --git a/app/components/PastDrops.tsx b/app/components/PastDrops.tsx index 62d1541..098d270 100644 --- a/app/components/PastDrops.tsx +++ b/app/components/PastDrops.tsx @@ -1,44 +1,119 @@ +'use client' + +import { useState, useEffect } from 'react' import Image from 'next/image' interface PastDrop { - name: string - image: string - soldIn: string + id: number + item: string + size: number + fill: number + unit: string + ppu: number + image_url: string | null + created_at: string + soldOutInHours: number } -const pastDrops: PastDrop[] = [ - { - name: 'Swiss Gold', - image: 'https://images.unsplash.com/photo-1581091012184-5c7b4c101899', - soldIn: 'Sold out in 42h', - }, - { - name: 'Lemon T1', - image: 'https://images.unsplash.com/photo-1512436991641-6745cdb1723f', - soldIn: 'Sold out in 19h', - }, - { - name: 'Alpine Frost', - image: 'https://images.unsplash.com/photo-1600431521340-491eca880813', - soldIn: 'Sold out in 31h', - }, -] - export default function PastDrops() { + const [drops, setDrops] = useState([]) + const [loading, setLoading] = useState(true) + + useEffect(() => { + fetchPastDrops() + }, []) + + const fetchPastDrops = async () => { + try { + const response = await fetch('/api/drops/past') + if (response.ok) { + const data = await response.json() + setDrops(data) + } + } catch (error) { + console.error('Error fetching past drops:', error) + } finally { + setLoading(false) + } + } + + const formatSoldOutTime = (hours: number) => { + if (hours < 1) { + return 'Sold out in less than 1h' + } else if (hours === 1) { + return 'Sold out in 1h' + } else if (hours < 24) { + return `Sold out in ${hours}h` + } else { + const days = Math.floor(hours / 24) + const remainingHours = hours % 24 + if (remainingHours === 0) { + return days === 1 ? 'Sold out in 1 day' : `Sold out in ${days} days` + } else { + return `Sold out in ${days}d ${remainingHours}h` + } + } + } + + if (loading) { + return ( +
+

Loading past drops...

+
+ ) + } + + if (drops.length === 0) { + return ( +
+

+ No past drops yet. Check back soon! +

+
+ ) + } + return (
- {pastDrops.map((drop, index) => ( -
- {drop.name} - {drop.name} + {drops.map((drop) => ( +
+ {drop.image_url ? ( +
+ {drop.item} +
+ ) : ( +
+ No Image +
+ )} + {drop.item}
- {drop.soldIn} + {formatSoldOutTime(drop.soldOutInHours)}
))}
diff --git a/app/globals.css b/app/globals.css index de81a31..dc28b9f 100644 --- a/app/globals.css +++ b/app/globals.css @@ -206,8 +206,9 @@ header p { .past { display: grid; - grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(400px, 400px)); gap: 30px; + justify-content: center; } .past .card { @@ -215,6 +216,7 @@ header p { border-radius: 16px; padding: 20px; border: 1px solid var(--border); + width: 400px; } .past img { diff --git a/app/page.tsx b/app/page.tsx index e305db9..21f4966 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,3 +1,7 @@ +'use client' + +import { useEffect, Suspense } from 'react' +import { useSearchParams } from 'next/navigation' import Nav from './components/Nav' import Drop from './components/Drop' import InfoBox from './components/InfoBox' @@ -5,9 +9,33 @@ import Signup from './components/Signup' import PastDrops from './components/PastDrops' import Footer from './components/Footer' +function PaymentHandler() { + const searchParams = useSearchParams() + + useEffect(() => { + const payment = searchParams.get('payment') + const orderId = searchParams.get('order_id') + + if (payment === 'success' && orderId) { + // Clean up URL - IPN is handled by external service + window.history.replaceState({}, '', window.location.pathname) + } else if (payment === 'cancelled') { + alert('Payment was cancelled.') + // Clean up URL + window.history.replaceState({}, '', window.location.pathname) + } + }, [searchParams]) + + return null +} + export default function Home() { + return ( <> + + +