sync
This commit is contained in:
57
README.md
57
README.md
@@ -23,6 +23,22 @@ DB_PORT=3306
|
|||||||
DB_USER=root
|
DB_USER=root
|
||||||
DB_PASSWORD=your_password
|
DB_PASSWORD=your_password
|
||||||
DB_NAME=cbd420
|
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
|
### Installation
|
||||||
@@ -57,11 +73,52 @@ Access the admin panel at `/admin` to:
|
|||||||
- **Product Image**: Optional product image upload (JPEG, PNG, WebP, max 5MB)
|
- **Product Image**: Optional product image upload (JPEG, PNG, WebP, max 5MB)
|
||||||
3. Click "Create Drop"
|
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
|
## Project Structure
|
||||||
|
|
||||||
- `app/` - Next.js app directory
|
- `app/` - Next.js app directory
|
||||||
- `api/drops/` - API routes for drop management
|
- `api/drops/` - API routes for drop management
|
||||||
|
- `api/payments/` - Payment integration endpoints
|
||||||
- `admin/` - Admin panel page
|
- `admin/` - Admin panel page
|
||||||
- `components/` - React components
|
- `components/` - React components
|
||||||
- `lib/db.ts` - Database connection pool
|
- `lib/db.ts` - Database connection pool
|
||||||
|
- `lib/nowpayments.ts` - NOWPayments API configuration
|
||||||
- `cbd420.sql` - Database schema
|
- `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)
|
// GET /api/drops/active - Get the earliest unfilled drop (not sold out)
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
|
// Get all drops ordered by creation date
|
||||||
const [rows] = await pool.execute(
|
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[]
|
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) {
|
} catch (error) {
|
||||||
console.error('Error fetching active drop:', error)
|
console.error('Error fetching active drop:', error)
|
||||||
return NextResponse.json(
|
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(
|
const [rows] = await pool.execute(
|
||||||
'SELECT * FROM drops ORDER BY created_at DESC'
|
'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) {
|
} catch (error) {
|
||||||
console.error('Error fetching drops:', error)
|
console.error('Error fetching drops:', error)
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -34,8 +61,9 @@ export async function POST(request: NextRequest) {
|
|||||||
// Insert new drop
|
// Insert new drop
|
||||||
// Note: If imageUrl column doesn't exist in database, add it with:
|
// 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;
|
// 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(
|
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]
|
[item, size, unit, ppu, imageUrl || null]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -46,7 +74,13 @@ export async function POST(request: NextRequest) {
|
|||||||
insertId,
|
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) {
|
} catch (error) {
|
||||||
console.error('Error creating drop:', error)
|
console.error('Error creating drop:', error)
|
||||||
return NextResponse.json(
|
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 { useState, useEffect } from 'react'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
|
import AuthModal from './AuthModal'
|
||||||
|
|
||||||
interface DropData {
|
interface DropData {
|
||||||
id: number
|
id: number
|
||||||
@@ -14,15 +15,43 @@ interface DropData {
|
|||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
email: string
|
||||||
|
}
|
||||||
|
|
||||||
export default function Drop() {
|
export default function Drop() {
|
||||||
const [drop, setDrop] = useState<DropData | null>(null)
|
const [drop, setDrop] = useState<DropData | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [selectedSize, setSelectedSize] = useState(50)
|
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(() => {
|
useEffect(() => {
|
||||||
fetchActiveDrop()
|
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 () => {
|
const fetchActiveDrop = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/drops/active')
|
const response = await fetch('/api/drops/active')
|
||||||
@@ -65,6 +94,85 @@ export default function Drop() {
|
|||||||
return sizes.filter((size) => size <= remainingInGrams)
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="drop">
|
<div className="drop">
|
||||||
@@ -137,7 +245,7 @@ export default function Drop() {
|
|||||||
<span style={{ width: `${progressPercentage}%` }}></span>
|
<span style={{ width: `${progressPercentage}%` }}></span>
|
||||||
</div>
|
</div>
|
||||||
<div className="meta">
|
<div className="meta">
|
||||||
{drop.fill}
|
{drop.unit === 'kg' ? drop.fill.toFixed(2) : Math.round(drop.fill)}
|
||||||
{drop.unit} of {drop.size}
|
{drop.unit} of {drop.size}
|
||||||
{drop.unit} reserved
|
{drop.unit} reserved
|
||||||
</div>
|
</div>
|
||||||
@@ -156,7 +264,9 @@ export default function Drop() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
||||||
)}
|
)}
|
||||||
</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>
|
</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() {
|
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 (
|
return (
|
||||||
|
<>
|
||||||
<nav>
|
<nav>
|
||||||
<div className="brand">420Deals.ch</div>
|
<div className="brand">420Deals.ch</div>
|
||||||
<div className="links">
|
<div className="links">
|
||||||
<a href="#drop">Drop</a>
|
<a href="#drop">Drop</a>
|
||||||
<a href="#past">Past Drops</a>
|
<a href="#past">Past Drops</a>
|
||||||
<a href="#community">Community</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>
|
</div>
|
||||||
</nav>
|
</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'
|
import Image from 'next/image'
|
||||||
|
|
||||||
interface PastDrop {
|
interface PastDrop {
|
||||||
name: string
|
id: number
|
||||||
image: string
|
item: string
|
||||||
soldIn: 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() {
|
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 (
|
return (
|
||||||
<div className="past">
|
<div className="past">
|
||||||
{pastDrops.map((drop, index) => (
|
<p style={{ color: 'var(--muted)', textAlign: 'center' }}>Loading past drops...</p>
|
||||||
<div key={index} className="card">
|
</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
|
<Image
|
||||||
src={drop.image}
|
src={drop.image_url}
|
||||||
alt={drop.name}
|
alt={drop.item}
|
||||||
width={240}
|
width={300}
|
||||||
height={240}
|
height={300}
|
||||||
style={{ width: '100%', height: 'auto', borderRadius: '12px', marginBottom: '12px' }}
|
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 />
|
<br />
|
||||||
<span className="meta">{drop.soldIn}</span>
|
<span className="meta">{formatSoldOutTime(drop.soldOutInHours)}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -206,8 +206,9 @@ header p {
|
|||||||
|
|
||||||
.past {
|
.past {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(400px, 400px));
|
||||||
gap: 30px;
|
gap: 30px;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.past .card {
|
.past .card {
|
||||||
@@ -215,6 +216,7 @@ header p {
|
|||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
|
width: 400px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.past img {
|
.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 Nav from './components/Nav'
|
||||||
import Drop from './components/Drop'
|
import Drop from './components/Drop'
|
||||||
import InfoBox from './components/InfoBox'
|
import InfoBox from './components/InfoBox'
|
||||||
@@ -5,9 +9,33 @@ import Signup from './components/Signup'
|
|||||||
import PastDrops from './components/PastDrops'
|
import PastDrops from './components/PastDrops'
|
||||||
import Footer from './components/Footer'
|
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() {
|
export default function Home() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<PaymentHandler />
|
||||||
|
</Suspense>
|
||||||
<Nav />
|
<Nav />
|
||||||
<header className="container">
|
<header className="container">
|
||||||
<h1>Shop together. Wholesale prices for private buyers.</h1>
|
<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",
|
"name": "420deals-ch",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
"mysql2": "^3.16.0",
|
"mysql2": "^3.16.0",
|
||||||
"next": "^14.2.0",
|
"next": "^14.2.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/bcrypt": "^6.0.0",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^18",
|
"@types/react": "^18",
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
@@ -186,6 +188,16 @@
|
|||||||
"tslib": "^2.4.0"
|
"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": {
|
"node_modules/@types/node": {
|
||||||
"version": "20.19.27",
|
"version": "20.19.27",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.27.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.27.tgz",
|
||||||
@@ -234,6 +246,20 @@
|
|||||||
"node": ">= 6.0.0"
|
"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": {
|
"node_modules/busboy": {
|
||||||
"version": "1.6.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
"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": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
|
|||||||
@@ -9,12 +9,14 @@
|
|||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
"mysql2": "^3.16.0",
|
"mysql2": "^3.16.0",
|
||||||
"next": "^14.2.0",
|
"next": "^14.2.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/bcrypt": "^6.0.0",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^18",
|
"@types/react": "^18",
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
|
|||||||
Reference in New Issue
Block a user