sync
This commit is contained in:
57
README.md
57
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
|
||||
|
||||
74
app/api/auth/login/route.ts
Normal file
74
app/api/auth/login/route.ts
Normal file
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
18
app/api/auth/logout/route.ts
Normal file
18
app/api/auth/logout/route.ts
Normal file
@@ -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
|
||||
}
|
||||
|
||||
117
app/api/auth/register/route.ts
Normal file
117
app/api/auth/register/route.ts
Normal file
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
22
app/api/auth/session/route.ts
Normal file
22
app/api/auth/session/route.ts
Normal file
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
65
app/api/drops/past/route.ts
Normal file
65
app/api/drops/past/route.ts
Normal file
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
94
app/api/payments/check-status/route.ts
Normal file
94
app/api/payments/check-status/route.ts
Normal file
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
153
app/api/payments/create-invoice/route.ts
Normal file
153
app/api/payments/create-invoice/route.ts
Normal file
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
132
app/api/payments/ipn-callback/route.ts
Normal file
132
app/api/payments/ipn-callback/route.ts
Normal file
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
104
app/api/sales/route.ts
Normal file
104
app/api/sales/route.ts
Normal file
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
316
app/components/AuthModal.tsx
Normal file
316
app/components/AuthModal.tsx
Normal file
@@ -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 (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0, 0, 0, 0.7)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000,
|
||||
padding: '20px',
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--card)',
|
||||
borderRadius: '16px',
|
||||
padding: '32px',
|
||||
maxWidth: '400px',
|
||||
width: '100%',
|
||||
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.3)',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
||||
<h2 style={{ margin: 0 }}>
|
||||
{isLogin ? 'Login' : 'Register'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
fontSize: '24px',
|
||||
cursor: 'pointer',
|
||||
color: 'var(--muted)',
|
||||
padding: 0,
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
{!isLogin && (
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label
|
||||
htmlFor="email"
|
||||
style={{
|
||||
display: 'block',
|
||||
marginBottom: '8px',
|
||||
fontSize: '14px',
|
||||
color: 'var(--text)',
|
||||
}}
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
value={email}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label
|
||||
htmlFor="username"
|
||||
style={{
|
||||
display: 'block',
|
||||
marginBottom: '8px',
|
||||
fontSize: '14px',
|
||||
color: 'var(--text)',
|
||||
}}
|
||||
>
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
value={username}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<label
|
||||
htmlFor="password"
|
||||
style={{
|
||||
display: 'block',
|
||||
marginBottom: '8px',
|
||||
fontSize: '14px',
|
||||
color: 'var(--text)',
|
||||
}}
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
value={password}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
padding: '12px',
|
||||
background: 'rgba(255, 0, 0, 0.1)',
|
||||
border: '1px solid rgba(255, 0, 0, 0.3)',
|
||||
borderRadius: '8px',
|
||||
color: '#ff4444',
|
||||
fontSize: '14px',
|
||||
marginBottom: '16px',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="cta"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
cursor: loading ? 'not-allowed' : 'pointer',
|
||||
opacity: loading ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{loading
|
||||
? 'Processing...'
|
||||
: isLogin
|
||||
? 'Login'
|
||||
: 'Register'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: '20px',
|
||||
textAlign: 'center',
|
||||
fontSize: '14px',
|
||||
color: 'var(--muted)',
|
||||
}}
|
||||
>
|
||||
{isLogin ? (
|
||||
<>
|
||||
Don't have an account?{' '}
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsLogin(false)
|
||||
setError('')
|
||||
}}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: 'var(--accent)',
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'underline',
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
Register
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Already have an account?{' '}
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsLogin(true)
|
||||
setError('')
|
||||
}}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: 'var(--accent)',
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'underline',
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<DropData | null>(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<User | null>(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 (
|
||||
<div className="drop">
|
||||
@@ -137,7 +245,7 @@ export default function Drop() {
|
||||
<span style={{ width: `${progressPercentage}%` }}></span>
|
||||
</div>
|
||||
<div className="meta">
|
||||
{drop.fill}
|
||||
{drop.unit === 'kg' ? drop.fill.toFixed(2) : Math.round(drop.fill)}
|
||||
{drop.unit} of {drop.size}
|
||||
{drop.unit} reserved
|
||||
</div>
|
||||
@@ -156,7 +264,9 @@ export default function Drop() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button className="cta">Join Drop</button>
|
||||
<button className="cta" onClick={handleJoinDrop}>
|
||||
Join Drop
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -174,6 +284,129 @@ export default function Drop() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confirmation Modal */}
|
||||
{showConfirmModal && drop && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0, 0, 0, 0.7)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000,
|
||||
padding: '20px',
|
||||
}}
|
||||
onClick={handleCancelPurchase}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--card)',
|
||||
borderRadius: '16px',
|
||||
padding: '32px',
|
||||
maxWidth: '500px',
|
||||
width: '100%',
|
||||
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.3)',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 style={{ marginTop: 0, marginBottom: '20px' }}>
|
||||
Confirm Purchase
|
||||
</h2>
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<p style={{ marginBottom: '12px', color: 'var(--muted)' }}>
|
||||
<strong>Item:</strong> {drop.item}
|
||||
</p>
|
||||
<p style={{ marginBottom: '12px', color: 'var(--muted)' }}>
|
||||
<strong>Quantity:</strong> {selectedSize}g
|
||||
</p>
|
||||
<p style={{ marginBottom: '12px', color: 'var(--muted)' }}>
|
||||
<strong>Price per {drop.unit}:</strong> {drop.ppu.toFixed(2)} CHF
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
padding: '16px',
|
||||
background: 'var(--bg-soft)',
|
||||
borderRadius: '8px',
|
||||
marginTop: '16px',
|
||||
}}
|
||||
>
|
||||
<p style={{ margin: 0, fontSize: '18px', fontWeight: 'bold' }}>
|
||||
Total: {calculatePrice().toFixed(2)} CHF
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
margin: '4px 0 0 0',
|
||||
fontSize: '14px',
|
||||
color: 'var(--muted)',
|
||||
}}
|
||||
>
|
||||
incl. 2.5% VAT
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '12px',
|
||||
justifyContent: 'flex-end',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={handleCancelPurchase}
|
||||
disabled={processing}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
background: '#dc2626',
|
||||
border: 'none',
|
||||
borderRadius: '14px',
|
||||
cursor: processing ? 'not-allowed' : 'pointer',
|
||||
color: '#fff',
|
||||
fontSize: '15px',
|
||||
fontWeight: 500,
|
||||
opacity: processing ? 0.6 : 1,
|
||||
lineHeight: '1.5',
|
||||
boxSizing: 'border-box',
|
||||
display: 'inline-block',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirmPurchase}
|
||||
disabled={processing}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
background: 'var(--accent)',
|
||||
color: '#000',
|
||||
border: 'none',
|
||||
borderRadius: '14px',
|
||||
cursor: processing ? 'not-allowed' : 'pointer',
|
||||
fontSize: '15px',
|
||||
fontWeight: 500,
|
||||
opacity: processing ? 0.6 : 1,
|
||||
lineHeight: '1.5',
|
||||
boxSizing: 'border-box',
|
||||
display: 'inline-block',
|
||||
}}
|
||||
>
|
||||
{processing ? 'Processing...' : 'Confirm Purchase'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Auth Modal */}
|
||||
<AuthModal
|
||||
isOpen={showAuthModal}
|
||||
onClose={() => setShowAuthModal(false)}
|
||||
onLogin={handleLogin}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<User | null>(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 (
|
||||
<>
|
||||
<nav>
|
||||
<div className="brand">420Deals.ch</div>
|
||||
<div className="links">
|
||||
<a href="#drop">Drop</a>
|
||||
<a href="#past">Past Drops</a>
|
||||
<a href="#community">Community</a>
|
||||
{!loading && (
|
||||
user ? (
|
||||
<>
|
||||
<span style={{ color: 'var(--muted)', fontSize: '14px', marginLeft: '48px' }}>
|
||||
{user.username}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: '1px solid var(--border)',
|
||||
color: 'var(--text)',
|
||||
padding: '8px 16px',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
marginLeft: '12px',
|
||||
lineHeight: '1',
|
||||
boxSizing: 'border-box',
|
||||
display: 'inline-block',
|
||||
}}
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowAuthModal(true)}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
fontSize: '14px',
|
||||
background: 'var(--accent)',
|
||||
color: '#000',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
fontWeight: 500,
|
||||
marginLeft: '48px',
|
||||
lineHeight: '1',
|
||||
boxSizing: 'border-box',
|
||||
display: 'inline-block',
|
||||
}}
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<AuthModal
|
||||
isOpen={showAuthModal}
|
||||
onClose={() => setShowAuthModal(false)}
|
||||
onLogin={handleLogin}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<PastDrop[]>([])
|
||||
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 (
|
||||
<div className="past">
|
||||
{pastDrops.map((drop, index) => (
|
||||
<div key={index} className="card">
|
||||
<p style={{ color: 'var(--muted)', textAlign: 'center' }}>Loading past drops...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (drops.length === 0) {
|
||||
return (
|
||||
<div className="past">
|
||||
<p style={{ color: 'var(--muted)', textAlign: 'center' }}>
|
||||
No past drops yet. Check back soon!
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="past">
|
||||
{drops.map((drop) => (
|
||||
<div key={drop.id} className="card">
|
||||
{drop.image_url ? (
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<Image
|
||||
src={drop.image}
|
||||
alt={drop.name}
|
||||
width={240}
|
||||
height={240}
|
||||
style={{ width: '100%', height: 'auto', borderRadius: '12px', marginBottom: '12px' }}
|
||||
src={drop.image_url}
|
||||
alt={drop.item}
|
||||
width={300}
|
||||
height={300}
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: '300px',
|
||||
height: '300px',
|
||||
borderRadius: '12px',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
<strong>{drop.name}</strong>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: '300px',
|
||||
height: '300px',
|
||||
background: 'var(--bg-soft)',
|
||||
borderRadius: '12px',
|
||||
marginBottom: '12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'var(--muted)',
|
||||
}}
|
||||
>
|
||||
No Image
|
||||
</div>
|
||||
)}
|
||||
<strong>{drop.item}</strong>
|
||||
<br />
|
||||
<span className="meta">{drop.soldIn}</span>
|
||||
<span className="meta">{formatSoldOutTime(drop.soldOutInHours)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
28
app/page.tsx
28
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 (
|
||||
<>
|
||||
<Suspense fallback={null}>
|
||||
<PaymentHandler />
|
||||
</Suspense>
|
||||
<Nav />
|
||||
<header className="container">
|
||||
<h1>Shop together. Wholesale prices for private buyers.</h1>
|
||||
|
||||
68
lib/auth.ts
Normal file
68
lib/auth.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { cookies } from 'next/headers'
|
||||
import pool from './db'
|
||||
|
||||
export interface User {
|
||||
id: number
|
||||
username: string
|
||||
email: string
|
||||
}
|
||||
|
||||
// Get the current user from session cookie
|
||||
export async function getCurrentUser(): Promise<User | null> {
|
||||
try {
|
||||
const cookieStore = await cookies()
|
||||
const buyerId = cookieStore.get('buyer_id')?.value
|
||||
|
||||
if (!buyerId) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [rows] = await pool.execute(
|
||||
'SELECT id, username, email FROM buyers WHERE id = ?',
|
||||
[buyerId]
|
||||
)
|
||||
|
||||
const buyers = rows as any[]
|
||||
if (buyers.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
id: buyers[0].id,
|
||||
username: buyers[0].username,
|
||||
email: buyers[0].email,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting current user:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Get buyer ID from request cookies (for API routes)
|
||||
export async function getBuyerIdFromRequest(
|
||||
request: Request
|
||||
): Promise<number | null> {
|
||||
try {
|
||||
const cookieHeader = request.headers.get('cookie')
|
||||
if (!cookieHeader) {
|
||||
return null
|
||||
}
|
||||
|
||||
const cookies = cookieHeader.split(';').reduce((acc, cookie) => {
|
||||
const [key, value] = cookie.trim().split('=')
|
||||
acc[key] = value
|
||||
return acc
|
||||
}, {} as Record<string, string>)
|
||||
|
||||
const buyerId = cookies['buyer_id']
|
||||
if (!buyerId) {
|
||||
return null
|
||||
}
|
||||
|
||||
return parseInt(buyerId, 10)
|
||||
} catch (error) {
|
||||
console.error('Error getting buyer ID from request:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
33
lib/nowpayments.ts
Normal file
33
lib/nowpayments.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
// NOWPayments API configuration
|
||||
export function getNowPaymentsConfig() {
|
||||
const isTestnet = process.env.NOWPAYMENTS_TESTNET === 'true'
|
||||
|
||||
// For testnet, use sandbox API key if available, otherwise fall back to regular API key
|
||||
const apiKey = isTestnet
|
||||
? (process.env.NOWPAYMENTS_SANDBOX_API_KEY || process.env.NOWPAYMENTS_API_KEY || '')
|
||||
: (process.env.NOWPAYMENTS_API_KEY || '')
|
||||
|
||||
// Sandbox/testnet uses api-sandbox.nowpayments.io
|
||||
// If the environment variable is not explicitly set, default to production
|
||||
const baseUrl = isTestnet
|
||||
? 'https://api-sandbox.nowpayments.io'
|
||||
: 'https://api.nowpayments.io'
|
||||
|
||||
// Currency configuration
|
||||
// Default: USD for testnet (sandbox doesn't support CHF), CHF for production
|
||||
// Can be overridden with NOWPAYMENTS_CURRENCY env variable
|
||||
const defaultCurrency = isTestnet ? 'usd' : 'chf'
|
||||
const currency = (process.env.NOWPAYMENTS_CURRENCY || defaultCurrency).toLowerCase()
|
||||
|
||||
if (isTestnet) {
|
||||
console.log('Using NOWPayments Sandbox/Testnet environment')
|
||||
}
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
apiKey,
|
||||
isTestnet,
|
||||
currency,
|
||||
}
|
||||
}
|
||||
|
||||
20
migrations/create_pending_orders.sql
Normal file
20
migrations/create_pending_orders.sql
Normal file
@@ -0,0 +1,20 @@
|
||||
-- Create pending_orders table to store invoice info before payment confirmation
|
||||
CREATE TABLE IF NOT EXISTS `pending_orders` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`payment_id` varchar(255) NOT NULL,
|
||||
`order_id` varchar(255) NOT NULL,
|
||||
`drop_id` int(11) NOT NULL,
|
||||
`buyer_id` int(11) NOT NULL,
|
||||
`size` int(11) NOT NULL,
|
||||
`price_amount` decimal(10,2) NOT NULL,
|
||||
`price_currency` varchar(10) NOT NULL DEFAULT 'chf',
|
||||
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `payment_id` (`payment_id`),
|
||||
UNIQUE KEY `order_id` (`order_id`),
|
||||
KEY `drop_id` (`drop_id`),
|
||||
KEY `buyer_id` (`buyer_id`),
|
||||
CONSTRAINT `pending_orders_ibfk_1` FOREIGN KEY (`drop_id`) REFERENCES `drops` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT `pending_orders_ibfk_2` FOREIGN KEY (`buyer_id`) REFERENCES `buyers` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
|
||||
46
package-lock.json
generated
46
package-lock.json
generated
@@ -8,12 +8,14 @@
|
||||
"name": "420deals-ch",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"bcrypt": "^6.0.0",
|
||||
"mysql2": "^3.16.0",
|
||||
"next": "^14.2.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
@@ -186,6 +188,16 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/bcrypt": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz",
|
||||
"integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.19.27",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.27.tgz",
|
||||
@@ -234,6 +246,20 @@
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bcrypt": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
|
||||
"integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-addon-api": "^8.3.0",
|
||||
"node-gyp-build": "^4.8.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/busboy": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
||||
@@ -463,6 +489,26 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/node-addon-api": {
|
||||
"version": "8.5.0",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz",
|
||||
"integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18 || ^20 || >= 21"
|
||||
}
|
||||
},
|
||||
"node_modules/node-gyp-build": {
|
||||
"version": "4.8.4",
|
||||
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
|
||||
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"node-gyp-build": "bin.js",
|
||||
"node-gyp-build-optional": "optional.js",
|
||||
"node-gyp-build-test": "build-test.js"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
|
||||
@@ -9,12 +9,14 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcrypt": "^6.0.0",
|
||||
"mysql2": "^3.16.0",
|
||||
"next": "^14.2.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
|
||||
Reference in New Issue
Block a user