+ π Wholesale prices locked β 1 / 3 referrals completed Β· 2 to go
+
+ 3 verified sign-ups unlock wholesale prices forever.
+ Unlock now
+
+
+
+
Buy together. Wholesale prices for private buyers.
+
Limited CBD drops directly from Swiss producers. No retail. No marketing markup. Just collective volume pricing.
+
+
+
+
+
+
+
Harlequin β Collective Drop
+
1kg batch Β· Indoor Β· Switzerland
+
+
+ Standard price: 2.50 CHF / g
+
+ Wholesale: 1.90 CHF / g π unlock
+
+
Unlock once. Keep wholesale forever.
+
+
+
+
620g of 1,000g reserved
+
+
+
+
+
+
+
+
+
No subscription Β· No obligation
+
+
+
+
+
+
Why so cheap?
+
Retail prices average around 10 CHF/g. By buying collectively, we purchase like wholesalers β without intermediaries.
+
+
+
Taxes & Legal
+
Bulk sales with 2.5% VAT. No retail packaging, no tobacco tax.
+
+
+
Drop model
+
One strain per drop. Once sold out, the next drop goes live.
+
+
+
+
+
+
+
Drop notifications
+
Receive updates about new drops via email or WhatsApp.
+
+
+
+
+ Counts as a referral sign-up if invited.
+
+
+
+
+
Past Drops
+
+
+
+ Swiss Gold Sold out in 42h
+
+
+
+ Lemon T1 Sold out in 19h
+
+
+
+ Alpine Frost Sold out in 31h
+
+
+
+
+
+
+
+
diff --git a/IPN_INTEGRATION_README.md b/IPN_INTEGRATION_README.md
deleted file mode 100644
index d663c7d..0000000
--- a/IPN_INTEGRATION_README.md
+++ /dev/null
@@ -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.
-
diff --git a/RACE_CONDITION_FIX.md b/RACE_CONDITION_FIX.md
deleted file mode 100644
index 8077542..0000000
--- a/RACE_CONDITION_FIX.md
+++ /dev/null
@@ -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
-
diff --git a/api-doc/create-payment.MD b/api-doc/create-payment.MD
deleted file mode 100644
index 66c09d0..0000000
--- a/api-doc/create-payment.MD
+++ /dev/null
@@ -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 customerβs 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. Customerβs 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"
-}
\ No newline at end of file
diff --git a/app/api/auth/register/route.ts b/app/api/auth/register/route.ts
index 2105cd0..abe6e44 100644
--- a/app/api/auth/register/route.ts
+++ b/app/api/auth/register/route.ts
@@ -6,7 +6,7 @@ import bcrypt from 'bcrypt'
export async function POST(request: NextRequest) {
try {
const body = await request.json()
- const { username, password, email } = body
+ const { username, password, email, referral_id } = body
// Validate required fields
if (!username || !password || !email) {
@@ -84,6 +84,27 @@ export async function POST(request: NextRequest) {
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
const response = NextResponse.json(
{
diff --git a/app/api/orders/route.ts b/app/api/orders/route.ts
new file mode 100644
index 0000000..e97548e
--- /dev/null
+++ b/app/api/orders/route.ts
@@ -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 }
+ )
+ }
+}
+
diff --git a/app/api/payments/create-invoice/route.ts b/app/api/payments/create-invoice/route.ts
index 0445626..9ef6aa0 100644
--- a/app/api/payments/create-invoice/route.ts
+++ b/app/api/payments/create-invoice/route.ts
@@ -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
// ppu is stored as integer where 1000 = $1.00, so divide by 1000 to get actual price
- const pricePerUnit = drop.ppu / 1000
- let priceAmount = 0
- if (drop.unit === 'kg') {
- priceAmount = (size / 1000) * pricePerUnit
- } else {
- priceAmount = size * pricePerUnit
- }
+ // Assuming ppu is per gram
+ const pricePerGram = drop.ppu / 1000
+ const priceToUse = isWholesaleUnlocked ? pricePerGram * 0.76 : pricePerGram
+ const priceAmount = size * priceToUse
// Round to 2 decimal places
- priceAmount = Math.round(priceAmount * 100) / 100
+ const roundedPriceAmount = Math.round(priceAmount * 100) / 100
// Generate order ID
const orderId = `SALE-${Date.now()}-${drop_id}-${buyer_id}`
@@ -163,7 +168,7 @@ export async function POST(request: NextRequest) {
'Content-Type': 'application/json',
},
body: JSON.stringify({
- price_amount: priceAmount,
+ price_amount: roundedPriceAmount,
price_currency: nowPaymentsConfig.currency,
pay_currency: payCurrency, // Required: crypto currency (btc, eth, etc)
order_id: orderId,
@@ -190,7 +195,7 @@ export async function POST(request: NextRequest) {
// payment.payment_id is the NOWPayments payment ID
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 (?, ?, ?, ?, ?, ?, ?, ?, ?)',
- [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
diff --git a/app/api/referrals/link/route.ts b/app/api/referrals/link/route.ts
new file mode 100644
index 0000000..8f9b37d
--- /dev/null
+++ b/app/api/referrals/link/route.ts
@@ -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 }
+ )
+ }
+}
+
diff --git a/app/api/referrals/status/route.ts b/app/api/referrals/status/route.ts
new file mode 100644
index 0000000..639aaef
--- /dev/null
+++ b/app/api/referrals/status/route.ts
@@ -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 }
+ )
+ }
+}
+
diff --git a/app/components/AuthModal.tsx b/app/components/AuthModal.tsx
index 462e425..f0b7f10 100644
--- a/app/components/AuthModal.tsx
+++ b/app/components/AuthModal.tsx
@@ -1,6 +1,7 @@
'use client'
import { useState, useEffect } from 'react'
+import { useSearchParams } from 'next/navigation'
interface User {
id: number
@@ -15,10 +16,12 @@ interface AuthModalProps {
}
export default function AuthModal({ isOpen, onClose, onLogin }: AuthModalProps) {
+ const searchParams = useSearchParams()
const [isLogin, setIsLogin] = useState(true)
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [email, setEmail] = useState('')
+ const [referralId, setReferralId] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
@@ -30,8 +33,26 @@ export default function AuthModal({ isOpen, onClose, onLogin }: AuthModalProps)
setEmail('')
setError('')
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) => {
e.preventDefault()
@@ -40,9 +61,15 @@ export default function AuthModal({ isOpen, onClose, onLogin }: AuthModalProps)
try {
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
? { username, password }
- : { username, password, email }
+ : { username, password, email, referral_id: referralIdToUse }
const response = await fetch(endpoint, {
method: 'POST',
@@ -192,7 +219,7 @@ export default function AuthModal({ isOpen, onClose, onLogin }: AuthModalProps)
/>
-