This commit is contained in:
root
2025-12-21 12:46:27 +01:00
parent 5e65144934
commit bb1c5b43d6
18 changed files with 1375 additions and 691 deletions

View File

@@ -0,0 +1,355 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>420Deals.ch Premium Swiss CBD Drops</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
<style>
:root {
--bg: #0e0e0e;
--bg-soft: #151515;
--card: #1c1c1c;
--text: #eaeaea;
--muted: #9a9a9a;
--accent: #3ddc84;
--border: #2a2a2a;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: 'Inter', sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.6;
}
a { color: inherit; text-decoration: none; }
nav {
position: sticky;
top: 0;
z-index: 10;
background: rgba(14,14,14,0.9);
backdrop-filter: blur(10px);
border-bottom: 1px solid var(--border);
padding: 20px 40px;
display: flex;
justify-content: space-between;
align-items: center;
}
nav .brand { font-weight: 600; font-size: 18px; letter-spacing: 0.5px; }
nav .links a { margin-left: 28px; font-size: 14px; color: var(--muted); }
nav .links a:hover { color: var(--text); }
.unlock-bar {
background: var(--bg-soft);
border-bottom: 1px solid var(--border);
padding: 12px 20px;
font-size: 14px;
color: var(--muted);
text-align: center;
}
.unlock-bar strong { color: var(--text); }
.unlock-bar a {
margin-left: 10px;
color: var(--accent);
font-weight: 500;
}
.container { max-width: 1200px; margin: 0 auto; padding: 80px 20px; }
header { padding-top: 120px; padding-bottom: 80px; }
header h1 {
font-size: 44px;
font-weight: 600;
max-width: 760px;
letter-spacing: -0.5px;
}
header p {
margin-top: 20px;
font-size: 18px;
color: var(--muted);
max-width: 620px;
}
.drop {
background: var(--card);
border-radius: 20px;
padding: 40px;
display: grid;
grid-template-columns: 420px 1fr;
gap: 50px;
border: 1px solid var(--border);
}
.drop img {
width: 100%;
border-radius: 16px;
object-fit: cover;
}
.drop h2 { font-size: 28px; margin: 0 0 10px; }
.drop .meta { color: var(--muted); margin-bottom: 20px; }
.price {
font-size: 22px;
font-weight: 500;
margin-bottom: 20px;
}
.price .muted {
display: block;
margin-top: 6px;
font-size: 14px;
color: var(--muted);
}
.price .hint {
font-size: 13px;
color: var(--muted);
margin-top: 4px;
}
.price a {
color: var(--accent);
font-weight: 500;
}
.progress {
background: var(--bg-soft);
border-radius: 10px;
height: 10px;
overflow: hidden;
margin-bottom: 12px;
}
.progress span {
display: block;
height: 100%;
width: 62%;
background: linear-gradient(90deg, var(--accent), #1fa463);
}
.options button {
background: transparent;
border: 1px solid var(--border);
color: var(--text);
padding: 14px 20px;
margin-right: 12px;
border-radius: 12px;
cursor: pointer;
font-size: 14px;
}
.options button.active,
.options button:hover {
border-color: var(--accent);
color: var(--accent);
}
.cta {
margin-top: 30px;
padding: 16px 28px;
background: var(--accent);
color: #000;
border: none;
border-radius: 14px;
font-size: 15px;
font-weight: 500;
cursor: pointer;
}
.cta-note {
margin-top: 8px;
font-size: 13px;
color: var(--muted);
}
.info-box {
margin-top: 60px;
background: var(--card);
border: 1px solid var(--border);
border-radius: 16px;
padding: 30px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px,1fr));
gap: 30px;
}
.info-box div h3 { margin-bottom: 8px; font-size: 18px; }
.info-box div p { color: var(--muted); font-size: 14px; }
.signup {
background: var(--card);
border-radius: 20px;
padding: 50px;
text-align: center;
border: 1px solid var(--border);
}
.signup input {
width: 260px;
padding: 14px;
margin: 10px;
border-radius: 12px;
border: 1px solid var(--border);
background: var(--bg-soft);
color: var(--text);
}
.signup small {
display: block;
margin-top: 10px;
font-size: 13px;
color: var(--muted);
}
.signup button {
margin-top: 20px;
padding: 14px 28px;
background: var(--accent);
color: #000;
border: none;
border-radius: 14px;
cursor: pointer;
}
.past {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px,1fr));
gap: 30px;
}
.past .card {
background: var(--card);
border-radius: 16px;
padding: 20px;
border: 1px solid var(--border);
}
.past img { width: 100%; border-radius: 12px; margin-bottom: 12px; }
footer {
padding: 60px 20px;
text-align: center;
color: var(--muted);
font-size: 13px;
border-top: 1px solid var(--border);
}
@media(max-width:900px) {
.drop { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<nav>
<div class="brand">420Deals.ch</div>
<div class="links">
<a href="#drop">Drop</a>
<a href="#past">Past Drops</a>
<a href="#community">Community</a>
</div>
</nav>
<div class="unlock-bar">
🔒 Wholesale prices locked — <strong>1 / 3 referrals completed</strong> · 2 to go
<br>
<small>3 verified sign-ups unlock wholesale prices forever.</small>
<a href="#unlock">Unlock now</a>
</div>
<header class="container">
<h1>Buy together. Wholesale prices for private buyers.</h1>
<p>Limited CBD drops directly from Swiss producers. No retail. No marketing markup. Just collective volume pricing.</p>
</header>
<section class="container" id="drop">
<div class="drop">
<img src="https://images.unsplash.com/photo-1604908554027-0b6c2c9c7e92" />
<div>
<h2>Harlequin Collective Drop</h2>
<div class="meta">1kg batch · Indoor · Switzerland</div>
<div class="price">
<strong>Standard price: 2.50 CHF / g</strong><br>
<span class="muted">
Wholesale: 1.90 CHF / g 🔒 <a href="#unlock">unlock</a>
</span>
<div class="hint">Unlock once. Keep wholesale forever.</div>
</div>
<div class="progress"><span></span></div>
<div class="meta">620g of 1,000g reserved</div>
<div class="options">
<button class="active">50g</button>
<button>100g</button>
<button>250g</button>
</div>
<button class="cta">Join the drop</button>
<div class="cta-note">No subscription · No obligation</div>
</div>
</div>
<div class="info-box">
<div>
<h3>Why so cheap?</h3>
<p>Retail prices average around 10 CHF/g. By buying collectively, we purchase like wholesalers — without intermediaries.</p>
</div>
<div>
<h3>Taxes & Legal</h3>
<p>Bulk sales with 2.5% VAT. No retail packaging, no tobacco tax.</p>
</div>
<div>
<h3>Drop model</h3>
<p>One strain per drop. Once sold out, the next drop goes live.</p>
</div>
</div>
</section>
<section class="container" id="community">
<div class="signup">
<h2>Drop notifications</h2>
<p>Receive updates about new drops via email or WhatsApp.</p>
<input type="email" placeholder="Email" />
<input type="text" placeholder="WhatsApp number" />
<br />
<button>Notify me</button>
<small>Counts as a referral sign-up if invited.</small>
</div>
</section>
<section class="container" id="past">
<h2>Past Drops</h2>
<div class="past">
<div class="card">
<img src="https://images.unsplash.com/photo-1581091012184-5c7b4c101899" />
<strong>Swiss Gold</strong><br><span class="meta">Sold out in 42h</span>
</div>
<div class="card">
<img src="https://images.unsplash.com/photo-1512436991641-6745cdb1723f" />
<strong>Lemon T1</strong><br><span class="meta">Sold out in 19h</span>
</div>
<div class="card">
<img src="https://images.unsplash.com/photo-1600431521340-491eca880813" />
<strong>Alpine Frost</strong><br><span class="meta">Sold out in 31h</span>
</div>
</div>
</section>
<footer>
© 2025 420Deals.ch · CBD &lt; 1% THC · 18+ only · Switzerland
</footer>
</body>
</html>

View File

@@ -1,399 +0,0 @@
# IPN Callback Integration Guide
## Overview
This document describes the race condition prevention system implemented for the 420Deals.ch collective drop platform. The system uses a **10-minute reservation mechanism** via the `pending_orders` table to prevent overselling when multiple buyers attempt to purchase the last available units simultaneously.
## Database Schema
### `pending_orders` Table
```sql
CREATE TABLE `pending_orders` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`payment_id` varchar(255) NOT NULL, -- NOWPayments invoice/payment ID
`order_id` varchar(255) NOT NULL, -- Internal order ID (format: SALE-{timestamp}-{drop_id}-{buyer_id})
`drop_id` int(11) NOT NULL, -- Foreign key to drops table
`buyer_id` int(11) NOT NULL, -- Foreign key to buyers table
`size` int(11) NOT NULL, -- Quantity in grams
`price_amount` decimal(10,2) NOT NULL, -- Price amount
`price_currency` varchar(10) NOT NULL DEFAULT 'chf',
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
`expires_at` datetime NOT NULL, -- Expiration time (10 minutes from creation)
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`),
KEY `idx_expires_at` (`expires_at`), -- Index for cleanup queries
FOREIGN KEY (`drop_id`) REFERENCES `drops` (`id`) ON DELETE CASCADE,
FOREIGN KEY (`buyer_id`) REFERENCES `buyers` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
### `sales` Table
```sql
CREATE TABLE `sales` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`drop_id` int(11) NOT NULL,
`buyer_id` int(11) NOT NULL,
`size` int(11) NOT NULL DEFAULT 1, -- Quantity in grams
`payment_id` text NOT NULL DEFAULT '', -- NOWPayments payment ID (matches pending_orders.payment_id)
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
KEY `drop_id` (`drop_id`),
KEY `buyer_id` (`buyer_id`),
FOREIGN KEY (`drop_id`) REFERENCES `drops` (`id`) ON DELETE CASCADE,
FOREIGN KEY (`buyer_id`) REFERENCES `buyers` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
## How the System Works
### 1. Purchase Flow
When a buyer initiates a purchase:
1. **Inventory Check**: System checks available inventory = `drop.size - (sales.total + non_expired_pending_orders.total)`
2. **Reservation**: If inventory available, creates `pending_order` with:
- `expires_at` = NOW() + 10 minutes
- Inventory is now "on hold"
3. **Invoice Creation**: Creates NOWPayments invoice with `invoice_timeout: 600` (10 minutes)
4. **Transaction**: All of the above happens atomically in a database transaction
### 2. Inventory Calculation
**Available Inventory** = `drop.size - (SUM(sales.size) + SUM(pending_orders.size WHERE expires_at > NOW()))`
- `sales.size`: Confirmed purchases (permanent)
- `pending_orders.size`: Temporary reservations (expire after 10 minutes)
### 3. Expiration
- Pending orders expire 10 minutes after creation (`expires_at < NOW()`)
- Expired orders should be cleaned up periodically (recommended: every 1-2 minutes)
- Cleanup endpoint: `POST /api/payments/cleanup-expired`
- Expired orders are automatically excluded from inventory calculations
## IPN Callback Handling
### NOWPayments IPN Callback Format
The system expects IPN callbacks with the following structure:
```json
{
"payment_id": "string", // NOWPayments payment/invoice ID
"invoice_id": "string", // Alternative field name (used if payment_id not present)
"order_id": "string", // Internal order ID (format: SALE-{timestamp}-{drop_id}-{buyer_id})
"payment_status": "string", // Status: waiting, confirming, confirmed, finished, failed, expired, etc.
"pay_amount": "number",
"pay_currency": "string",
"price_amount": "number",
"price_currency": "string"
}
```
### IPN Callback Processing Logic
Your IPN callback handler should follow this flow:
#### Step 1: Find Pending Order
```sql
SELECT * FROM pending_orders
WHERE payment_id = ? OR payment_id = ?
-- Try both payment_id and invoice_id from callback
```
**Important**: The system uses `payment_id` OR `invoice_id` to find pending orders. Check both fields.
#### Step 2: Check Expiration
```sql
-- Verify order hasn't expired
SELECT * FROM pending_orders
WHERE id = ? AND expires_at > NOW()
```
**Action if expired**:
- Delete the pending order
- Return error response (don't create sale)
- Log the expiration
#### Step 3: Validate Payment Status
Process based on `payment_status`:
- **`finished`** or **`confirmed`**: Payment successful → proceed to Step 4
- **`failed`** or **`expired`**: Payment failed → delete pending order, return success
- **`waiting`**, **`confirming`**: Payment in progress → return success, wait for final status
#### Step 4: Final Inventory Check (Before Creating Sale)
```sql
-- Get drop details
SELECT * FROM drops WHERE id = ?
-- Calculate current inventory
SELECT COALESCE(SUM(size), 0) as total_sales
FROM sales WHERE drop_id = ?
-- Calculate other pending orders (excluding current one)
SELECT COALESCE(SUM(size), 0) as total_pending
FROM pending_orders
WHERE drop_id = ?
AND id != ?
AND expires_at > NOW()
-- Check availability
-- Available = drop.size - (total_sales + total_pending)
-- If pending_order.size > Available: REJECT
```
**Important**: Always check inventory again before creating the sale, as other buyers may have reserved inventory in the meantime.
#### Step 5: Create Sale Record
If inventory is available:
```sql
-- Create sale (include buyer_data_id for delivery information)
INSERT INTO sales (drop_id, buyer_id, buyer_data_id, size, payment_id)
VALUES (?, ?, ?, ?, ?)
-- Delete pending order
DELETE FROM pending_orders WHERE id = ?
```
**Important**:
- Use the same `payment_id` from pending_order for the sale record
- Delete the pending order after creating the sale
- This should be done in a transaction to ensure atomicity
#### Step 6: Handle Idempotency
Before creating a sale, check if it already exists:
```sql
SELECT * FROM sales WHERE payment_id = ?
```
If sale exists, return success (idempotent operation).
### Example IPN Callback Handler (Pseudocode)
```javascript
async function handleIPNCallback(callbackData) {
const { payment_id, invoice_id, order_id, payment_status } = callbackData;
// Step 1: Find pending order
const paymentIdToFind = invoice_id || payment_id;
const pendingOrder = await db.query(
'SELECT * FROM pending_orders WHERE payment_id = ?',
[paymentIdToFind]
);
if (!pendingOrder) {
// Check if sale already exists (idempotency)
const existingSale = await db.query(
'SELECT * FROM sales WHERE payment_id = ?',
[paymentIdToFind]
);
if (existingSale) {
return { status: 'ok' }; // Already processed
}
return { error: 'Pending order not found' };
}
// Step 2: Check expiration
if (new Date(pendingOrder.expires_at) < new Date()) {
await db.query('DELETE FROM pending_orders WHERE id = ?', [pendingOrder.id]);
return { error: 'Order expired' };
}
// Step 3: Process payment status
if (payment_status === 'finished' || payment_status === 'confirmed') {
// Step 4: Final inventory check
const drop = await db.query('SELECT * FROM drops WHERE id = ?', [pendingOrder.drop_id]);
const sales = await db.query(
'SELECT COALESCE(SUM(size), 0) as total FROM sales WHERE drop_id = ?',
[pendingOrder.drop_id]
);
const otherPending = await db.query(
'SELECT COALESCE(SUM(size), 0) as total FROM pending_orders WHERE drop_id = ? AND id != ? AND expires_at > NOW()',
[pendingOrder.drop_id, pendingOrder.id]
);
const totalReserved = sales.total + otherPending.total;
const available = drop.size - totalReserved;
if (pendingOrder.size > available) {
await db.query('DELETE FROM pending_orders WHERE id = ?', [pendingOrder.id]);
return { error: 'Inventory no longer available' };
}
// Step 5: Create sale (include buyer_data_id for delivery information)
await db.transaction(async (tx) => {
await tx.query(
'INSERT INTO sales (drop_id, buyer_id, buyer_data_id, size, payment_id) VALUES (?, ?, ?, ?, ?)',
[pendingOrder.drop_id, pendingOrder.buyer_id, pendingOrder.buyer_data_id, pendingOrder.size, pendingOrder.payment_id]
);
await tx.query('DELETE FROM pending_orders WHERE id = ?', [pendingOrder.id]);
});
return { status: 'ok' };
} else if (payment_status === 'failed' || payment_status === 'expired') {
await db.query('DELETE FROM pending_orders WHERE id = ?', [pendingOrder.id]);
return { status: 'ok' };
}
return { status: 'ok' }; // Payment still in progress
}
```
## Important Considerations
### 1. Database Transactions
- Always use transactions when creating sales and deleting pending orders
- This ensures atomicity and prevents race conditions
### 2. Expiration Handling
- Expired pending orders should be excluded from inventory calculations
- Clean up expired orders periodically (every 1-2 minutes recommended)
- The main application has a cleanup endpoint: `POST /api/payments/cleanup-expired`
### 3. Unit Conversion
- All `size` values in `sales` and `pending_orders` are stored in **grams**
- `drops.size` and `drops.unit` may be in different units (g or kg)
- When calculating inventory, convert to the drop's unit:
```javascript
if (drop.unit === 'kg') {
sizeInDropUnit = sizeInGrams / 1000;
}
```
### 4. Idempotency
- IPN callbacks may be sent multiple times
- Always check if a sale already exists before creating a new one
- Use `payment_id` to check for existing sales
### 5. Error Handling
- Always return HTTP 200 to NOWPayments, even on errors
- Log errors for debugging
- Don't retry failed operations indefinitely
### 6. Inventory Availability
- Inventory is calculated as: `drop.size - (sales + non_expired_pending_orders)`
- Always re-check inventory before creating a sale
- Other buyers may have reserved inventory between payment initiation and confirmation
## API Endpoints Reference
### Cleanup Expired Orders
```
POST /api/payments/cleanup-expired
Authorization: Bearer {CLEANUP_API_TOKEN} (optional)
Response:
{
"message": "Cleaned up X expired pending orders",
"cleaned": 5,
"total": 5
}
```
### Check Expired Orders Count
```
GET /api/payments/cleanup-expired
Response:
{
"expired_orders_count": 3,
"message": "There are 3 expired pending orders that need cleanup"
}
```
## Testing Checklist
When implementing your IPN callback handler, test:
1. ✅ Payment success → Sale created, pending order deleted
2. ✅ Payment failure → Pending order deleted, no sale created
3. ✅ Payment expiration → Pending order deleted, no sale created
4. ✅ Expired pending order → Rejected, no sale created
5. ✅ Insufficient inventory → Pending order deleted, no sale created
6. ✅ Duplicate IPN callbacks → Idempotent (sale not created twice)
7. ✅ Race condition → Only one sale created when multiple payments complete simultaneously
## Database Queries Reference
### Find Pending Order by Payment ID
```sql
SELECT * FROM pending_orders
WHERE payment_id = ? OR payment_id = ?
```
### Check if Pending Order is Expired
```sql
SELECT * FROM pending_orders
WHERE id = ? AND expires_at > NOW()
```
### Calculate Available Inventory
```sql
-- Sales
SELECT COALESCE(SUM(size), 0) as total_sales
FROM sales WHERE drop_id = ?
-- Non-expired pending orders
SELECT COALESCE(SUM(size), 0) as total_pending
FROM pending_orders
WHERE drop_id = ? AND expires_at > NOW()
-- Available = drop.size - (total_sales + total_pending)
```
### Create Sale and Delete Pending Order (Transaction)
```sql
START TRANSACTION;
INSERT INTO sales (drop_id, buyer_id, buyer_data_id, size, payment_id)
VALUES (?, ?, ?, ?, ?);
DELETE FROM pending_orders WHERE id = ?;
COMMIT;
```
### Check for Existing Sale (Idempotency)
```sql
SELECT * FROM sales WHERE payment_id = ?
```
## Environment Variables
The main application uses these environment variables (for reference):
- `IPN_CALLBACK_URL`: URL where NOWPayments sends IPN callbacks
- `CLEANUP_API_TOKEN`: (Optional) Token for cleanup endpoint authentication
- `NOWPAYMENTS_API_KEY`: NOWPayments API key
- `NOWPAYMENTS_TESTNET`: Set to 'true' for sandbox environment
## Support
For questions or issues with the IPN callback integration, refer to:
- Database schema: `cbd420.sql`
- Race condition fix documentation: `RACE_CONDITION_FIX.md`
**Note**: The main application does not include an IPN callback handler. All IPN callbacks must be handled by your external service using the logic described in this document.

View File

@@ -1,148 +0,0 @@
# Race Condition Fix - Implementation Summary
## Problem
When multiple buyers attempt to purchase the last available units simultaneously, a race condition occurs where inventory can be oversold.
## Solution
Implemented a 10-minute reservation system using the `pending_orders` table to temporarily hold inventory during checkout.
## Changes Made
### 1. Database Migration
- **File**: `migrations/add_expires_at_to_pending_orders.sql`
- Added `expires_at` column to `pending_orders` table
- Added index on `expires_at` for efficient cleanup queries
**To apply the migration:**
```sql
-- Run the migration file
SOURCE migrations/add_expires_at_to_pending_orders.sql;
```
### 2. Create Invoice Endpoint (`app/api/payments/create-invoice/route.ts`)
- **Atomic Inventory Reservation**: Uses database transactions to atomically check and reserve inventory
- **Pending Orders Check**: Includes non-expired pending orders when calculating available inventory
- **Expiration Time**: Sets `expires_at` to 10 minutes from creation
- **NOWPayments Integration**: Sets `invoice_timeout` to 600 seconds (10 minutes) when creating invoice
### 3. Active Drop Endpoint (`app/api/drops/active/route.ts`)
- **Fill Calculation**: Includes non-expired pending orders in fill calculation
- **UI Display**: Progress bar now shows total reserved inventory (sales + pending orders)
### 4. Cleanup Endpoint (`app/api/payments/cleanup-expired/route.ts`)
- **Automatic Cleanup**: Removes expired pending orders
- **NOWPayments Status Check**: Verifies payment status before cleanup
- **Monitoring**: GET endpoint to check count of expired orders
### 5. UI Updates (`app/components/Drop.tsx`)
- **Visual Indicator**: Added note that items are held for 10 minutes during checkout
- **Progress Bar**: Automatically reflects reserved inventory (including pending orders)
## Setup Instructions
### 1. Run Database Migration
```bash
mysql -u your_user -p your_database < migrations/add_expires_at_to_pending_orders.sql
```
### 2. Set Up Cleanup Job
The cleanup endpoint should be called periodically (recommended: every 1-2 minutes) to remove expired pending orders.
#### Option A: Cron Job (Linux/Mac)
```bash
# Add to crontab (crontab -e)
*/2 * * * * curl -X POST https://your-domain.com/api/payments/cleanup-expired -H "Authorization: Bearer YOUR_CLEANUP_TOKEN"
```
#### Option B: Vercel Cron (if using Vercel)
Add to `vercel.json`:
```json
{
"crons": [{
"path": "/api/payments/cleanup-expired",
"schedule": "*/2 * * * *"
}]
}
```
#### Option C: Node.js Cron Library
```javascript
const cron = require('node-cron');
const fetch = require('node-fetch');
cron.schedule('*/2 * * * *', async () => {
await fetch('http://localhost:3000/api/payments/cleanup-expired', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.CLEANUP_API_TOKEN}`
}
});
});
```
### 3. Environment Variables
Add to your `.env` file:
```
# Required: URL for external IPN handler service
IPN_CALLBACK_URL=http://your-ipn-service.com/api/payments/ipn-callback
# Optional: Token for cleanup endpoint security
CLEANUP_API_TOKEN=your-secure-random-token
```
**Note**: IPN callbacks are handled by an external service. The `IPN_CALLBACK_URL` must point to your external IPN handler that processes payment confirmations and creates sales. See `IPN_INTEGRATION_README.md` for integration details.
## How It Works
1. **Purchase Initiation**: When a buyer clicks "Join Drop":
- System checks available inventory (sales + non-expired pending orders)
- If available, creates pending order with 10-minute expiration
- Creates NOWPayments invoice with 10-minute timeout
- Inventory is now "on hold" for 10 minutes
2. **During Checkout**:
- Progress bar shows reserved inventory (including pending orders)
- Other buyers see reduced availability
- If buyer completes payment within 10 minutes → sale is created
- If buyer doesn't complete payment within 10 minutes → order expires
3. **Expiration**:
- Cleanup job runs every 1-2 minutes
- Expired pending orders are removed
- Inventory becomes available again
- NOWPayments invoice expires on their end
4. **Payment Confirmation** (handled by external IPN service):
- External IPN handler receives payment notification from NOWPayments
- Checks if pending order is expired
- Validates final inventory availability
- Creates sale record if valid
- Deletes pending order
## Testing
1. **Test Race Condition**:
- Open two browser windows
- Try to purchase the last available unit simultaneously
- Only one should succeed
- The other should see "Not enough inventory remaining"
2. **Test Expiration**:
- Create a pending order
- Wait 10+ minutes
- Run cleanup endpoint manually
- Verify order is removed
3. **Test Payment Flow**:
- Create pending order
- Complete payment within 10 minutes
- Verify sale is created
- Verify pending order is deleted
## Notes
- Pending orders are automatically included in inventory calculations
- The 10-minute hold prevents overselling while giving buyers time to complete payment
- NOWPayments invoices also expire after 10 minutes
- Cleanup job should run frequently (every 1-2 minutes) to free up inventory quickly

View File

@@ -1,97 +0,0 @@
POSTCreate payment
https://api.nowpayments.io/v1/payment
Creates payment. With this method, your customer will be able to complete the payment without leaving your website.
Be sure to consider the details of repeated and wrong-asset deposits from 'Repeated Deposits and Wrong-Asset Deposits' section when processing payments.
Data must be sent as a JSON-object payload.
Required request fields:
price_amount (required) - the fiat equivalent of the price to be paid in crypto. If the pay_amount parameter is left empty, our system will automatically convert this fiat price into its crypto equivalent. Please note that this does not enable fiat payments, only provides a fiat price for yours and the customers convenience and information. NOTE: Some of the assets (KISHU, NWC, FTT, CHR, XYM, SRK, KLV, SUPER, OM, XCUR, NOW, SHIB, SAND, MATIC, CTSI, MANA, FRONT, FTM, DAO, LGCY), have a maximum price amount of ~$2000;
price_currency (required) - the fiat currency in which the price_amount is specified (usd, eur, etc);
pay_amount (optional) - the amount that users have to pay for the order stated in crypto. You can either specify it yourself, or we will automatically convert the amount you indicated in price_amount;
pay_currency (required) - the crypto currency in which the pay_amount is specified (btc, eth, etc), or one of available fiat currencies if it's enabled for your account (USD, EUR, ILS, GBP, AUD, RON);
NOTE: some of the currencies require a Memo, Destination Tag, etc., to complete a payment (AVA, EOS, BNBMAINNET, XLM, XRP). This is unique for each payment. This ID is received in “payin_extra_id” parameter of the response. Payments made without "payin_extra_id" cannot be detected automatically;
ipn_callback_url (optional) - url to receive callbacks, should contain "http" or "https", eg. "https://nowpayments.io";
order_id (optional) - inner store order ID, e.g. "RGDBP-21314";
order_description (optional) - inner store order description, e.g. "Apple Macbook Pro 2019 x 1";
payout_address (optional) - usually the funds will go to the address you specify in your Personal account. In case you want to receive funds on another address, you can specify it in this parameter;
payout_currency (optional) - currency of your external payout_address, required when payout_adress is specified;
payout_extra_id(optional) - extra id or memo or tag for external payout_address;
is_fixed_rate(optional) - boolean, can be true or false. Required for fixed-rate exchanges;
NOTE: the rate of exchange will be frozen for 20 minutes. If there are no incoming payments during this period, the payment status changes to "expired".
is_fee_paid_by_user(optional) - boolean, can be true or false. Required for fixed-rate exchanges with all fees paid by users;
NOTE: the rate of exchange will be frozen for 20 minutes. If there are no incoming payments during this period, the payment status changes to "expired". The fee paid by user payment can be only fixed rate. If you disable fixed rate during payment creation process, this flag would enforce fixed_rate to be true;
Here the list of available statuses of payment:
waiting - waiting for the customer to send the payment. The initial status of each payment;
confirming - the transaction is being processed on the blockchain. Appears when NOWPayments detect the funds from the user on the blockchain;
Please note: each currency has its own amount of confirmations required to start the processing.
confirmed - the process is confirmed by the blockchain. Customers funds have accumulated enough confirmations;
sending - the funds are being sent to your personal wallet. We are in the process of sending the funds to you;
partially_paid - it shows that the customer sent less than the actual price. Appears when the funds have arrived in your wallet;
finished - the funds have reached your personal address and the payment is finished;
failed - the payment wasn't completed due to the error of some kind;
expired - the user didn't send the funds to the specified address in the 7 days time window;
Please note: when you're creating a fiat2crypto payment you also should include additional header to your request - "origin-ip : xxx", where xxx is your customer IP address.
Request Example:
curl --location 'https://api.nowpayments.io/v1/payment' \
--header 'x-api-key: {{api-key}}' \
--header 'Content-Type: application/json' \
--data '{
"price_amount": 3999.5,
"price_currency": "usd",
"pay_currency": "btc",
"ipn_callback_url": "https://nowpayments.io",
"order_id": "RGDBP-21314",
"order_description": "Apple Macbook Pro 2019 x 1"
}'
Response:
{
"payment_id": "5745459419",
"payment_status": "waiting",
"pay_address": "3EZ2uTdVDAMFXTfc6uLDDKR6o8qKBZXVkj",
"price_amount": 3999.5,
"price_currency": "usd",
"pay_amount": 0.17070286,
"pay_currency": "btc",
"order_id": "RGDBP-21314",
"order_description": "Apple Macbook Pro 2019 x 1",
"ipn_callback_url": "https://nowpayments.io",
"created_at": "2020-12-22T15:00:22.742Z",
"updated_at": "2020-12-22T15:00:22.742Z",
"purchase_id": "5837122679",
"amount_received": null,
"payin_extra_id": null,
"smart_contract": "",
"network": "btc",
"network_precision": 8,
"time_limit": null,
"burning_percent": null,
"expiration_estimate_date": "2020-12-23T15:00:22.742Z"
}

View File

@@ -6,7 +6,7 @@ import bcrypt from 'bcrypt'
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const body = await request.json() const body = await request.json()
const { username, password, email } = body const { username, password, email, referral_id } = body
// Validate required fields // Validate required fields
if (!username || !password || !email) { if (!username || !password || !email) {
@@ -84,6 +84,27 @@ export async function POST(request: NextRequest) {
const buyer = (rows as any[])[0] const buyer = (rows as any[])[0]
// Handle referral if provided
if (referral_id) {
const referrerId = parseInt(referral_id, 10)
// Validate that referrer exists and is not the same as the new user
if (referrerId && referrerId !== buyer.id) {
const [referrerRows] = await pool.execute(
'SELECT id FROM buyers WHERE id = ?',
[referrerId]
)
if ((referrerRows as any[]).length > 0) {
// Create referral record
await pool.execute(
'INSERT INTO referrals (referrer, referree) VALUES (?, ?)',
[referrerId, buyer.id]
)
}
}
}
// Create session cookie // Create session cookie
const response = NextResponse.json( const response = NextResponse.json(
{ {

54
app/api/orders/route.ts Normal file
View File

@@ -0,0 +1,54 @@
import { NextRequest, NextResponse } from 'next/server'
import { cookies } from 'next/headers'
import pool from '@/lib/db'
// GET /api/orders - Get all orders (sales) for the current user
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)
// Get all sales for this buyer with drop and buyer_data information
const [rows] = await pool.execute(
`SELECT
s.id,
s.drop_id,
s.buyer_id,
s.size,
s.payment_id,
s.created_at,
d.item as drop_item,
d.unit as drop_unit,
d.ppu as drop_ppu,
d.image_url as drop_image_url,
bd.fullname as buyer_fullname,
bd.address as buyer_address,
bd.phone as buyer_phone
FROM sales s
LEFT JOIN drops d ON s.drop_id = d.id
LEFT JOIN buyer_data bd ON s.buyer_data_id = bd.id
WHERE s.buyer_id = ?
ORDER BY s.created_at DESC`,
[buyer_id]
)
return NextResponse.json(rows)
} catch (error) {
console.error('Error fetching orders:', error)
return NextResponse.json(
{ error: 'Failed to fetch orders' },
{ status: 500 }
)
}
}

View File

@@ -119,18 +119,23 @@ export async function POST(request: NextRequest) {
) )
} }
// Check if user has unlocked wholesale prices
const [referralRows] = await pool.execute(
'SELECT COUNT(*) as count FROM referrals WHERE referrer = ?',
[buyer_id]
)
const referralCount = (referralRows as any[])[0]?.count || 0
const isWholesaleUnlocked = referralCount >= 3
// Calculate price // Calculate price
// ppu is stored as integer where 1000 = $1.00, so divide by 1000 to get actual price // ppu is stored as integer where 1000 = $1.00, so divide by 1000 to get actual price
const pricePerUnit = drop.ppu / 1000 // Assuming ppu is per gram
let priceAmount = 0 const pricePerGram = drop.ppu / 1000
if (drop.unit === 'kg') { const priceToUse = isWholesaleUnlocked ? pricePerGram * 0.76 : pricePerGram
priceAmount = (size / 1000) * pricePerUnit const priceAmount = size * priceToUse
} else {
priceAmount = size * pricePerUnit
}
// Round to 2 decimal places // Round to 2 decimal places
priceAmount = Math.round(priceAmount * 100) / 100 const roundedPriceAmount = Math.round(priceAmount * 100) / 100
// Generate order ID // Generate order ID
const orderId = `SALE-${Date.now()}-${drop_id}-${buyer_id}` const orderId = `SALE-${Date.now()}-${drop_id}-${buyer_id}`
@@ -163,7 +168,7 @@ export async function POST(request: NextRequest) {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ body: JSON.stringify({
price_amount: priceAmount, price_amount: roundedPriceAmount,
price_currency: nowPaymentsConfig.currency, price_currency: nowPaymentsConfig.currency,
pay_currency: payCurrency, // Required: crypto currency (btc, eth, etc) pay_currency: payCurrency, // Required: crypto currency (btc, eth, etc)
order_id: orderId, order_id: orderId,
@@ -190,7 +195,7 @@ export async function POST(request: NextRequest) {
// payment.payment_id is the NOWPayments payment ID // payment.payment_id is the NOWPayments payment ID
await connection.execute( await connection.execute(
'INSERT INTO pending_orders (payment_id, order_id, drop_id, buyer_id, buyer_data_id, size, price_amount, price_currency, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', 'INSERT INTO pending_orders (payment_id, order_id, drop_id, buyer_id, buyer_data_id, size, price_amount, price_currency, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
[payment.payment_id, orderId, drop_id, buyer_id, buyer_data_id, size, priceAmount, nowPaymentsConfig.currency, expiresAt] [payment.payment_id, orderId, drop_id, buyer_id, buyer_data_id, size, roundedPriceAmount, nowPaymentsConfig.currency, expiresAt]
) )
// Commit transaction - inventory is now reserved // Commit transaction - inventory is now reserved

View File

@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from 'next/server'
import { cookies } from 'next/headers'
// GET /api/referrals/link - Get referral link for current user
export async function GET(request: NextRequest) {
try {
const cookieStore = cookies()
const buyerIdCookie = cookieStore.get('buyer_id')?.value
if (!buyerIdCookie) {
return NextResponse.json(
{ error: 'Authentication required' },
{ status: 401 }
)
}
const buyer_id = parseInt(buyerIdCookie, 10)
// Get base URL
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ||
request.headers.get('origin') ||
'http://localhost:3000'
// Create referral link with buyer_id as referral parameter
const referralLink = `${baseUrl}?ref=${buyer_id}`
return NextResponse.json({
referralLink,
referralId: buyer_id,
})
} catch (error) {
console.error('Error generating referral link:', error)
return NextResponse.json(
{ error: 'Failed to generate referral link' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,50 @@
import { NextRequest, NextResponse } from 'next/server'
import { cookies } from 'next/headers'
import pool from '@/lib/db'
// GET /api/referrals/status - Get referral count and unlock status for current user
export async function GET(request: NextRequest) {
try {
const cookieStore = cookies()
const buyerIdCookie = cookieStore.get('buyer_id')?.value
if (!buyerIdCookie) {
return NextResponse.json(
{
referralCount: 0,
isUnlocked: false,
referralsNeeded: 3,
referralsRemaining: 3
},
{ status: 200 }
)
}
const buyer_id = parseInt(buyerIdCookie, 10)
// Count referrals for this user
const [referralRows] = await pool.execute(
'SELECT COUNT(*) as count FROM referrals WHERE referrer = ?',
[buyer_id]
)
const referralCount = (referralRows as any[])[0]?.count || 0
const isUnlocked = referralCount >= 3
const referralsNeeded = 3
const referralsRemaining = Math.max(0, referralsNeeded - referralCount)
return NextResponse.json({
referralCount,
isUnlocked,
referralsNeeded,
referralsRemaining,
})
} catch (error) {
console.error('Error fetching referral status:', error)
return NextResponse.json(
{ error: 'Failed to fetch referral status' },
{ status: 500 }
)
}
}

View File

@@ -1,6 +1,7 @@
'use client' 'use client'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useSearchParams } from 'next/navigation'
interface User { interface User {
id: number id: number
@@ -15,10 +16,12 @@ interface AuthModalProps {
} }
export default function AuthModal({ isOpen, onClose, onLogin }: AuthModalProps) { export default function AuthModal({ isOpen, onClose, onLogin }: AuthModalProps) {
const searchParams = useSearchParams()
const [isLogin, setIsLogin] = useState(true) const [isLogin, setIsLogin] = useState(true)
const [username, setUsername] = useState('') const [username, setUsername] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [referralId, setReferralId] = useState('')
const [error, setError] = useState('') const [error, setError] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
@@ -30,8 +33,26 @@ export default function AuthModal({ isOpen, onClose, onLogin }: AuthModalProps)
setEmail('') setEmail('')
setError('') setError('')
setIsLogin(true) setIsLogin(true)
// Auto-fill referral ID from URL if present
const refFromUrl = searchParams?.get('ref')
if (refFromUrl) {
setReferralId(refFromUrl)
} else {
setReferralId('')
}
} }
}, [isOpen]) }, [isOpen, searchParams])
// Update referral ID when switching to register mode and URL has ref parameter
useEffect(() => {
if (!isLogin) {
const refFromUrl = searchParams?.get('ref')
if (refFromUrl && !referralId) {
setReferralId(refFromUrl)
}
}
}, [isLogin, searchParams, referralId])
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
@@ -40,9 +61,15 @@ export default function AuthModal({ isOpen, onClose, onLogin }: AuthModalProps)
try { try {
const endpoint = isLogin ? '/api/auth/login' : '/api/auth/register' const endpoint = isLogin ? '/api/auth/login' : '/api/auth/register'
// Use referral ID from input field, or fall back to URL parameter
const referralIdToUse = !isLogin && referralId.trim()
? referralId.trim()
: (!isLogin ? searchParams?.get('ref') : null)
const body = isLogin const body = isLogin
? { username, password } ? { username, password }
: { username, password, email } : { username, password, email, referral_id: referralIdToUse }
const response = await fetch(endpoint, { const response = await fetch(endpoint, {
method: 'POST', method: 'POST',
@@ -192,7 +219,7 @@ export default function AuthModal({ isOpen, onClose, onLogin }: AuthModalProps)
/> />
</div> </div>
<div style={{ marginBottom: '20px' }}> <div style={{ marginBottom: '16px' }}>
<label <label
htmlFor="password" htmlFor="password"
style={{ style={{
@@ -224,6 +251,43 @@ export default function AuthModal({ isOpen, onClose, onLogin }: AuthModalProps)
/> />
</div> </div>
{!isLogin && (
<div style={{ marginBottom: '20px' }}>
<label
htmlFor="referralId"
style={{
display: 'block',
marginBottom: '8px',
fontSize: '14px',
color: 'var(--text)',
}}
>
Referral ID <span style={{ color: 'var(--muted)', fontSize: '12px', fontWeight: 'normal' }}>(optional)</span>
</label>
<input
type="text"
id="referralId"
value={referralId}
onChange={(e) => setReferralId(e.target.value)}
style={{
width: '100%',
padding: '12px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--bg-soft)',
color: 'var(--text)',
fontSize: '14px',
}}
placeholder="Enter referral ID"
/>
{searchParams?.get('ref') && referralId === searchParams.get('ref') && (
<small style={{ display: 'block', marginTop: '4px', fontSize: '12px', color: 'var(--accent)' }}>
Auto-filled from referral link
</small>
)}
</div>
)}
{error && ( {error && (
<div <div
style={{ style={{

View File

@@ -1,8 +1,9 @@
'use client' 'use client'
import { useState, useEffect } from 'react' import { useState, useEffect, Suspense } from 'react'
import Image from 'next/image' import Image from 'next/image'
import AuthModal from './AuthModal' import AuthModal from './AuthModal'
import UnlockModal from './UnlockModal'
interface DropData { interface DropData {
id: number id: number
@@ -45,12 +46,29 @@ export default function Drop() {
const [processing, setProcessing] = useState(false) const [processing, setProcessing] = useState(false)
const [user, setUser] = useState<User | null>(null) const [user, setUser] = useState<User | null>(null)
const [checkingAuth, setCheckingAuth] = useState(true) const [checkingAuth, setCheckingAuth] = useState(true)
const [isWholesaleUnlocked, setIsWholesaleUnlocked] = useState(false)
const [showUnlockModal, setShowUnlockModal] = useState(false)
useEffect(() => { useEffect(() => {
fetchActiveDrop() fetchActiveDrop()
checkAuth() checkAuth()
checkWholesaleStatus()
}, []) }, [])
const checkWholesaleStatus = async () => {
try {
const response = await fetch('/api/referrals/status', {
credentials: 'include',
})
if (response.ok) {
const data = await response.json()
setIsWholesaleUnlocked(data.isUnlocked || false)
}
} catch (error) {
console.error('Error checking wholesale status:', error)
}
}
// Poll payment status when payment modal is open // Poll payment status when payment modal is open
useEffect(() => { useEffect(() => {
if (!showPaymentModal || !paymentData?.payment_id) return if (!showPaymentModal || !paymentData?.payment_id) return
@@ -333,11 +351,10 @@ export default function Drop() {
const calculatePrice = () => { const calculatePrice = () => {
if (!drop) return 0 if (!drop) return 0
// ppu is stored as integer where 1000 = $1.00, so divide by 1000 to get actual price // ppu is stored as integer where 1000 = $1.00, so divide by 1000 to get actual price
const pricePerUnit = drop.ppu / 1000 // Assuming ppu is per gram
if (drop.unit === 'kg') { const pricePerGram = drop.ppu / 1000
return (selectedSize / 1000) * pricePerUnit const priceToUse = isWholesaleUnlocked ? pricePerGram * 0.76 : pricePerGram
} return selectedSize * priceToUse
return selectedSize * pricePerUnit
} }
const getTimeUntilStart = () => { const getTimeUntilStart = () => {
@@ -426,10 +443,36 @@ export default function Drop() {
<div> <div>
<h2>{drop.item}</h2> <h2>{drop.item}</h2>
<div className="meta"> <div className="meta">
{formatSize(drop.size, drop.unit)} Batch {formatSize(drop.size, drop.unit)} batch
</div> </div>
<div className="price"> <div className="price">
{(drop.ppu / 1000).toFixed(2)} CHF / {drop.unit} · incl. 2.5% VAT {(() => {
// ppu is stored as integer where 1000 = $1.00
// Assuming ppu is always per gram for display purposes
const pricePerGram = drop.ppu / 1000;
const wholesalePricePerGram = pricePerGram * 0.76;
if (isWholesaleUnlocked) {
return (
<>
<strong>Wholesale price: {wholesalePricePerGram.toFixed(2)} CHF / g</strong>
<span className="muted" style={{ display: 'block', marginTop: '6px', fontSize: '14px' }}>
Standard: {pricePerGram.toFixed(2)} CHF / g
</span>
</>
);
}
return (
<>
<strong>Standard price: {pricePerGram.toFixed(2)} CHF / g</strong>
<span className="muted">
Wholesale: {wholesalePricePerGram.toFixed(2)} CHF / g 🔒 <a href="#unlock" onClick={(e) => { e.preventDefault(); setShowUnlockModal(true); }}>unlock</a>
</span>
<div className="hint">Unlock once. Keep wholesale forever.</div>
</>
);
})()}
</div> </div>
{isUpcoming ? ( {isUpcoming ? (
@@ -444,9 +487,11 @@ export default function Drop() {
<span style={{ width: `${progressPercentage}%` }}></span> <span style={{ width: `${progressPercentage}%` }}></span>
</div> </div>
<div className="meta"> <div className="meta">
{drop.unit === 'kg' ? drop.fill.toFixed(2) : Math.round(drop.fill)} {(() => {
{drop.unit} of {drop.size} const fillDisplay = drop.unit === 'kg' ? Math.round(drop.fill * 1000) : Math.round(drop.fill);
{drop.unit} reserved const sizeDisplay = drop.unit === 'kg' ? Math.round(drop.size * 1000) : drop.size;
return `${fillDisplay}g of ${sizeDisplay}g reserved`;
})()}
</div> </div>
{(() => { {(() => {
const pendingFill = Number(drop.pending_fill) || 0; const pendingFill = Number(drop.pending_fill) || 0;
@@ -476,8 +521,9 @@ export default function Drop() {
</div> </div>
<button className="cta" onClick={handleJoinDrop}> <button className="cta" onClick={handleJoinDrop}>
Join Drop Join the drop
</button> </button>
<div className="cta-note">No subscription · No obligation</div>
</> </>
)} )}
@@ -751,10 +797,18 @@ export default function Drop() {
)} )}
{/* Auth Modal */} {/* Auth Modal */}
<AuthModal <Suspense fallback={null}>
isOpen={showAuthModal} <AuthModal
onClose={() => setShowAuthModal(false)} isOpen={showAuthModal}
onLogin={handleLogin} onClose={() => setShowAuthModal(false)}
onLogin={handleLogin}
/>
</Suspense>
{/* Unlock Modal */}
<UnlockModal
isOpen={showUnlockModal}
onClose={() => setShowUnlockModal(false)}
/> />
{/* Success Modal */} {/* Success Modal */}

View File

@@ -69,14 +69,33 @@ export default function Nav() {
<span style={{ color: 'var(--muted)', fontSize: '14px', marginLeft: '48px' }}> <span style={{ color: 'var(--muted)', fontSize: '14px', marginLeft: '48px' }}>
{user.username} {user.username}
</span> </span>
<button <a
onClick={handleLogout} href="/orders"
style={{ style={{
background: 'transparent', background: 'transparent',
border: '1px solid var(--border)', border: '1px solid var(--border)',
color: 'var(--text)', color: 'var(--text)',
padding: '8px 16px', padding: '8px 16px',
borderRadius: '8px', borderRadius: '8px',
fontSize: '14px',
marginLeft: '12px',
lineHeight: '1',
boxSizing: 'border-box',
display: 'inline-block',
textDecoration: 'none',
cursor: 'pointer',
}}
>
Orders
</a>
<button
onClick={handleLogout}
style={{
background: 'transparent',
border: '1px solid #e57373',
color: '#e57373',
padding: '8px 16px',
borderRadius: '8px',
cursor: 'pointer', cursor: 'pointer',
fontSize: '14px', fontSize: '14px',
marginLeft: '12px', marginLeft: '12px',

View File

@@ -0,0 +1,87 @@
'use client'
import { useState, useEffect } from 'react'
import UnlockModal from './UnlockModal'
interface ReferralStatus {
referralCount: number
isUnlocked: boolean
referralsNeeded: number
referralsRemaining: number
}
export default function UnlockBar() {
const [referralStatus, setReferralStatus] = useState<ReferralStatus | null>(null)
const [showModal, setShowModal] = useState(false)
const [loading, setLoading] = useState(true)
useEffect(() => {
fetchReferralStatus()
}, [])
const fetchReferralStatus = async () => {
try {
const response = await fetch('/api/referrals/status', {
credentials: 'include',
})
const data = await response.json()
setReferralStatus(data)
} catch (error) {
console.error('Error fetching referral status:', error)
// Set default values on error
setReferralStatus({
referralCount: 0,
isUnlocked: false,
referralsNeeded: 3,
referralsRemaining: 3,
})
} finally {
setLoading(false)
}
}
const handleUnlockClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
e.preventDefault()
setShowModal(true)
}
if (loading) {
return (
<div className="unlock-bar">
🔒 Wholesale prices locked <strong>Loading...</strong>
<br />
<small>3 verified sign-ups unlock wholesale prices forever.</small>
<a href="#unlock" onClick={handleUnlockClick}>Unlock now</a>
</div>
)
}
const status = referralStatus || {
referralCount: 0,
isUnlocked: false,
referralsNeeded: 3,
referralsRemaining: 3,
}
// If unlocked, show different message or hide bar
if (status.isUnlocked) {
return (
<div className="unlock-bar" style={{ background: 'var(--accent)', color: '#000' }}>
Wholesale prices unlocked <strong>You have access to wholesale pricing!</strong>
</div>
)
}
return (
<>
<div className="unlock-bar">
🔒 Wholesale prices locked <strong>{status.referralCount} / {status.referralsNeeded} referrals completed</strong> · {status.referralsRemaining} to go
<br />
<small>{status.referralsNeeded} verified sign-ups unlock wholesale prices forever.</small>
<a href="#unlock" onClick={handleUnlockClick}>Unlock now</a>
</div>
<UnlockModal isOpen={showModal} onClose={() => setShowModal(false)} />
</>
)
}

View File

@@ -0,0 +1,251 @@
'use client'
import { useState, useEffect } from 'react'
interface UnlockModalProps {
isOpen: boolean
onClose: () => void
}
interface ReferralStatus {
referralCount: number
isUnlocked: boolean
referralsNeeded: number
referralsRemaining: number
}
export default function UnlockModal({ isOpen, onClose }: UnlockModalProps) {
const [referralStatus, setReferralStatus] = useState<ReferralStatus | null>(null)
const [referralLink, setReferralLink] = useState<string>('')
const [loading, setLoading] = useState(true)
const [copied, setCopied] = useState(false)
useEffect(() => {
if (isOpen) {
fetchReferralData()
}
}, [isOpen])
const fetchReferralData = async () => {
setLoading(true)
try {
// Fetch referral status
const statusResponse = await fetch('/api/referrals/status', {
credentials: 'include',
})
const statusData = await statusResponse.json()
setReferralStatus(statusData)
// Fetch referral link if user is logged in
const linkResponse = await fetch('/api/referrals/link', {
credentials: 'include',
})
if (linkResponse.ok) {
const linkData = await linkResponse.json()
setReferralLink(linkData.referralLink)
}
} catch (error) {
console.error('Error fetching referral data:', error)
} finally {
setLoading(false)
}
}
const handleCopyLink = async () => {
if (!referralLink) return
try {
await navigator.clipboard.writeText(referralLink)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch (error) {
console.error('Failed to copy link:', error)
}
}
if (!isOpen) return null
const status = referralStatus || {
referralCount: 0,
isUnlocked: false,
referralsNeeded: 3,
referralsRemaining: 3,
}
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: '500px',
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 }}>Unlock wholesale prices</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>
{loading ? (
<p style={{ color: 'var(--muted)', textAlign: 'center' }}>Loading...</p>
) : (
<>
<div style={{ marginBottom: '24px', textAlign: 'center' }}>
<div style={{ fontSize: '18px', marginBottom: '8px' }}>
🔒 {status.referralCount} of {status.referralsNeeded} referrals completed
</div>
<p style={{ color: 'var(--muted)', fontSize: '14px', margin: '8px 0' }}>
Invite {status.referralsNeeded} friends to sign up.
<br />
Once they do, wholesale prices unlock forever.
</p>
</div>
{referralLink ? (
<div style={{ marginBottom: '24px' }}>
<label
style={{
display: 'block',
marginBottom: '8px',
fontSize: '14px',
color: 'var(--muted)',
fontWeight: 500,
}}
>
Your referral link
</label>
<div style={{ display: 'flex', gap: '8px' }}>
<input
type="text"
value={referralLink}
readOnly
style={{
flex: 1,
padding: '12px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--bg-soft)',
color: 'var(--text)',
fontSize: '14px',
fontFamily: 'monospace',
}}
/>
<button
onClick={handleCopyLink}
style={{
padding: '12px 20px',
background: copied ? 'var(--accent)' : 'var(--bg-soft)',
border: '1px solid var(--border)',
borderRadius: '8px',
color: copied ? '#000' : 'var(--text)',
cursor: 'pointer',
fontSize: '14px',
fontWeight: 500,
whiteSpace: 'nowrap',
}}
>
{copied ? 'Copied!' : 'Copy link'}
</button>
</div>
</div>
) : (
<div
style={{
padding: '16px',
background: 'var(--bg-soft)',
borderRadius: '8px',
marginBottom: '24px',
textAlign: 'center',
color: 'var(--muted)',
fontSize: '14px',
}}
>
Please log in to get your referral link
</div>
)}
<div
style={{
padding: '12px',
background: 'var(--bg-soft)',
borderRadius: '8px',
marginBottom: '24px',
fontSize: '13px',
color: 'var(--muted)',
textAlign: 'center',
}}
>
Friends must sign up to count.
</div>
<div
style={{
textAlign: 'center',
fontSize: '16px',
fontWeight: 500,
color: 'var(--text)',
marginBottom: '24px',
}}
>
{status.referralsRemaining} referral{status.referralsRemaining !== 1 ? 's' : ''} to go
</div>
<button
onClick={onClose}
style={{
width: '100%',
padding: '12px 24px',
background: 'var(--accent)',
color: '#000',
border: 'none',
borderRadius: '14px',
cursor: 'pointer',
fontSize: '15px',
fontWeight: 500,
}}
>
Close
</button>
</>
)}
</div>
</div>
)
}

View File

@@ -54,6 +54,25 @@ nav .links a:hover {
color: var(--text); color: var(--text);
} }
.unlock-bar {
background: var(--bg-soft);
border-bottom: 1px solid var(--border);
padding: 12px 20px;
font-size: 14px;
color: var(--muted);
text-align: center;
}
.unlock-bar strong {
color: var(--text);
}
.unlock-bar a {
margin-left: 10px;
color: var(--accent);
font-weight: 500;
}
.container { .container {
max-width: 1200px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
@@ -111,12 +130,30 @@ header p {
margin-bottom: 20px; margin-bottom: 20px;
} }
.price .muted {
display: block;
margin-top: 6px;
font-size: 14px;
color: var(--muted);
}
.price .hint {
font-size: 13px;
color: var(--muted);
margin-top: 4px;
}
.price a {
color: var(--accent);
font-weight: 500;
}
.progress { .progress {
background: var(--bg-soft); background: var(--bg-soft);
border-radius: 10px; border-radius: 10px;
height: 10px; height: 10px;
overflow: hidden; overflow: hidden;
margin-bottom: 20px; margin-bottom: 12px;
} }
.progress span { .progress span {
@@ -146,8 +183,8 @@ header p {
.cta { .cta {
margin-top: 30px; margin-top: 30px;
padding: 16px 28px; padding: 16px 28px;
background: #0a7931; background: var(--accent);
color: #fff; color: #000;
border: none; border: none;
border-radius: 14px; border-radius: 14px;
font-size: 15px; font-size: 15px;
@@ -155,6 +192,12 @@ header p {
cursor: pointer; cursor: pointer;
} }
.cta-note {
margin-top: 8px;
font-size: 13px;
color: var(--muted);
}
.info-box { .info-box {
margin-top: 60px; margin-top: 60px;
background: var(--card); background: var(--card);

291
app/orders/page.tsx Normal file
View File

@@ -0,0 +1,291 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import Image from 'next/image'
import Nav from '../components/Nav'
interface Order {
id: number
drop_id: number
buyer_id: number
size: number
payment_id: string
created_at: string
drop_item: string
drop_unit: string
drop_ppu: number
drop_image_url: string | null
buyer_fullname: string
buyer_address: string
buyer_phone: string
}
export default function OrdersPage() {
const router = useRouter()
const [orders, setOrders] = useState<Order[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [user, setUser] = useState<any>(null)
useEffect(() => {
checkAuth()
fetchOrders()
}, [])
const checkAuth = async () => {
try {
const response = await fetch('/api/auth/session', {
credentials: 'include',
})
if (response.ok) {
const data = await response.json()
setUser(data.user)
} else {
// Not authenticated, redirect to home
router.push('/')
}
} catch (error) {
console.error('Error checking auth:', error)
router.push('/')
}
}
const fetchOrders = async () => {
try {
const response = await fetch('/api/orders', {
credentials: 'include',
})
if (response.status === 401) {
router.push('/')
return
}
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to fetch orders')
}
const data = await response.json()
setOrders(data)
} catch (error) {
console.error('Error fetching orders:', error)
setError(error instanceof Error ? error.message : 'Failed to load orders')
} finally {
setLoading(false)
}
}
const formatDate = (dateString: string) => {
const date = new Date(dateString)
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
const formatSize = (size: number, unit: string) => {
if (unit === 'kg' && size >= 1000) {
return `${(size / 1000).toFixed(1)}kg`
}
return `${size}${unit}`
}
const calculatePrice = (order: Order) => {
// ppu is stored as integer where 1000 = $1.00
const pricePerGram = order.drop_ppu / 1000
// Size is in grams
return (order.size * pricePerGram).toFixed(2)
}
if (loading) {
return (
<>
<Nav />
<div className="container" style={{ paddingTop: '120px', textAlign: 'center' }}>
<p style={{ color: 'var(--muted)' }}>Loading orders...</p>
</div>
</>
)
}
return (
<>
<Nav />
<div className="container" style={{ paddingTop: '120px' }}>
<button
onClick={() => router.push('/')}
style={{
background: 'transparent',
border: '1px solid var(--border)',
color: 'var(--text)',
padding: '8px 16px',
borderRadius: '8px',
cursor: 'pointer',
fontSize: '14px',
marginBottom: '24px',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
>
Back
</button>
<h1 style={{ marginBottom: '40px' }}>My Orders</h1>
{error && (
<div
style={{
padding: '16px',
background: 'rgba(255, 0, 0, 0.1)',
border: '1px solid rgba(255, 0, 0, 0.3)',
borderRadius: '12px',
color: '#ff4444',
marginBottom: '24px',
}}
>
{error}
</div>
)}
{orders.length === 0 ? (
<div
style={{
padding: '60px 20px',
textAlign: 'center',
background: 'var(--card)',
borderRadius: '16px',
border: '1px solid var(--border)',
}}
>
<p style={{ color: 'var(--muted)', fontSize: '18px', marginBottom: '12px' }}>
No orders yet
</p>
<p style={{ color: 'var(--muted)', fontSize: '14px' }}>
Your purchase history will appear here once you make your first order.
</p>
<a
href="/"
style={{
display: 'inline-block',
marginTop: '24px',
padding: '12px 24px',
background: 'var(--accent)',
color: '#000',
borderRadius: '12px',
textDecoration: 'none',
fontWeight: 500,
}}
>
Browse Drops
</a>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
{orders.map((order) => (
<div
key={order.id}
style={{
background: 'var(--card)',
borderRadius: '16px',
padding: '24px',
border: '1px solid var(--border)',
display: 'grid',
gridTemplateColumns: '120px 1fr',
gap: '24px',
}}
>
{order.drop_image_url ? (
<Image
src={order.drop_image_url}
alt={order.drop_item}
width={120}
height={120}
style={{
width: '100%',
height: '120px',
borderRadius: '12px',
objectFit: 'cover',
}}
/>
) : (
<div
style={{
width: '100%',
height: '120px',
background: 'var(--bg-soft)',
borderRadius: '12px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--muted)',
fontSize: '14px',
}}
>
No Image
</div>
)}
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: '12px' }}>
<div>
<h3 style={{ margin: 0, marginBottom: '8px', fontSize: '20px' }}>
{order.drop_item}
</h3>
<p style={{ margin: 0, color: 'var(--muted)', fontSize: '14px' }}>
Order #{order.id} · {formatDate(order.created_at)}
</p>
</div>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: '18px', fontWeight: 500, marginBottom: '4px' }}>
{calculatePrice(order)} CHF
</div>
<div style={{ fontSize: '14px', color: 'var(--muted)' }}>
{formatSize(order.size, 'g')}
</div>
</div>
</div>
<div
style={{
padding: '16px',
background: 'var(--bg-soft)',
borderRadius: '8px',
marginTop: '16px',
}}
>
<div style={{ fontSize: '13px', color: 'var(--muted)', marginBottom: '8px' }}>
Delivery Information:
</div>
<div style={{ fontSize: '14px' }}>
<div style={{ marginBottom: '4px' }}>
<strong>{order.buyer_fullname}</strong>
</div>
<div style={{ color: 'var(--muted)', marginBottom: '4px' }}>
{order.buyer_address}
</div>
<div style={{ color: 'var(--muted)' }}>
{order.buyer_phone}
</div>
</div>
</div>
{order.payment_id && (
<div style={{ marginTop: '12px', fontSize: '12px', color: 'var(--muted)' }}>
Payment ID: {order.payment_id}
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
</>
)
}

View File

@@ -8,6 +8,7 @@ import InfoBox from './components/InfoBox'
import Signup from './components/Signup' 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'
import UnlockBar from './components/UnlockBar'
function PaymentHandler() { function PaymentHandler() {
const searchParams = useSearchParams() const searchParams = useSearchParams()
@@ -37,6 +38,7 @@ export default function Home() {
<PaymentHandler /> <PaymentHandler />
</Suspense> </Suspense>
<Nav /> <Nav />
<UnlockBar />
<header className="container"> <header className="container">
<h1>Shop together. Wholesale prices for private buyers.</h1> <h1>Shop together. Wholesale prices for private buyers.</h1>
<p> <p>

View File

@@ -3,7 +3,7 @@
-- https://www.phpmyadmin.net/ -- https://www.phpmyadmin.net/
-- --
-- Host: localhost:3306 -- Host: localhost:3306
-- Generation Time: Dec 21, 2025 at 10:29 AM -- Generation Time: Dec 21, 2025 at 11:13 AM
-- Server version: 10.11.14-MariaDB-0+deb12u2 -- Server version: 10.11.14-MariaDB-0+deb12u2
-- PHP Version: 8.2.29 -- PHP Version: 8.2.29
@@ -87,10 +87,9 @@ CREATE TABLE `drops` (
-- --
CREATE TABLE `notification_subscribers` ( CREATE TABLE `notification_subscribers` (
`id` int(11) NOT NULL, `address` varchar(100) NOT NULL,
`buyer_id` int(11) DEFAULT NULL, `type` text NOT NULL DEFAULT '\'email\'',
`type` text NOT NULL DEFAULT 'email', `buyer_id` int(11) DEFAULT NULL
`address` text NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- -------------------------------------------------------- -- --------------------------------------------------------
@@ -175,7 +174,7 @@ ALTER TABLE `drops`
-- Indexes for table `notification_subscribers` -- Indexes for table `notification_subscribers`
-- --
ALTER TABLE `notification_subscribers` ALTER TABLE `notification_subscribers`
ADD PRIMARY KEY (`id`), ADD PRIMARY KEY (`address`),
ADD KEY `buyer_id` (`buyer_id`); ADD KEY `buyer_id` (`buyer_id`);
-- --
@@ -235,12 +234,6 @@ ALTER TABLE `deliveries`
ALTER TABLE `drops` ALTER TABLE `drops`
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT; MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;
--
-- AUTO_INCREMENT for table `notification_subscribers`
--
ALTER TABLE `notification_subscribers`
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;
-- --
-- AUTO_INCREMENT for table `pending_orders` -- AUTO_INCREMENT for table `pending_orders`
-- --