From d138dae2ca3b928248c49e499e868804a62cf123 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 31 Dec 2025 08:19:59 +0000 Subject: [PATCH] rc --- app/api/referral-points/redeem/route.ts | 216 +++++++++++++ app/api/referral-points/route.ts | 8 + app/components/Drop.tsx | 3 + app/components/Nav.tsx | 68 +++- app/components/RedeemPointsModal.tsx | 411 ++++++++++++++++++++++++ lib/translations/de.json | 22 ++ lib/translations/en.json | 22 ++ migrations/add_point_redemptions.sql | 43 +++ 8 files changed, 785 insertions(+), 8 deletions(-) create mode 100644 app/api/referral-points/redeem/route.ts create mode 100644 app/components/RedeemPointsModal.tsx create mode 100644 migrations/add_point_redemptions.sql diff --git a/app/api/referral-points/redeem/route.ts b/app/api/referral-points/redeem/route.ts new file mode 100644 index 0000000..ad10504 --- /dev/null +++ b/app/api/referral-points/redeem/route.ts @@ -0,0 +1,216 @@ +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 + if (!isAllowedCurrency(normalizedCryptoCurrency)) { + return NextResponse.json( + { error: `Unsupported cryptocurrency. Allowed: ${ALLOWED_PAYMENT_CURRENCIES.join(', ')}` }, + { 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 { + 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 = { + 'btc': 85000, + 'eth': 2500, + 'sol': 100, + 'xrp': 0.6, + 'bnbbsc': 300, + 'usdterc20': 0.9, // Approximate CHF per USDT + } + + return mockRates[crypto.toLowerCase()] || null + } catch (error) { + console.error('Error fetching exchange rate:', error) + return null + } +} + diff --git a/app/api/referral-points/route.ts b/app/api/referral-points/route.ts index 9c29018..c21b9ac 100644 --- a/app/api/referral-points/route.ts +++ b/app/api/referral-points/route.ts @@ -44,6 +44,12 @@ export async function GET(request: NextRequest) { const pointsPerChf = parseFloat( settings.find(s => s.setting_key === 'points_per_chf')?.setting_value || '10' ) + 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 const maxDiscountChf = referralPoints / pointsToChf @@ -52,6 +58,8 @@ export async function GET(request: NextRequest) { referral_points: referralPoints, points_to_chf: pointsToChf, points_per_chf: pointsPerChf, + points_to_crypto_chf: pointsToCryptoChf, + min_redemption_points: minRedemptionPoints, max_discount_chf: maxDiscountChf, }) } catch (error) { diff --git a/app/components/Drop.tsx b/app/components/Drop.tsx index 71f1e6a..a6deba6 100644 --- a/app/components/Drop.tsx +++ b/app/components/Drop.tsx @@ -960,6 +960,8 @@ export default function Drop() { padding: '32px', maxWidth: '500px', width: '100%', + maxHeight: '90vh', + overflowY: 'auto', boxShadow: '0 20px 60px rgba(0, 0, 0, 0.3)', }} onClick={(e) => e.stopPropagation()} @@ -1241,6 +1243,7 @@ export default function Drop() { display: 'flex', gap: '12px', justifyContent: 'flex-end', + marginTop: '24px', }} > + )} setShowAuthModal(false)} onLogin={handleLogin} /> + + {user && user.referral_points !== undefined && ( + setShowRedeemModal(false)} + currentPoints={user.referral_points} + onRedeemSuccess={handleRedeemSuccess} + /> + )} ) } diff --git a/app/components/RedeemPointsModal.tsx b/app/components/RedeemPointsModal.tsx new file mode 100644 index 0000000..e70ea46 --- /dev/null +++ b/app/components/RedeemPointsModal.tsx @@ -0,0 +1,411 @@ +'use client' + +import { useState, useEffect } from 'react' +import { useI18n } from '@/lib/i18n' +import { ALLOWED_PAYMENT_CURRENCIES } from '@/lib/payment-currencies' + +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('') + const [cryptoCurrency, setCryptoCurrency] = useState('btc') + const [walletAddress, setWalletAddress] = useState('') + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + const [success, setSuccess] = useState(false) + const [redemptionDetails, setRedemptionDetails] = useState(null) + const [pointsToCryptoChf, setPointsToCryptoChf] = useState(100) + const [minRedemptionPoints, setMinRedemptionPoints] = useState(1000) + const [estimatedCrypto, setEstimatedCrypto] = useState(0) + + useEffect(() => { + if (isOpen) { + fetchRedemptionSettings() + // Reset form + setPointsToRedeem('') + setWalletAddress('') + setCryptoCurrency('btc') + 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 or currency changes + if (pointsToRedeem && !isNaN(parseFloat(pointsToRedeem))) { + const points = parseFloat(pointsToRedeem) + const chfValue = points / pointsToCryptoChf + // Mock exchange rates (should match API) + const mockRates: Record = { + 'btc': 85000, + 'eth': 2500, + 'sol': 100, + 'xrp': 0.6, + 'bnbbsc': 300, + 'usdterc20': 0.9, + } + const rate = mockRates[cryptoCurrency.toLowerCase()] || 1 + setEstimatedCrypto(chfValue / rate) + } else { + setEstimatedCrypto(0) + } + }, [pointsToRedeem, cryptoCurrency, 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 ( +
+
e.stopPropagation()} + > +
+

+ {t('redeemPoints.title')} +

+ +
+ + {success ? ( +
+
+ ✓ {t('redeemPoints.success')} +
+ {redemptionDetails && ( +
+

{t('redeemPoints.redemptionId')}: #{redemptionDetails.redemption_id}

+

{t('redeemPoints.pointsRedeemed')}: {redemptionDetails.points_redeemed.toFixed(2)}

+

{t('redeemPoints.cryptoAmount')}: {redemptionDetails.crypto_amount.toFixed(8)} {redemptionDetails.crypto_currency.toUpperCase()}

+

{t('redeemPoints.newBalance')}: {redemptionDetails.new_balance.toFixed(2)} {t('redeemPoints.points')}

+

+ {redemptionDetails.message} +

+
+ )} + +
+ ) : ( +
+
+
+
+ {t('redeemPoints.currentBalance')} +
+
+ ⭐ {currentPoints.toFixed(2)} {t('redeemPoints.points')} +
+
+ + + + + + 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 + /> + + + setPointsToRedeem(e.target.value)} + min={minRedemptionPoints} + max={currentPoints} + step="1" + placeholder={minRedemptionPoints.toString()} + style={{ + width: '100%', + padding: '12px', + background: 'var(--bg-soft)', + border: '1px solid var(--border)', + borderRadius: '8px', + fontSize: '14px', + color: 'var(--text)', + marginBottom: '16px', + }} + required + /> + + {pointsNum > 0 && ( +
+
+ {t('redeemPoints.estimatedValue')}: +
+
+ {chfValue.toFixed(2)} CHF ≈ {estimatedCrypto.toFixed(8)} {cryptoCurrency.toUpperCase()} +
+
+ )} + + {error && ( +
+ {error} +
+ )} +
+ +
+ + +
+
+ )} +
+
+ ) +} + diff --git a/lib/translations/de.json b/lib/translations/de.json index 1655150..8729bcf 100644 --- a/lib/translations/de.json +++ b/lib/translations/de.json @@ -210,6 +210,28 @@ }, "payment": { "cancelled": "Zahlung wurde abgebrochen." + }, + "redeemPoints": { + "title": "Punkte gegen Krypto einlösen", + "currentBalance": "Aktueller Kontostand", + "points": "Punkte", + "selectCrypto": "Kryptowährung auswählen", + "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" } } diff --git a/lib/translations/en.json b/lib/translations/en.json index fed5394..bb958e9 100644 --- a/lib/translations/en.json +++ b/lib/translations/en.json @@ -207,6 +207,28 @@ }, "payment": { "cancelled": "Payment was cancelled." + }, + "redeemPoints": { + "title": "Redeem Points to Crypto", + "currentBalance": "Current Balance", + "points": "points", + "selectCrypto": "Select 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" } } diff --git a/migrations/add_point_redemptions.sql b/migrations/add_point_redemptions.sql new file mode 100644 index 0000000..635f506 --- /dev/null +++ b/migrations/add_point_redemptions.sql @@ -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; +