From 0d8c2ea3a370bb7dfe9e39d18c4771e82c73f23b Mon Sep 17 00:00:00 2001 From: root Date: Wed, 31 Dec 2025 07:49:35 +0000 Subject: [PATCH] final 0.9 --- REFERRAL_POINTS_README.md | 145 ++++++++++++ app/api/payments/create-invoice/route.ts | 130 ++++++++++- app/api/referral-points/route.ts | 65 ++++++ app/api/sales/from-pending-order/route.ts | 109 +++++++++ app/components/Drop.tsx | 182 ++++++++++++++- app/components/InfoBox.tsx | 55 +++-- app/components/Nav.tsx | 30 ++- app/components/UnlockModal.tsx | 49 ++++- cbd420.sql | 257 +++++++++++++++++++++- lib/auth.ts | 4 +- lib/translations/de.json | 13 +- lib/translations/en.json | 13 +- referral_points_migration.sql | 184 ++++++++++++++++ 13 files changed, 1195 insertions(+), 41 deletions(-) create mode 100644 REFERRAL_POINTS_README.md create mode 100644 app/api/referral-points/route.ts create mode 100644 app/api/sales/from-pending-order/route.ts create mode 100644 referral_points_migration.sql diff --git a/REFERRAL_POINTS_README.md b/REFERRAL_POINTS_README.md new file mode 100644 index 0000000..97f1d68 --- /dev/null +++ b/REFERRAL_POINTS_README.md @@ -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. + diff --git a/app/api/payments/create-invoice/route.ts b/app/api/payments/create-invoice/route.ts index 6486e0a..7c71260 100644 --- a/app/api/payments/create-invoice/route.ts +++ b/app/api/payments/create-invoice/route.ts @@ -25,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) { @@ -35,6 +35,15 @@ 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)) { @@ -153,16 +162,102 @@ export async function POST(request: NextRequest) { // 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_chf setting + const [settingsRows] = await connection.execute( + 'SELECT setting_value FROM referral_settings WHERE setting_key = ?', + ['points_to_chf'] + ) + const settings = settingsRows as any[] + const pointsToChf = parseFloat(settings[0]?.setting_value || '100') + + // Calculate discount in CHF, then convert to user's currency + const discountChf = pointsToUse / pointsToChf + + // Convert discount based on user's currency + if (currency === 'CHF') { + pointsDiscount = discountChf + } else { + // Convert CHF to EUR (1 CHF ≈ 1.03 EUR) + pointsDiscount = discountChf * 1.03 + } + + // 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 - const totalPriceAmount = priceAmount + shippingFee + // Add shipping fee to total price (shipping is not discounted) + const totalPriceAmount = priceAfterDiscount + shippingFee // Round to 2 decimal places const roundedPriceAmount = Math.round(totalPriceAmount * 100) / 100 const roundedShippingFee = Math.round(shippingFee * 100) / 100 - const roundedSubtotal = Math.round(priceAmount * 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}` @@ -225,10 +320,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, roundedPriceAmount, priceCurrency, 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() @@ -241,10 +351,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, // Total price in fiat (includes shipping) + 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 + 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 diff --git a/app/api/referral-points/route.ts b/app/api/referral-points/route.ts new file mode 100644 index 0000000..9c29018 --- /dev/null +++ b/app/api/referral-points/route.ts @@ -0,0 +1,65 @@ +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[] + + 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' + ) + + // Calculate maximum discount available + const maxDiscountChf = referralPoints / pointsToChf + + return NextResponse.json({ + referral_points: referralPoints, + points_to_chf: pointsToChf, + points_per_chf: pointsPerChf, + max_discount_chf: maxDiscountChf, + }) + } catch (error) { + console.error('Error fetching referral points:', error) + return NextResponse.json( + { error: 'Failed to fetch referral points' }, + { status: 500 } + ) + } +} + diff --git a/app/api/sales/from-pending-order/route.ts b/app/api/sales/from-pending-order/route.ts new file mode 100644 index 0000000..d40f0f2 --- /dev/null +++ b/app/api/sales/from-pending-order/route.ts @@ -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 } + ) + } +} + diff --git a/app/components/Drop.tsx b/app/components/Drop.tsx index eea8fb7..71f1e6a 100644 --- a/app/components/Drop.tsx +++ b/app/components/Drop.tsx @@ -26,6 +26,7 @@ interface User { id: number username: string email: string + referral_points?: number } export default function Drop() { @@ -57,12 +58,17 @@ export default function Drop() { const [shippingFee, setShippingFee] = useState(null) const [loadingShippingFee, setLoadingShippingFee] = useState(false) const [currency, setCurrency] = useState<'CHF' | 'EUR'>('EUR') // Default to EUR + const [referralPoints, setReferralPoints] = useState(0) + const [pointsToChf, setPointsToChf] = useState(100) + const [pointsToUse, setPointsToUse] = useState(0) + const [loadingPoints, setLoadingPoints] = useState(false) useEffect(() => { fetchActiveDrop() checkAuth() checkWholesaleStatus() fetchShippingFee() // Fetch currency info on mount + fetchReferralPoints() // Poll active drop every 30 seconds const interval = setInterval(() => { @@ -72,6 +78,16 @@ export default function Drop() { return () => clearInterval(interval) }, []) + // Fetch referral points when user is authenticated + useEffect(() => { + if (user) { + fetchReferralPoints() + } else { + setReferralPoints(0) + setPointsToUse(0) + } + }, [user]) + const checkWholesaleStatus = async () => { try { const response = await fetch('/api/referrals/status', { @@ -86,6 +102,30 @@ export default function Drop() { } } + const fetchReferralPoints = async () => { + if (!user) { + setReferralPoints(0) + setPointsToUse(0) + return + } + + setLoadingPoints(true) + try { + const response = await fetch('/api/referral-points', { + credentials: 'include', + }) + if (response.ok) { + const data = await response.json() + setReferralPoints(data.referral_points || 0) + setPointsToChf(data.points_to_chf || 100) + } + } catch (error) { + console.error('Error fetching referral points:', error) + } finally { + setLoadingPoints(false) + } + } + // Poll payment status when payment modal is open useEffect(() => { if (!showPaymentModal || !paymentData?.payment_id) return @@ -429,6 +469,7 @@ export default function Drop() { size: selectedSize, // Size in grams pay_currency: selectedCurrency, // Selected payment currency buyer_data_id: buyerData.buyer_data_id, // Buyer delivery data ID + points_to_use: pointsToUse, // Points to use for discount }), }) @@ -451,6 +492,12 @@ export default function Drop() { const data = await response.json() + // Refresh referral points if any were used + if (pointsToUse > 0) { + fetchReferralPoints() + setPointsToUse(0) // Reset points used + } + // Close confirmation modal setShowConfirmModal(false) setProcessing(false) @@ -474,6 +521,39 @@ export default function Drop() { const handleCancelPurchase = () => { setShowConfirmModal(false) + setPointsToUse(0) // Reset points when canceling + } + + const handlePointsToUseChange = (value: string) => { + const numValue = parseFloat(value) || 0 + const maxPoints = Math.min(referralPoints, calculatePriceBeforeDiscount() * pointsToChf) + setPointsToUse(Math.max(0, Math.min(numValue, maxPoints))) + } + + const calculatePriceBeforeDiscount = () => { + if (!drop) return 0 + const pricePerGramEur = drop.ppu / 1000 + const priceToUseEur = isWholesaleUnlocked ? pricePerGramEur * 0.76 : pricePerGramEur + const priceEur = selectedSize * priceToUseEur + return convertPrice(priceEur) + } + + const getMaxDiscountFromPoints = () => { + if (pointsToChf === 0) return 0 + return referralPoints / pointsToChf + } + + const calculateDiscountFromPoints = () => { + if (pointsToUse === 0 || pointsToChf === 0) return 0 + // Calculate discount in CHF, then convert to user's currency + const discountChf = pointsToUse / pointsToChf + // Convert CHF discount to user's currency + if (currency === 'CHF') { + return discountChf + } else { + // Convert CHF to EUR (1 CHF ≈ 1.03 EUR) + return discountChf * 1.03 + } } const calculatePrice = () => { @@ -484,7 +564,10 @@ export default function Drop() { const priceToUseEur = isWholesaleUnlocked ? pricePerGramEur * 0.76 : pricePerGramEur const priceEur = selectedSize * priceToUseEur // Convert to user's currency - return convertPrice(priceEur) + const price = convertPrice(priceEur) + // Apply points discount + const discount = calculateDiscountFromPoints() + return Math.max(0, price - discount) } const calculateStandardPrice = () => { @@ -587,6 +670,11 @@ export default function Drop() { } const progressPercentage = getProgressPercentage(drop.fill, drop.size) + // Calculate separate percentages for sales and pending + const salesFill = Number(drop.sales_fill) || 0 + const pendingFill = Number(drop.pending_fill) || 0 + const salesPercentage = getProgressPercentage(salesFill, drop.size) + const pendingPercentage = getProgressPercentage(pendingFill, drop.size) const availableSizes = getAvailableSizes() const timeUntilStart = getTimeUntilStart() const isUpcoming = drop.is_upcoming && timeUntilStart @@ -716,7 +804,24 @@ export default function Drop() { ) : ( <>
- +
+ {salesPercentage > 0 && ( + + )} + {pendingPercentage > 0 && ( + + )} +
{(() => { @@ -1001,6 +1106,62 @@ export default function Drop() { )}
+ {/* Referral Points Usage */} + {user && referralPoints > 0 && ( +
+ +
+ handlePointsToUseChange(e.target.value)} + style={{ + flex: 1, + padding: '12px', + background: 'var(--bg-soft)', + border: '1px solid var(--border)', + borderRadius: '8px', + fontSize: '14px', + color: 'var(--text)', + }} + placeholder="0" + /> + +
+ {pointsToUse > 0 && ( +
+ {t('drop.pointsDiscount')}: {(pointsToUse / pointsToChf).toFixed(2)} {currency === 'CHF' ? 'CHF' : 'EUR'} +
+ )} +
+ )} +
{t('drop.subtotal')}: - {calculatePrice().toFixed(2)} {currency} + {calculatePriceBeforeDiscount().toFixed(2)} {currency}
+ {pointsToUse > 0 && ( +
+ + ⭐ {t('drop.pointsDiscount')}: + + + -{calculateDiscountFromPoints().toFixed(2)} {currency} + +
+ )}
{t('drop.shippingFee')}: @@ -1039,6 +1210,11 @@ export default function Drop() { {loadingShippingFee ? '...' : ((calculatePrice() + (shippingFee || 40)).toFixed(2))} {currency}
+ {pointsToUse > 0 && ( +
+ {t('drop.pointsWillBeDeducted')} +
+ )}

{ + 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) => `${match}` + ) + + return processed + } return ( -

-
-

{t('infoBox.whyCheap')}

-

{t('infoBox.whyCheapText')}

+ <> +
+
+

{t('infoBox.whyCheap')}

+

{t('infoBox.whyCheapText')}

+
+
+

{t('infoBox.taxesLegal')}

+

{ + const target = e.target as HTMLElement + if (target.classList.contains('referral-link-clickable')) { + e.preventDefault() + setShowUnlockModal(true) + } + }} + /> +

+
+

{t('infoBox.dropModel')}

+

{t('infoBox.dropModelText')}

+
-
-

{t('infoBox.taxesLegal')}

-

{t('infoBox.taxesLegalText')}

-
-
-

{t('infoBox.dropModel')}

-

{t('infoBox.dropModelText')}

-
-
+ setShowUnlockModal(false)} /> + ) } diff --git a/app/components/Nav.tsx b/app/components/Nav.tsx index 5eae6aa..ad779b6 100644 --- a/app/components/Nav.tsx +++ b/app/components/Nav.tsx @@ -9,6 +9,7 @@ interface User { id: number username: string email: string + referral_points?: number } export default function Nav() { @@ -29,7 +30,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) @@ -85,6 +103,16 @@ export default function Nav() { {user.username} + {user.referral_points !== undefined && user.referral_points > 0 && ( + + ⭐ {user.referral_points.toFixed(0)} pts + + )} setMobileMenuOpen(false)} diff --git a/app/components/UnlockModal.tsx b/app/components/UnlockModal.tsx index 2d3e71e..d798e23 100644 --- a/app/components/UnlockModal.tsx +++ b/app/components/UnlockModal.tsx @@ -15,11 +15,19 @@ interface User { 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) { @@ -86,8 +94,35 @@ export default function UnlockModal({ isOpen, onClose }: UnlockModalProps) { 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 (
e.stopPropagation()} >
-

{t('unlockModal.title')}

+

{modalTitle}

diff --git a/cbd420.sql b/cbd420.sql index 826c5d8..7729bd5 100644 --- a/cbd420.sql +++ b/cbd420.sql @@ -3,7 +3,7 @@ -- https://www.phpmyadmin.net/ -- -- Host: localhost:3306 --- Generation Time: Dec 21, 2025 at 11:13 AM +-- Generation Time: Dec 28, 2025 at 01:36 AM -- Server version: 10.11.14-MariaDB-0+deb12u2 -- PHP Version: 8.2.29 @@ -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; @@ -82,6 +83,20 @@ CREATE TABLE `drops` ( -- -------------------------------------------------------- +-- +-- 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` -- @@ -108,12 +123,44 @@ 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 `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') 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 `referrals` -- @@ -137,6 +184,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; @@ -170,6 +220,14 @@ 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` -- @@ -189,6 +247,22 @@ ALTER TABLE `pending_orders` ADD KEY `idx_expires_at` (`expires_at`), ADD KEY `buyer_data_id` (`buyer_data_id`); +-- +-- 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 `referrals` -- @@ -234,12 +308,30 @@ 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 `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 `referrals` -- @@ -268,6 +360,12 @@ ALTER TABLE `buyer_data` 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` -- @@ -282,6 +380,14 @@ 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 `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 `referrals` -- @@ -296,6 +402,155 @@ ALTER TABLE `sales` ADD CONSTRAINT `sales_ibfk_1` FOREIGN KEY (`drop_id`) REFERENCES `drops` (`id`) ON DELETE CASCADE ON UPDATE CASCADE, ADD CONSTRAINT `sales_ibfk_2` FOREIGN KEY (`buyer_id`) REFERENCES `buyers` (`id`) ON DELETE CASCADE ON UPDATE CASCADE, ADD CONSTRAINT `sales_ibfk_3` FOREIGN KEY (`buyer_data_id`) REFERENCES `buyer_data` (`id`); + +-- +-- 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'); + +-- +-- Stored procedure to award referral points when a sale is completed +-- This procedure should be called after a sale is created +-- Parameters: sale_id - The ID of the sale that was just created +-- +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) + -- Note: This assumes price_amount is already in CHF, or convert if needed + 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$$ + +-- +-- Stored procedure to spend referral points for a purchase +-- This procedure deducts points from buyer's balance and records the transaction +-- Parameters: +-- p_buyer_id - The ID of the buyer spending points +-- p_points_to_spend - Amount of points to spend +-- p_pending_order_id - Optional: ID of pending order if spending for pending order +-- p_sale_id - Optional: ID of sale if spending for completed sale +-- Returns: 1 if successful, 0 if insufficient points +-- +DELIMITER $$ + +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; /*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; diff --git a/lib/auth.ts b/lib/auth.ts index 1aca963..909c8fe 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -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 { } 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 { 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) diff --git a/lib/translations/de.json b/lib/translations/de.json index a679a45..1655150 100644 --- a/lib/translations/de.json +++ b/lib/translations/de.json @@ -125,13 +125,18 @@ "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" + "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. 10 CHF/g. Durch kollektive Sammelbestellungen kaufen wir wie Grosshändler ein – ohne Zwischenstufen.", + "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 420deals.ch und erhalte 10 % vom Umsatz deiner Empfehlungen als Punkte — für immer. Nutze deine Punkte für kommende Drops oder tausche sie gegen Krypto.", + "taxesLegalText": "Teile deinen Referral-Link und verdiene dauerhaft 10 % des Umsatzes deiner Empfehlungen 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." }, @@ -184,9 +189,11 @@ }, "unlockModal": { "title": "Großhandelspreise 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 Großhandelspreise 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!", diff --git a/lib/translations/en.json b/lib/translations/en.json index 5220c9a..fed5394 100644 --- a/lib/translations/en.json +++ b/lib/translations/en.json @@ -122,13 +122,18 @@ "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" + "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 10 CHF/g. Through collective bulk orders, we buy like wholesalers – without intermediaries.", + "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 420deals.ch and earn 10% of your referrals' revenue as points — forever. Use your points for upcoming drops or swap them to crypto.", + "taxesLegalText": "Share your referral link and earn a lifetime 10% of your referrals' revenue 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." }, @@ -181,9 +186,11 @@ }, "unlockModal": { "title": "Unlock Wholesale Prices", + "innerCircleTitle": "Unlock Inner chat circle", "referralsCompleted": "{count} of {needed} referrals completed", "inviteFriends": "Invite {needed} friends to sign up.", "unlockForever": "Once they do, wholesale prices unlock forever.", + "innerCircleUnlockForever": "Once they do, Inner chat circle unlocks forever.", "yourReferralLink": "Your referral link", "copyLink": "Copy Link", "copied": "Copied!", diff --git a/referral_points_migration.sql b/referral_points_migration.sql new file mode 100644 index 0000000..6cbd0a4 --- /dev/null +++ b/referral_points_migration.sql @@ -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; +