Compare commits
10 Commits
514e04f43d
...
eeaa9a66bb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eeaa9a66bb | ||
|
|
d138dae2ca | ||
|
|
0d8c2ea3a3 | ||
|
|
312810bb56 | ||
|
|
e75e4f08a9 | ||
|
|
6f4ca75faf | ||
|
|
a940d51475 | ||
|
|
8a0835c564 | ||
|
|
bb1c5b43d6 | ||
|
|
5e65144934 |
355
HTML Code with referral style.html
Normal file
355
HTML Code with referral style.html
Normal 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 < 1% THC · 18+ only · Switzerland
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -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.
|
||||
|
||||
@@ -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
145
REFERRAL_POINTS_README.md
Normal 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.
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
POSTCreate payment
|
||||
https://api.nowpayments.io/v1/payment
|
||||
|
||||
Creates payment. With this method, your customer will be able to complete the payment without leaving your website.
|
||||
|
||||
Be sure to consider the details of repeated and wrong-asset deposits from 'Repeated Deposits and Wrong-Asset Deposits' section when processing payments.
|
||||
|
||||
Data must be sent as a JSON-object payload.
|
||||
Required request fields:
|
||||
|
||||
price_amount (required) - the fiat equivalent of the price to be paid in crypto. If the pay_amount parameter is left empty, our system will automatically convert this fiat price into its crypto equivalent. Please note that this does not enable fiat payments, only provides a fiat price for yours and the customer’s convenience and information. NOTE: Some of the assets (KISHU, NWC, FTT, CHR, XYM, SRK, KLV, SUPER, OM, XCUR, NOW, SHIB, SAND, MATIC, CTSI, MANA, FRONT, FTM, DAO, LGCY), have a maximum price amount of ~$2000;
|
||||
|
||||
price_currency (required) - the fiat currency in which the price_amount is specified (usd, eur, etc);
|
||||
|
||||
pay_amount (optional) - the amount that users have to pay for the order stated in crypto. You can either specify it yourself, or we will automatically convert the amount you indicated in price_amount;
|
||||
|
||||
pay_currency (required) - the crypto currency in which the pay_amount is specified (btc, eth, etc), or one of available fiat currencies if it's enabled for your account (USD, EUR, ILS, GBP, AUD, RON);
|
||||
NOTE: some of the currencies require a Memo, Destination Tag, etc., to complete a payment (AVA, EOS, BNBMAINNET, XLM, XRP). This is unique for each payment. This ID is received in “payin_extra_id” parameter of the response. Payments made without "payin_extra_id" cannot be detected automatically;
|
||||
|
||||
ipn_callback_url (optional) - url to receive callbacks, should contain "http" or "https", eg. "https://nowpayments.io";
|
||||
|
||||
order_id (optional) - inner store order ID, e.g. "RGDBP-21314";
|
||||
|
||||
order_description (optional) - inner store order description, e.g. "Apple Macbook Pro 2019 x 1";
|
||||
|
||||
payout_address (optional) - usually the funds will go to the address you specify in your Personal account. In case you want to receive funds on another address, you can specify it in this parameter;
|
||||
|
||||
payout_currency (optional) - currency of your external payout_address, required when payout_adress is specified;
|
||||
|
||||
payout_extra_id(optional) - extra id or memo or tag for external payout_address;
|
||||
|
||||
is_fixed_rate(optional) - boolean, can be true or false. Required for fixed-rate exchanges;
|
||||
NOTE: the rate of exchange will be frozen for 20 minutes. If there are no incoming payments during this period, the payment status changes to "expired".
|
||||
|
||||
is_fee_paid_by_user(optional) - boolean, can be true or false. Required for fixed-rate exchanges with all fees paid by users;
|
||||
NOTE: the rate of exchange will be frozen for 20 minutes. If there are no incoming payments during this period, the payment status changes to "expired". The fee paid by user payment can be only fixed rate. If you disable fixed rate during payment creation process, this flag would enforce fixed_rate to be true;
|
||||
|
||||
Here the list of available statuses of payment:
|
||||
|
||||
waiting - waiting for the customer to send the payment. The initial status of each payment;
|
||||
|
||||
confirming - the transaction is being processed on the blockchain. Appears when NOWPayments detect the funds from the user on the blockchain;
|
||||
Please note: each currency has its own amount of confirmations required to start the processing.
|
||||
|
||||
confirmed - the process is confirmed by the blockchain. Customer’s funds have accumulated enough confirmations;
|
||||
|
||||
sending - the funds are being sent to your personal wallet. We are in the process of sending the funds to you;
|
||||
|
||||
partially_paid - it shows that the customer sent less than the actual price. Appears when the funds have arrived in your wallet;
|
||||
|
||||
finished - the funds have reached your personal address and the payment is finished;
|
||||
|
||||
failed - the payment wasn't completed due to the error of some kind;
|
||||
|
||||
expired - the user didn't send the funds to the specified address in the 7 days time window;
|
||||
|
||||
Please note: when you're creating a fiat2crypto payment you also should include additional header to your request - "origin-ip : xxx", where xxx is your customer IP address.
|
||||
|
||||
|
||||
|
||||
Request Example:
|
||||
curl --location 'https://api.nowpayments.io/v1/payment' \
|
||||
--header 'x-api-key: {{api-key}}' \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data '{
|
||||
"price_amount": 3999.5,
|
||||
"price_currency": "usd",
|
||||
"pay_currency": "btc",
|
||||
"ipn_callback_url": "https://nowpayments.io",
|
||||
"order_id": "RGDBP-21314",
|
||||
"order_description": "Apple Macbook Pro 2019 x 1"
|
||||
}'
|
||||
|
||||
Response:
|
||||
{
|
||||
"payment_id": "5745459419",
|
||||
"payment_status": "waiting",
|
||||
"pay_address": "3EZ2uTdVDAMFXTfc6uLDDKR6o8qKBZXVkj",
|
||||
"price_amount": 3999.5,
|
||||
"price_currency": "usd",
|
||||
"pay_amount": 0.17070286,
|
||||
"pay_currency": "btc",
|
||||
"order_id": "RGDBP-21314",
|
||||
"order_description": "Apple Macbook Pro 2019 x 1",
|
||||
"ipn_callback_url": "https://nowpayments.io",
|
||||
"created_at": "2020-12-22T15:00:22.742Z",
|
||||
"updated_at": "2020-12-22T15:00:22.742Z",
|
||||
"purchase_id": "5837122679",
|
||||
"amount_received": null,
|
||||
"payin_extra_id": null,
|
||||
"smart_contract": "",
|
||||
"network": "btc",
|
||||
"network_precision": 8,
|
||||
"time_limit": null,
|
||||
"burning_percent": null,
|
||||
"expiration_estimate_date": "2020-12-23T15:00:22.742Z"
|
||||
}
|
||||
425
app/admin/buyers/page.tsx
Normal file
425
app/admin/buyers/page.tsx
Normal 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
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
116
app/admin/login/page.tsx
Normal 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
384
app/admin/sales/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
12
app/api/admin/check/route.ts
Normal file
12
app/api/admin/check/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
|
||||
33
app/api/admin/login/route.ts
Normal file
33
app/api/admin/login/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
16
app/api/admin/logout/route.ts
Normal file
16
app/api/admin/logout/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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
265
app/api/drops/[id]/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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})`)
|
||||
|
||||
72
app/api/drops/images/route.ts
Normal file
72
app/api/drops/images/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
76
app/api/notifications/subscribe/route.ts
Normal file
76
app/api/notifications/subscribe/route.ts
Normal 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
54
app/api/orders/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
217
app/api/referral-points/redeem/route.ts
Normal file
217
app/api/referral-points/redeem/route.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
||||
96
app/api/referral-points/route.ts
Normal file
96
app/api/referral-points/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
39
app/api/referrals/link/route.ts
Normal file
39
app/api/referrals/link/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
74
app/api/referrals/status/route.ts
Normal file
74
app/api/referrals/status/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
51
app/api/sales/drop/[dropId]/route.ts
Normal file
51
app/api/sales/drop/[dropId]/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
109
app/api/sales/from-pending-order/route.ts
Normal file
109
app/api/sales/from-pending-order/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
27
app/api/shipping-fee/route.ts
Normal file
27
app/api/shipping-fee/route.ts
Normal 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',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -1,7 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { useI18n } from '@/lib/i18n'
|
||||
|
||||
export default function Footer() {
|
||||
const { t } = useI18n()
|
||||
|
||||
return (
|
||||
<footer>
|
||||
© 2025 420Deals.ch · CBD < 1% THC · Sale from 18 years · Switzerland
|
||||
{t('footer.text')}
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
45
app/components/LanguageSwitcher.tsx
Normal file
45
app/components/LanguageSwitcher.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
419
app/components/RedeemPointsModal.tsx
Normal file
419
app/components/RedeemPointsModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
149
app/components/UnlockBar.tsx
Normal file
149
app/components/UnlockBar.tsx
Normal 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)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
329
app/components/UnlockModal.tsx
Normal file
329
app/components/UnlockModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
137
app/globals.css
137
app/globals.css
@@ -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);
|
||||
|
||||
@@ -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
291
app/orders/page.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
18
app/page.tsx
18
app/page.tsx
@@ -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>
|
||||
|
||||
|
||||
195
cbd420.sql
195
cbd420.sql
@@ -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
58
lib/admin-auth.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
51
lib/currency.ts
Normal 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'
|
||||
}
|
||||
|
||||
@@ -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
92
lib/geolocation.ts
Normal 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
109
lib/i18n.tsx
Normal 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
27
lib/payment-currencies.ts
Normal 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
238
lib/translations/de.json
Normal 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
235
lib/translations/en.json
Normal 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"
|
||||
}
|
||||
}
|
||||
|
||||
14
migrations/add_drop_description.sql
Normal file
14
migrations/add_drop_description.sql
Normal 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;
|
||||
|
||||
|
||||
18
migrations/add_eur_referral_points.sql
Normal file
18
migrations/add_eur_referral_points.sql
Normal 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;
|
||||
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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`;
|
||||
|
||||
30
migrations/add_multi_currency_prices.sql
Normal file
30
migrations/add_multi_currency_prices.sql
Normal 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;
|
||||
|
||||
|
||||
43
migrations/add_point_redemptions.sql
Normal file
43
migrations/add_point_redemptions.sql
Normal 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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
BIN
public/header.png
Normal file
BIN
public/header.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
BIN
public/icon_ref_points.png
Normal file
BIN
public/icon_ref_points.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
184
referral_points_migration.sql
Normal file
184
referral_points_migration.sql
Normal 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;
|
||||
|
||||
Reference in New Issue
Block a user