Compare commits

...

10 Commits

Author SHA1 Message Date
root
eeaa9a66bb final 2026-01-03 06:06:54 +00:00
root
d138dae2ca rc 2025-12-31 08:19:59 +00:00
root
0d8c2ea3a3 final 0.9 2025-12-31 07:49:35 +00:00
root
312810bb56 texts 2025-12-28 02:23:51 +01:00
root
e75e4f08a9 sync 2025-12-25 15:28:11 +01:00
root
6f4ca75faf rc 1.0 2025-12-22 06:43:19 +01:00
root
a940d51475 sync 2025-12-21 17:37:18 +01:00
root
8a0835c564 rc 1.0 2025-12-21 17:36:44 +01:00
root
bb1c5b43d6 final 2025-12-21 12:46:27 +01:00
root
5e65144934 notification + admin panel 2025-12-21 11:39:41 +01:00
69 changed files with 8267 additions and 1829 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

145
REFERRAL_POINTS_README.md Normal file
View File

@@ -0,0 +1,145 @@
# Referral Points System
This document describes the referral points system implementation for the CBD420 platform.
## Overview
The referral points system allows:
1. **Earning Points**: Referrers earn points when their referred users make purchases
2. **Spending Points**: Buyers can use their referral points to discount purchases
3. **Configurable Rates**: Both earning and redemption rates are configurable via the `referral_settings` table
## Database Changes
### New Columns
1. **`buyers` table**: Added `referral_points` column (decimal(10,2), default 0.00) to track current point balance
2. **`sales` table**:
- Added `points_used` column (decimal(10,2), default 0.00) to track points used in the sale
- Added `price_amount` column (decimal(10,2)) to track actual amount paid
- Added `price_currency` column (varchar(10), default 'chf') to track currency
3. **`pending_orders` table**: Added `points_used` column (decimal(10,2), default 0.00) to track points used in pending orders
### New Tables
1. **`referral_point_transactions`**: Tracks all point transactions (earned/spent)
- Records when points are earned or spent
- Links to sales and pending orders
- Includes description for audit trail
2. **`referral_settings`**: Stores configurable system settings
- `points_per_chf`: Points earned per 1 CHF purchase (default: 10)
- `points_to_chf`: Points required to redeem 1 CHF discount (default: 100)
### Stored Procedures
1. **`award_referral_points(p_sale_id INT)`**:
- Awards referral points to the referrer when a sale is completed
- Calculates points based on purchase amount and `points_per_chf` setting
- Should be called after a sale is created
2. **`spend_referral_points(p_buyer_id, p_points_to_spend, p_pending_order_id, p_sale_id, OUT p_success)`**:
- Deducts points from buyer's balance
- Records the transaction
- Returns 1 if successful, 0 if insufficient points
## Configuration
The system uses two configurable multipliers stored in `referral_settings`:
- **`points_per_chf`**: Number of points earned per 1 CHF purchase (default: 10)
- Example: If set to 10, a 5 CHF purchase earns 50 points
- **`points_to_chf`**: Number of points required for 1 CHF discount (default: 100)
- Example: If set to 100, 500 points = 5 CHF discount
To update these values:
```sql
UPDATE referral_settings SET setting_value = '20' WHERE setting_key = 'points_per_chf';
UPDATE referral_settings SET setting_value = '50' WHERE setting_key = 'points_to_chf';
```
## Usage Examples
### Awarding Points After Sale Completion
When a sale is created (e.g., in your IPN/webhook handler), call:
```sql
CALL award_referral_points(@sale_id);
```
This will:
1. Check if the buyer was referred by someone
2. Calculate points based on purchase amount
3. Add points to referrer's balance
4. Record the transaction
### Spending Points During Purchase
Before creating a pending order, check available points and calculate discount:
```sql
-- Get buyer's available points
SELECT referral_points FROM buyers WHERE id = @buyer_id;
-- Calculate maximum discount (points / points_to_chf)
SELECT
b.referral_points,
CAST(rs.setting_value AS DECIMAL(10,2)) as points_to_chf,
b.referral_points / CAST(rs.setting_value AS DECIMAL(10,2)) as max_discount_chf
FROM buyers b
CROSS JOIN referral_settings rs
WHERE b.id = @buyer_id AND rs.setting_key = 'points_to_chf';
```
When creating a pending order with points:
```sql
SET @points_to_spend = 500; -- User wants to spend 500 points
SET @success = 0;
CALL spend_referral_points(@buyer_id, @points_to_spend, @pending_order_id, NULL, @success);
IF @success = 1 THEN
-- Points deducted successfully, update pending_order with points_used
UPDATE pending_orders SET points_used = @points_to_spend WHERE id = @pending_order_id;
ELSE
-- Insufficient points, handle error
SELECT 'Insufficient referral points' AS error;
END IF;
```
### Calculating Final Price After Points Discount
```sql
-- Calculate discount amount from points
SELECT
@original_price as original_price,
@points_to_spend as points_used,
CAST((SELECT setting_value FROM referral_settings WHERE setting_key = 'points_to_chf') AS DECIMAL(10,2)) as points_to_chf,
(@points_to_spend / CAST((SELECT setting_value FROM referral_settings WHERE setting_key = 'points_to_chf') AS DECIMAL(10,2))) as discount_amount,
(@original_price - (@points_to_spend / CAST((SELECT setting_value FROM referral_settings WHERE setting_key = 'points_to_chf') AS DECIMAL(10,2)))) as final_price;
```
## Implementation Notes
1. **Price Tracking**: The `sales` table now includes `price_amount` to accurately track the amount paid. When converting a pending_order to a sale, copy the `price_amount` (after points discount) to the sale.
2. **Points Calculation**: Points are awarded based on the actual amount paid (after any points discount), not the original price.
3. **Transaction History**: All point transactions are recorded in `referral_point_transactions` for audit and reporting purposes.
4. **Currency**: Currently assumes CHF as the base currency. If your system uses multiple currencies, you may need to adjust the calculation logic.
5. **Application Integration**: You'll need to:
- Call `award_referral_points()` after creating a sale (in your IPN/webhook handler)
- Handle point spending in your payment/invoice creation flow
- Display available points to users in the UI
- Allow users to select how many points to use during checkout
## Migration
For existing databases, run the `referral_points_migration.sql` file to add all the necessary tables, columns, and procedures.
For new installations, the updated `cbd420.sql` includes all referral points functionality.

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"
}

425
app/admin/buyers/page.tsx Normal file
View File

@@ -0,0 +1,425 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
interface Buyer {
id: number
username: string
email: string
created_at?: string
referral_count?: number
hasWholesaleAccess?: boolean
hasInnerCircleAccess?: boolean
}
export default function BuyersManagementPage() {
const router = useRouter()
const [buyers, setBuyers] = useState<Buyer[]>([])
const [filteredBuyers, setFilteredBuyers] = useState<Buyer[]>([])
const [loading, setLoading] = useState(true)
const [authenticated, setAuthenticated] = useState(false)
const [editingBuyer, setEditingBuyer] = useState<Buyer | null>(null)
const [formData, setFormData] = useState({
username: '',
email: '',
password: '',
})
const [filters, setFilters] = useState({
wholesaleOnly: false,
innerCircleOnly: false,
})
useEffect(() => {
// Check authentication
fetch('/api/admin/check')
.then((res) => res.json())
.then((data) => {
if (data.authenticated) {
setAuthenticated(true)
fetchBuyers()
} else {
router.push('/admin/login')
}
})
.catch(() => {
router.push('/admin/login')
})
}, [router])
const fetchBuyers = async () => {
try {
const response = await fetch('/api/buyers')
if (response.ok) {
const data = await response.json()
const buyersList = Array.isArray(data) ? data : []
setBuyers(buyersList)
applyFilters(buyersList, filters)
}
} catch (error) {
console.error('Error fetching buyers:', error)
} finally {
setLoading(false)
}
}
const applyFilters = (buyersList: Buyer[], currentFilters: typeof filters) => {
let filtered = [...buyersList]
if (currentFilters.wholesaleOnly) {
filtered = filtered.filter(buyer => buyer.hasWholesaleAccess)
}
if (currentFilters.innerCircleOnly) {
filtered = filtered.filter(buyer => buyer.hasInnerCircleAccess)
}
setFilteredBuyers(filtered)
}
useEffect(() => {
applyFilters(buyers, filters)
}, [filters])
const handleEdit = (buyer: Buyer) => {
setEditingBuyer(buyer)
setFormData({
username: buyer.username,
email: buyer.email,
password: '',
})
}
const handleSave = async () => {
if (!editingBuyer) return
try {
const updateData: any = {}
if (formData.username !== editingBuyer.username) {
updateData.username = formData.username
}
if (formData.email !== editingBuyer.email) {
updateData.email = formData.email
}
if (formData.password) {
updateData.password = formData.password
}
if (Object.keys(updateData).length === 0) {
setEditingBuyer(null)
return
}
const response = await fetch(`/api/buyers/${editingBuyer.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updateData),
})
if (response.ok) {
alert('Buyer updated successfully')
setEditingBuyer(null)
fetchBuyers()
} else {
const error = await response.json()
alert(`Error: ${error.error}`)
}
} catch (error) {
console.error('Error updating buyer:', error)
alert('Failed to update buyer')
}
}
const handleDelete = async (id: number) => {
if (!confirm('Are you sure you want to delete this buyer? This will also delete all their sales.')) {
return
}
try {
const response = await fetch(`/api/buyers/${id}`, {
method: 'DELETE',
})
if (response.ok) {
alert('Buyer deleted successfully')
fetchBuyers()
} else {
const error = await response.json()
alert(`Error: ${error.error}`)
}
} catch (error) {
console.error('Error deleting buyer:', error)
alert('Failed to delete buyer')
}
}
if (loading) {
return (
<div style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--bg)'
}}>
<p style={{ color: 'var(--muted)' }}>Loading...</p>
</div>
)
}
if (!authenticated) {
return null
}
return (
<div style={{
minHeight: '100vh',
background: 'var(--bg)',
padding: '40px 20px'
}}>
<div className="container" style={{ maxWidth: '1200px', margin: '0 auto' }}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '30px'
}}>
<h1>Buyer Management</h1>
<button
onClick={() => router.push('/admin')}
style={{
padding: '10px 20px',
background: 'transparent',
border: '1px solid var(--border)',
borderRadius: '8px',
color: 'var(--text)',
cursor: 'pointer',
fontSize: '14px'
}}
>
Back to Dashboard
</button>
</div>
<div style={{
marginBottom: '20px',
padding: '16px',
background: 'var(--card)',
borderRadius: '8px',
border: '1px solid var(--border)'
}}>
<h3 style={{ marginBottom: '12px', fontSize: '16px' }}>Filters</h3>
<div style={{ display: 'flex', gap: '20px', flexWrap: 'wrap' }}>
<label style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
cursor: 'pointer',
userSelect: 'none'
}}>
<input
type="checkbox"
checked={filters.wholesaleOnly}
onChange={(e) => setFilters({ ...filters, wholesaleOnly: e.target.checked })}
style={{ width: '16px', height: '16px', cursor: 'pointer' }}
/>
<span>Wholesale Access (3+ referrals)</span>
</label>
<label style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
cursor: 'pointer',
userSelect: 'none'
}}>
<input
type="checkbox"
checked={filters.innerCircleOnly}
onChange={(e) => setFilters({ ...filters, innerCircleOnly: e.target.checked })}
style={{ width: '16px', height: '16px', cursor: 'pointer' }}
/>
<span>Inner Circle Access (10+ referrals)</span>
</label>
</div>
{(filters.wholesaleOnly || filters.innerCircleOnly) && (
<p style={{
marginTop: '12px',
color: 'var(--muted)',
fontSize: '14px'
}}>
Showing {filteredBuyers.length} of {buyers.length} buyers
</p>
)}
</div>
{(filters.wholesaleOnly || filters.innerCircleOnly ? filteredBuyers : buyers).length === 0 ? (
<p style={{ color: 'var(--muted)' }}>No buyers found</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
{(filters.wholesaleOnly || filters.innerCircleOnly ? filteredBuyers : buyers).map((buyer) => (
<div
key={buyer.id}
className="drop-card"
style={{
background: editingBuyer?.id === buyer.id ? 'var(--bg-soft)' : 'var(--card)',
padding: '20px'
}}
>
{editingBuyer?.id === buyer.id ? (
<div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '16px' }}>
<div>
<label style={{ display: 'block', marginBottom: '8px' }}>Username</label>
<input
type="text"
value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
style={{
width: '100%',
padding: '8px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--bg-soft)',
color: 'var(--text)'
}}
/>
</div>
<div>
<label style={{ display: 'block', marginBottom: '8px' }}>Email</label>
<input
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
style={{
width: '100%',
padding: '8px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--bg-soft)',
color: 'var(--text)'
}}
/>
</div>
<div>
<label style={{ display: 'block', marginBottom: '8px' }}>New Password (leave empty to keep current)</label>
<input
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
style={{
width: '100%',
padding: '8px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--bg-soft)',
color: 'var(--text)'
}}
/>
</div>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button
onClick={handleSave}
className="cta"
style={{ padding: '8px 16px', fontSize: '14px' }}
>
Save
</button>
<button
onClick={() => setEditingBuyer(null)}
style={{
padding: '8px 16px',
fontSize: '14px',
background: 'transparent',
border: '1px solid var(--border)',
borderRadius: '8px',
color: 'var(--text)',
cursor: 'pointer'
}}
>
Cancel
</button>
</div>
</div>
) : (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div style={{ flex: 1 }}>
<h3 style={{ marginBottom: '8px' }}>{buyer.username}</h3>
<p style={{ color: 'var(--muted)', fontSize: '14px', marginBottom: '4px' }}>
Email: {buyer.email}
</p>
<p style={{ color: 'var(--muted)', fontSize: '14px', marginBottom: '4px' }}>
ID: {buyer.id}
</p>
{buyer.created_at && (
<p style={{ color: 'var(--muted)', fontSize: '12px', marginBottom: '4px' }}>
Created: {new Date(buyer.created_at).toLocaleString()}
</p>
)}
<div style={{
display: 'flex',
gap: '8px',
marginTop: '8px',
flexWrap: 'wrap'
}}>
<span style={{
padding: '4px 8px',
borderRadius: '4px',
fontSize: '12px',
background: buyer.hasWholesaleAccess ? '#10b981' : '#6b7280',
color: '#fff'
}}>
{buyer.referral_count || 0} referrals - Wholesale {buyer.hasWholesaleAccess ? '✓' : '✗'}
</span>
<span style={{
padding: '4px 8px',
borderRadius: '4px',
fontSize: '12px',
background: buyer.hasInnerCircleAccess ? '#8b5cf6' : '#6b7280',
color: '#fff'
}}>
Inner Circle {buyer.hasInnerCircleAccess ? '✓' : '✗'}
</span>
</div>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button
onClick={() => handleEdit(buyer)}
style={{
padding: '6px 12px',
fontSize: '12px',
background: 'transparent',
border: '1px solid var(--border)',
borderRadius: '6px',
color: 'var(--text)',
cursor: 'pointer'
}}
>
Edit
</button>
<button
onClick={() => handleDelete(buyer.id)}
style={{
padding: '6px 12px',
fontSize: '12px',
background: '#dc2626',
border: 'none',
borderRadius: '6px',
color: '#fff',
cursor: 'pointer'
}}
>
Delete
</button>
</div>
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
)
}

1428
app/admin/drops/page.tsx Normal file

File diff suppressed because it is too large Load Diff

116
app/admin/login/page.tsx Normal file
View File

@@ -0,0 +1,116 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
export default function AdminLoginPage() {
const router = useRouter()
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
useEffect(() => {
// Check if already authenticated
fetch('/api/admin/check')
.then((res) => res.json())
.then((data) => {
if (data.authenticated) {
router.push('/admin')
}
})
}, [router])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
try {
const response = await fetch('/api/admin/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ password }),
})
const data = await response.json()
if (response.ok) {
router.push('/admin')
} else {
setError(data.error || 'Invalid password')
}
} catch (error) {
setError('Failed to login. Please try again.')
} finally {
setLoading(false)
}
}
return (
<div style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--bg)',
padding: '20px'
}}>
<div style={{
background: 'var(--card)',
padding: '40px',
borderRadius: '12px',
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
maxWidth: '400px',
width: '100%'
}}>
<h1 style={{ marginBottom: '24px', textAlign: 'center' }}>Admin Login</h1>
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: '20px' }}>
<label htmlFor="password" style={{ display: 'block', marginBottom: '8px' }}>
Password
</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
style={{
width: '100%',
padding: '12px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--bg-soft)',
color: 'var(--text)',
fontSize: '16px'
}}
/>
</div>
{error && (
<div style={{
padding: '12px',
background: '#fee2e2',
color: '#dc2626',
borderRadius: '8px',
marginBottom: '20px',
fontSize: '14px'
}}>
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="cta"
style={{ width: '100%' }}
>
{loading ? 'Logging in...' : 'Login'}
</button>
</form>
</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

384
app/admin/sales/page.tsx Normal file
View File

@@ -0,0 +1,384 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
interface Sale {
id: number
drop_id: number
buyer_id: number
size: number
payment_id: string | null
created_at: string
drop_item?: string
drop_unit?: string
drop_ppu?: number
buyer_username?: string
buyer_email?: string
buyer_fullname?: string
buyer_address?: string
buyer_phone?: string
}
export default function SalesManagementPage() {
const router = useRouter()
const [sales, setSales] = useState<Sale[]>([])
const [loading, setLoading] = useState(true)
const [authenticated, setAuthenticated] = useState(false)
const [editingSale, setEditingSale] = useState<Sale | null>(null)
const [formData, setFormData] = useState({
drop_id: '',
buyer_id: '',
size: '',
payment_id: '',
})
useEffect(() => {
// Check authentication
fetch('/api/admin/check')
.then((res) => res.json())
.then((data) => {
if (data.authenticated) {
setAuthenticated(true)
fetchSales()
} else {
router.push('/admin/login')
}
})
.catch(() => {
router.push('/admin/login')
})
}, [router])
const fetchSales = async () => {
try {
const response = await fetch('/api/sales/list')
if (response.ok) {
const data = await response.json()
setSales(Array.isArray(data) ? data : [])
}
} catch (error) {
console.error('Error fetching sales:', error)
} finally {
setLoading(false)
}
}
const handleEdit = (sale: Sale) => {
setEditingSale(sale)
setFormData({
drop_id: sale.drop_id.toString(),
buyer_id: sale.buyer_id.toString(),
size: sale.size.toString(),
payment_id: sale.payment_id || '',
})
}
const handleSave = async () => {
if (!editingSale) return
try {
const updateData: any = {}
if (parseInt(formData.drop_id) !== editingSale.drop_id) {
updateData.drop_id = parseInt(formData.drop_id)
}
if (parseInt(formData.buyer_id) !== editingSale.buyer_id) {
updateData.buyer_id = parseInt(formData.buyer_id)
}
if (parseInt(formData.size) !== editingSale.size) {
updateData.size = parseInt(formData.size)
}
if (formData.payment_id !== (editingSale.payment_id || '')) {
updateData.payment_id = formData.payment_id || null
}
if (Object.keys(updateData).length === 0) {
setEditingSale(null)
return
}
const response = await fetch(`/api/sales/${editingSale.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updateData),
})
if (response.ok) {
alert('Sale updated successfully')
setEditingSale(null)
fetchSales()
} else {
const error = await response.json()
alert(`Error: ${error.error}`)
}
} catch (error) {
console.error('Error updating sale:', error)
alert('Failed to update sale')
}
}
const handleDelete = async (id: number) => {
if (!confirm('Are you sure you want to delete this sale?')) {
return
}
try {
const response = await fetch(`/api/sales/${id}`, {
method: 'DELETE',
})
if (response.ok) {
alert('Sale deleted successfully')
fetchSales()
} else {
const error = await response.json()
alert(`Error: ${error.error}`)
}
} catch (error) {
console.error('Error deleting sale:', error)
alert('Failed to delete sale')
}
}
if (loading) {
return (
<div style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--bg)'
}}>
<p style={{ color: 'var(--muted)' }}>Loading...</p>
</div>
)
}
if (!authenticated) {
return null
}
return (
<div style={{
minHeight: '100vh',
background: 'var(--bg)',
padding: '40px 20px'
}}>
<div className="container" style={{ maxWidth: '1200px', margin: '0 auto' }}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '30px'
}}>
<h1>Sales Management</h1>
<button
onClick={() => router.push('/admin')}
style={{
padding: '10px 20px',
background: 'transparent',
border: '1px solid var(--border)',
borderRadius: '8px',
color: 'var(--text)',
cursor: 'pointer',
fontSize: '14px'
}}
>
Back to Dashboard
</button>
</div>
{sales.length === 0 ? (
<p style={{ color: 'var(--muted)' }}>No sales found</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
{sales.map((sale) => (
<div
key={sale.id}
className="drop-card"
style={{
background: editingSale?.id === sale.id ? 'var(--bg-soft)' : 'var(--card)',
padding: '20px'
}}
>
{editingSale?.id === sale.id ? (
<div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '16px' }}>
<div>
<label style={{ display: 'block', marginBottom: '8px' }}>Drop ID</label>
<input
type="number"
value={formData.drop_id}
onChange={(e) => setFormData({ ...formData, drop_id: e.target.value })}
style={{
width: '100%',
padding: '8px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--bg-soft)',
color: 'var(--text)'
}}
/>
</div>
<div>
<label style={{ display: 'block', marginBottom: '8px' }}>Buyer ID</label>
<input
type="number"
value={formData.buyer_id}
onChange={(e) => setFormData({ ...formData, buyer_id: e.target.value })}
style={{
width: '100%',
padding: '8px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--bg-soft)',
color: 'var(--text)'
}}
/>
</div>
<div>
<label style={{ display: 'block', marginBottom: '8px' }}>Size (grams)</label>
<input
type="number"
value={formData.size}
onChange={(e) => setFormData({ ...formData, size: e.target.value })}
style={{
width: '100%',
padding: '8px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--bg-soft)',
color: 'var(--text)'
}}
/>
</div>
<div>
<label style={{ display: 'block', marginBottom: '8px' }}>Payment ID</label>
<input
type="text"
value={formData.payment_id}
onChange={(e) => setFormData({ ...formData, payment_id: e.target.value })}
style={{
width: '100%',
padding: '8px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--bg-soft)',
color: 'var(--text)'
}}
/>
</div>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button
onClick={handleSave}
className="cta"
style={{ padding: '8px 16px', fontSize: '14px' }}
>
Save
</button>
<button
onClick={() => setEditingSale(null)}
style={{
padding: '8px 16px',
fontSize: '14px',
background: 'transparent',
border: '1px solid var(--border)',
borderRadius: '8px',
color: 'var(--text)',
cursor: 'pointer'
}}
>
Cancel
</button>
</div>
</div>
) : (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div style={{ flex: 1 }}>
<h3 style={{ marginBottom: '8px' }}>Sale #{sale.id}</h3>
<p style={{ color: 'var(--muted)', fontSize: '14px', marginBottom: '4px' }}>
Drop: {sale.drop_item || `#${sale.drop_id}`} · Size: {sale.size}g
</p>
<p style={{ color: 'var(--muted)', fontSize: '14px', marginBottom: '4px' }}>
Buyer: {sale.buyer_username || `#${sale.buyer_id}`} ({sale.buyer_email || 'N/A'})
</p>
{(sale.buyer_fullname || sale.buyer_address || sale.buyer_phone) && (
<div style={{
background: 'var(--bg-soft)',
padding: '12px',
borderRadius: '8px',
marginTop: '8px',
marginBottom: '8px'
}}>
<p style={{ color: 'var(--text)', fontSize: '13px', fontWeight: '600', marginBottom: '6px' }}>
Delivery Information:
</p>
{sale.buyer_fullname && (
<p style={{ color: 'var(--muted)', fontSize: '13px', marginBottom: '4px' }}>
Name: {sale.buyer_fullname}
</p>
)}
{sale.buyer_address && (
<p style={{ color: 'var(--muted)', fontSize: '13px', marginBottom: '4px' }}>
Address: {sale.buyer_address}
</p>
)}
{sale.buyer_phone && (
<p style={{ color: 'var(--muted)', fontSize: '13px', marginBottom: '4px' }}>
Phone: {sale.buyer_phone}
</p>
)}
</div>
)}
{sale.drop_ppu && (
<p style={{ color: 'var(--muted)', fontSize: '14px', marginBottom: '4px' }}>
Price: {((sale.drop_ppu / 1000) * sale.size).toFixed(2)} CHF
</p>
)}
<p style={{ color: 'var(--muted)', fontSize: '12px' }}>
Created: {new Date(sale.created_at).toLocaleString()}
{sale.payment_id && ` · Payment: ${sale.payment_id}`}
</p>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button
onClick={() => handleEdit(sale)}
style={{
padding: '6px 12px',
fontSize: '12px',
background: 'transparent',
border: '1px solid var(--border)',
borderRadius: '6px',
color: 'var(--text)',
cursor: 'pointer'
}}
>
Edit
</button>
<button
onClick={() => handleDelete(sale.id)}
style={{
padding: '6px 12px',
fontSize: '12px',
background: '#dc2626',
border: 'none',
borderRadius: '6px',
color: '#fff',
cursor: 'pointer'
}}
>
Delete
</button>
</div>
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,12 @@
import { NextRequest, NextResponse } from 'next/server'
import { isAdminAuthenticated } from '@/lib/admin-auth'
export async function GET(request: NextRequest) {
try {
const authenticated = await isAdminAuthenticated()
return NextResponse.json({ authenticated })
} catch (error) {
return NextResponse.json({ authenticated: false })
}
}

View File

@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from 'next/server'
import { verifyAdminPassword, setAdminSession } from '@/lib/admin-auth'
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { password } = body
if (!password) {
return NextResponse.json(
{ error: 'Password is required' },
{ status: 400 }
)
}
if (verifyAdminPassword(password)) {
await setAdminSession()
return NextResponse.json({ success: true })
} else {
return NextResponse.json(
{ error: 'Invalid password' },
{ status: 401 }
)
}
} catch (error) {
console.error('Error during admin login:', error)
return NextResponse.json(
{ error: 'Failed to process login' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,16 @@
import { NextRequest, NextResponse } from 'next/server'
import { clearAdminSession } from '@/lib/admin-auth'
export async function POST(request: NextRequest) {
try {
await clearAdminSession()
return NextResponse.json({ success: true })
} catch (error) {
console.error('Error during admin logout:', error)
return NextResponse.json(
{ error: 'Failed to process logout' },
{ status: 500 }
)
}
}

View File

@@ -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(
{

View File

@@ -1,13 +1,37 @@
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
// GET /api/buyers - Get all buyers
// GET /api/buyers - Get all buyers with referral counts
export async function GET(request: NextRequest) {
try {
const [rows] = await pool.execute(
'SELECT id, username, email, created_at FROM buyers ORDER BY created_at DESC'
`SELECT
b.id,
b.username,
b.email,
b.created_at,
COALESCE(ref_counts.referral_count, 0) as referral_count
FROM buyers b
LEFT JOIN (
SELECT referrer, COUNT(*) as referral_count
FROM referrals
GROUP BY referrer
) ref_counts ON b.id = ref_counts.referrer
ORDER BY b.created_at DESC`
)
return NextResponse.json(rows)
// Add access status for each buyer
const buyersWithAccess = (rows as any[]).map((buyer: any) => {
const referralCount = parseInt(buyer.referral_count) || 0
return {
...buyer,
referral_count: referralCount,
hasWholesaleAccess: referralCount >= 3,
hasInnerCircleAccess: referralCount >= 10
}
})
return NextResponse.json(buyersWithAccess)
} catch (error) {
console.error('Error fetching buyers:', error)
return NextResponse.json(

265
app/api/drops/[id]/route.ts Normal file
View File

@@ -0,0 +1,265 @@
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
// GET /api/drops/[id] - Get a specific drop
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const id = parseInt(params.id, 10)
if (isNaN(id)) {
return NextResponse.json(
{ error: 'Invalid drop ID' },
{ status: 400 }
)
}
const [rows] = await pool.execute(
'SELECT * FROM drops WHERE id = ?',
[id]
)
const drops = rows as any[]
if (drops.length === 0) {
return NextResponse.json(
{ error: 'Drop not found' },
{ status: 404 }
)
}
const drop = drops[0]
// Calculate fill from sales
const [salesRows] = await pool.execute(
'SELECT COALESCE(SUM(size), 0) as total_fill FROM sales WHERE drop_id = ?',
[id]
)
const salesData = salesRows as any[]
const totalFillInGrams = salesData[0]?.total_fill || 0
let fill = totalFillInGrams
if (drop.unit === 'kg') {
fill = totalFillInGrams / 1000
}
return NextResponse.json({
...drop,
fill: fill,
})
} catch (error) {
console.error('Error fetching drop:', error)
return NextResponse.json(
{ error: 'Failed to fetch drop' },
{ status: 500 }
)
}
}
// PUT /api/drops/[id] - Update a drop
export async function PUT(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const id = parseInt(params.id, 10)
if (isNaN(id)) {
return NextResponse.json(
{ error: 'Invalid drop ID' },
{ status: 400 }
)
}
const body = await request.json()
const { item, description, size, unit, ppu, priceChf, priceEur, wholesalePriceChf, wholesalePriceEur, imageUrl, startTime } = body
// Check if drop exists
const [existingRows] = await pool.execute(
'SELECT id FROM drops WHERE id = ?',
[id]
)
const existing = existingRows as any[]
if (existing.length === 0) {
return NextResponse.json(
{ error: 'Drop not found' },
{ status: 404 }
)
}
// Build update query dynamically
const updates: string[] = []
const values: any[] = []
if (item !== undefined) {
updates.push('item = ?')
values.push(item)
}
if (description !== undefined) {
updates.push('description = ?')
values.push(description || null)
}
if (size !== undefined) {
if (size <= 0) {
return NextResponse.json(
{ error: 'Size must be greater than 0' },
{ status: 400 }
)
}
updates.push('size = ?')
values.push(size)
}
if (unit !== undefined) {
updates.push('unit = ?')
values.push(unit)
}
if (ppu !== undefined) {
if (ppu <= 0) {
return NextResponse.json(
{ error: 'Price per unit must be greater than 0' },
{ status: 400 }
)
}
updates.push('ppu = ?')
values.push(ppu)
}
if (priceChf !== undefined) {
if (priceChf !== null && priceChf < 0) {
return NextResponse.json(
{ error: 'Price CHF must be greater than or equal to 0' },
{ status: 400 }
)
}
updates.push('price_chf = ?')
values.push(priceChf !== null && priceChf !== '' ? priceChf : null)
}
if (priceEur !== undefined) {
if (priceEur !== null && priceEur < 0) {
return NextResponse.json(
{ error: 'Price EUR must be greater than or equal to 0' },
{ status: 400 }
)
}
updates.push('price_eur = ?')
values.push(priceEur !== null && priceEur !== '' ? priceEur : null)
}
if (wholesalePriceChf !== undefined) {
if (wholesalePriceChf !== null && wholesalePriceChf < 0) {
return NextResponse.json(
{ error: 'Wholesale price CHF must be greater than or equal to 0' },
{ status: 400 }
)
}
updates.push('wholesale_price_chf = ?')
values.push(wholesalePriceChf !== null && wholesalePriceChf !== '' ? wholesalePriceChf : null)
}
if (wholesalePriceEur !== undefined) {
if (wholesalePriceEur !== null && wholesalePriceEur < 0) {
return NextResponse.json(
{ error: 'Wholesale price EUR must be greater than or equal to 0' },
{ status: 400 }
)
}
updates.push('wholesale_price_eur = ?')
values.push(wholesalePriceEur !== null && wholesalePriceEur !== '' ? wholesalePriceEur : null)
}
if (imageUrl !== undefined) {
updates.push('image_url = ?')
values.push(imageUrl || null)
}
if (startTime !== undefined) {
updates.push('start_time = ?')
values.push(startTime || null)
}
if (updates.length === 0) {
return NextResponse.json(
{ error: 'No fields to update' },
{ status: 400 }
)
}
values.push(id)
const query = `UPDATE drops SET ${updates.join(', ')} WHERE id = ?`
await pool.execute(query, values)
// Fetch updated drop
const [rows] = await pool.execute('SELECT * FROM drops WHERE id = ?', [id])
const drops = rows as any[]
const drop = drops[0]
// Calculate fill
const [salesRows] = await pool.execute(
'SELECT COALESCE(SUM(size), 0) as total_fill FROM sales WHERE drop_id = ?',
[id]
)
const salesData = salesRows as any[]
const totalFillInGrams = salesData[0]?.total_fill || 0
let fill = totalFillInGrams
if (drop.unit === 'kg') {
fill = totalFillInGrams / 1000
}
return NextResponse.json({
...drop,
fill: fill,
})
} catch (error) {
console.error('Error updating drop:', error)
return NextResponse.json(
{ error: 'Failed to update drop' },
{ status: 500 }
)
}
}
// DELETE /api/drops/[id] - Delete a drop
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const id = parseInt(params.id, 10)
if (isNaN(id)) {
return NextResponse.json(
{ error: 'Invalid drop ID' },
{ status: 400 }
)
}
// Check if drop exists
const [existingRows] = await pool.execute(
'SELECT id FROM drops WHERE id = ?',
[id]
)
const existing = existingRows as any[]
if (existing.length === 0) {
return NextResponse.json(
{ error: 'Drop not found' },
{ status: 404 }
)
}
// Delete drop (cascade will handle related sales)
await pool.execute('DELETE FROM drops WHERE id = ?', [id])
return NextResponse.json({ success: true })
} catch (error) {
console.error('Error deleting drop:', error)
return NextResponse.json(
{ error: 'Failed to delete drop' },
{ status: 500 }
)
}
}

View File

@@ -51,6 +51,13 @@ export async function GET() {
}
const totalFill = salesFill + pendingFill
// Fetch images for this drop
const [imageRows] = await pool.execute(
'SELECT image_url FROM drop_images WHERE drop_id = ? ORDER BY display_order ASC LIMIT 4',
[drop.id]
)
const images = (imageRows as any[]).map((row: any) => row.image_url)
console.log(`Returning upcoming drop ${drop.id} (${drop.item}): fill=${totalFill}, size=${drop.size}, starts at ${startTime.toISOString()}`)
return NextResponse.json({
...drop,
@@ -59,6 +66,7 @@ export async function GET() {
pending_fill: pendingFill,
is_upcoming: true,
start_time: drop.start_time || drop.created_at,
images: images.length > 0 ? images : (drop.image_url ? [drop.image_url] : []), // Support legacy single image_url
})
}
@@ -118,6 +126,14 @@ export async function GET() {
if (remaining > epsilon) {
// Ensure pending_fill is explicitly 0 if no pending orders
const finalPendingFill = Number(pendingFill) || 0
// Fetch images for this drop
const [imageRows] = await pool.execute(
'SELECT image_url FROM drop_images WHERE drop_id = ? ORDER BY display_order ASC LIMIT 4',
[drop.id]
)
const images = (imageRows as any[]).map((row: any) => row.image_url)
console.log(`Returning active drop ${drop.id} with fill ${fillNum} < size ${dropSize}, pending_fill=${finalPendingFill} (raw: ${pendingFill})`)
return NextResponse.json({
...drop,
@@ -126,6 +142,7 @@ export async function GET() {
pending_fill: finalPendingFill, // Items on hold (explicitly 0 if no pending orders)
is_upcoming: false,
start_time: drop.start_time || drop.created_at,
images: images.length > 0 ? images : (drop.image_url ? [drop.image_url] : []), // Support legacy single image_url
})
} else {
console.log(`Drop ${drop.id} is sold out: fill=${fillNum} >= size=${dropSize} (remaining=${remaining})`)

View File

@@ -0,0 +1,72 @@
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
// GET /api/drops/images?drop_id=X - Get all images for a drop
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams
const dropId = searchParams.get('drop_id')
if (!dropId) {
return NextResponse.json(
{ error: 'drop_id parameter is required' },
{ status: 400 }
)
}
const [rows] = await pool.execute(
'SELECT id, image_url, display_order FROM drop_images WHERE drop_id = ? ORDER BY display_order ASC LIMIT 4',
[dropId]
)
return NextResponse.json(rows)
} catch (error) {
console.error('Error fetching drop images:', error)
return NextResponse.json(
{ error: 'Failed to fetch drop images' },
{ status: 500 }
)
}
}
// POST /api/drops/images - Add images to a drop
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { drop_id, image_urls } = body
if (!drop_id || !image_urls || !Array.isArray(image_urls)) {
return NextResponse.json(
{ error: 'drop_id and image_urls array are required' },
{ status: 400 }
)
}
if (image_urls.length > 4) {
return NextResponse.json(
{ error: 'Maximum 4 images allowed per drop' },
{ status: 400 }
)
}
// Delete existing images for this drop
await pool.execute('DELETE FROM drop_images WHERE drop_id = ?', [drop_id])
// Insert new images
for (let i = 0; i < image_urls.length; i++) {
await pool.execute(
'INSERT INTO drop_images (drop_id, image_url, display_order) VALUES (?, ?, ?)',
[drop_id, image_urls[i], i]
)
}
return NextResponse.json({ success: true })
} catch (error) {
console.error('Error saving drop images:', error)
return NextResponse.json(
{ error: 'Failed to save drop images' },
{ status: 500 }
)
}
}

View File

@@ -45,10 +45,18 @@ export async function GET() {
(soldOutDate.getTime() - dropDate.getTime()) / (1000 * 60 * 60)
)
// Fetch images for this drop
const [imageRows] = await pool.execute(
'SELECT image_url FROM drop_images WHERE drop_id = ? ORDER BY display_order ASC LIMIT 4',
[drop.id]
)
const images = (imageRows as any[]).map((row: any) => row.image_url)
soldOutDrops.push({
...drop,
fill: fill,
soldOutInHours: hoursDiff,
images: images.length > 0 ? images : (drop.image_url ? [drop.image_url] : []), // Support legacy single image_url
})
}
}

View File

@@ -9,7 +9,7 @@ export async function GET(request: NextRequest) {
)
const drops = rows as any[]
// Calculate fill from sales for each drop
// Calculate fill from sales for each drop and fetch images
const dropsWithFill = await Promise.all(
drops.map(async (drop) => {
// Calculate fill from sales records
@@ -27,9 +27,17 @@ export async function GET(request: NextRequest) {
fill = totalFillInGrams / 1000
}
// Fetch images for this drop
const [imageRows] = await pool.execute(
'SELECT image_url FROM drop_images WHERE drop_id = ? ORDER BY display_order ASC',
[drop.id]
)
const images = (imageRows as any[]).map((row) => row.image_url)
return {
...drop,
fill: fill,
images: images.length > 0 ? images : (drop.image_url ? [drop.image_url] : []),
}
})
)
@@ -48,7 +56,7 @@ export async function GET(request: NextRequest) {
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { item, size, unit = 'g', ppu, imageUrl, startTime } = body
const { item, description, size, unit = 'g', ppu, priceChf, priceEur, wholesalePriceChf, wholesalePriceEur, imageUrl, startTime } = body
// Validate required fields
if (!item || !size || !ppu) {
@@ -63,8 +71,8 @@ export async function POST(request: NextRequest) {
// ALTER TABLE drops ADD COLUMN image_url VARCHAR(255) DEFAULT NULL AFTER unit;
// Note: fill is no longer stored, it's calculated from sales
const [result] = await pool.execute(
'INSERT INTO drops (item, size, unit, ppu, image_url, start_time) VALUES (?, ?, ?, ?, ?, ?)',
[item, size, unit, ppu, imageUrl || null, startTime || null]
'INSERT INTO drops (item, description, size, unit, ppu, price_chf, price_eur, wholesale_price_chf, wholesale_price_eur, image_url, start_time) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[item, description || null, size, unit, ppu, priceChf || null, priceEur || null, wholesalePriceChf || null, wholesalePriceEur || null, imageUrl || null, startTime || null]
)
const insertId = (result as any).insertId

View File

@@ -0,0 +1,76 @@
import { NextRequest, NextResponse } from 'next/server'
import { cookies } from 'next/headers'
import pool from '@/lib/db'
// POST /api/notifications/subscribe - Subscribe to notifications
export async function POST(request: NextRequest) {
try {
// Get buyer_id from session cookie if logged in
const cookieStore = await cookies()
const buyerIdCookie = cookieStore.get('buyer_id')?.value
const buyer_id = buyerIdCookie ? parseInt(buyerIdCookie, 10) : null
const body = await request.json()
const { email, phone } = body
// Validate that at least one field is provided
if (!email && !phone) {
return NextResponse.json(
{ error: 'Email or phone number is required' },
{ status: 400 }
)
}
// Validate email format if provided
if (email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(email)) {
return NextResponse.json(
{ error: 'Invalid email format' },
{ status: 400 }
)
}
}
// Validate phone format if provided (basic validation)
if (phone) {
const phoneRegex = /^[+]?[\d\s\-()]{10,15}$/
if (!phoneRegex.test(phone)) {
return NextResponse.json(
{ error: 'Invalid phone number format' },
{ status: 400 }
)
}
}
// Insert email subscription if provided
// Using INSERT IGNORE to handle duplicate addresses (address is now primary key)
if (email) {
await pool.execute(
'INSERT IGNORE INTO notification_subscribers (buyer_id, type, address) VALUES (?, ?, ?)',
[buyer_id, 'email', email.trim()]
)
}
// Insert phone subscription if provided
// Using INSERT IGNORE to handle duplicate addresses (address is now primary key)
if (phone) {
await pool.execute(
'INSERT IGNORE INTO notification_subscribers (buyer_id, type, address) VALUES (?, ?, ?)',
[buyer_id, 'phone', phone.trim()]
)
}
return NextResponse.json(
{ success: true, message: 'Successfully subscribed to notifications' },
{ status: 200 }
)
} catch (error) {
console.error('Error subscribing to notifications:', error)
return NextResponse.json(
{ error: 'Failed to subscribe to notifications' },
{ status: 500 }
)
}
}

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

@@ -2,6 +2,9 @@ import { NextRequest, NextResponse } from 'next/server'
import { cookies } from 'next/headers'
import pool from '@/lib/db'
import { getNowPaymentsConfig } from '@/lib/nowpayments'
import { ALLOWED_PAYMENT_CURRENCIES, isAllowedCurrency } from '@/lib/payment-currencies'
import { getCountryFromIp, calculateShippingFee } from '@/lib/geolocation'
import { getCurrencyForCountry, convertPriceForCountry } from '@/lib/currency'
// POST /api/payments/create-invoice - Create a NOWPayments payment
// Note: Endpoint name kept as "create-invoice" for backward compatibility
@@ -22,7 +25,7 @@ export async function POST(request: NextRequest) {
const buyer_id = parseInt(buyerIdCookie, 10)
const body = await request.json()
const { drop_id, size, pay_currency, buyer_data_id } = body
const { drop_id, size, pay_currency, buyer_data_id, points_to_use } = body
// Validate required fields
if (!drop_id || !size || !buyer_data_id) {
@@ -32,6 +35,24 @@ export async function POST(request: NextRequest) {
)
}
// Validate and parse points_to_use
const pointsToUse = points_to_use ? parseFloat(points_to_use) : 0
if (pointsToUse < 0) {
return NextResponse.json(
{ error: 'points_to_use must be non-negative' },
{ status: 400 }
)
}
// Validate pay_currency against allowed list
const normalizedPayCurrency = pay_currency ? String(pay_currency).trim().toLowerCase() : null
if (normalizedPayCurrency && !isAllowedCurrency(normalizedPayCurrency)) {
return NextResponse.json(
{ error: `Invalid payment currency. Allowed currencies: ${ALLOWED_PAYMENT_CURRENCIES.join(', ').toUpperCase()}` },
{ status: 400 }
)
}
// Verify buyer_data_id exists and belongs to the buyer
const [buyerDataRows] = await pool.execute(
'SELECT id FROM buyer_data WHERE id = ? AND buyer_id = ?',
@@ -119,18 +140,136 @@ export async function POST(request: NextRequest) {
)
}
// 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
// Check if user has unlocked wholesale prices (use transaction connection to avoid connection leak)
const [referralRows] = await connection.execute(
'SELECT COUNT(*) as count FROM referrals WHERE referrer = ?',
[buyer_id]
)
const referralCount = (referralRows as any[])[0]?.count || 0
const isWholesaleUnlocked = referralCount >= 3
// Get country from IP to determine currency
const countryCode = await getCountryFromIp(request)
const currency = getCurrencyForCountry(countryCode)
// Calculate price in EUR (database stores prices in EUR)
// ppu is stored as integer where 1000 = 1.00 EUR, so divide by 1000 to get actual price
// Assuming ppu is per gram
const pricePerGramEur = drop.ppu / 1000
const priceToUseEur = isWholesaleUnlocked ? pricePerGramEur * 0.76 : pricePerGramEur
const priceAmountEur = size * priceToUseEur
// Convert price to user's currency (CHF for Swiss, EUR for others)
const priceAmount = convertPriceForCountry(priceAmountEur, countryCode)
// Handle referral points discount if points are being used
let pointsDiscount = 0
let actualPointsUsed = 0
if (pointsToUse > 0) {
// Get buyer's current points balance and points_to_chf setting
const [buyerPointsRows] = await connection.execute(
'SELECT referral_points FROM buyers WHERE id = ?',
[buyer_id]
)
const buyerPointsData = buyerPointsRows as any[]
if (buyerPointsData.length === 0) {
await connection.rollback()
connection.release()
return NextResponse.json(
{ error: 'Buyer not found' },
{ status: 404 }
)
}
const availablePoints = parseFloat(buyerPointsData[0].referral_points) || 0
if (pointsToUse > availablePoints) {
await connection.rollback()
connection.release()
return NextResponse.json(
{ error: 'Insufficient referral points' },
{ status: 400 }
)
}
// Get points_to_eur setting (fallback to points_to_chf for backward compatibility)
const [settingsRows] = await connection.execute(
'SELECT setting_key, setting_value FROM referral_settings WHERE setting_key IN (?, ?)',
['points_to_eur', 'points_to_chf']
)
const settings = settingsRows as any[]
let pointsToEur = parseFloat(settings.find(s => s.setting_key === 'points_to_eur')?.setting_value || '0')
// If points_to_eur not found, use points_to_chf and convert
if (pointsToEur === 0) {
const pointsToChf = parseFloat(settings.find(s => s.setting_key === 'points_to_chf')?.setting_value || '100')
// Convert CHF-based points to EUR-based (1 CHF ≈ 1.0309 EUR)
pointsToEur = pointsToChf / 1.030927835
}
if (pointsToEur === 0) {
pointsToEur = 100 // Default fallback
}
// Calculate discount in EUR first (universal base currency)
const discountEur = pointsToUse / pointsToEur
// Convert discount to user's currency
if (currency === 'CHF') {
// Convert EUR to CHF (1 EUR = 0.97 CHF)
pointsDiscount = discountEur * 0.97
} else {
// Already in EUR
pointsDiscount = discountEur
}
// Don't allow discount to exceed the product price (before shipping)
pointsDiscount = Math.min(pointsDiscount, priceAmount)
actualPointsUsed = pointsToUse
// Deduct points directly (stored procedures can be tricky with transactions)
// Get current points balance
const [currentPointsRows] = await connection.execute(
'SELECT referral_points FROM buyers WHERE id = ? FOR UPDATE',
[buyer_id]
)
const currentPointsData = currentPointsRows as any[]
const currentPoints = parseFloat(currentPointsData[0]?.referral_points) || 0
if (currentPoints < actualPointsUsed) {
await connection.rollback()
connection.release()
return NextResponse.json(
{ error: 'Insufficient referral points' },
{ status: 400 }
)
}
// Deduct points
const newBalance = currentPoints - actualPointsUsed
await connection.execute(
'UPDATE buyers SET referral_points = ? WHERE id = ?',
[newBalance, buyer_id]
)
// Record the transaction (we'll update pending_order_id later when we have it)
// For now, we'll record it after creating the pending order
}
// Calculate final price after discount
const priceAfterDiscount = Math.max(0, priceAmount - pointsDiscount)
// Calculate shipping fee (already in correct currency: CHF for CH, EUR for others)
const shippingFee = calculateShippingFee(countryCode)
// Add shipping fee to total price (shipping is not discounted)
const totalPriceAmount = priceAfterDiscount + shippingFee
// Round to 2 decimal places
priceAmount = Math.round(priceAmount * 100) / 100
const roundedPriceAmount = Math.round(totalPriceAmount * 100) / 100
const roundedShippingFee = Math.round(shippingFee * 100) / 100
const roundedSubtotal = Math.round(priceAfterDiscount * 100) / 100
const roundedPointsDiscount = Math.round(pointsDiscount * 100) / 100
// Generate order ID
const orderId = `SALE-${Date.now()}-${drop_id}-${buyer_id}`
@@ -143,14 +282,19 @@ export async function POST(request: NextRequest) {
// Get NOWPayments config (testnet or production)
const nowPaymentsConfig = getNowPaymentsConfig()
// Use currency based on user location (CHF for Swiss, EUR for others)
// Override the default currency from config with user's currency
const priceCurrency = currency.toLowerCase()
// Calculate expiration time (10 minutes from now)
const expiresAt = new Date()
expiresAt.setMinutes(expiresAt.getMinutes() + 10)
// Create NOWPayments payment
// Note: Payment API requires pay_currency (crypto currency)
// Use currency from request, or fall back to env/default
const payCurrency = pay_currency || process.env.NOWPAYMENTS_PAY_CURRENCY || 'btc'
// Use currency from request (already validated), or fall back to env/default (must be in allowed list)
const defaultCurrency = process.env.NOWPAYMENTS_PAY_CURRENCY?.toLowerCase() || 'btc'
const payCurrency = normalizedPayCurrency || (isAllowedCurrency(defaultCurrency) ? defaultCurrency : 'btc')
// Optional: Use fixed rate for 20 minutes (prevents rate changes during checkout)
// If is_fixed_rate is true, payment expires after 20 minutes if not paid
@@ -163,8 +307,8 @@ export async function POST(request: NextRequest) {
'Content-Type': 'application/json',
},
body: JSON.stringify({
price_amount: priceAmount,
price_currency: nowPaymentsConfig.currency,
price_amount: roundedPriceAmount,
price_currency: priceCurrency, // CHF for Swiss users, EUR for others
pay_currency: payCurrency, // Required: crypto currency (btc, eth, etc)
order_id: orderId,
order_description: `${drop.item} - ${size}g`,
@@ -188,10 +332,25 @@ export async function POST(request: NextRequest) {
// Store pending order with expiration time (atomically reserves inventory)
// 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]
const [pendingOrderResult] = await connection.execute(
'INSERT INTO pending_orders (payment_id, order_id, drop_id, buyer_id, buyer_data_id, size, price_amount, price_currency, points_used, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[payment.payment_id, orderId, drop_id, buyer_id, buyer_data_id, size, roundedPriceAmount, priceCurrency, actualPointsUsed, expiresAt]
)
const pendingOrderId = (pendingOrderResult as any).insertId
// Record referral point transaction if points were used
if (actualPointsUsed > 0) {
await connection.execute(
'INSERT INTO referral_point_transactions (buyer_id, points, type, pending_order_id, description) VALUES (?, ?, ?, ?, ?)',
[
buyer_id,
actualPointsUsed,
'spent',
pendingOrderId,
`Points spent for purchase (Pending Order #${pendingOrderId})`
]
)
}
// Commit transaction - inventory is now reserved
await connection.commit()
@@ -204,8 +363,12 @@ export async function POST(request: NextRequest) {
pay_address: payment.pay_address, // Address where customer sends payment
pay_amount: payment.pay_amount, // Amount in crypto to pay
pay_currency: payment.pay_currency, // Crypto currency
price_amount: payment.price_amount, // Price in fiat
price_currency: payment.price_currency, // Fiat currency
price_amount: payment.price_amount, // Total price in fiat (includes shipping, after points discount)
price_currency: payment.price_currency, // Fiat currency (CHF or EUR)
shipping_fee: roundedShippingFee, // Shipping fee in user's currency
subtotal: roundedSubtotal, // Product price without shipping in user's currency (after discount)
points_used: actualPointsUsed, // Points used for discount
points_discount: roundedPointsDiscount, // Discount amount in user's currency
order_id: orderId,
payin_extra_id: payment.payin_extra_id, // Memo/tag for certain currencies (XRP, XLM, etc)
expiration_estimate_date: payment.expiration_estimate_date, // When payment expires

View File

@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server'
import { getNowPaymentsConfig } from '@/lib/nowpayments'
import { ALLOWED_PAYMENT_CURRENCIES } from '@/lib/payment-currencies'
// GET /api/payments/currencies - Get available payment currencies from NOWPayments
export async function GET() {
@@ -27,10 +28,27 @@ export async function GET() {
}
const data = await response.json()
// Filter currencies to only include the selected list
const currencies = (data.currencies || []).filter((c: any) => {
let currencyCode: string | null = null
// Handle object format (when fixed_rate=true)
if (typeof c === 'object' && c !== null && c.currency) {
currencyCode = String(c.currency).trim().toLowerCase()
}
// Handle string format (when fixed_rate=false)
else if (typeof c === 'string') {
currencyCode = c.trim().toLowerCase()
}
// Check if currency is in the allowed list
return currencyCode && ALLOWED_PAYMENT_CURRENCIES.includes(currencyCode as any)
})
// Return the currencies array
// Return the filtered currencies array
return NextResponse.json({
currencies: data.currencies || [],
currencies: currencies,
})
} catch (error) {
console.error('Error fetching currencies:', error)

View File

@@ -0,0 +1,217 @@
import { NextRequest, NextResponse } from 'next/server'
import { cookies } from 'next/headers'
import pool from '@/lib/db'
import { ALLOWED_PAYMENT_CURRENCIES, isAllowedCurrency } from '@/lib/payment-currencies'
// POST /api/referral-points/redeem - Redeem referral points to crypto
export async function POST(request: NextRequest) {
const connection = await pool.getConnection()
try {
const cookieStore = await cookies()
const buyerIdCookie = cookieStore.get('buyer_id')?.value
if (!buyerIdCookie) {
return NextResponse.json(
{ error: 'Authentication required' },
{ status: 401 }
)
}
const buyer_id = parseInt(buyerIdCookie, 10)
const body = await request.json()
const { points, crypto_currency, wallet_address } = body
// Validate required fields
if (!points || !crypto_currency || !wallet_address) {
return NextResponse.json(
{ error: 'Missing required fields: points, crypto_currency, wallet_address' },
{ status: 400 }
)
}
const pointsToRedeem = parseFloat(points)
const normalizedCryptoCurrency = crypto_currency.toLowerCase().trim()
const normalizedWalletAddress = wallet_address.trim()
// Validate points amount
if (isNaN(pointsToRedeem) || pointsToRedeem <= 0) {
return NextResponse.json(
{ error: 'Points must be a positive number' },
{ status: 400 }
)
}
// Validate crypto currency - only USDT (SOL) is allowed for redemption
if (normalizedCryptoCurrency !== 'usdtsol') {
return NextResponse.json(
{ error: 'Only USDT (SOL) is supported for point redemption' },
{ status: 400 }
)
}
// Basic wallet address validation (non-empty, reasonable length)
if (normalizedWalletAddress.length < 10 || normalizedWalletAddress.length > 255) {
return NextResponse.json(
{ error: 'Invalid wallet address format' },
{ status: 400 }
)
}
await connection.beginTransaction()
try {
// Get buyer's current points balance
const [buyerRows] = await connection.execute(
'SELECT referral_points FROM buyers WHERE id = ? FOR UPDATE',
[buyer_id]
)
const buyers = buyerRows as any[]
if (buyers.length === 0) {
await connection.rollback()
return NextResponse.json(
{ error: 'Buyer not found' },
{ status: 404 }
)
}
const currentPoints = parseFloat(buyers[0].referral_points) || 0
// Get redemption settings
const [settingsRows] = await connection.execute(
'SELECT setting_key, setting_value FROM referral_settings'
)
const settings = settingsRows as any[]
const pointsToCryptoChf = parseFloat(
settings.find(s => s.setting_key === 'points_to_crypto_chf')?.setting_value || '100'
)
const minRedemptionPoints = parseFloat(
settings.find(s => s.setting_key === 'min_redemption_points')?.setting_value || '1000'
)
// Validate minimum redemption amount
if (pointsToRedeem < minRedemptionPoints) {
await connection.rollback()
return NextResponse.json(
{ error: `Minimum redemption is ${minRedemptionPoints} points` },
{ status: 400 }
)
}
// Validate user has enough points
if (currentPoints < pointsToRedeem) {
await connection.rollback()
return NextResponse.json(
{ error: 'Insufficient points' },
{ status: 400 }
)
}
// Calculate CHF value of points
const chfValue = pointsToRedeem / pointsToCryptoChf
// TODO: Get current crypto exchange rate
// For now, we'll use a placeholder that would need to be replaced with actual exchange rate API
// This should fetch the current rate from an exchange API (e.g., CoinGecko, Binance, etc.)
const cryptoExchangeRate = await getCryptoExchangeRate(normalizedCryptoCurrency, 'chf')
if (!cryptoExchangeRate) {
await connection.rollback()
return NextResponse.json(
{ error: 'Failed to fetch exchange rate. Please try again later.' },
{ status: 500 }
)
}
// Calculate crypto amount
const cryptoAmount = chfValue / cryptoExchangeRate
// Deduct points from buyer's balance
const newBalance = currentPoints - pointsToRedeem
await connection.execute(
'UPDATE buyers SET referral_points = ? WHERE id = ?',
[newBalance, buyer_id]
)
// Create redemption record
const [redemptionResult] = await connection.execute(
`INSERT INTO point_redemptions
(buyer_id, points, crypto_currency, wallet_address, crypto_amount, status)
VALUES (?, ?, ?, ?, ?, 'pending')`,
[buyer_id, pointsToRedeem, normalizedCryptoCurrency, normalizedWalletAddress, cryptoAmount]
)
const redemptionId = (redemptionResult as any).insertId
// Record transaction
await connection.execute(
`INSERT INTO referral_point_transactions
(buyer_id, points, type, description)
VALUES (?, ?, 'redeemed', ?)`,
[
buyer_id,
pointsToRedeem,
`Points redeemed to ${normalizedCryptoCurrency.toUpperCase()} (Redemption #${redemptionId})`
]
)
await connection.commit()
return NextResponse.json({
success: true,
redemption_id: redemptionId,
points_redeemed: pointsToRedeem,
crypto_currency: normalizedCryptoCurrency,
crypto_amount: cryptoAmount,
chf_value: chfValue,
new_balance: newBalance,
message: 'Redemption request created successfully. Your crypto will be sent within 24-48 hours.'
})
} catch (error) {
await connection.rollback()
throw error
}
} catch (error: any) {
console.error('Error redeeming points:', error)
return NextResponse.json(
{ error: error.message || 'Failed to redeem points' },
{ status: 500 }
)
} finally {
connection.release()
}
}
// Helper function to get crypto exchange rate
// TODO: Replace with actual exchange rate API integration
async function getCryptoExchangeRate(crypto: string, fiat: string): Promise<number | null> {
try {
// Placeholder: In production, this should call a real exchange rate API
// Examples: CoinGecko, Binance, Coinbase, etc.
// For now, return a mock rate (this should be replaced)
// In production, you would do something like:
// const response = await fetch(`https://api.coingecko.com/api/v3/simple/price?ids=${crypto}&vs_currencies=${fiat}`)
// const data = await response.json()
// return data[crypto][fiat]
// Mock rates (CHF per 1 unit of crypto) - REPLACE WITH REAL API
const mockRates: Record<string, number> = {
'btc': 85000,
'eth': 2500,
'sol': 100,
'xrp': 0.6,
'bnbbsc': 300,
'usdterc20': 0.9, // Approximate CHF per USDT
'usdtsol': 0.9, // Approximate CHF per USDT on Solana
}
return mockRates[crypto.toLowerCase()] || null
} catch (error) {
console.error('Error fetching exchange rate:', error)
return null
}
}

View File

@@ -0,0 +1,96 @@
import { NextRequest, NextResponse } from 'next/server'
import { cookies } from 'next/headers'
import pool from '@/lib/db'
// GET /api/referral-points - Get current user's referral points and settings
export async function GET(request: NextRequest) {
try {
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 buyer's referral points
const [buyerRows] = await pool.execute(
'SELECT referral_points FROM buyers WHERE id = ?',
[buyer_id]
)
const buyers = buyerRows as any[]
if (buyers.length === 0) {
return NextResponse.json(
{ error: 'Buyer not found' },
{ status: 404 }
)
}
const referralPoints = parseFloat(buyers[0].referral_points) || 0
// Get referral settings
const [settingsRows] = await pool.execute(
'SELECT setting_key, setting_value FROM referral_settings'
)
const settings = settingsRows as any[]
// Get EUR-based settings (preferred)
let pointsToEur = parseFloat(
settings.find(s => s.setting_key === 'points_to_eur')?.setting_value || '0'
)
let pointsPerEur = parseFloat(
settings.find(s => s.setting_key === 'points_per_eur')?.setting_value || '0'
)
// Get CHF-based settings (for backward compatibility)
const pointsToChf = parseFloat(
settings.find(s => s.setting_key === 'points_to_chf')?.setting_value || '100'
)
const pointsPerChf = parseFloat(
settings.find(s => s.setting_key === 'points_per_chf')?.setting_value || '10'
)
// If EUR settings not found, convert from CHF (1 CHF ≈ 1.0309 EUR)
const chfToEurRate = 1.030927835
if (pointsToEur === 0) {
pointsToEur = pointsToChf / chfToEurRate
}
if (pointsPerEur === 0) {
pointsPerEur = pointsPerChf * chfToEurRate
}
const pointsToCryptoChf = parseFloat(
settings.find(s => s.setting_key === 'points_to_crypto_chf')?.setting_value || '100'
)
const minRedemptionPoints = parseFloat(
settings.find(s => s.setting_key === 'min_redemption_points')?.setting_value || '1000'
)
// Calculate maximum discount available (in EUR, then convert to CHF for display)
const maxDiscountEur = referralPoints / pointsToEur
const maxDiscountChf = maxDiscountEur * 0.97 // Convert EUR to CHF
return NextResponse.json({
referral_points: referralPoints,
points_to_eur: pointsToEur,
points_per_eur: pointsPerEur,
points_to_chf: pointsToChf, // Keep for backward compatibility
points_per_chf: pointsPerChf, // Keep for backward compatibility
points_to_crypto_chf: pointsToCryptoChf,
min_redemption_points: minRedemptionPoints,
max_discount_eur: maxDiscountEur,
max_discount_chf: maxDiscountChf,
})
} catch (error) {
console.error('Error fetching referral points:', error)
return NextResponse.json(
{ error: 'Failed to fetch referral points' },
{ status: 500 }
)
}
}

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,74 @@
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,
wholesaleTier: {
referralsNeeded: 3,
referralsRemaining: 3,
isUnlocked: false
},
innerCircleTier: {
referralsNeeded: 10,
referralsRemaining: 10,
isUnlocked: false
}
},
{ 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 isWholesaleUnlocked = referralCount >= 3
const isInnerCircleUnlocked = referralCount >= 10
// Determine which tier to show
const wholesaleTier = {
referralsNeeded: 3,
referralsRemaining: Math.max(0, 3 - referralCount),
isUnlocked: isWholesaleUnlocked
}
const innerCircleTier = {
referralsNeeded: 10,
referralsRemaining: Math.max(0, 10 - referralCount),
isUnlocked: isInnerCircleUnlocked
}
return NextResponse.json({
referralCount,
isUnlocked: isWholesaleUnlocked, // Keep for backward compatibility
referralsNeeded: isWholesaleUnlocked ? innerCircleTier.referralsNeeded : wholesaleTier.referralsNeeded,
referralsRemaining: isWholesaleUnlocked ? innerCircleTier.referralsRemaining : wholesaleTier.referralsRemaining,
wholesaleTier,
innerCircleTier,
})
} catch (error) {
console.error('Error fetching referral status:', error)
return NextResponse.json(
{ error: 'Failed to fetch referral status' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,51 @@
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
// GET /api/sales/drop/[dropId] - Get all sales for a specific drop
export async function GET(
request: NextRequest,
{ params }: { params: { dropId: string } }
) {
try {
const dropId = parseInt(params.dropId, 10)
if (isNaN(dropId)) {
return NextResponse.json(
{ error: 'Invalid drop ID' },
{ status: 400 }
)
}
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,
b.username as buyer_username,
b.email as buyer_email,
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 buyers b ON s.buyer_id = b.id
LEFT JOIN buyer_data bd ON s.buyer_data_id = bd.id
WHERE s.drop_id = ?
ORDER BY s.created_at DESC`,
[dropId]
)
return NextResponse.json(rows)
} catch (error) {
console.error('Error fetching sales for drop:', error)
return NextResponse.json(
{ error: 'Failed to fetch sales' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,109 @@
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
// POST /api/sales/from-pending-order - Create a sale from a pending order (for IPN handlers)
// This endpoint should be called by your IPN/webhook handler when payment is confirmed
// It creates the sale and awards referral points
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { payment_id } = body
if (!payment_id) {
return NextResponse.json(
{ error: 'Missing required field: payment_id' },
{ status: 400 }
)
}
const connection = await pool.getConnection()
await connection.beginTransaction()
try {
// Find the pending order
const [pendingRows] = await connection.execute(
'SELECT * FROM pending_orders WHERE payment_id = ?',
[payment_id]
)
const pendingOrders = pendingRows as any[]
if (pendingOrders.length === 0) {
await connection.rollback()
connection.release()
return NextResponse.json(
{ error: 'Pending order not found' },
{ status: 404 }
)
}
const pendingOrder = pendingOrders[0]
// Check if sale already exists
const [existingSalesRows] = await connection.execute(
'SELECT id FROM sales WHERE payment_id = ?',
[payment_id]
)
const existingSales = existingSalesRows as any[]
if (existingSales.length > 0) {
// Sale already exists, return it
await connection.commit()
connection.release()
return NextResponse.json({
sale_id: existingSales[0].id,
message: 'Sale already exists',
})
}
// Create the sale from pending order data
const [result] = await connection.execute(
'INSERT INTO sales (drop_id, buyer_id, buyer_data_id, size, payment_id, price_amount, price_currency, points_used) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[
pendingOrder.drop_id,
pendingOrder.buyer_id,
pendingOrder.buyer_data_id,
pendingOrder.size,
payment_id,
pendingOrder.price_amount,
pendingOrder.price_currency,
pendingOrder.points_used || 0,
]
)
const saleId = (result as any).insertId
// Award referral points to the referrer (if any)
// The stored procedure will:
// 1. Check if the buyer has a referrer
// 2. Calculate points based on purchase amount and points_per_chf setting
// 3. Update the referrer's referral_points balance
// 4. Record the transaction in referral_point_transactions
await connection.execute('CALL award_referral_points(?)', [saleId])
// Delete the pending order (it's now converted to a sale)
await connection.execute(
'DELETE FROM pending_orders WHERE payment_id = ?',
[payment_id]
)
await connection.commit()
connection.release()
return NextResponse.json({
sale_id: saleId,
message: 'Sale created successfully',
}, { status: 201 })
} catch (error) {
await connection.rollback()
connection.release()
throw error
}
} catch (error) {
console.error('Error creating sale from pending order:', error)
return NextResponse.json(
{ error: 'Failed to create sale from pending order' },
{ status: 500 }
)
}
}

View File

@@ -16,10 +16,14 @@ export async function GET(request: NextRequest) {
d.unit as drop_unit,
d.ppu as drop_ppu,
b.username as buyer_username,
b.email as buyer_email
b.email as buyer_email,
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 buyers b ON s.buyer_id = b.id
LEFT JOIN buyer_data bd ON s.buyer_data_id = bd.id
ORDER BY s.created_at DESC`
)
return NextResponse.json(rows)

View File

@@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from 'next/server'
import { getCountryFromIp, calculateShippingFee } from '@/lib/geolocation'
import { getCurrencyForCountry } from '@/lib/currency'
// GET /api/shipping-fee - Get shipping fee based on user's IP location
export async function GET(request: NextRequest) {
try {
const countryCode = await getCountryFromIp(request)
const shippingFee = calculateShippingFee(countryCode)
const currency = getCurrencyForCountry(countryCode)
return NextResponse.json({
shipping_fee: shippingFee,
country_code: countryCode,
currency: currency,
})
} catch (error) {
console.error('Error calculating shipping fee:', error)
// Default to 40 EUR if detection fails
return NextResponse.json({
shipping_fee: 40,
country_code: null,
currency: 'EUR',
})
}
}

View File

@@ -1,6 +1,8 @@
'use client'
import { useState, useEffect } from 'react'
import { useSearchParams } from 'next/navigation'
import { useI18n } from '@/lib/i18n'
interface User {
id: number
@@ -15,10 +17,13 @@ interface AuthModalProps {
}
export default function AuthModal({ isOpen, onClose, onLogin }: AuthModalProps) {
const { t } = useI18n()
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 +35,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 +63,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',
@@ -56,7 +85,7 @@ export default function AuthModal({ isOpen, onClose, onLogin }: AuthModalProps)
const data = await response.json()
if (!response.ok) {
setError(data.error || 'An error occurred')
setError(data.error || t('auth.anErrorOccurred'))
setLoading(false)
return
}
@@ -66,7 +95,7 @@ export default function AuthModal({ isOpen, onClose, onLogin }: AuthModalProps)
onClose()
} catch (error) {
console.error('Auth error:', error)
setError('An unexpected error occurred')
setError(t('auth.unexpectedError'))
} finally {
setLoading(false)
}
@@ -104,7 +133,7 @@ export default function AuthModal({ isOpen, onClose, onLogin }: AuthModalProps)
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
<h2 style={{ margin: 0 }}>
{isLogin ? 'Login' : 'Register'}
{isLogin ? t('auth.login') : t('auth.register')}
</h2>
<button
onClick={onClose}
@@ -138,7 +167,7 @@ export default function AuthModal({ isOpen, onClose, onLogin }: AuthModalProps)
color: 'var(--text)',
}}
>
Email
{t('auth.email')}
</label>
<input
type="email"
@@ -155,7 +184,7 @@ export default function AuthModal({ isOpen, onClose, onLogin }: AuthModalProps)
color: 'var(--text)',
fontSize: '14px',
}}
placeholder="your@email.com"
placeholder={t('auth.email')}
/>
</div>
)}
@@ -170,7 +199,7 @@ export default function AuthModal({ isOpen, onClose, onLogin }: AuthModalProps)
color: 'var(--text)',
}}
>
Username
{t('auth.username')}
</label>
<input
type="text"
@@ -188,11 +217,11 @@ export default function AuthModal({ isOpen, onClose, onLogin }: AuthModalProps)
color: 'var(--text)',
fontSize: '14px',
}}
placeholder="username"
placeholder={t('auth.username')}
/>
</div>
<div style={{ marginBottom: '20px' }}>
<div style={{ marginBottom: '16px' }}>
<label
htmlFor="password"
style={{
@@ -202,7 +231,7 @@ export default function AuthModal({ isOpen, onClose, onLogin }: AuthModalProps)
color: 'var(--text)',
}}
>
Password
{t('auth.password')}
</label>
<input
type="password"
@@ -220,10 +249,47 @@ export default function AuthModal({ isOpen, onClose, onLogin }: AuthModalProps)
color: 'var(--text)',
fontSize: '14px',
}}
placeholder="password"
placeholder={t('auth.password')}
/>
</div>
{!isLogin && (
<div style={{ marginBottom: '20px' }}>
<label
htmlFor="referralId"
style={{
display: 'block',
marginBottom: '8px',
fontSize: '14px',
color: 'var(--text)',
}}
>
{t('auth.referralId')} <span style={{ color: 'var(--muted)', fontSize: '12px', fontWeight: 'normal' }}>({t('auth.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={t('auth.referralId')}
/>
{searchParams?.get('ref') && referralId === searchParams.get('ref') && (
<small style={{ display: 'block', marginTop: '4px', fontSize: '12px', color: 'var(--accent)' }}>
{t('auth.autoFilled')}
</small>
)}
</div>
)}
{error && (
<div
style={{
@@ -252,10 +318,10 @@ export default function AuthModal({ isOpen, onClose, onLogin }: AuthModalProps)
}}
>
{loading
? 'Processing...'
? t('common.processing')
: isLogin
? 'Login'
: 'Register'}
? t('auth.login')
: t('auth.register')}
</button>
</form>
@@ -269,7 +335,7 @@ export default function AuthModal({ isOpen, onClose, onLogin }: AuthModalProps)
>
{isLogin ? (
<>
Don't have an account?{' '}
{t('auth.dontHaveAccount')}{' '}
<button
onClick={() => {
setIsLogin(false)
@@ -284,12 +350,12 @@ export default function AuthModal({ isOpen, onClose, onLogin }: AuthModalProps)
padding: 0,
}}
>
Register
{t('auth.register')}
</button>
</>
) : (
<>
Already have an account?{' '}
{t('auth.alreadyHaveAccount')}{' '}
<button
onClick={() => {
setIsLogin(true)
@@ -304,7 +370,7 @@ export default function AuthModal({ isOpen, onClose, onLogin }: AuthModalProps)
padding: 0,
}}
>
Login
{t('auth.login')}
</button>
</>
)}

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,13 @@
'use client'
import { useI18n } from '@/lib/i18n'
export default function Footer() {
const { t } = useI18n()
return (
<footer>
© 2025 420Deals.ch · CBD &lt; 1% THC · Sale from 18 years · Switzerland
{t('footer.text')}
</footer>
)
}

View File

@@ -1,28 +1,54 @@
'use client'
import { useState } from 'react'
import { useI18n } from '@/lib/i18n'
import UnlockModal from './UnlockModal'
export default function InfoBox() {
const { t } = useI18n()
const [showUnlockModal, setShowUnlockModal] = useState(false)
// Process the text to make "referral link" clickable
const processTaxesLegalText = () => {
const text = t('infoBox.taxesLegalText')
// Replace "referral link" or "Referral-Link" with a clickable link
// Preserve the original case of the matched text
const processed = text.replace(
/(referral link|Referral-Link)/gi,
(match) => `<a href="#" class="referral-link-clickable" style="cursor:pointer;text-decoration:underline;color:inherit;">${match}</a>`
)
return processed
}
return (
<div className="info-box">
<div>
<h3>Why so cheap?</h3>
<p>
Retail prices are around 10 CHF/g. Through collective
bulk orders, we buy like wholesalers without
intermediaries.
</p>
<>
<div className="info-box">
<div>
<h3>{t('infoBox.whyCheap')}</h3>
<p>{t('infoBox.whyCheapText')}</p>
</div>
<div>
<h3>{t('infoBox.taxesLegal')}</h3>
<p
dangerouslySetInnerHTML={{ __html: processTaxesLegalText() }}
onClick={(e) => {
const target = e.target as HTMLElement
if (target.classList.contains('referral-link-clickable')) {
e.preventDefault()
setShowUnlockModal(true)
}
}}
/>
</div>
<div>
<h3>{t('infoBox.dropModel')}</h3>
<p>{t('infoBox.dropModelText')}</p>
</div>
</div>
<div>
<h3>Taxes & Legal</h3>
<p>
Bulk sale with 2.5% VAT. No retail packaging, no
tobacco tax.
</p>
</div>
<div>
<h3>Drop Model</h3>
<p>
One variety per drop. Only when sold out then the next drop.
</p>
</div>
</div>
<UnlockModal isOpen={showUnlockModal} onClose={() => setShowUnlockModal(false)} />
</>
)
}

View File

@@ -0,0 +1,45 @@
'use client'
import { useI18n } from '@/lib/i18n'
export default function LanguageSwitcher() {
const { language, setLanguage } = useI18n()
return (
<div style={{ display: 'flex', gap: '8px', alignItems: 'center', marginLeft: '16px' }}>
<button
onClick={() => setLanguage('en')}
style={{
padding: '6px 12px',
fontSize: '13px',
background: language === 'en' ? 'var(--accent)' : 'transparent',
color: language === 'en' ? '#000' : 'var(--muted)',
border: `1px solid ${language === 'en' ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: '6px',
cursor: 'pointer',
fontWeight: language === 'en' ? 500 : 400,
transition: 'all 0.2s',
}}
>
EN
</button>
<button
onClick={() => setLanguage('de')}
style={{
padding: '6px 12px',
fontSize: '13px',
background: language === 'de' ? 'var(--accent)' : 'transparent',
color: language === 'de' ? '#000' : 'var(--muted)',
border: `1px solid ${language === 'de' ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: '6px',
cursor: 'pointer',
fontWeight: language === 'de' ? 500 : 400,
transition: 'all 0.2s',
}}
>
DE
</button>
</div>
)
}

View File

@@ -1,18 +1,26 @@
'use client'
import { useState, useEffect } from 'react'
import Image from 'next/image'
import AuthModal from './AuthModal'
import RedeemPointsModal from './RedeemPointsModal'
import LanguageSwitcher from './LanguageSwitcher'
import { useI18n } from '@/lib/i18n'
interface User {
id: number
username: string
email: string
referral_points?: number
}
export default function Nav() {
const { t } = useI18n()
const [user, setUser] = useState<User | null>(null)
const [showAuthModal, setShowAuthModal] = useState(false)
const [showRedeemModal, setShowRedeemModal] = useState(false)
const [loading, setLoading] = useState(true)
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
useEffect(() => {
checkAuth()
@@ -25,7 +33,24 @@ export default function Nav() {
})
if (response.ok) {
const data = await response.json()
setUser(data.user)
const userData = data.user
// If user is logged in, fetch referral points
if (userData) {
try {
const pointsResponse = await fetch('/api/referral-points', {
credentials: 'include',
})
if (pointsResponse.ok) {
const pointsData = await pointsResponse.json()
userData.referral_points = pointsData.referral_points
}
} catch (error) {
console.error('Error fetching referral points:', error)
}
}
setUser(userData)
}
} catch (error) {
console.error('Error checking auth:', error)
@@ -51,32 +76,128 @@ export default function Nav() {
}
}
const handleRedeemSuccess = async () => {
// Refresh points after successful redemption
if (user) {
try {
const pointsResponse = await fetch('/api/referral-points', {
credentials: 'include',
})
if (pointsResponse.ok) {
const pointsData = await pointsResponse.json()
setUser({ ...user, referral_points: pointsData.referral_points })
}
} catch (error) {
console.error('Error fetching referral points:', error)
}
}
}
return (
<>
<nav>
<div className="brand">
<a href="/" style={{ display: 'inline-block', textDecoration: 'none' }}>
<img src="/header.jpg" alt="420Deals.ch" style={{ height: '40px', width: 'auto' }} />
</a>
</div>
<div className="links">
<a href="#drop">Drop</a>
<a href="#past">Past Drops</a>
<a href="#community">Community</a>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
<div className="brand">
<a href="/" style={{ display: 'inline-block', textDecoration: 'none' }}>
<img src="/header.png" alt="420Deals.ch" style={{ height: '50px', width: 'auto' }} />
</a>
</div>
<button
className="mobile-menu-toggle"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
aria-label="Toggle menu"
>
<span style={{ display: mobileMenuOpen ? 'none' : 'block' }}></span>
<span style={{ display: mobileMenuOpen ? 'block' : 'none' }}></span>
</button>
</div>
<div className={`links ${mobileMenuOpen ? 'mobile-open' : ''}`}>
<a href="#drop" onClick={() => setMobileMenuOpen(false)}>{t('nav.drop')}</a>
<a href="#past" onClick={() => setMobileMenuOpen(false)}>{t('nav.pastDrops')}</a>
<a href="#community" onClick={() => setMobileMenuOpen(false)}>{t('nav.community')}</a>
<LanguageSwitcher />
{!loading && (
user ? (
<>
<span style={{ color: 'var(--muted)', fontSize: '14px', marginLeft: '48px' }}>
{user.username}
</span>
<button
onClick={handleLogout}
{user.referral_points !== undefined && user.referral_points > 0 && (
<>
<span style={{
color: '#0a7931',
fontSize: '14px',
marginLeft: '12px',
fontWeight: 500,
display: 'flex',
alignItems: 'center',
gap: '6px',
}}>
<Image
src="/icon_ref_points.png"
alt="Referral Points"
width={16}
height={16}
style={{ display: 'inline-block', verticalAlign: 'middle' }}
/>
{user.referral_points.toFixed(0)} pts
</span>
<button
onClick={() => {
setShowRedeemModal(true)
setMobileMenuOpen(false)
}}
style={{
background: 'transparent',
border: '1px solid #0a7931',
color: '#0a7931',
padding: '8px 16px',
borderRadius: '8px',
fontSize: '14px',
marginLeft: '12px',
lineHeight: '1',
boxSizing: 'border-box',
display: 'inline-block',
cursor: 'pointer',
fontWeight: 500,
}}
>
{t('redeemPoints.redeem')}
</button>
</>
)}
<a
href="/orders"
onClick={() => setMobileMenuOpen(false)}
style={{
background: 'transparent',
border: '1px solid var(--border)',
color: 'var(--text)',
padding: '8px 16px',
borderRadius: '8px',
fontSize: '14px',
marginLeft: '12px',
lineHeight: '1',
boxSizing: 'border-box',
display: 'inline-block',
textDecoration: 'none',
cursor: 'pointer',
}}
>
{t('nav.orders')}
</a>
<button
onClick={() => {
handleLogout()
setMobileMenuOpen(false)
}}
style={{
background: 'transparent',
border: '1px solid #e57373',
color: '#e57373',
padding: '8px 16px',
borderRadius: '8px',
cursor: 'pointer',
fontSize: '14px',
marginLeft: '12px',
@@ -85,12 +206,15 @@ export default function Nav() {
display: 'inline-block',
}}
>
Logout
{t('nav.logout')}
</button>
</>
) : (
<button
onClick={() => setShowAuthModal(true)}
onClick={() => {
setShowAuthModal(true)
setMobileMenuOpen(false)
}}
style={{
padding: '8px 16px',
fontSize: '14px',
@@ -106,10 +230,11 @@ export default function Nav() {
display: 'inline-block',
}}
>
Login
{t('nav.login')}
</button>
)
)}
</div>
</div>
</nav>
@@ -118,6 +243,15 @@ export default function Nav() {
onClose={() => setShowAuthModal(false)}
onLogin={handleLogin}
/>
{user && user.referral_points !== undefined && (
<RedeemPointsModal
isOpen={showRedeemModal}
onClose={() => setShowRedeemModal(false)}
currentPoints={user.referral_points}
onRedeemSuccess={handleRedeemSuccess}
/>
)}
</>
)
}

View File

@@ -2,6 +2,7 @@
import { useState, useEffect } from 'react'
import Image from 'next/image'
import { useI18n } from '@/lib/i18n'
interface PastDrop {
id: number
@@ -11,6 +12,7 @@ interface PastDrop {
unit: string
ppu: number
image_url: string | null
images?: string[] // Array of image URLs (up to 4)
created_at: string
soldOutInHours: number
}
@@ -21,16 +23,27 @@ interface PastDropsProps {
}
export default function PastDrops({ limit, showMoreLink = false }: PastDropsProps = {}) {
const { t } = useI18n()
const [drops, setDrops] = useState<PastDrop[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetchPastDrops()
// Poll past drops every 30 seconds
const interval = setInterval(() => {
fetchPastDrops()
}, 30000) // 30 seconds
return () => clearInterval(interval)
}, [])
const fetchPastDrops = async () => {
try {
const response = await fetch('/api/drops/past')
const response = await fetch('/api/drops/past', {
// Add cache control to prevent stale data
cache: 'no-store',
})
if (response.ok) {
const data = await response.json()
setDrops(data)
@@ -44,18 +57,20 @@ export default function PastDrops({ limit, showMoreLink = false }: PastDropsProp
const formatSoldOutTime = (hours: number) => {
if (hours < 1) {
return 'Sold out in less than 1h'
return `${t('pastDrops.soldOutIn')} ${t('pastDrops.lessThan1h')}`
} else if (hours === 1) {
return 'Sold out in 1h'
return `${t('pastDrops.soldOutIn')} ${t('pastDrops.1h')}`
} else if (hours < 24) {
return `Sold out in ${hours}h`
return `${t('pastDrops.soldOutIn')} ${t('pastDrops.hours', { hours })}`
} else {
const days = Math.floor(hours / 24)
const remainingHours = hours % 24
if (remainingHours === 0) {
return days === 1 ? 'Sold out in 1 day' : `Sold out in ${days} days`
return days === 1
? `${t('pastDrops.soldOutIn')} ${t('pastDrops.1day')}`
: `${t('pastDrops.soldOutIn')} ${t('pastDrops.days', { days })}`
} else {
return `Sold out in ${days}d ${remainingHours}h`
return `${t('pastDrops.soldOutIn')} ${t('pastDrops.daysHours', { days, hours: remainingHours })}`
}
}
}
@@ -77,7 +92,7 @@ export default function PastDrops({ limit, showMoreLink = false }: PastDropsProp
if (loading) {
return (
<div className="past">
<p style={{ color: 'var(--muted)', textAlign: 'center' }}>Loading past drops...</p>
<p style={{ color: 'var(--muted)', textAlign: 'center' }}>{t('pastDrops.loading')}</p>
</div>
)
}
@@ -86,7 +101,7 @@ export default function PastDrops({ limit, showMoreLink = false }: PastDropsProp
return (
<div className="past">
<p style={{ color: 'var(--muted)', textAlign: 'center' }}>
No past drops yet. Check back soon!
{t('pastDrops.noDrops')}
</p>
</div>
)
@@ -98,49 +113,59 @@ export default function PastDrops({ limit, showMoreLink = false }: PastDropsProp
return (
<>
<div className="past">
{displayedDrops.map((drop) => (
<div key={drop.id} className="card">
{drop.image_url ? (
<div style={{ marginBottom: '12px' }}>
<Image
src={drop.image_url}
alt={drop.item}
width={300}
height={300}
{displayedDrops.map((drop) => {
// Get images array (prioritize new images array, fallback to legacy image_url)
const images = drop.images && drop.images.length > 0
? drop.images
: (drop.image_url ? [drop.image_url] : [])
return (
<div key={drop.id} className="card">
{images.length > 0 ? (
<div style={{ marginBottom: '12px', display: 'grid', gridTemplateColumns: images.length > 1 ? '1fr 1fr' : '1fr', gap: '4px' }}>
{images.slice(0, 4).map((imgUrl, index) => (
<Image
key={index}
src={imgUrl}
alt={`${drop.item} - Image ${index + 1}`}
width={300}
height={300}
style={{
width: '100%',
height: '200px',
borderRadius: '12px',
objectFit: 'cover',
}}
/>
))}
</div>
) : (
<div
style={{
width: '100%',
height: '200px',
background: 'var(--bg-soft)',
borderRadius: '12px',
objectFit: 'cover',
marginBottom: '12px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--muted)',
}}
/>
</div>
) : (
<div
style={{
width: '100%',
height: '200px',
background: 'var(--bg-soft)',
borderRadius: '12px',
marginBottom: '12px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--muted)',
}}
>
No Image
</div>
)}
<strong>{drop.item}</strong>
<br />
<span className="meta">{formatSoldOutTime(drop.soldOutInHours)}</span>
<br />
<span className="meta" style={{ fontSize: '13px', marginTop: '4px', display: 'block' }}>
{formatQuantity(drop.size, drop.unit)} · {formatDateAndTime(drop.created_at)}
</span>
</div>
))}
>
{t('common.noImage')}
</div>
)}
<strong>{drop.item}</strong>
<br />
<span className="meta">{formatSoldOutTime(drop.soldOutInHours)}</span>
<br />
<span className="meta" style={{ fontSize: '13px', marginTop: '4px', display: 'block' }}>
{formatQuantity(drop.size, drop.unit)} · {formatDateAndTime(drop.created_at)}
</span>
</div>
)
})}
</div>
{showMoreLink && hasMore && (
<div style={{ textAlign: 'center', marginTop: '30px' }}>
@@ -153,7 +178,7 @@ export default function PastDrops({ limit, showMoreLink = false }: PastDropsProp
fontWeight: 500,
}}
>
More
{t('pastDrops.more')}
</a>
</div>
)}

View File

@@ -0,0 +1,419 @@
'use client'
import { useState, useEffect } from 'react'
import Image from 'next/image'
import { useI18n } from '@/lib/i18n'
interface RedeemPointsModalProps {
isOpen: boolean
onClose: () => void
currentPoints: number
onRedeemSuccess: () => void
}
export default function RedeemPointsModal({
isOpen,
onClose,
currentPoints,
onRedeemSuccess,
}: RedeemPointsModalProps) {
const { t } = useI18n()
const [pointsToRedeem, setPointsToRedeem] = useState<string>('')
const [cryptoCurrency] = useState<string>('usdtsol') // Fixed to USDT (SOL) only
const [walletAddress, setWalletAddress] = useState<string>('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string>('')
const [success, setSuccess] = useState(false)
const [redemptionDetails, setRedemptionDetails] = useState<any>(null)
const [pointsToCryptoChf, setPointsToCryptoChf] = useState<number>(100)
const [minRedemptionPoints, setMinRedemptionPoints] = useState<number>(1000)
const [estimatedCrypto, setEstimatedCrypto] = useState<number>(0)
useEffect(() => {
if (isOpen) {
fetchRedemptionSettings()
// Reset form
setPointsToRedeem('')
setWalletAddress('')
setError('')
setSuccess(false)
setRedemptionDetails(null)
}
}, [isOpen])
const fetchRedemptionSettings = async () => {
try {
const response = await fetch('/api/referral-points', {
credentials: 'include',
})
if (response.ok) {
const data = await response.json()
setPointsToCryptoChf(data.points_to_crypto_chf || data.points_to_chf || 100)
setMinRedemptionPoints(data.min_redemption_points || 1000)
}
} catch (error) {
console.error('Error fetching redemption settings:', error)
}
}
useEffect(() => {
// Calculate estimated crypto amount when points changes
// USDT (SOL) rate is approximately 0.9 CHF per USDT
if (pointsToRedeem && !isNaN(parseFloat(pointsToRedeem))) {
const points = parseFloat(pointsToRedeem)
const chfValue = points / pointsToCryptoChf
const usdtRate = 0.9 // CHF per USDT (SOL)
setEstimatedCrypto(chfValue / usdtRate)
} else {
setEstimatedCrypto(0)
}
}, [pointsToRedeem, pointsToCryptoChf])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
try {
const points = parseFloat(pointsToRedeem)
if (isNaN(points) || points <= 0) {
setError(t('redeemPoints.invalidPoints'))
setLoading(false)
return
}
if (points < minRedemptionPoints) {
setError(t('redeemPoints.minPoints', { min: minRedemptionPoints }))
setLoading(false)
return
}
if (points > currentPoints) {
setError(t('redeemPoints.insufficientPoints'))
setLoading(false)
return
}
if (!walletAddress.trim()) {
setError(t('redeemPoints.invalidWallet'))
setLoading(false)
return
}
const response = await fetch('/api/referral-points/redeem', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
points: points,
crypto_currency: cryptoCurrency,
wallet_address: walletAddress.trim(),
}),
})
const data = await response.json()
if (!response.ok) {
setError(data.error || t('redeemPoints.error'))
setLoading(false)
return
}
setSuccess(true)
setRedemptionDetails(data)
onRedeemSuccess()
} catch (error: any) {
console.error('Error redeeming points:', error)
setError(error.message || t('redeemPoints.error'))
} finally {
setLoading(false)
}
}
if (!isOpen) return null
const pointsNum = parseFloat(pointsToRedeem) || 0
const chfValue = pointsNum / pointsToCryptoChf
return (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
padding: '20px',
}}
onClick={onClose}
>
<div
style={{
background: 'var(--bg)',
borderRadius: '12px',
padding: '32px',
maxWidth: '500px',
width: '100%',
maxHeight: '90vh',
overflowY: 'auto',
boxShadow: '0 4px 20px 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, fontSize: '24px', fontWeight: 600 }}>
{t('redeemPoints.title')}
</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>
{success ? (
<div>
<div style={{
background: '#0a7931',
color: 'white',
padding: '16px',
borderRadius: '8px',
marginBottom: '24px'
}}>
<strong> {t('redeemPoints.success')}</strong>
</div>
{redemptionDetails && (
<div style={{ marginBottom: '24px' }}>
<p><strong>{t('redeemPoints.redemptionId')}:</strong> #{redemptionDetails.redemption_id}</p>
<p><strong>{t('redeemPoints.pointsRedeemed')}:</strong> {redemptionDetails.points_redeemed.toFixed(2)}</p>
<p><strong>{t('redeemPoints.cryptoAmount')}:</strong> {redemptionDetails.crypto_amount.toFixed(8)} USDT</p>
<p><strong>{t('redeemPoints.newBalance')}:</strong> {redemptionDetails.new_balance.toFixed(2)} {t('redeemPoints.points')}</p>
<p style={{ marginTop: '16px', fontSize: '14px', color: 'var(--muted)' }}>
{redemptionDetails.message}
</p>
</div>
)}
<button
onClick={onClose}
style={{
width: '100%',
padding: '12px',
background: 'var(--primary)',
color: 'white',
border: 'none',
borderRadius: '8px',
fontSize: '16px',
cursor: 'pointer',
fontWeight: 500,
}}
>
{t('common.close')}
</button>
</div>
) : (
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: '20px' }}>
<div style={{
background: 'var(--bg-soft)',
padding: '16px',
borderRadius: '8px',
marginBottom: '16px'
}}>
<div style={{ fontSize: '14px', color: 'var(--muted)', marginBottom: '4px' }}>
{t('redeemPoints.currentBalance')}
</div>
<div style={{ fontSize: '24px', fontWeight: 600, color: '#0a7931', display: 'flex', alignItems: 'center', gap: '8px' }}>
<Image
src="/icon_ref_points.png"
alt="Referral Points"
width={24}
height={24}
style={{ display: 'inline-block', verticalAlign: 'middle' }}
/>
{currentPoints.toFixed(2)} {t('redeemPoints.points')}
</div>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: 500 }}>
{t('redeemPoints.cryptoCurrency')}
</label>
<div style={{
width: '100%',
padding: '12px',
background: 'var(--bg-soft)',
border: '1px solid var(--border)',
borderRadius: '8px',
fontSize: '14px',
color: 'var(--text)',
}}>
USDT (SOL)
</div>
</div>
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: 500 }}>
{t('redeemPoints.walletAddress')} *
</label>
<input
type="text"
value={walletAddress}
onChange={(e) => setWalletAddress(e.target.value)}
placeholder={t('redeemPoints.walletAddressPlaceholder')}
style={{
width: '100%',
padding: '12px',
background: 'var(--bg-soft)',
border: '1px solid var(--border)',
borderRadius: '8px',
fontSize: '14px',
color: 'var(--text)',
marginBottom: '16px',
fontFamily: 'monospace',
}}
required
/>
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: 500 }}>
{t('redeemPoints.pointsToRedeem')} *
<span style={{ marginLeft: '8px', fontSize: '12px', color: 'var(--muted)', fontWeight: 'normal' }}>
({t('redeemPoints.min')}: {minRedemptionPoints})
</span>
</label>
<div style={{ display: 'flex', gap: '8px', marginBottom: '16px' }}>
<input
type="number"
value={pointsToRedeem}
onChange={(e) => setPointsToRedeem(e.target.value)}
min={minRedemptionPoints}
max={currentPoints}
step="1"
placeholder={minRedemptionPoints.toString()}
style={{
flex: 1,
padding: '12px',
background: 'var(--bg-soft)',
border: '1px solid var(--border)',
borderRadius: '8px',
fontSize: '14px',
color: 'var(--text)',
}}
required
/>
<button
type="button"
onClick={() => setPointsToRedeem(currentPoints.toString())}
style={{
padding: '12px 20px',
background: 'var(--bg-soft)',
border: '1px solid var(--border)',
borderRadius: '8px',
fontSize: '14px',
color: 'var(--text)',
cursor: 'pointer',
fontWeight: 500,
whiteSpace: 'nowrap',
}}
>
{t('drop.max')}
</button>
</div>
{pointsNum > 0 && (
<div style={{
background: 'var(--bg-soft)',
padding: '12px',
borderRadius: '8px',
marginBottom: '16px',
fontSize: '14px',
}}>
<div style={{ marginBottom: '4px' }}>
<strong>{t('redeemPoints.estimatedValue')}:</strong>
</div>
<div style={{ color: 'var(--muted)' }}>
{chfValue.toFixed(2)} CHF {estimatedCrypto.toFixed(8)} USDT
</div>
</div>
)}
{error && (
<div style={{
background: '#ff4444',
color: 'white',
padding: '12px',
borderRadius: '8px',
marginBottom: '16px',
fontSize: '14px',
}}>
{error}
</div>
)}
</div>
<div style={{ display: 'flex', gap: '12px' }}>
<button
type="button"
onClick={onClose}
style={{
flex: 1,
padding: '12px',
background: 'transparent',
color: 'var(--text)',
border: '1px solid var(--border)',
borderRadius: '8px',
fontSize: '16px',
cursor: 'pointer',
fontWeight: 500,
}}
>
{t('common.cancel')}
</button>
<button
type="submit"
disabled={loading || pointsNum < minRedemptionPoints || pointsNum > currentPoints}
style={{
flex: 1,
padding: '12px',
background: loading || pointsNum < minRedemptionPoints || pointsNum > currentPoints
? 'var(--muted)'
: 'var(--primary)',
color: 'white',
border: 'none',
borderRadius: '8px',
fontSize: '16px',
cursor: loading || pointsNum < minRedemptionPoints || pointsNum > currentPoints
? 'not-allowed'
: 'pointer',
fontWeight: 500,
}}
>
{loading ? t('common.processing') : t('redeemPoints.redeem')}
</button>
</div>
</form>
)}
</div>
</div>
)
}

View File

@@ -1,37 +1,39 @@
'use client'
import { useState } from 'react'
import { useI18n } from '@/lib/i18n'
export default function Signup() {
const [email, setEmail] = useState('')
const [whatsapp, setWhatsapp] = useState('')
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
// Handle form submission
console.log('Form submitted', { email, whatsapp })
}
const { t } = useI18n()
return (
<div className="signup">
<h2>Drop Notifications</h2>
<p>Receive updates about new drops via email or WhatsApp.</p>
<form onSubmit={handleSubmit}>
<input
type="email"
placeholder="E-Mail"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<input
type="text"
placeholder="WhatsApp Number"
value={whatsapp}
onChange={(e) => setWhatsapp(e.target.value)}
/>
<br />
<button type="submit">Get Notified</button>
</form>
<h2>{t('signup.title')}</h2>
<p>{t('signup.subtitle')}</p>
<a
href="https://t.me/official420deals"
target="_blank"
rel="noopener noreferrer"
style={{
display: 'inline-block',
marginTop: '20px',
padding: '12px 24px',
background: '#0088cc',
color: '#fff',
textDecoration: 'none',
borderRadius: '8px',
fontSize: '16px',
fontWeight: '500',
transition: 'background 0.2s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#006ba3'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#0088cc'
}}
>
{t('signup.joinTelegram')}
</a>
</div>
)
}

View File

@@ -0,0 +1,149 @@
'use client'
import { useState, useEffect } from 'react'
import UnlockModal from './UnlockModal'
import { useI18n } from '@/lib/i18n'
interface ReferralTier {
referralsNeeded: number
referralsRemaining: number
isUnlocked: boolean
}
interface ReferralStatus {
referralCount: number
isUnlocked: boolean
referralsNeeded: number
referralsRemaining: number
wholesaleTier?: ReferralTier
innerCircleTier?: ReferralTier
}
export default function UnlockBar() {
const { t } = useI18n()
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,
wholesaleTier: {
referralsNeeded: 3,
referralsRemaining: 3,
isUnlocked: false
},
innerCircleTier: {
referralsNeeded: 10,
referralsRemaining: 10,
isUnlocked: false
}
})
} finally {
setLoading(false)
}
}
const handleUnlockClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
e.preventDefault()
setShowModal(true)
}
if (loading) {
return null
}
const status = referralStatus || {
referralCount: 0,
isUnlocked: false,
referralsNeeded: 3,
referralsRemaining: 3,
wholesaleTier: {
referralsNeeded: 3,
referralsRemaining: 3,
isUnlocked: false
},
innerCircleTier: {
referralsNeeded: 10,
referralsRemaining: 10,
isUnlocked: false
}
}
const wholesaleTier = status.wholesaleTier || {
referralsNeeded: 3,
referralsRemaining: Math.max(0, 3 - status.referralCount),
isUnlocked: status.isUnlocked
}
const innerCircleTier = status.innerCircleTier || {
referralsNeeded: 10,
referralsRemaining: Math.max(0, 10 - status.referralCount),
isUnlocked: status.referralCount >= 10
}
// If wholesale is unlocked but inner circle is not, show inner circle as next level
if (wholesaleTier.isUnlocked && !innerCircleTier.isUnlocked) {
return (
<>
<div className="unlock-bar" style={{ background: 'var(--accent)', color: '#000' }}>
<div>
{t('unlockBar.unlocked')} <strong>{t('unlockBar.unlockedText')}</strong>
</div>
</div>
<div className="unlock-bar">
<div>
{t('unlockBar.innerCircleLocked')} <strong>{t('unlockBar.referralsCompleted', { count: status.referralCount, needed: innerCircleTier.referralsNeeded })}</strong> · {t('unlockBar.toGo', { remaining: innerCircleTier.referralsRemaining })}
<br />
<small>{t('unlockBar.innerCircleUnlockText', { needed: innerCircleTier.referralsNeeded })}</small>
<a href="#unlock" onClick={handleUnlockClick}>{t('unlockBar.unlockNow')}</a>
</div>
</div>
<UnlockModal isOpen={showModal} onClose={() => setShowModal(false)} />
</>
)
}
// If both are unlocked, show success message
if (wholesaleTier.isUnlocked && innerCircleTier.isUnlocked) {
return (
<div className="unlock-bar" style={{ background: 'var(--accent)', color: '#000' }}>
<div>
{t('unlockBar.unlocked')} <strong>{t('unlockBar.unlockedText')}</strong> · {t('unlockBar.innerCircleUnlocked')}
</div>
</div>
)
}
// Show wholesale unlock progress
return (
<>
<div className="unlock-bar">
<div>
{t('unlockBar.locked')} <strong>{t('unlockBar.referralsCompleted', { count: status.referralCount, needed: wholesaleTier.referralsNeeded })}</strong> · {t('unlockBar.toGo', { remaining: wholesaleTier.referralsRemaining })}
<br />
<small>{t('unlockBar.unlockText', { needed: wholesaleTier.referralsNeeded })}</small>
<a href="#unlock" onClick={handleUnlockClick}>{t('unlockBar.unlockNow')}</a>
</div>
</div>
<UnlockModal isOpen={showModal} onClose={() => setShowModal(false)} />
</>
)
}

View File

@@ -0,0 +1,329 @@
'use client'
import { useState, useEffect, Suspense } from 'react'
import AuthModal from './AuthModal'
import { useI18n } from '@/lib/i18n'
interface UnlockModalProps {
isOpen: boolean
onClose: () => void
}
interface User {
id: number
username: string
email: string
}
interface ReferralTier {
referralsNeeded: number
referralsRemaining: number
isUnlocked: boolean
}
interface ReferralStatus {
referralCount: number
isUnlocked: boolean
referralsNeeded: number
referralsRemaining: number
wholesaleTier?: ReferralTier
innerCircleTier?: ReferralTier
}
export default function UnlockModal({ isOpen, onClose }: UnlockModalProps) {
const { t } = useI18n()
const [referralStatus, setReferralStatus] = useState<ReferralStatus | null>(null)
const [referralLink, setReferralLink] = useState<string>('')
const [loading, setLoading] = useState(true)
const [copied, setCopied] = useState(false)
const [showAuthModal, setShowAuthModal] = 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)
}
}
const handleLogin = async (user: User) => {
setShowAuthModal(false)
// Refresh referral data after login
await fetchReferralData()
}
if (!isOpen) return null
const status = referralStatus || {
referralCount: 0,
isUnlocked: false,
referralsNeeded: 3,
referralsRemaining: 3,
wholesaleTier: {
referralsNeeded: 3,
referralsRemaining: 3,
isUnlocked: false
},
innerCircleTier: {
referralsNeeded: 10,
referralsRemaining: 10,
isUnlocked: false
}
}
const wholesaleTier = status.wholesaleTier || {
referralsNeeded: 3,
referralsRemaining: Math.max(0, 3 - status.referralCount),
isUnlocked: status.isUnlocked
}
const innerCircleTier = status.innerCircleTier || {
referralsNeeded: 10,
referralsRemaining: Math.max(0, 10 - status.referralCount),
isUnlocked: status.referralCount >= 10
}
// Determine which tier to show and which title to use
const isShowingInnerCircle = wholesaleTier.isUnlocked && !innerCircleTier.isUnlocked
const currentTier = isShowingInnerCircle ? innerCircleTier : wholesaleTier
const modalTitle = isShowingInnerCircle ? t('unlockModal.innerCircleTitle') : t('unlockModal.title')
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 }}>{modalTitle}</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' }}>{t('common.loading')}</p>
) : (
<>
<div style={{ marginBottom: '24px', textAlign: 'center' }}>
<div style={{ fontSize: '18px', marginBottom: '8px' }}>
🔒 {t('unlockModal.referralsCompleted', { count: status.referralCount, needed: currentTier.referralsNeeded })}
</div>
<p style={{ color: 'var(--muted)', fontSize: '14px', margin: '8px 0' }}>
{t('unlockModal.inviteFriends', { needed: currentTier.referralsNeeded })}
<br />
{isShowingInnerCircle ? t('unlockModal.innerCircleUnlockForever') : t('unlockModal.unlockForever')}
</p>
</div>
{referralLink ? (
<div style={{ marginBottom: '24px' }}>
<label
style={{
display: 'block',
marginBottom: '8px',
fontSize: '14px',
color: 'var(--muted)',
fontWeight: 500,
}}
>
{t('unlockModal.yourReferralLink')}
</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 ? t('unlockModal.copied') : t('unlockModal.copyLink')}
</button>
</div>
</div>
) : (
<div
style={{
padding: '16px',
background: 'var(--bg-soft)',
borderRadius: '8px',
marginBottom: '24px',
textAlign: 'center',
}}
>
<p style={{ color: 'var(--muted)', fontSize: '14px', margin: '0 0 12px 0' }}>
{t('unlockModal.yourReferralLink')}
</p>
<button
onClick={() => setShowAuthModal(true)}
style={{
padding: '10px 20px',
background: 'var(--accent)',
color: '#000',
border: 'none',
borderRadius: '8px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: 500,
}}
>
{t('auth.login')}
</button>
</div>
)}
<div
style={{
padding: '12px',
background: 'var(--bg-soft)',
borderRadius: '8px',
marginBottom: '24px',
fontSize: '13px',
color: 'var(--muted)',
textAlign: 'center',
}}
>
{t('unlockModal.friendsMustSignUp')}
</div>
<div
style={{
textAlign: 'center',
fontSize: '16px',
fontWeight: 500,
color: 'var(--text)',
marginBottom: '24px',
}}
>
{currentTier.referralsRemaining === 1
? t('unlockModal.referralsToGoSingular', { remaining: currentTier.referralsRemaining })
: t('unlockModal.referralsToGoPlural', { remaining: currentTier.referralsRemaining })
}
</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,
}}
>
{t('common.close')}
</button>
</>
)}
</div>
{/* Auth Modal */}
<Suspense fallback={null}>
<AuthModal
isOpen={showAuthModal}
onClose={() => setShowAuthModal(false)}
onLogin={handleLogin}
/>
</Suspense>
</div>
)
}

View File

@@ -32,7 +32,14 @@ nav {
background: rgba(14, 14, 14, 0.9);
backdrop-filter: blur(10px);
border-bottom: 1px solid var(--border);
padding: 20px 40px;
padding: 20px;
display: flex;
justify-content: center;
}
nav > div {
max-width: 1200px;
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
@@ -44,6 +51,24 @@ nav .brand {
letter-spacing: 0.5px;
}
.mobile-menu-toggle {
display: none;
background: transparent;
border: 1px solid var(--border);
color: var(--text);
padding: 8px 12px;
border-radius: 8px;
cursor: pointer;
font-size: 20px;
line-height: 1;
}
nav .links {
display: flex;
align-items: center;
flex-wrap: wrap;
}
nav .links a {
margin-left: 28px;
font-size: 14px;
@@ -54,6 +79,86 @@ nav .links a:hover {
color: var(--text);
}
@media (max-width: 768px) {
nav > div {
flex-direction: column;
align-items: flex-start;
}
nav > div > div:first-child {
width: 100%;
justify-content: space-between;
}
.mobile-menu-toggle {
display: block;
}
nav .links {
display: none;
flex-direction: column;
width: 100%;
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid var(--border);
gap: 16px;
}
nav .links.mobile-open {
display: flex;
}
nav .links a {
margin-left: 0;
padding: 12px 0;
width: 100%;
text-align: left;
border-bottom: 1px solid var(--border);
}
nav .links a:last-child {
border-bottom: none;
}
nav .links span,
nav .links button {
margin-left: 0 !important;
margin-top: 12px;
width: 100%;
text-align: left;
}
nav .links button {
justify-content: flex-start;
}
}
.unlock-bar {
background: var(--bg-soft);
border-bottom: 1px solid var(--border);
padding: 12px 20px;
font-size: 14px;
color: var(--muted);
text-align: center;
display: flex;
justify-content: center;
}
.unlock-bar > div {
max-width: 1200px;
width: 100%;
}
.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;
@@ -111,12 +216,30 @@ header p {
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: 20px;
margin-bottom: 12px;
}
.progress span {
@@ -146,8 +269,8 @@ header p {
.cta {
margin-top: 30px;
padding: 16px 28px;
background: #0a7931;
color: #fff;
background: var(--accent);
color: #000;
border: none;
border-radius: 14px;
font-size: 15px;
@@ -155,6 +278,12 @@ header p {
cursor: pointer;
}
.cta-note {
margin-top: 8px;
font-size: 13px;
color: var(--muted);
}
.info-box {
margin-top: 60px;
background: var(--card);

View File

@@ -1,12 +1,13 @@
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
import { I18nProvider } from '@/lib/i18n'
const inter = Inter({ subsets: ['latin'], weight: ['300', '400', '500', '600'] })
export const metadata: Metadata = {
title: '420Deals.ch Premium Swiss CBD Drops',
description: 'Shop together. Wholesale prices for private buyers.',
title: 'CBD Großhandelspreise Schweiz | 420deals.ch',
description: 'CBD direkt vom Produzenten kaufen. Gemeinsam einkaufen, Retail umgehen und von kollektiven Großhandelspreisen profitieren.',
}
export default function RootLayout({
@@ -15,8 +16,10 @@ export default function RootLayout({
children: React.ReactNode
}) {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
<html lang="de">
<body className={inter.className}>
<I18nProvider>{children}</I18nProvider>
</body>
</html>
)
}

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,9 +8,12 @@ import InfoBox from './components/InfoBox'
import Signup from './components/Signup'
import PastDrops from './components/PastDrops'
import Footer from './components/Footer'
import UnlockBar from './components/UnlockBar'
import { useI18n } from '@/lib/i18n'
function PaymentHandler() {
const searchParams = useSearchParams()
const { t } = useI18n()
useEffect(() => {
const payment = searchParams.get('payment')
@@ -20,16 +23,17 @@ function PaymentHandler() {
// Clean up URL - IPN is handled by external service
window.history.replaceState({}, '', window.location.pathname)
} else if (payment === 'cancelled') {
alert('Payment was cancelled.')
alert(t('payment.cancelled'))
// Clean up URL
window.history.replaceState({}, '', window.location.pathname)
}
}, [searchParams])
}, [searchParams, t])
return null
}
export default function Home() {
const { t } = useI18n()
return (
<>
@@ -37,12 +41,10 @@ export default function Home() {
<PaymentHandler />
</Suspense>
<Nav />
<UnlockBar />
<header className="container">
<h1>Shop together. Wholesale prices for private buyers.</h1>
<p>
Limited CBD drops directly from Swiss producers. No retail.
No markup. Just collective bulk prices.
</p>
<h1>{t('header.title')}</h1>
<p>{t('header.subtitle')}</p>
</header>
<section className="container" id="drop">
@@ -55,7 +57,7 @@ export default function Home() {
</section>
<section className="container" id="past">
<h2>Past Drops</h2>
<h2>{t('pastDrops.title')}</h2>
<PastDrops limit={3} showMoreLink={true} />
</section>

View File

@@ -1,11 +1,11 @@
-- phpMyAdmin SQL Dump
-- version 5.2.1deb1+deb12u1
-- version 5.2.3-1.el9.remi
-- https://www.phpmyadmin.net/
--
-- Host: localhost:3306
-- Generation Time: Dec 21, 2025 at 09:44 AM
-- Server version: 10.11.14-MariaDB-0+deb12u2
-- PHP Version: 8.2.29
-- Host: localhost
-- Generation Time: Dec 31, 2025 at 05:36 PM
-- Server version: 10.5.29-MariaDB
-- PHP Version: 8.2.28
SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO";
START TRANSACTION;
@@ -32,6 +32,7 @@ CREATE TABLE `buyers` (
`username` varchar(255) NOT NULL,
`password` varchar(255) NOT NULL,
`email` varchar(255) NOT NULL,
`referral_points` decimal(10,2) NOT NULL DEFAULT 0.00,
`created_at` datetime NOT NULL DEFAULT current_timestamp()
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
@@ -71,17 +72,48 @@ CREATE TABLE `deliveries` (
CREATE TABLE `drops` (
`id` int(11) NOT NULL,
`item` text NOT NULL,
`description` text DEFAULT NULL,
`size` int(11) NOT NULL DEFAULT 100,
`fill` int(11) NOT NULL DEFAULT 0,
`unit` varchar(12) NOT NULL DEFAULT 'g',
`image_url` varchar(255) DEFAULT NULL,
`ppu` int(11) NOT NULL DEFAULT 1,
`price_chf` decimal(10,4) DEFAULT NULL,
`price_eur` decimal(10,4) DEFAULT NULL,
`wholesale_price_chf` decimal(10,4) DEFAULT NULL,
`wholesale_price_eur` decimal(10,4) DEFAULT NULL,
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
`start_time` datetime DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- --------------------------------------------------------
--
-- Table structure for table `drop_images`
--
CREATE TABLE `drop_images` (
`id` int(11) NOT NULL,
`drop_id` int(11) NOT NULL,
`image_url` varchar(255) NOT NULL,
`display_order` int(11) NOT NULL DEFAULT 0,
`created_at` datetime NOT NULL DEFAULT current_timestamp()
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- --------------------------------------------------------
--
-- Table structure for table `notification_subscribers`
--
CREATE TABLE `notification_subscribers` (
`address` varchar(100) NOT NULL,
`type` text NOT NULL DEFAULT '\'email\'',
`buyer_id` int(11) DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- --------------------------------------------------------
--
-- Table structure for table `pending_orders`
--
@@ -96,12 +128,33 @@ CREATE TABLE `pending_orders` (
`size` int(11) NOT NULL,
`price_amount` decimal(10,2) NOT NULL,
`price_currency` varchar(10) NOT NULL DEFAULT 'chf',
`points_used` decimal(10,2) NOT NULL DEFAULT 0.00,
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
`expires_at` datetime NOT NULL DEFAULT (current_timestamp() + interval 10 minute)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- --------------------------------------------------------
--
-- Table structure for table `point_redemptions`
--
CREATE TABLE `point_redemptions` (
`id` int(11) NOT NULL,
`buyer_id` int(11) NOT NULL,
`points` decimal(10,2) NOT NULL,
`crypto_currency` varchar(20) NOT NULL,
`wallet_address` varchar(255) NOT NULL,
`crypto_amount` decimal(20,8) DEFAULT NULL,
`status` enum('pending','processing','completed','failed','cancelled') NOT NULL DEFAULT 'pending',
`transaction_hash` varchar(255) DEFAULT NULL,
`error_message` text DEFAULT NULL,
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
`updated_at` datetime NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp()
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- --------------------------------------------------------
--
-- Table structure for table `referrals`
--
@@ -114,6 +167,37 @@ CREATE TABLE `referrals` (
-- --------------------------------------------------------
--
-- Table structure for table `referral_point_transactions`
--
CREATE TABLE `referral_point_transactions` (
`id` int(11) NOT NULL,
`buyer_id` int(11) NOT NULL,
`points` decimal(10,2) NOT NULL,
`type` enum('earned','spent','redeemed') NOT NULL,
`sale_id` int(11) DEFAULT NULL,
`pending_order_id` int(11) DEFAULT NULL,
`description` text DEFAULT NULL,
`created_at` datetime NOT NULL DEFAULT current_timestamp()
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- --------------------------------------------------------
--
-- Table structure for table `referral_settings`
--
CREATE TABLE `referral_settings` (
`id` int(11) NOT NULL,
`setting_key` varchar(100) NOT NULL,
`setting_value` varchar(255) NOT NULL,
`description` text DEFAULT NULL,
`updated_at` datetime NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp()
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- --------------------------------------------------------
--
-- Table structure for table `sales`
--
@@ -125,6 +209,9 @@ CREATE TABLE `sales` (
`buyer_data_id` int(11) NOT NULL,
`size` int(11) NOT NULL DEFAULT 1,
`payment_id` text NOT NULL DEFAULT '',
`price_amount` decimal(10,2) DEFAULT NULL,
`price_currency` varchar(10) NOT NULL DEFAULT 'chf',
`points_used` decimal(10,2) NOT NULL DEFAULT 0.00,
`created_at` datetime NOT NULL DEFAULT current_timestamp()
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
@@ -142,7 +229,8 @@ ALTER TABLE `buyers`
-- Indexes for table `buyer_data`
--
ALTER TABLE `buyer_data`
ADD PRIMARY KEY (`id`);
ADD PRIMARY KEY (`id`),
ADD KEY `buyer_id` (`buyer_id`);
--
-- Indexes for table `deliveries`
@@ -157,6 +245,21 @@ ALTER TABLE `deliveries`
ALTER TABLE `drops`
ADD PRIMARY KEY (`id`);
--
-- Indexes for table `drop_images`
--
ALTER TABLE `drop_images`
ADD PRIMARY KEY (`id`),
ADD KEY `drop_id` (`drop_id`),
ADD KEY `idx_drop_images_drop_order` (`drop_id`,`display_order`);
--
-- Indexes for table `notification_subscribers`
--
ALTER TABLE `notification_subscribers`
ADD PRIMARY KEY (`address`),
ADD KEY `buyer_id` (`buyer_id`);
--
-- Indexes for table `pending_orders`
--
@@ -169,6 +272,14 @@ ALTER TABLE `pending_orders`
ADD KEY `idx_expires_at` (`expires_at`),
ADD KEY `buyer_data_id` (`buyer_data_id`);
--
-- Indexes for table `point_redemptions`
--
ALTER TABLE `point_redemptions`
ADD PRIMARY KEY (`id`),
ADD KEY `buyer_id` (`buyer_id`),
ADD KEY `status` (`status`);
--
-- Indexes for table `referrals`
--
@@ -177,6 +288,22 @@ ALTER TABLE `referrals`
ADD KEY `referree` (`referree`),
ADD KEY `referrer` (`referrer`);
--
-- Indexes for table `referral_point_transactions`
--
ALTER TABLE `referral_point_transactions`
ADD PRIMARY KEY (`id`),
ADD KEY `buyer_id` (`buyer_id`),
ADD KEY `sale_id` (`sale_id`),
ADD KEY `pending_order_id` (`pending_order_id`);
--
-- Indexes for table `referral_settings`
--
ALTER TABLE `referral_settings`
ADD PRIMARY KEY (`id`),
ADD UNIQUE KEY `setting_key` (`setting_key`);
--
-- Indexes for table `sales`
--
@@ -214,18 +341,42 @@ ALTER TABLE `deliveries`
ALTER TABLE `drops`
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;
--
-- AUTO_INCREMENT for table `drop_images`
--
ALTER TABLE `drop_images`
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;
--
-- AUTO_INCREMENT for table `pending_orders`
--
ALTER TABLE `pending_orders`
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;
--
-- AUTO_INCREMENT for table `point_redemptions`
--
ALTER TABLE `point_redemptions`
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;
--
-- AUTO_INCREMENT for table `referrals`
--
ALTER TABLE `referrals`
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;
--
-- AUTO_INCREMENT for table `referral_point_transactions`
--
ALTER TABLE `referral_point_transactions`
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;
--
-- AUTO_INCREMENT for table `referral_settings`
--
ALTER TABLE `referral_settings`
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;
--
-- AUTO_INCREMENT for table `sales`
--
@@ -236,12 +387,30 @@ ALTER TABLE `sales`
-- Constraints for dumped tables
--
--
-- Constraints for table `buyer_data`
--
ALTER TABLE `buyer_data`
ADD CONSTRAINT `buyer_data_ibfk_1` FOREIGN KEY (`buyer_id`) REFERENCES `buyers` (`id`);
--
-- Constraints for table `deliveries`
--
ALTER TABLE `deliveries`
ADD CONSTRAINT `deliveries_ibfk_1` FOREIGN KEY (`sale_id`) REFERENCES `sales` (`id`) ON DELETE CASCADE ON UPDATE CASCADE;
--
-- Constraints for table `drop_images`
--
ALTER TABLE `drop_images`
ADD CONSTRAINT `drop_images_ibfk_1` FOREIGN KEY (`drop_id`) REFERENCES `drops` (`id`) ON DELETE CASCADE ON UPDATE CASCADE;
--
-- Constraints for table `notification_subscribers`
--
ALTER TABLE `notification_subscribers`
ADD CONSTRAINT `notification_subscribers_ibfk_1` FOREIGN KEY (`buyer_id`) REFERENCES `buyers` (`id`);
--
-- Constraints for table `pending_orders`
--
@@ -250,6 +419,12 @@ ALTER TABLE `pending_orders`
ADD CONSTRAINT `pending_orders_ibfk_2` FOREIGN KEY (`buyer_id`) REFERENCES `buyers` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
ADD CONSTRAINT `pending_orders_ibfk_3` FOREIGN KEY (`buyer_data_id`) REFERENCES `buyer_data` (`id`);
--
-- Constraints for table `point_redemptions`
--
ALTER TABLE `point_redemptions`
ADD CONSTRAINT `point_redemptions_ibfk_1` FOREIGN KEY (`buyer_id`) REFERENCES `buyers` (`id`) ON DELETE CASCADE ON UPDATE CASCADE;
--
-- Constraints for table `referrals`
--
@@ -257,6 +432,14 @@ ALTER TABLE `referrals`
ADD CONSTRAINT `referrals_ibfk_1` FOREIGN KEY (`referree`) REFERENCES `buyers` (`id`),
ADD CONSTRAINT `referrals_ibfk_2` FOREIGN KEY (`referrer`) REFERENCES `buyers` (`id`);
--
-- Constraints for table `referral_point_transactions`
--
ALTER TABLE `referral_point_transactions`
ADD CONSTRAINT `referral_point_transactions_ibfk_1` FOREIGN KEY (`buyer_id`) REFERENCES `buyers` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
ADD CONSTRAINT `referral_point_transactions_ibfk_2` FOREIGN KEY (`sale_id`) REFERENCES `sales` (`id`) ON DELETE SET NULL ON UPDATE CASCADE,
ADD CONSTRAINT `referral_point_transactions_ibfk_3` FOREIGN KEY (`pending_order_id`) REFERENCES `pending_orders` (`id`) ON DELETE SET NULL ON UPDATE CASCADE;
--
-- Constraints for table `sales`
--

58
lib/admin-auth.ts Normal file
View File

@@ -0,0 +1,58 @@
import { cookies } from 'next/headers'
const ADMIN_PASSWORD = 'HelloWorld'
const ADMIN_SESSION_COOKIE = 'admin_session'
// Check if admin is authenticated
export async function isAdminAuthenticated(): Promise<boolean> {
try {
const cookieStore = await cookies()
const session = cookieStore.get(ADMIN_SESSION_COOKIE)?.value
return session === 'authenticated'
} catch (error) {
return false
}
}
// Verify admin password
export function verifyAdminPassword(password: string): boolean {
return password === ADMIN_PASSWORD
}
// Set admin session
export async function setAdminSession(): Promise<void> {
const cookieStore = await cookies()
cookieStore.set(ADMIN_SESSION_COOKIE, 'authenticated', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24, // 24 hours
})
}
// Clear admin session
export async function clearAdminSession(): Promise<void> {
const cookieStore = await cookies()
cookieStore.delete(ADMIN_SESSION_COOKIE)
}
// Get admin session from request (for API routes)
export function getAdminSessionFromRequest(request: Request): boolean {
try {
const cookieHeader = request.headers.get('cookie')
if (!cookieHeader) {
return false
}
const cookies = cookieHeader.split(';').reduce((acc, cookie) => {
const [key, value] = cookie.trim().split('=')
acc[key] = value
return acc
}, {} as Record<string, string>)
return cookies[ADMIN_SESSION_COOKIE] === 'authenticated'
} catch (error) {
return false
}
}

View File

@@ -5,6 +5,7 @@ export interface User {
id: number
username: string
email: string
referral_points?: number
}
// Get the current user from session cookie
@@ -18,7 +19,7 @@ export async function getCurrentUser(): Promise<User | null> {
}
const [rows] = await pool.execute(
'SELECT id, username, email FROM buyers WHERE id = ?',
'SELECT id, username, email, referral_points FROM buyers WHERE id = ?',
[buyerId]
)
@@ -31,6 +32,7 @@ export async function getCurrentUser(): Promise<User | null> {
id: buyers[0].id,
username: buyers[0].username,
email: buyers[0].email,
referral_points: parseFloat(buyers[0].referral_points) || 0,
}
} catch (error) {
console.error('Error getting current user:', error)

51
lib/currency.ts Normal file
View File

@@ -0,0 +1,51 @@
/**
* Currency conversion utilities
* Database stores prices in EUR
* Countries in CHF_COUNTRIES see CHF (converted from EUR)
* All other countries see EUR
*/
// List of country codes that use CHF currency
// Add or remove country codes here to change which countries get CHF pricing
export const CHF_COUNTRIES = ['CH'] as const
// EUR to CHF exchange rate
// Using a fixed rate - in production, you might want to fetch this from an API
// Current approximate rate: 1 EUR ≈ 0.97 CHF (as of 2025)
// Note: This is approximate. For production, consider using a real-time exchange rate API
const EUR_TO_CHF_RATE = 0.97
/**
* Convert EUR amount to CHF
*/
export function convertEurToChf(eurAmount: number): number {
return eurAmount * EUR_TO_CHF_RATE
}
/**
* Get the currency to use based on country code
* Returns 'CHF' for countries in CHF_COUNTRIES, 'EUR' for all other countries
*/
export function getCurrencyForCountry(countryCode: string | null): 'CHF' | 'EUR' {
return countryCode && CHF_COUNTRIES.includes(countryCode as any) ? 'CHF' : 'EUR'
}
/**
* Convert price based on country
* If country is in CHF_COUNTRIES, convert EUR to CHF
* Otherwise, return EUR amount as-is
*/
export function convertPriceForCountry(priceInEur: number, countryCode: string | null): number {
if (countryCode && CHF_COUNTRIES.includes(countryCode as any)) {
return convertEurToChf(priceInEur)
}
return priceInEur
}
/**
* Get currency symbol for display
*/
export function getCurrencySymbol(currency: 'CHF' | 'EUR'): string {
return currency === 'CHF' ? 'CHF' : 'EUR'
}

View File

@@ -7,8 +7,10 @@ const pool = mysql.createPool({
password: process.env.DB_PASSWORD || '',
database: process.env.DB_NAME || 'cbd420',
waitForConnections: true,
connectionLimit: 10,
connectionLimit: 50, // Increased from 10 to handle more concurrent requests
queueLimit: 0,
connectTimeout: 60000, // 60 seconds timeout for establishing connection
idleTimeout: 600000, // 10 minutes - close idle connections after this time
})
export default pool

92
lib/geolocation.ts Normal file
View File

@@ -0,0 +1,92 @@
import { NextRequest } from 'next/server'
import { CHF_COUNTRIES } from '@/lib/currency'
/**
* Get client IP address from request headers
*/
function getClientIp(request: NextRequest): string | null {
// Check various headers that might contain the real IP
const forwardedFor = request.headers.get('x-forwarded-for')
if (forwardedFor) {
// x-forwarded-for can contain multiple IPs, take the first one
return forwardedFor.split(',')[0].trim()
}
const realIp = request.headers.get('x-real-ip')
if (realIp) {
return realIp.trim()
}
const cfConnectingIp = request.headers.get('cf-connecting-ip') // Cloudflare
if (cfConnectingIp) {
return cfConnectingIp.trim()
}
// Fallback to remote address if available
const remoteAddress = request.headers.get('remote-addr')
if (remoteAddress) {
return remoteAddress.trim()
}
return null
}
/**
* Get country code from IP address using ip-api.com (free, no API key required)
* Returns country code (e.g., 'CH' for Switzerland, 'SG' for Singapore), or null if detection fails
*/
export async function getCountryFromIp(request: NextRequest): Promise<string | null> {
try {
const ip = getClientIp(request)
if (!ip) {
console.warn('Could not determine client IP')
return null
}
// Skip localhost/private IPs
if (ip === '127.0.0.1' || ip === '::1' || ip.startsWith('192.168.') || ip.startsWith('10.') || ip.startsWith('172.')) {
// For local development, default to Switzerland
return 'CH'
}
// Use ip-api.com free service (no API key required, rate limited)
// Returns JSON with country code
const response = await fetch(`http://ip-api.com/json/${ip}?fields=countryCode`, {
method: 'GET',
headers: {
'Accept': 'application/json',
},
})
if (!response.ok) {
console.warn(`Failed to fetch geolocation: ${response.status}`)
return null
}
const data = await response.json()
if (data.countryCode) {
return data.countryCode
}
return null
} catch (error) {
console.error('Error detecting country from IP:', error)
return null
}
}
/**
* Calculate shipping fee based on country
* Returns shipping fee in the appropriate currency
* 15 CHF for countries in CHF_COUNTRIES, 40 EUR for all other countries
*/
export function calculateShippingFee(countryCode: string | null): number {
// 15 CHF for countries in CHF_COUNTRIES, 40 EUR for all other countries
if (countryCode && CHF_COUNTRIES.includes(countryCode as any)) {
return 15
}
return 40
}

109
lib/i18n.tsx Normal file
View File

@@ -0,0 +1,109 @@
'use client'
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'
import enTranslations from './translations/en.json'
import deTranslations from './translations/de.json'
type Language = 'en' | 'de'
type TranslationKey = string
type Translations = typeof enTranslations
interface I18nContextType {
language: Language
setLanguage: (lang: Language) => void
t: (key: TranslationKey, params?: Record<string, string | number>) => string
}
const I18nContext = createContext<I18nContextType | undefined>(undefined)
const translations: Record<Language, Translations> = {
en: enTranslations,
de: deTranslations,
}
export function I18nProvider({ children }: { children: ReactNode }) {
const [language, setLanguageState] = useState<Language>('en')
// Load language from localStorage on mount
useEffect(() => {
const savedLanguage = localStorage.getItem('language') as Language
if (savedLanguage && (savedLanguage === 'en' || savedLanguage === 'de')) {
setLanguageState(savedLanguage)
} else {
// Detect browser language
const browserLang = navigator.language.split('-')[0]
if (browserLang === 'de') {
setLanguageState('de')
} else {
setLanguageState('en')
}
}
}, [])
const setLanguage = (lang: Language) => {
setLanguageState(lang)
localStorage.setItem('language', lang)
// Update HTML lang attribute
if (typeof document !== 'undefined') {
document.documentElement.lang = lang
}
}
const t = (key: TranslationKey, params?: Record<string, string | number>): string => {
const keys = key.split('.')
let value: any = translations[language]
for (const k of keys) {
if (value && typeof value === 'object' && k in value) {
value = value[k]
} else {
// Fallback to English if key not found
value = translations.en
for (const fallbackKey of keys) {
if (value && typeof value === 'object' && fallbackKey in value) {
value = value[fallbackKey]
} else {
return key // Return key if translation not found
}
}
break
}
}
if (typeof value !== 'string') {
return key
}
// Replace parameters in the translation string
if (params) {
return value.replace(/\{(\w+)\}/g, (match, paramKey) => {
return params[paramKey]?.toString() || match
})
}
return value
}
// Update HTML lang attribute when language changes
useEffect(() => {
if (typeof document !== 'undefined') {
document.documentElement.lang = language
}
}, [language])
return (
<I18nContext.Provider value={{ language, setLanguage, t }}>
{children}
</I18nContext.Provider>
)
}
export function useI18n() {
const context = useContext(I18nContext)
if (context === undefined) {
throw new Error('useI18n must be used within an I18nProvider')
}
return context
}

27
lib/payment-currencies.ts Normal file
View File

@@ -0,0 +1,27 @@
/**
* Allowed payment cryptocurrencies
*
* Edit this list to add or remove supported payment currencies.
* All currencies should be in lowercase.
*/
export const ALLOWED_PAYMENT_CURRENCIES = [
'btc',
'eth',
'sol',
'xrp',
'bnbbsc',
'usdterc20',
] as const
/**
* Type for allowed payment currency
*/
export type AllowedPaymentCurrency = typeof ALLOWED_PAYMENT_CURRENCIES[number]
/**
* Check if a currency is in the allowed list
*/
export function isAllowedCurrency(currency: string): boolean {
return ALLOWED_PAYMENT_CURRENCIES.includes(currency.toLowerCase() as AllowedPaymentCurrency)
}

238
lib/translations/de.json Normal file
View File

@@ -0,0 +1,238 @@
{
"common": {
"loading": "Lädt...",
"error": "Ein Fehler ist aufgetreten",
"ok": "OK",
"cancel": "Abbrechen",
"close": "Schließen",
"save": "Speichern",
"delete": "Löschen",
"edit": "Bearbeiten",
"submit": "Absenden",
"processing": "Wird verarbeitet...",
"noImage": "Kein Bild"
},
"nav": {
"drop": "Drop",
"pastDrops": "Vergangene Drops",
"community": "Community",
"orders": "Bestellungen",
"login": "Anmelden",
"logout": "Abmelden"
},
"header": {
"title": "Zugang zu Großhandelspreisen für alle.",
"subtitle": "Gemeinsam einkaufen. Limitierte CBD-Drops direkt von Schweizer Produzenten. Kein Einzelhandel. Faire Konditionen. Kollektive Großhandelspreise."
},
"drop": {
"loading": "Lädt...",
"soldOut": "Drop ausverkauft",
"nextDropComing": "Nächster kollektiver Drop kommt bald",
"joinDrop": "Am Drop teilnehmen",
"reserved": "reserviert",
"of": "von",
"batch": "Batch",
"indoor": "Indoor",
"switzerland": "Schweiz",
"inclVat": "inkl. 2.5% MWST",
"perGram": "pro Gramm",
"selectQuantity": "Menge auswählen",
"customQuantity": "Individuelle Menge",
"minimumRequired": "Mindestens {minimum}g erforderlich (5 CHF Minimum)",
"maximumAvailable": "Maximal {maximum}g verfügbar",
"enterValidNumber": "Bitte geben Sie eine gültige Zahl ein",
"fillDeliveryInfo": "Bitte füllen Sie alle Lieferinformationen aus (Vollständiger Name, Adresse und Telefon)",
"fullName": "Vollständiger Name",
"address": "Adresse",
"phone": "Telefon",
"confirmPurchase": "Kauf bestätigen",
"totalPrice": "Gesamtpreis",
"standardPrice": "Standardpreis",
"wholesalePrice": "420deals-Memberpreis",
"paymentCurrency": "Zahlungswährung",
"selectCurrency": "Währung auswählen",
"upcomingIn": "Kommt in",
"day": "Tag",
"days": "Tage",
"hour": "Stunde",
"hours": "Stunden",
"minute": "Minute",
"minutes": "Minuten",
"paymentAddress": "Zahlungsadresse",
"paymentAmount": "Zahlungsbetrag",
"paymentId": "Zahlungs-ID",
"copyAddress": "Adresse kopieren",
"copied": "Kopiert!",
"paymentInstructions": "Senden Sie genau {amount} {currency} an die oben angegebene Adresse. Die Zahlung läuft in 20 Minuten ab.",
"paymentExpired": "Zahlung abgelaufen. Bitte versuchen Sie es erneut.",
"paymentPending": "Zahlung ausstehend...",
"paymentSuccess": "Zahlung erfolgreich!",
"paymentFailed": "Zahlung fehlgeschlagen. Bitte versuchen Sie es erneut.",
"orderConfirmed": "Bestellung bestätigt!",
"orderFailed": "Bestellung fehlgeschlagen. Bitte versuchen Sie es erneut.",
"dropSoldOut": "Drop ausverkauft",
"fullyReserved": "Der aktuelle kollektive Drop wurde vollständig reserviert.",
"nextDropComingSoon": "Nächster kollektiver Drop kommt bald.",
"batch": "Batch",
"reserved": "reserviert",
"wholesalePriceLabel": "420deals-Memberpreis:",
"standardPriceLabel": "Standardpreis:",
"standard": "Standard",
"wholesale": "420deals-Memberpreis",
"unlock": "freischalten",
"unlockOnce": "Einmal freischalten. Großhandelspreis für immer behalten.",
"dropStartsIn": "Drop startet in",
"onHold": "in Wartestellung (10 Minuten Checkout-Fenster)",
"custom": "Individuell (g)",
"min": "Min",
"max": "Max",
"total": "Gesamt",
"standardTotal": "Standard gesamt",
"wholesaleTotal": "420deals-Memberpreis gesamt",
"joinTheDrop": "Am Drop teilnehmen",
"noSubscription": "Kein Abonnement · Keine Verpflichtung",
"lessThanRemaining": "Weniger als {amount}{unit} verbleibend. Dieser Drop ist fast vollständig reserviert.",
"fullyReservedText": "Dieser Drop ist vollständig reserviert",
"item": "Artikel",
"quantity": "Menge",
"pricePerUnit": "Preis pro {unit}",
"deliveryInformation": "Lieferinformationen",
"fullNameRequired": "Vollständiger Name *",
"enterFullName": "Geben Sie Ihren vollständigen Namen ein",
"addressRequired": "Adresse *",
"enterAddress": "Geben Sie Ihre Lieferadresse ein",
"phoneRequired": "Telefonnummer *",
"enterPhone": "Geben Sie Ihre Telefonnummer ein",
"loadingCurrencies": "Lädt Währungen...",
"payWith": "Zahlen mit",
"completePayment": "Zahlung abschließen",
"amountToPay": "Zu zahlender Betrag",
"price": "Preis",
"subtotal": "Zwischensumme",
"shippingFee": "Verpackung & Versand",
"shippingFeeNote": "A-Post Paketversand mit Unterschrift & Versicherung",
"sendPaymentTo": "Senden Sie die Zahlung an diese Adresse",
"copyAddress": "Adresse kopieren",
"memoRequired": "Memo / Ziel-Tag (Erforderlich)",
"copyMemo": "Memo kopieren",
"paymentExpires": "Zahlung läuft ab",
"status": "Status",
"closingWarning": "⚠️ Das Schließen dieses Fensters wird Ihre Reservierung stornieren und den Bestand freigeben.",
"paymentConfirmed": "Zahlung bestätigt ✔️",
"orderProcessed": "Ihre Bestellung wurde erfolgreich verarbeitet und ist jetzt in diesem Drop reserviert.",
"whatHappensNext": "Was als Nächstes passiert",
"orderProcessed24h": "Ihre Bestellung wird innerhalb von 24 Stunden bearbeitet",
"shippedExpress": "Versand per Express-Lieferung",
"shippingConfirmation": "Sie erhalten eine Versandbestätigung und Tracking-Link per E-Mail",
"thankYouCollective": "Vielen Dank, dass Sie Teil des Kollektivs sind.",
"error": "⚠️ Fehler",
"useReferralPoints": "Empfehlungspunkte verwenden",
"available": "verfügbar",
"useMax": "Maximum verwenden",
"pointsDiscount": "Punkte-Rabatt",
"pointsWillBeDeducted": "Punkte werden von Ihrem Konto abgezogen"
},
"infoBox": {
"whyCheap": "Warum so günstig?",
"whyCheapText": "Retailpreise liegen bei ca. 5-10 CHF/g. Durch kollektive Sammelbestellungen kaufen wir wie Grosshändler ein ohne Zwischenstufen.",
"taxesLegal": "Passives Einkommen, ganz einfach",
"taxesLegalText": "Teile deinen Referral-Link und <strong>verdiene dauerhaft 10 % des Umsatzes deiner Empfehlungen</strong> als Punkte. Löse sie bei kommenden Drops ein oder tausche sie gegen Krypto.",
"dropModel": "Drop-Modell",
"dropModelText": "Pro Drop nur eine Sorte. Erst ausverkauft dann der nächste Drop."
},
"signup": {
"title": "Drop-Benachrichtigungen",
"subtitle": "Tritt diesem Telegram-Kanal bei, um über neue Drops etc. benachrichtigt zu werden.",
"joinTelegram": "Telegram-Kanal beitreten"
},
"pastDrops": {
"title": "Vergangene Drops",
"loading": "Lädt vergangene Drops...",
"noDrops": "Noch keine vergangenen Drops. Schauen Sie bald wieder vorbei!",
"soldOutIn": "Ausverkauft in",
"lessThan1h": "weniger als 1h",
"1h": "1h",
"hours": "{hours}h",
"1day": "1 Tag",
"days": "{days} Tage",
"daysHours": "{days}T {hours}h",
"more": "Mehr →"
},
"footer": {
"text": "© 2025 420Deals.ch · CBD < 1% THC · Verkauf ab 18 Jahren · Schweiz"
},
"auth": {
"login": "Anmelden",
"register": "Registrieren",
"username": "Benutzername",
"password": "Passwort",
"email": "E-Mail",
"referralId": "Empfehlungs-ID",
"optional": "optional",
"autoFilled": "✓ Automatisch von Empfehlungslink ausgefüllt",
"dontHaveAccount": "Haben Sie noch kein Konto?",
"alreadyHaveAccount": "Haben Sie bereits ein Konto?",
"anErrorOccurred": "Ein Fehler ist aufgetreten",
"unexpectedError": "Ein unerwarteter Fehler ist aufgetreten"
},
"unlockBar": {
"unlocked": "✅ 420deals-Memberpreise freigeschaltet —",
"unlockedText": "Sie haben Zugang zu 420deals-Memberpreisen!",
"locked": "🔒 420deals-Memberpreise gesperrt —",
"referralsCompleted": "{count} / {needed} Empfehlungen abgeschlossen",
"toGo": "{remaining} verbleibend",
"unlockText": "{needed} verifizierte Anmeldungen schalten 420deals-Memberpreise für immer frei.",
"unlockNow": "Jetzt freischalten",
"innerCircleLocked": "🔒 Inner Circle Chat gesperrt —",
"innerCircleUnlockText": "{needed} verifizierte Anmeldungen schalten den Zugang zu unserem Inner Circle Chat für immer frei.",
"innerCircleUnlocked": "Inner Circle Chat freigeschaltet!"
},
"unlockModal": {
"title": "420deals-Memberpreise freischalten",
"innerCircleTitle": "Inner Circle Chat freischalten",
"referralsCompleted": "{count} von {needed} Empfehlungen abgeschlossen",
"inviteFriends": "Laden Sie {needed} Freunde zur Anmeldung ein.",
"unlockForever": "Sobald sie sich anmelden, werden die 420deals-Memberpreise für immer freigeschaltet.",
"innerCircleUnlockForever": "Sobald sie sich anmelden, wird der Inner Circle Chat für immer freigeschaltet.",
"yourReferralLink": "Ihr Empfehlungslink",
"copyLink": "Link kopieren",
"copied": "Kopiert!",
"shareVia": "Teilen über",
"email": "E-Mail",
"whatsapp": "WhatsApp",
"referralStats": "Empfehlungsstatistiken",
"totalReferrals": "Gesamt Empfehlungen",
"verifiedReferrals": "Verifizierte Empfehlungen",
"pendingReferrals": "Ausstehende Empfehlungen",
"friendsMustSignUp": "Freunde müssen sich anmelden, damit es zählt.",
"referralsToGoSingular": "{remaining} Empfehlung verbleibend",
"referralsToGoPlural": "{remaining} Empfehlungen verbleibend"
},
"payment": {
"cancelled": "Zahlung wurde abgebrochen."
},
"redeemPoints": {
"title": "Punkte gegen Krypto einlösen",
"currentBalance": "Aktueller Kontostand",
"points": "Punkte",
"selectCrypto": "Kryptowährung auswählen",
"cryptoCurrency": "Kryptowährung",
"walletAddress": "Wallet-Adresse",
"walletAddressPlaceholder": "Geben Sie Ihre Krypto-Wallet-Adresse ein",
"pointsToRedeem": "Einzulösende Punkte",
"min": "Minimum",
"estimatedValue": "Geschätzter Wert",
"redeem": "Einlösen",
"success": "Einlösungsanfrage erfolgreich!",
"redemptionId": "Einlösungs-ID",
"pointsRedeemed": "Eingelöste Punkte",
"cryptoAmount": "Kryptobetrag",
"newBalance": "Neuer Kontostand",
"invalidPoints": "Bitte geben Sie eine gültige Anzahl von Punkten ein",
"minPoints": "Die Mindesteinlösung beträgt {min} Punkte",
"insufficientPoints": "Sie haben nicht genug Punkte",
"invalidWallet": "Bitte geben Sie eine gültige Wallet-Adresse ein",
"error": "Beim Verarbeiten Ihrer Einlösung ist ein Fehler aufgetreten"
}
}

235
lib/translations/en.json Normal file
View File

@@ -0,0 +1,235 @@
{
"common": {
"loading": "Loading...",
"error": "An error occurred",
"ok": "OK",
"cancel": "Cancel",
"close": "Close",
"save": "Save",
"delete": "Delete",
"edit": "Edit",
"submit": "Submit",
"processing": "Processing...",
"noImage": "No Image"
},
"nav": {
"drop": "Drop",
"pastDrops": "Past Drops",
"community": "Community",
"orders": "Orders",
"login": "Login",
"logout": "Logout"
},
"header": {
"title": "Wholesale access for everyone.",
"subtitle": "Shop together. Limited CBD drops directly from Swiss producers. No retail. No markup. Just collective bulk prices."
},
"drop": {
"loading": "Loading...",
"soldOut": "Drop sold out",
"nextDropComing": "Next collective drop coming soon",
"joinDrop": "Join the Drop",
"reserved": "reserved",
"of": "of",
"batch": "Batch",
"indoor": "Indoor",
"switzerland": "Switzerland",
"inclVat": "incl. 2.5% VAT",
"perGram": "per gram",
"selectQuantity": "Select quantity",
"customQuantity": "Custom quantity",
"minimumRequired": "Minimum {minimum}g required (5 CHF minimum)",
"maximumAvailable": "Maximum {maximum}g available",
"enterValidNumber": "Please enter a valid number",
"fillDeliveryInfo": "Please fill in all delivery information (full name, address, and phone)",
"fullName": "Full Name",
"address": "Address",
"phone": "Phone",
"confirmPurchase": "Confirm Purchase",
"totalPrice": "Total Price",
"standardPrice": "Standard Price",
"wholesalePrice": "420deals Member Price",
"paymentCurrency": "Payment Currency",
"selectCurrency": "Select currency",
"upcomingIn": "Upcoming in",
"day": "day",
"days": "days",
"hour": "hour",
"hours": "hours",
"minute": "minute",
"minutes": "minutes",
"paymentAddress": "Payment Address",
"paymentAmount": "Payment Amount",
"paymentId": "Payment ID",
"copyAddress": "Copy Address",
"copied": "Copied!",
"paymentInstructions": "Send exactly {amount} {currency} to the address above. Payment expires in 20 minutes.",
"paymentExpired": "Payment expired. Please try again.",
"paymentPending": "Payment pending...",
"paymentSuccess": "Payment successful!",
"paymentFailed": "Payment failed. Please try again.",
"orderConfirmed": "Order confirmed!",
"orderFailed": "Order failed. Please try again.",
"dropSoldOut": "Drop Sold Out",
"fullyReserved": "The current collective drop has been fully reserved.",
"nextDropComingSoon": "Next collective drop coming soon.",
"wholesalePriceLabel": "420deals Member Price:",
"standardPriceLabel": "Standard price:",
"standard": "Standard",
"wholesale": "420deals Member Price",
"unlock": "unlock",
"unlockOnce": "Unlock once. Keep wholesale forever.",
"dropStartsIn": "Drop starts in",
"onHold": "on hold (10 min checkout window)",
"custom": "Custom (g)",
"min": "Min",
"max": "Max",
"total": "Total",
"standardTotal": "Standard total",
"wholesaleTotal": "420deals Member Price total",
"joinTheDrop": "Join the drop",
"noSubscription": "No subscription · No obligation",
"lessThanRemaining": "Less than {amount}{unit} remaining. This drop is almost fully reserved.",
"fullyReservedText": "This drop is fully reserved",
"item": "Item",
"quantity": "Quantity",
"pricePerUnit": "Price per {unit}",
"deliveryInformation": "Delivery Information",
"fullNameRequired": "Full Name *",
"enterFullName": "Enter your full name",
"addressRequired": "Address *",
"enterAddress": "Enter your delivery address",
"phoneRequired": "Phone Number *",
"enterPhone": "Enter your phone number",
"loadingCurrencies": "Loading currencies...",
"payWith": "Pay with",
"completePayment": "Complete Payment",
"amountToPay": "Amount to Pay",
"price": "Price",
"subtotal": "Subtotal",
"shippingFee": "Packaging & Shipping",
"shippingFeeNote": "A-Post parcel delivery with signature & insurance",
"sendPaymentTo": "Send payment to this address",
"memoRequired": "Memo / Destination Tag (Required)",
"copyMemo": "Copy Memo",
"paymentExpires": "Payment expires",
"status": "Status",
"closingWarning": "⚠️ Closing this window will cancel your reservation and free up the inventory.",
"paymentConfirmed": "Payment confirmed ✔️",
"orderProcessed": "Your order has been successfully processed and is now reserved in this drop.",
"whatHappensNext": "What happens next",
"orderProcessed24h": "Your order will be processed within 24 hours",
"shippedExpress": "Shipped via express delivery",
"shippingConfirmation": "You'll receive a shipping confirmation and tracking link by email",
"thankYouCollective": "Thank you for being part of the collective.",
"error": "⚠️ Error",
"useReferralPoints": "Use Referral Points",
"available": "available",
"useMax": "Use Max",
"pointsDiscount": "Points Discount",
"pointsWillBeDeducted": "Points will be deducted from your account"
},
"infoBox": {
"whyCheap": "Why so cheap?",
"whyCheapText": "Retail prices are around 5-10 CHF/g. Through collective bulk orders, we buy like wholesalers without intermediaries.",
"taxesLegal": "Earn Passive Income, Simply",
"taxesLegalText": "Share your referral link and <strong>earn a lifetime 10% of your referrals' revenue</strong> in points. Redeem them for upcoming drops or swap them for crypto.",
"dropModel": "Drop Model",
"dropModelText": "One variety per drop. Only when sold out then the next drop."
},
"signup": {
"title": "Drop Notifications",
"subtitle": "Join this telegram channel to be notified on new drops etc.",
"joinTelegram": "Join Telegram Channel"
},
"pastDrops": {
"title": "Past Drops",
"loading": "Loading past drops...",
"noDrops": "No past drops yet. Check back soon!",
"soldOutIn": "Sold out in",
"lessThan1h": "less than 1h",
"1h": "1h",
"hours": "{hours}h",
"1day": "1 day",
"days": "{days} days",
"daysHours": "{days}d {hours}h",
"more": "More →"
},
"footer": {
"text": "© 2025 420Deals.ch · CBD < 1% THC · Sale from 18 years · Switzerland"
},
"auth": {
"login": "Login",
"register": "Register",
"username": "Username",
"password": "Password",
"email": "Email",
"referralId": "Referral ID",
"optional": "optional",
"autoFilled": "✓ Auto-filled from referral link",
"dontHaveAccount": "Don't have an account?",
"alreadyHaveAccount": "Already have an account?",
"anErrorOccurred": "An error occurred",
"unexpectedError": "An unexpected error occurred"
},
"unlockBar": {
"unlocked": "✅ 420deals Member prices unlocked —",
"unlockedText": "You have access to 420deals Member pricing!",
"locked": "🔒 420deals Member prices locked —",
"referralsCompleted": "{count} / {needed} referrals completed",
"toGo": "{remaining} to go",
"unlockText": "{needed} verified sign-ups unlock 420deals Member prices forever.",
"unlockNow": "Unlock now",
"innerCircleLocked": "🔒 Inner circle chat locked —",
"innerCircleUnlockText": "{needed} verified sign-ups unlock access to our Inner circle chat forever.",
"innerCircleUnlocked": "Inner circle chat unlocked!"
},
"unlockModal": {
"title": "Unlock 420deals Member Prices",
"innerCircleTitle": "Unlock Inner chat circle",
"referralsCompleted": "{count} of {needed} referrals completed",
"inviteFriends": "Invite {needed} friends to sign up.",
"unlockForever": "Once they do, 420deals Member prices unlock forever.",
"innerCircleUnlockForever": "Once they do, Inner chat circle unlocks forever.",
"yourReferralLink": "Your referral link",
"copyLink": "Copy Link",
"copied": "Copied!",
"shareVia": "Share via",
"email": "Email",
"whatsapp": "WhatsApp",
"referralStats": "Referral Stats",
"totalReferrals": "Total Referrals",
"verifiedReferrals": "Verified Referrals",
"pendingReferrals": "Pending Referrals",
"friendsMustSignUp": "Friends must sign up to count.",
"referralsToGoSingular": "{remaining} referral to go",
"referralsToGoPlural": "{remaining} referrals to go"
},
"payment": {
"cancelled": "Payment was cancelled."
},
"redeemPoints": {
"title": "Redeem Points to Crypto",
"currentBalance": "Current Balance",
"points": "points",
"selectCrypto": "Select Cryptocurrency",
"cryptoCurrency": "Cryptocurrency",
"walletAddress": "Wallet Address",
"walletAddressPlaceholder": "Enter your crypto wallet address",
"pointsToRedeem": "Points to Redeem",
"min": "Minimum",
"estimatedValue": "Estimated Value",
"redeem": "Redeem",
"success": "Redemption Request Successful!",
"redemptionId": "Redemption ID",
"pointsRedeemed": "Points Redeemed",
"cryptoAmount": "Crypto Amount",
"newBalance": "New Balance",
"invalidPoints": "Please enter a valid number of points",
"minPoints": "Minimum redemption is {min} points",
"insufficientPoints": "You don't have enough points",
"invalidWallet": "Please enter a valid wallet address",
"error": "An error occurred while processing your redemption"
}
}

View File

@@ -0,0 +1,14 @@
-- Migration to add description field to drops table
-- Date: 2025-01-XX
SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO";
START TRANSACTION;
SET time_zone = "+00:00";
-- Add description column to drops table
ALTER TABLE `drops`
ADD COLUMN `description` text DEFAULT NULL AFTER `item`;
COMMIT;

View File

@@ -0,0 +1,18 @@
-- Migration to add EUR-based referral points settings
-- This makes referral points universal across currencies by using EUR as the base
-- Date: 2025-01-XX
SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO";
START TRANSACTION;
SET time_zone = "+00:00";
-- Add EUR-based referral point settings
INSERT INTO `referral_settings` (`setting_key`, `setting_value`, `description`) VALUES
('points_per_eur', '10', 'Number of referral points earned per 1 EUR purchase by referred user'),
('points_to_eur', '100', 'Number of referral points required to redeem 1 EUR discount')
ON DUPLICATE KEY UPDATE
`description` = VALUES(`description`);
COMMIT;

View File

@@ -1,7 +0,0 @@
-- Add expires_at column to pending_orders table for 10-minute reservation timeout
ALTER TABLE `pending_orders`
ADD COLUMN `expires_at` datetime NOT NULL DEFAULT (DATE_ADD(NOW(), INTERVAL 10 MINUTE));
-- Add index on expires_at for efficient cleanup queries
CREATE INDEX `idx_expires_at` ON `pending_orders` (`expires_at`);

View File

@@ -1,6 +0,0 @@
-- Migration: Add image_url column to drops table
-- Run this to add support for image uploads
ALTER TABLE `drops`
ADD COLUMN `image_url` VARCHAR(255) DEFAULT NULL AFTER `unit`;

View File

@@ -0,0 +1,30 @@
-- Migration to add multi-currency pricing support for drops
-- Date: 2025-01-XX
-- Adds support for CHF and EUR prices, both regular and wholesale
SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO";
START TRANSACTION;
SET time_zone = "+00:00";
-- Add price columns to drops table
-- Prices are stored as decimal per gram
ALTER TABLE `drops`
ADD COLUMN `price_chf` decimal(10,4) DEFAULT NULL AFTER `ppu`,
ADD COLUMN `price_eur` decimal(10,4) DEFAULT NULL AFTER `price_chf`,
ADD COLUMN `wholesale_price_chf` decimal(10,4) DEFAULT NULL AFTER `price_eur`,
ADD COLUMN `wholesale_price_eur` decimal(10,4) DEFAULT NULL AFTER `wholesale_price_chf`;
-- Migrate existing ppu data to new price fields
-- ppu is stored as integer where 1000 = 1.00 EUR per gram
-- Convert to decimal format and set both EUR and CHF prices
UPDATE `drops`
SET
`price_eur` = `ppu` / 1000.0,
`price_chf` = (`ppu` / 1000.0) * 0.97, -- Convert EUR to CHF (1 EUR ≈ 0.97 CHF)
`wholesale_price_eur` = (`ppu` / 1000.0) * 0.76, -- 76% of regular price
`wholesale_price_chf` = (`ppu` / 1000.0) * 0.76 * 0.97 -- 76% of regular price in CHF
WHERE `ppu` IS NOT NULL AND `ppu` > 0;
COMMIT;

View File

@@ -0,0 +1,43 @@
-- Migration to add point redemption to crypto feature
-- Date: 2025-01-XX
SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO";
START TRANSACTION;
SET time_zone = "+00:00";
-- Update referral_point_transactions type enum to include 'redeemed'
ALTER TABLE `referral_point_transactions`
MODIFY COLUMN `type` enum('earned','spent','redeemed') NOT NULL;
-- Create point_redemptions table to track crypto redemptions
CREATE TABLE `point_redemptions` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`buyer_id` int(11) NOT NULL,
`points` decimal(10,2) NOT NULL,
`crypto_currency` varchar(20) NOT NULL,
`wallet_address` varchar(255) NOT NULL,
`crypto_amount` decimal(20,8) DEFAULT NULL,
`status` enum('pending','processing','completed','failed','cancelled') NOT NULL DEFAULT 'pending',
`transaction_hash` varchar(255) DEFAULT NULL,
`error_message` text DEFAULT NULL,
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
`updated_at` datetime NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
PRIMARY KEY (`id`),
KEY `buyer_id` (`buyer_id`),
KEY `status` (`status`),
CONSTRAINT `point_redemptions_ibfk_1` FOREIGN KEY (`buyer_id`) REFERENCES `buyers` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- Add redemption rate setting (points per 1 CHF worth of crypto)
-- This determines how many points are needed to redeem 1 CHF worth of crypto
INSERT INTO `referral_settings` (`setting_key`, `setting_value`, `description`)
VALUES ('points_to_crypto_chf', '100', 'Number of referral points required to redeem 1 CHF worth of crypto')
ON DUPLICATE KEY UPDATE `description` = 'Number of referral points required to redeem 1 CHF worth of crypto';
-- Add minimum redemption amount setting
INSERT INTO `referral_settings` (`setting_key`, `setting_value`, `description`)
VALUES ('min_redemption_points', '1000', 'Minimum number of points required for crypto redemption')
ON DUPLICATE KEY UPDATE `description` = 'Minimum number of points required for crypto redemption';
COMMIT;

View File

@@ -1,7 +0,0 @@
-- Add start_time column to drops table
ALTER TABLE `drops`
ADD COLUMN `start_time` datetime DEFAULT NULL AFTER `created_at`;
-- Update existing drops to have start_time = created_at (so they're immediately available)
UPDATE `drops` SET `start_time` = `created_at` WHERE `start_time` IS NULL;

View File

@@ -1,20 +0,0 @@
-- Create pending_orders table to store invoice info before payment confirmation
CREATE TABLE IF NOT EXISTS `pending_orders` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`payment_id` varchar(255) NOT NULL,
`order_id` varchar(255) NOT NULL,
`drop_id` int(11) NOT NULL,
`buyer_id` int(11) NOT NULL,
`size` int(11) NOT NULL,
`price_amount` decimal(10,2) NOT NULL,
`price_currency` varchar(10) NOT NULL DEFAULT 'chf',
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
UNIQUE KEY `payment_id` (`payment_id`),
UNIQUE KEY `order_id` (`order_id`),
KEY `drop_id` (`drop_id`),
KEY `buyer_id` (`buyer_id`),
CONSTRAINT `pending_orders_ibfk_1` FOREIGN KEY (`drop_id`) REFERENCES `drops` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT `pending_orders_ibfk_2` FOREIGN KEY (`buyer_id`) REFERENCES `buyers` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
public/header.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

BIN
public/icon_ref_points.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View File

@@ -0,0 +1,184 @@
-- Migration script to add referral points system to existing database
-- Run this script on your existing database to add referral points functionality
-- Date: 2025-12-28
SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO";
START TRANSACTION;
SET time_zone = "+00:00";
-- Add referral_points column to buyers table
ALTER TABLE `buyers`
ADD COLUMN `referral_points` decimal(10,2) NOT NULL DEFAULT 0.00 AFTER `email`;
-- Add points_used column to pending_orders table
ALTER TABLE `pending_orders`
ADD COLUMN `points_used` decimal(10,2) NOT NULL DEFAULT 0.00 AFTER `price_currency`;
-- Add points_used and price_amount columns to sales table
ALTER TABLE `sales`
ADD COLUMN `price_amount` decimal(10,2) DEFAULT NULL AFTER `payment_id`,
ADD COLUMN `price_currency` varchar(10) NOT NULL DEFAULT 'chf' AFTER `price_amount`,
ADD COLUMN `points_used` decimal(10,2) NOT NULL DEFAULT 0.00 AFTER `price_currency`;
-- Create referral_point_transactions table
CREATE TABLE `referral_point_transactions` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`buyer_id` int(11) NOT NULL,
`points` decimal(10,2) NOT NULL,
`type` enum('earned','spent') NOT NULL,
`sale_id` int(11) DEFAULT NULL,
`pending_order_id` int(11) DEFAULT NULL,
`description` text DEFAULT NULL,
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
KEY `buyer_id` (`buyer_id`),
KEY `sale_id` (`sale_id`),
KEY `pending_order_id` (`pending_order_id`),
CONSTRAINT `referral_point_transactions_ibfk_1` FOREIGN KEY (`buyer_id`) REFERENCES `buyers` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT `referral_point_transactions_ibfk_2` FOREIGN KEY (`sale_id`) REFERENCES `sales` (`id`) ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT `referral_point_transactions_ibfk_3` FOREIGN KEY (`pending_order_id`) REFERENCES `pending_orders` (`id`) ON DELETE SET NULL ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- Create referral_settings table
CREATE TABLE `referral_settings` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`setting_key` varchar(100) NOT NULL,
`setting_value` varchar(255) NOT NULL,
`description` text DEFAULT NULL,
`updated_at` datetime NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
PRIMARY KEY (`id`),
UNIQUE KEY `setting_key` (`setting_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- Insert default referral settings
INSERT INTO `referral_settings` (`setting_key`, `setting_value`, `description`) VALUES
('points_per_chf', '10', 'Number of referral points earned per 1 CHF purchase by referred user'),
('points_to_chf', '100', 'Number of referral points required to redeem 1 CHF discount');
-- Create stored procedure to award referral points
DELIMITER $$
CREATE PROCEDURE `award_referral_points`(IN p_sale_id INT)
BEGIN
DECLARE v_buyer_id INT;
DECLARE v_referrer_id INT;
DECLARE v_price_amount DECIMAL(10,2);
DECLARE v_points_per_chf DECIMAL(10,2);
DECLARE v_points_earned DECIMAL(10,2);
DECLARE v_drop_id INT;
DECLARE v_size INT;
DECLARE v_ppu DECIMAL(10,2);
DECLARE v_currency VARCHAR(10);
-- Get sale details
SELECT buyer_id, drop_id, size, COALESCE(price_amount, 0), price_currency
INTO v_buyer_id, v_drop_id, v_size, v_price_amount, v_currency
FROM sales
WHERE id = p_sale_id;
-- If price_amount is not set, calculate it from drop's ppu
IF v_price_amount = 0 OR v_price_amount IS NULL THEN
SELECT ppu INTO v_ppu FROM drops WHERE id = v_drop_id;
SET v_price_amount = v_ppu * v_size;
END IF;
-- Get the referrer for this buyer (if any)
SELECT referrer INTO v_referrer_id
FROM referrals
WHERE referree = v_buyer_id
LIMIT 1;
-- If there's a referrer, award points
IF v_referrer_id IS NOT NULL THEN
-- Get points_per_chf setting
SELECT CAST(setting_value AS DECIMAL(10,2)) INTO v_points_per_chf
FROM referral_settings
WHERE setting_key = 'points_per_chf'
LIMIT 1;
-- Default to 10 if setting not found
IF v_points_per_chf IS NULL THEN
SET v_points_per_chf = 10;
END IF;
-- Calculate points earned (based on actual purchase amount in CHF)
SET v_points_earned = v_price_amount * v_points_per_chf;
-- Update referrer's points balance
UPDATE buyers
SET referral_points = referral_points + v_points_earned
WHERE id = v_referrer_id;
-- Record the transaction
INSERT INTO referral_point_transactions (
buyer_id,
points,
type,
sale_id,
description
) VALUES (
v_referrer_id,
v_points_earned,
'earned',
p_sale_id,
CONCAT('Points earned from referral purchase (Sale #', p_sale_id, ', Amount: ', v_price_amount, ' ', v_currency, ')')
);
END IF;
END$$
-- Create stored procedure to spend referral points
CREATE PROCEDURE `spend_referral_points`(
IN p_buyer_id INT,
IN p_points_to_spend DECIMAL(10,2),
IN p_pending_order_id INT,
IN p_sale_id INT,
OUT p_success INT
)
BEGIN
DECLARE v_current_points DECIMAL(10,2);
DECLARE v_new_balance DECIMAL(10,2);
-- Get current points balance
SELECT referral_points INTO v_current_points
FROM buyers
WHERE id = p_buyer_id;
-- Check if buyer has enough points
IF v_current_points IS NULL OR v_current_points < p_points_to_spend THEN
SET p_success = 0;
ELSE
-- Deduct points
SET v_new_balance = v_current_points - p_points_to_spend;
UPDATE buyers
SET referral_points = v_new_balance
WHERE id = p_buyer_id;
-- Record the transaction
INSERT INTO referral_point_transactions (
buyer_id,
points,
type,
sale_id,
pending_order_id,
description
) VALUES (
p_buyer_id,
p_points_to_spend,
'spent',
p_sale_id,
p_pending_order_id,
CONCAT('Points spent for purchase',
IF(p_sale_id IS NOT NULL, CONCAT(' (Sale #', p_sale_id, ')'), ''),
IF(p_pending_order_id IS NOT NULL, CONCAT(' (Pending Order #', p_pending_order_id, ')'), '')
)
);
SET p_success = 1;
END IF;
END$$
DELIMITER ;
COMMIT;