rc
This commit is contained in:
216
app/api/referral-points/redeem/route.ts
Normal file
216
app/api/referral-points/redeem/route.ts
Normal file
@@ -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<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
|
||||
}
|
||||
|
||||
return mockRates[crypto.toLowerCase()] || null
|
||||
} catch (error) {
|
||||
console.error('Error fetching exchange rate:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import AuthModal from './AuthModal'
|
||||
import RedeemPointsModal from './RedeemPointsModal'
|
||||
import LanguageSwitcher from './LanguageSwitcher'
|
||||
import { useI18n } from '@/lib/i18n'
|
||||
|
||||
@@ -16,6 +17,7 @@ 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)
|
||||
|
||||
@@ -73,6 +75,23 @@ 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>
|
||||
@@ -104,6 +123,7 @@ export default function Nav() {
|
||||
{user.username}
|
||||
</span>
|
||||
{user.referral_points !== undefined && user.referral_points > 0 && (
|
||||
<>
|
||||
<span style={{
|
||||
color: '#0a7931',
|
||||
fontSize: '14px',
|
||||
@@ -112,6 +132,29 @@ export default function Nav() {
|
||||
}}>
|
||||
⭐ {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,
|
||||
}}
|
||||
>
|
||||
Redeem
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<a
|
||||
href="/orders"
|
||||
@@ -189,6 +232,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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
411
app/components/RedeemPointsModal.tsx
Normal file
411
app/components/RedeemPointsModal.tsx
Normal file
@@ -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<string>('')
|
||||
const [cryptoCurrency, setCryptoCurrency] = useState<string>('btc')
|
||||
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('')
|
||||
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<string, number> = {
|
||||
'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 (
|
||||
<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)} {redemptionDetails.crypto_currency.toUpperCase()}</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' }}>
|
||||
⭐ {currentPoints.toFixed(2)} {t('redeemPoints.points')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: 500 }}>
|
||||
{t('redeemPoints.selectCrypto')} *
|
||||
</label>
|
||||
<select
|
||||
value={cryptoCurrency}
|
||||
onChange={(e) => setCryptoCurrency(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
background: 'var(--bg-soft)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
color: 'var(--text)',
|
||||
marginBottom: '16px',
|
||||
}}
|
||||
required
|
||||
>
|
||||
{ALLOWED_PAYMENT_CURRENCIES.map((currency) => (
|
||||
<option key={currency} value={currency}>
|
||||
{currency.toUpperCase()}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<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>
|
||||
<input
|
||||
type="number"
|
||||
value={pointsToRedeem}
|
||||
onChange={(e) => 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 && (
|
||||
<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)} {cryptoCurrency.toUpperCase()}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user