This commit is contained in:
root
2026-01-03 06:06:54 +00:00
parent d138dae2ca
commit eeaa9a66bb
16 changed files with 728 additions and 348 deletions

View File

@@ -6,10 +6,15 @@ import { useRouter } from 'next/navigation'
interface Drop {
id: number
item: string
description?: string | null
size: number
fill: number
unit: string
ppu: number
price_chf?: number | null
price_eur?: number | null
wholesale_price_chf?: number | null
wholesale_price_eur?: number | null
image_url: string | null
images?: string[]
created_at: string
@@ -42,9 +47,14 @@ export default function DropsManagementPage() {
const [selectedDropId, setSelectedDropId] = useState<number | null>(null)
const [formData, setFormData] = useState({
item: '',
description: '',
size: '',
unit: 'g',
ppu: '',
priceChf: '',
priceEur: '',
wholesalePriceChf: '',
wholesalePriceEur: '',
imageUrl: '',
startTime: '',
})
@@ -88,9 +98,14 @@ export default function DropsManagementPage() {
setEditingDrop(drop)
setFormData({
item: drop.item,
description: drop.description || '',
size: drop.size.toString(),
unit: drop.unit,
ppu: drop.ppu.toString(),
priceChf: drop.price_chf?.toString() || '',
priceEur: drop.price_eur?.toString() || '',
wholesalePriceChf: drop.wholesale_price_chf?.toString() || '',
wholesalePriceEur: drop.wholesale_price_eur?.toString() || '',
imageUrl: drop.image_url || '',
startTime: drop.start_time ? new Date(drop.start_time).toISOString().slice(0, 16) : '',
})
@@ -157,9 +172,14 @@ export default function DropsManagementPage() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
item: formData.item,
description: formData.description || null,
size: parseInt(formData.size),
unit: formData.unit,
ppu: parseInt(formData.ppu),
priceChf: formData.priceChf ? parseFloat(formData.priceChf) : null,
priceEur: formData.priceEur ? parseFloat(formData.priceEur) : null,
wholesalePriceChf: formData.wholesalePriceChf ? parseFloat(formData.wholesalePriceChf) : null,
wholesalePriceEur: formData.wholesalePriceEur ? parseFloat(formData.wholesalePriceEur) : null,
imageUrl: finalImageUrls[0] || null, // Keep first image for legacy support
startTime: formData.startTime || null,
}),
@@ -312,9 +332,14 @@ export default function DropsManagementPage() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
item: formData.item,
description: formData.description || null,
size: parseInt(formData.size),
unit: formData.unit,
ppu: parseInt(formData.ppu),
priceChf: formData.priceChf ? parseFloat(formData.priceChf) : null,
priceEur: formData.priceEur ? parseFloat(formData.priceEur) : null,
wholesalePriceChf: formData.wholesalePriceChf ? parseFloat(formData.wholesalePriceChf) : null,
wholesalePriceEur: formData.wholesalePriceEur ? parseFloat(formData.wholesalePriceEur) : null,
imageUrl: finalImageUrls[0] || null, // Keep first image for legacy support
startTime: formData.startTime || null,
}),
@@ -350,9 +375,14 @@ export default function DropsManagementPage() {
alert('Drop created successfully')
setFormData({
item: '',
description: '',
size: '',
unit: 'g',
ppu: '',
priceChf: '',
priceEur: '',
wholesalePriceChf: '',
wholesalePriceEur: '',
imageUrl: '',
startTime: '',
})
@@ -434,9 +464,14 @@ export default function DropsManagementPage() {
// Reset form when canceling
setFormData({
item: '',
description: '',
size: '',
unit: 'g',
ppu: '',
priceChf: '',
priceEur: '',
wholesalePriceChf: '',
wholesalePriceEur: '',
imageUrl: '',
startTime: '',
})
@@ -492,6 +527,25 @@ export default function DropsManagementPage() {
}}
/>
</div>
<div style={{ gridColumn: '1 / -1' }}>
<label style={{ display: 'block', marginBottom: '8px' }}>Description</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={4}
placeholder="Enter a detailed description of the drop..."
style={{
width: '100%',
padding: '8px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--bg-soft)',
color: 'var(--text)',
fontFamily: 'inherit',
resize: 'vertical'
}}
/>
</div>
<div>
<label style={{ display: 'block', marginBottom: '8px' }}>Size *</label>
<input
@@ -548,6 +602,86 @@ export default function DropsManagementPage() {
}}
/>
</div>
<div>
<label style={{ display: 'block', marginBottom: '8px' }}>Regular Price CHF (per gram) *</label>
<input
type="number"
step="0.0001"
value={formData.priceChf}
onChange={(e) => setFormData({ ...formData, priceChf: e.target.value })}
required
min="0"
placeholder="0.0250"
style={{
width: '100%',
padding: '8px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--bg-soft)',
color: 'var(--text)'
}}
/>
</div>
<div>
<label style={{ display: 'block', marginBottom: '8px' }}>Regular Price EUR (per gram) *</label>
<input
type="number"
step="0.0001"
value={formData.priceEur}
onChange={(e) => setFormData({ ...formData, priceEur: e.target.value })}
required
min="0"
placeholder="0.0250"
style={{
width: '100%',
padding: '8px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--bg-soft)',
color: 'var(--text)'
}}
/>
</div>
<div>
<label style={{ display: 'block', marginBottom: '8px' }}>Wholesale Price CHF (per gram) *</label>
<input
type="number"
step="0.0001"
value={formData.wholesalePriceChf}
onChange={(e) => setFormData({ ...formData, wholesalePriceChf: e.target.value })}
required
min="0"
placeholder="0.0190"
style={{
width: '100%',
padding: '8px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--bg-soft)',
color: 'var(--text)'
}}
/>
</div>
<div>
<label style={{ display: 'block', marginBottom: '8px' }}>Wholesale Price EUR (per gram) *</label>
<input
type="number"
step="0.0001"
value={formData.wholesalePriceEur}
onChange={(e) => setFormData({ ...formData, wholesalePriceEur: e.target.value })}
required
min="0"
placeholder="0.0190"
style={{
width: '100%',
padding: '8px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--bg-soft)',
color: 'var(--text)'
}}
/>
</div>
<div style={{ gridColumn: '1 / -1' }}>
<label style={{ display: 'block', marginBottom: '8px' }}>
Product Images (up to 4)
@@ -732,6 +866,25 @@ export default function DropsManagementPage() {
}}
/>
</div>
<div style={{ gridColumn: '1 / -1' }}>
<label style={{ display: 'block', marginBottom: '8px' }}>Description</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={4}
placeholder="Enter a detailed description of the drop..."
style={{
width: '100%',
padding: '8px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--bg-soft)',
color: 'var(--text)',
fontFamily: 'inherit',
resize: 'vertical'
}}
/>
</div>
<div>
<label style={{ display: 'block', marginBottom: '8px' }}>Size</label>
<input
@@ -781,6 +934,82 @@ export default function DropsManagementPage() {
}}
/>
</div>
<div>
<label style={{ display: 'block', marginBottom: '8px' }}>Regular Price CHF (per gram)</label>
<input
type="number"
step="0.0001"
value={formData.priceChf}
onChange={(e) => setFormData({ ...formData, priceChf: e.target.value })}
min="0"
placeholder="0.0250"
style={{
width: '100%',
padding: '8px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--bg-soft)',
color: 'var(--text)'
}}
/>
</div>
<div>
<label style={{ display: 'block', marginBottom: '8px' }}>Regular Price EUR (per gram)</label>
<input
type="number"
step="0.0001"
value={formData.priceEur}
onChange={(e) => setFormData({ ...formData, priceEur: e.target.value })}
min="0"
placeholder="0.0250"
style={{
width: '100%',
padding: '8px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--bg-soft)',
color: 'var(--text)'
}}
/>
</div>
<div>
<label style={{ display: 'block', marginBottom: '8px' }}>Wholesale Price CHF (per gram)</label>
<input
type="number"
step="0.0001"
value={formData.wholesalePriceChf}
onChange={(e) => setFormData({ ...formData, wholesalePriceChf: e.target.value })}
min="0"
placeholder="0.0190"
style={{
width: '100%',
padding: '8px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--bg-soft)',
color: 'var(--text)'
}}
/>
</div>
<div>
<label style={{ display: 'block', marginBottom: '8px' }}>Wholesale Price EUR (per gram)</label>
<input
type="number"
step="0.0001"
value={formData.wholesalePriceEur}
onChange={(e) => setFormData({ ...formData, wholesalePriceEur: e.target.value })}
min="0"
placeholder="0.0190"
style={{
width: '100%',
padding: '8px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--bg-soft)',
color: 'var(--text)'
}}
/>
</div>
<div>
<label style={{ display: 'block', marginBottom: '8px' }}>Start Time</label>
<input

View File

@@ -71,7 +71,7 @@ export async function PUT(
}
const body = await request.json()
const { item, size, unit, ppu, imageUrl, startTime } = body
const { item, description, size, unit, ppu, priceChf, priceEur, wholesalePriceChf, wholesalePriceEur, imageUrl, startTime } = body
// Check if drop exists
const [existingRows] = await pool.execute(
@@ -95,6 +95,11 @@ export async function PUT(
values.push(item)
}
if (description !== undefined) {
updates.push('description = ?')
values.push(description || null)
}
if (size !== undefined) {
if (size <= 0) {
return NextResponse.json(
@@ -122,6 +127,50 @@ export async function PUT(
values.push(ppu)
}
if (priceChf !== undefined) {
if (priceChf !== null && priceChf < 0) {
return NextResponse.json(
{ error: 'Price CHF must be greater than or equal to 0' },
{ status: 400 }
)
}
updates.push('price_chf = ?')
values.push(priceChf !== null && priceChf !== '' ? priceChf : null)
}
if (priceEur !== undefined) {
if (priceEur !== null && priceEur < 0) {
return NextResponse.json(
{ error: 'Price EUR must be greater than or equal to 0' },
{ status: 400 }
)
}
updates.push('price_eur = ?')
values.push(priceEur !== null && priceEur !== '' ? priceEur : null)
}
if (wholesalePriceChf !== undefined) {
if (wholesalePriceChf !== null && wholesalePriceChf < 0) {
return NextResponse.json(
{ error: 'Wholesale price CHF must be greater than or equal to 0' },
{ status: 400 }
)
}
updates.push('wholesale_price_chf = ?')
values.push(wholesalePriceChf !== null && wholesalePriceChf !== '' ? wholesalePriceChf : null)
}
if (wholesalePriceEur !== undefined) {
if (wholesalePriceEur !== null && wholesalePriceEur < 0) {
return NextResponse.json(
{ error: 'Wholesale price EUR must be greater than or equal to 0' },
{ status: 400 }
)
}
updates.push('wholesale_price_eur = ?')
values.push(wholesalePriceEur !== null && wholesalePriceEur !== '' ? wholesalePriceEur : null)
}
if (imageUrl !== undefined) {
updates.push('image_url = ?')
values.push(imageUrl || null)

View File

@@ -56,7 +56,7 @@ export async function GET(request: NextRequest) {
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { item, size, unit = 'g', ppu, imageUrl, startTime } = body
const { item, description, size, unit = 'g', ppu, priceChf, priceEur, wholesalePriceChf, wholesalePriceEur, imageUrl, startTime } = body
// Validate required fields
if (!item || !size || !ppu) {
@@ -71,8 +71,8 @@ export async function POST(request: NextRequest) {
// ALTER TABLE drops ADD COLUMN image_url VARCHAR(255) DEFAULT NULL AFTER unit;
// Note: fill is no longer stored, it's calculated from sales
const [result] = await pool.execute(
'INSERT INTO drops (item, size, unit, ppu, image_url, start_time) VALUES (?, ?, ?, ?, ?, ?)',
[item, size, unit, ppu, imageUrl || null, startTime || null]
'INSERT INTO drops (item, description, size, unit, ppu, price_chf, price_eur, wholesale_price_chf, wholesale_price_eur, image_url, start_time) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[item, description || null, size, unit, ppu, priceChf || null, priceEur || null, wholesalePriceChf || null, wholesalePriceEur || null, imageUrl || null, startTime || null]
)
const insertId = (result as any).insertId

View File

@@ -192,23 +192,35 @@ export async function POST(request: NextRequest) {
)
}
// Get points_to_chf setting
// Get points_to_eur setting (fallback to points_to_chf for backward compatibility)
const [settingsRows] = await connection.execute(
'SELECT setting_value FROM referral_settings WHERE setting_key = ?',
['points_to_chf']
'SELECT setting_key, setting_value FROM referral_settings WHERE setting_key IN (?, ?)',
['points_to_eur', '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
let pointsToEur = parseFloat(settings.find(s => s.setting_key === 'points_to_eur')?.setting_value || '0')
// Convert discount based on user's currency
// If points_to_eur not found, use points_to_chf and convert
if (pointsToEur === 0) {
const pointsToChf = parseFloat(settings.find(s => s.setting_key === 'points_to_chf')?.setting_value || '100')
// Convert CHF-based points to EUR-based (1 CHF ≈ 1.0309 EUR)
pointsToEur = pointsToChf / 1.030927835
}
if (pointsToEur === 0) {
pointsToEur = 100 // Default fallback
}
// Calculate discount in EUR first (universal base currency)
const discountEur = pointsToUse / pointsToEur
// Convert discount to user's currency
if (currency === 'CHF') {
pointsDiscount = discountChf
// Convert EUR to CHF (1 EUR = 0.97 CHF)
pointsDiscount = discountEur * 0.97
} else {
// Convert CHF to EUR (1 CHF ≈ 1.03 EUR)
pointsDiscount = discountChf * 1.03
// Already in EUR
pointsDiscount = discountEur
}
// Don't allow discount to exceed the product price (before shipping)

View File

@@ -43,10 +43,10 @@ export async function POST(request: NextRequest) {
)
}
// Validate crypto currency
if (!isAllowedCurrency(normalizedCryptoCurrency)) {
// Validate crypto currency - only USDT (SOL) is allowed for redemption
if (normalizedCryptoCurrency !== 'usdtsol') {
return NextResponse.json(
{ error: `Unsupported cryptocurrency. Allowed: ${ALLOWED_PAYMENT_CURRENCIES.join(', ')}` },
{ error: 'Only USDT (SOL) is supported for point redemption' },
{ status: 400 }
)
}
@@ -205,6 +205,7 @@ async function getCryptoExchangeRate(crypto: string, fiat: string): Promise<numb
'xrp': 0.6,
'bnbbsc': 300,
'usdterc20': 0.9, // Approximate CHF per USDT
'usdtsol': 0.9, // Approximate CHF per USDT on Solana
}
return mockRates[crypto.toLowerCase()] || null

View File

@@ -38,12 +38,31 @@ export async function GET(request: NextRequest) {
)
const settings = settingsRows as any[]
// Get EUR-based settings (preferred)
let pointsToEur = parseFloat(
settings.find(s => s.setting_key === 'points_to_eur')?.setting_value || '0'
)
let pointsPerEur = parseFloat(
settings.find(s => s.setting_key === 'points_per_eur')?.setting_value || '0'
)
// Get CHF-based settings (for backward compatibility)
const pointsToChf = parseFloat(
settings.find(s => s.setting_key === 'points_to_chf')?.setting_value || '100'
)
const pointsPerChf = parseFloat(
settings.find(s => s.setting_key === 'points_per_chf')?.setting_value || '10'
)
// If EUR settings not found, convert from CHF (1 CHF ≈ 1.0309 EUR)
const chfToEurRate = 1.030927835
if (pointsToEur === 0) {
pointsToEur = pointsToChf / chfToEurRate
}
if (pointsPerEur === 0) {
pointsPerEur = pointsPerChf * chfToEurRate
}
const pointsToCryptoChf = parseFloat(
settings.find(s => s.setting_key === 'points_to_crypto_chf')?.setting_value || '100'
)
@@ -51,15 +70,19 @@ export async function GET(request: NextRequest) {
settings.find(s => s.setting_key === 'min_redemption_points')?.setting_value || '1000'
)
// Calculate maximum discount available
const maxDiscountChf = referralPoints / pointsToChf
// Calculate maximum discount available (in EUR, then convert to CHF for display)
const maxDiscountEur = referralPoints / pointsToEur
const maxDiscountChf = maxDiscountEur * 0.97 // Convert EUR to CHF
return NextResponse.json({
referral_points: referralPoints,
points_to_chf: pointsToChf,
points_per_chf: pointsPerChf,
points_to_eur: pointsToEur,
points_per_eur: pointsPerEur,
points_to_chf: pointsToChf, // Keep for backward compatibility
points_per_chf: pointsPerChf, // Keep for backward compatibility
points_to_crypto_chf: pointsToCryptoChf,
min_redemption_points: minRedemptionPoints,
max_discount_eur: maxDiscountEur,
max_discount_chf: maxDiscountChf,
})
} catch (error) {

View File

@@ -9,10 +9,15 @@ import { useI18n } from '@/lib/i18n'
interface DropData {
id: number
item: string
description?: string | null
size: number
fill: number
unit: string
ppu: number
price_chf?: number | null
price_eur?: number | null
wholesale_price_chf?: number | null
wholesale_price_eur?: number | null
image_url: string | null
images?: string[] // Array of image URLs (up to 4)
created_at: string
@@ -30,7 +35,7 @@ interface User {
}
export default function Drop() {
const { t } = useI18n()
const { t, language } = useI18n()
const [drop, setDrop] = useState<DropData | null>(null)
const [loading, setLoading] = useState(true)
const [selectedSize, setSelectedSize] = useState(50)
@@ -57,9 +62,11 @@ export default function Drop() {
const [selectedImageIndex, setSelectedImageIndex] = useState(0)
const [shippingFee, setShippingFee] = useState<number | null>(null)
const [loadingShippingFee, setLoadingShippingFee] = useState(false)
const [currency, setCurrency] = useState<'CHF' | 'EUR'>('EUR') // Default to EUR
// Currency is based on language: English (en) → EUR, German (de) → CHF
const currency: 'CHF' | 'EUR' = language === 'de' ? 'CHF' : 'EUR'
const [referralPoints, setReferralPoints] = useState<number>(0)
const [pointsToChf, setPointsToChf] = useState<number>(100)
const [pointsToEur, setPointsToEur] = useState<number>(100)
const [pointsToChf, setPointsToChf] = useState<number>(100) // Keep for backward compatibility
const [pointsToUse, setPointsToUse] = useState<number>(0)
const [loadingPoints, setLoadingPoints] = useState(false)
@@ -117,7 +124,14 @@ export default function Drop() {
if (response.ok) {
const data = await response.json()
setReferralPoints(data.referral_points || 0)
setPointsToChf(data.points_to_chf || 100)
// Use EUR-based setting (preferred), fallback to CHF converted to EUR
if (data.points_to_eur) {
setPointsToEur(data.points_to_eur)
} else {
// Convert CHF to EUR (1 CHF ≈ 1.0309 EUR)
setPointsToEur((data.points_to_chf || 100) / 1.030927835)
}
setPointsToChf(data.points_to_chf || 100) // Keep for backward compatibility
}
} catch (error) {
console.error('Error fetching referral points:', error)
@@ -241,10 +255,9 @@ export default function Drop() {
if (!drop) return 0
// Minimum price is 5 in user's currency
// Calculate minimum grams needed for 5 (EUR or CHF)
const pricePerGramEur = drop.ppu / 1000
const minPriceEur = currency === 'CHF' ? 5 / 0.97 : 5 // Convert min CHF to EUR equivalent
// Use the higher price (standard) to ensure minimum is met
return Math.ceil(minPriceEur / pricePerGramEur)
const pricePerGram = getPricePerGram()
const minPrice = 5 // 5 in user's currency
return Math.ceil(minPrice / pricePerGram)
}
const handleCustomQuantityChange = (value: string) => {
@@ -367,30 +380,46 @@ export default function Drop() {
if (response.ok) {
const data = await response.json()
setShippingFee(data.shipping_fee || 40)
setCurrency(data.currency || 'EUR')
// Currency is now based on language, not geolocation
} else {
// Default to 40 EUR if fetch fails
setShippingFee(40)
setCurrency('EUR')
}
} catch (error) {
console.error('Error fetching shipping fee:', error)
// Default to 40 EUR on error
setShippingFee(40)
setCurrency('EUR')
} finally {
setLoadingShippingFee(false)
}
}
// Convert EUR price to user's currency (CHF for Swiss, EUR for others)
// Database stores prices in EUR, so we need to convert if user is Swiss
const convertPrice = (priceInEur: number): number => {
if (currency === 'CHF') {
// Convert EUR to CHF (1 EUR ≈ 0.97 CHF)
return priceInEur * 0.97
// Get price per gram based on user's currency and wholesale status
const getPricePerGramFromDrop = (): number => {
if (!drop) return 0
// Use new price fields if available, otherwise fall back to ppu calculation
if (isWholesaleUnlocked) {
// Wholesale price
if (currency === 'CHF' && drop.wholesale_price_chf != null) {
return Number(drop.wholesale_price_chf) || 0
} else if (currency === 'EUR' && drop.wholesale_price_eur != null) {
return Number(drop.wholesale_price_eur) || 0
}
// Fallback to ppu calculation if new fields not set
const pricePerGramEur = Number(drop.ppu) / 1000
return currency === 'CHF' ? pricePerGramEur * 0.97 : pricePerGramEur
} else {
// Regular price
if (currency === 'CHF' && drop.price_chf != null) {
return Number(drop.price_chf) || 0
} else if (currency === 'EUR' && drop.price_eur != null) {
return Number(drop.price_eur) || 0
}
// Fallback to ppu calculation if new fields not set
const pricePerGramEur = Number(drop.ppu) / 1000
return currency === 'CHF' ? pricePerGramEur * 0.97 : pricePerGramEur
}
return priceInEur
}
const handleJoinDrop = () => {
@@ -470,6 +499,7 @@ export default function Drop() {
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
currency: currency, // Display currency based on language (EUR for en, CHF for de)
}),
})
@@ -526,78 +556,107 @@ export default function Drop() {
const handlePointsToUseChange = (value: string) => {
const numValue = parseFloat(value) || 0
const maxPoints = Math.min(referralPoints, calculatePriceBeforeDiscount() * pointsToChf)
// Calculate max points based on EUR price (universal base)
const priceEur = currency === 'CHF' ? calculatePriceBeforeDiscount() / 0.97 : calculatePriceBeforeDiscount()
const maxPoints = Math.min(referralPoints, priceEur * pointsToEur)
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 pricePerGram = getPricePerGramFromDrop()
return selectedSize * pricePerGram
}
const getMaxDiscountFromPoints = () => {
if (pointsToChf === 0) return 0
return referralPoints / pointsToChf
if (pointsToEur === 0) return 0
// Calculate discount in EUR (universal base)
const discountEur = referralPoints / pointsToEur
// Convert to user's currency for display
if (currency === 'CHF') {
return discountEur * 0.97 // Convert EUR to CHF
} else {
return discountEur // Already in EUR
}
}
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 (pointsToUse === 0 || pointsToEur === 0) return 0
// Calculate discount in EUR first (universal base)
const discountEur = pointsToUse / pointsToEur
// Convert to user's currency
if (currency === 'CHF') {
return discountChf
return discountEur * 0.97 // Convert EUR to CHF
} else {
// Convert CHF to EUR (1 CHF ≈ 1.03 EUR)
return discountChf * 1.03
return discountEur // Already in EUR
}
}
const calculatePrice = () => {
if (!drop) return 0
// ppu is stored as integer where 1000 = 1.00 EUR, so divide by 1000 to get actual price in EUR
// Assuming ppu is per gram
const pricePerGramEur = drop.ppu / 1000
const priceToUseEur = isWholesaleUnlocked ? pricePerGramEur * 0.76 : pricePerGramEur
const priceEur = selectedSize * priceToUseEur
// Convert to user's currency
const price = convertPrice(priceEur)
const pricePerGram = getPricePerGramFromDrop()
const price = selectedSize * pricePerGram
// Apply points discount
const discount = calculateDiscountFromPoints()
return Math.max(0, price - discount)
}
const calculateStandardPrice = () => {
const calculateStandardPrice = (): number => {
if (!drop) return 0
const pricePerGramEur = drop.ppu / 1000
const priceEur = selectedSize * pricePerGramEur
// Convert to user's currency
return convertPrice(priceEur)
// Get regular price (not wholesale)
let pricePerGram: number
if (currency === 'CHF' && drop.price_chf != null) {
pricePerGram = Number(drop.price_chf) || 0
} else if (currency === 'EUR' && drop.price_eur != null) {
pricePerGram = Number(drop.price_eur) || 0
} else {
// Fallback to ppu calculation
const pricePerGramEur = Number(drop.ppu) / 1000
pricePerGram = pricePerGramEur * (currency === 'CHF' ? 0.97 : 1)
}
return selectedSize * pricePerGram
}
const calculateWholesalePrice = () => {
const calculateWholesalePrice = (): number => {
if (!drop) return 0
const pricePerGramEur = drop.ppu / 1000
const priceEur = selectedSize * pricePerGramEur * 0.76
// Convert to user's currency
return convertPrice(priceEur)
// Get wholesale price
let pricePerGram: number
if (currency === 'CHF' && drop.wholesale_price_chf != null) {
pricePerGram = Number(drop.wholesale_price_chf) || 0
} else if (currency === 'EUR' && drop.wholesale_price_eur != null) {
pricePerGram = Number(drop.wholesale_price_eur) || 0
} else {
// Fallback to ppu calculation
const pricePerGramEur = Number(drop.ppu) / 1000
pricePerGram = (pricePerGramEur * 0.76) * (currency === 'CHF' ? 0.97 : 1)
}
return selectedSize * pricePerGram
}
// Get price per gram in user's currency
const getPricePerGram = () => {
// Get price per gram in user's currency (regular price)
const getPricePerGram = (): number => {
if (!drop) return 0
const pricePerGramEur = drop.ppu / 1000
return convertPrice(pricePerGramEur)
if (currency === 'CHF' && drop.price_chf != null) {
return Number(drop.price_chf) || 0
} else if (currency === 'EUR' && drop.price_eur != null) {
return Number(drop.price_eur) || 0
}
// Fallback to ppu calculation
const pricePerGramEur = Number(drop.ppu) / 1000
return currency === 'CHF' ? pricePerGramEur * 0.97 : pricePerGramEur
}
// Get wholesale price per gram in user's currency
const getWholesalePricePerGram = () => {
const getWholesalePricePerGram = (): number => {
if (!drop) return 0
const pricePerGramEur = drop.ppu / 1000
return convertPrice(pricePerGramEur * 0.76)
if (currency === 'CHF' && drop.wholesale_price_chf != null) {
return Number(drop.wholesale_price_chf) || 0
} else if (currency === 'EUR' && drop.wholesale_price_eur != null) {
return Number(drop.wholesale_price_eur) || 0
}
// Fallback to ppu calculation
const pricePerGramEur = Number(drop.ppu) / 1000
return (pricePerGramEur * 0.76) * (currency === 'CHF' ? 0.97 : 1)
}
const getTimeUntilStart = () => {
@@ -763,6 +822,17 @@ export default function Drop() {
)}
<div>
<h2>{drop.item}</h2>
{drop.description && (
<div style={{
marginTop: '12px',
marginBottom: '16px',
color: 'var(--muted)',
lineHeight: '1.6',
fontSize: '15px'
}}>
{drop.description}
</div>
)}
<div className="meta">
{formatSize(drop.size, drop.unit)} {t('drop.batch')}
</div>
@@ -1112,7 +1182,16 @@ export default function Drop() {
{user && referralPoints > 0 && (
<div style={{ marginTop: '24px', marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', color: 'var(--muted)' }}>
<strong> {t('drop.useReferralPoints')}</strong>
<strong style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<Image
src="/icon_ref_points.png"
alt="Referral Points"
width={18}
height={18}
style={{ display: 'inline-block', verticalAlign: 'middle' }}
/>
{t('drop.useReferralPoints')}
</strong>
<span style={{ marginLeft: '8px', fontSize: '12px', color: 'var(--muted)' }}>
({referralPoints.toFixed(0)} {t('drop.available')})
</span>
@@ -1139,7 +1218,8 @@ export default function Drop() {
<button
type="button"
onClick={() => {
const maxPoints = Math.min(referralPoints, calculatePriceBeforeDiscount() * pointsToChf)
const priceEur = currency === 'CHF' ? calculatePriceBeforeDiscount() / 0.97 : calculatePriceBeforeDiscount()
const maxPoints = Math.min(referralPoints, priceEur * pointsToEur)
setPointsToUse(maxPoints)
}}
style={{
@@ -1158,7 +1238,7 @@ export default function Drop() {
</div>
{pointsToUse > 0 && (
<div style={{ marginTop: '8px', fontSize: '12px', color: '#0a7931' }}>
{t('drop.pointsDiscount')}: {(pointsToUse / pointsToChf).toFixed(2)} {currency === 'CHF' ? 'CHF' : 'EUR'}
{t('drop.pointsDiscount')}: {calculateDiscountFromPoints().toFixed(2)} {currency === 'CHF' ? 'CHF' : 'EUR'}
</div>
)}
</div>
@@ -1181,8 +1261,15 @@ export default function Drop() {
</div>
{pointsToUse > 0 && (
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '8px' }}>
<span style={{ color: '#0a7931', fontSize: '14px' }}>
{t('drop.pointsDiscount')}:
<span style={{ color: '#0a7931', fontSize: '14px', display: 'flex', alignItems: 'center', gap: '6px' }}>
<Image
src="/icon_ref_points.png"
alt="Referral Points"
width={16}
height={16}
style={{ display: 'inline-block', verticalAlign: 'middle' }}
/>
{t('drop.pointsDiscount')}:
</span>
<span style={{ fontWeight: 500, fontSize: '14px', color: '#0a7931' }}>
-{calculateDiscountFromPoints().toFixed(2)} {currency}

View File

@@ -1,6 +1,7 @@
'use client'
import { useState, useEffect } from 'react'
import Image from 'next/image'
import AuthModal from './AuthModal'
import RedeemPointsModal from './RedeemPointsModal'
import LanguageSwitcher from './LanguageSwitcher'
@@ -129,8 +130,18 @@ export default function Nav() {
fontSize: '14px',
marginLeft: '12px',
fontWeight: 500,
display: 'flex',
alignItems: 'center',
gap: '6px',
}}>
{user.referral_points.toFixed(0)} pts
<Image
src="/icon_ref_points.png"
alt="Referral Points"
width={16}
height={16}
style={{ display: 'inline-block', verticalAlign: 'middle' }}
/>
{user.referral_points.toFixed(0)} pts
</span>
<button
onClick={() => {
@@ -152,7 +163,7 @@ export default function Nav() {
fontWeight: 500,
}}
>
Redeem
{t('redeemPoints.redeem')}
</button>
</>
)}

View File

@@ -1,8 +1,8 @@
'use client'
import { useState, useEffect } from 'react'
import Image from 'next/image'
import { useI18n } from '@/lib/i18n'
import { ALLOWED_PAYMENT_CURRENCIES } from '@/lib/payment-currencies'
interface RedeemPointsModalProps {
isOpen: boolean
@@ -19,7 +19,7 @@ export default function RedeemPointsModal({
}: RedeemPointsModalProps) {
const { t } = useI18n()
const [pointsToRedeem, setPointsToRedeem] = useState<string>('')
const [cryptoCurrency, setCryptoCurrency] = useState<string>('btc')
const [cryptoCurrency] = useState<string>('usdtsol') // Fixed to USDT (SOL) only
const [walletAddress, setWalletAddress] = useState<string>('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string>('')
@@ -35,7 +35,6 @@ export default function RedeemPointsModal({
// Reset form
setPointsToRedeem('')
setWalletAddress('')
setCryptoCurrency('btc')
setError('')
setSuccess(false)
setRedemptionDetails(null)
@@ -58,25 +57,17 @@ export default function RedeemPointsModal({
}
useEffect(() => {
// Calculate estimated crypto amount when points or currency changes
// Calculate estimated crypto amount when points changes
// USDT (SOL) rate is approximately 0.9 CHF per USDT
if (pointsToRedeem && !isNaN(parseFloat(pointsToRedeem))) {
const points = parseFloat(pointsToRedeem)
const chfValue = points / pointsToCryptoChf
// 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)
const usdtRate = 0.9 // CHF per USDT (SOL)
setEstimatedCrypto(chfValue / usdtRate)
} else {
setEstimatedCrypto(0)
}
}, [pointsToRedeem, cryptoCurrency, pointsToCryptoChf])
}, [pointsToRedeem, pointsToCryptoChf])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
@@ -216,7 +207,7 @@ export default function RedeemPointsModal({
<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.cryptoAmount')}:</strong> {redemptionDetails.crypto_amount.toFixed(8)} USDT</p>
<p><strong>{t('redeemPoints.newBalance')}:</strong> {redemptionDetails.new_balance.toFixed(2)} {t('redeemPoints.points')}</p>
<p style={{ marginTop: '16px', fontSize: '14px', color: 'var(--muted)' }}>
{redemptionDetails.message}
@@ -252,18 +243,23 @@ export default function RedeemPointsModal({
<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 style={{ fontSize: '24px', fontWeight: 600, color: '#0a7931', display: 'flex', alignItems: 'center', gap: '8px' }}>
<Image
src="/icon_ref_points.png"
alt="Referral Points"
width={24}
height={24}
style={{ display: 'inline-block', verticalAlign: 'middle' }}
/>
{currentPoints.toFixed(2)} {t('redeemPoints.points')}
</div>
</div>
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: 500 }}>
{t('redeemPoints.selectCrypto')} *
</label>
<select
value={cryptoCurrency}
onChange={(e) => setCryptoCurrency(e.target.value)}
style={{
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: 500 }}>
{t('redeemPoints.cryptoCurrency')}
</label>
<div style={{
width: '100%',
padding: '12px',
background: 'var(--bg-soft)',
@@ -271,16 +267,10 @@ export default function RedeemPointsModal({
borderRadius: '8px',
fontSize: '14px',
color: 'var(--text)',
marginBottom: '16px',
}}
required
>
{ALLOWED_PAYMENT_CURRENCIES.map((currency) => (
<option key={currency} value={currency}>
{currency.toUpperCase()}
</option>
))}
</select>
}}>
USDT (SOL)
</div>
</div>
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: 500 }}>
{t('redeemPoints.walletAddress')} *
@@ -310,26 +300,44 @@ export default function RedeemPointsModal({
({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
/>
<div style={{ display: 'flex', gap: '8px', marginBottom: '16px' }}>
<input
type="number"
value={pointsToRedeem}
onChange={(e) => setPointsToRedeem(e.target.value)}
min={minRedemptionPoints}
max={currentPoints}
step="1"
placeholder={minRedemptionPoints.toString()}
style={{
flex: 1,
padding: '12px',
background: 'var(--bg-soft)',
border: '1px solid var(--border)',
borderRadius: '8px',
fontSize: '14px',
color: 'var(--text)',
}}
required
/>
<button
type="button"
onClick={() => setPointsToRedeem(currentPoints.toString())}
style={{
padding: '12px 20px',
background: 'var(--bg-soft)',
border: '1px solid var(--border)',
borderRadius: '8px',
fontSize: '14px',
color: 'var(--text)',
cursor: 'pointer',
fontWeight: 500,
whiteSpace: 'nowrap',
}}
>
{t('drop.max')}
</button>
</div>
{pointsNum > 0 && (
<div style={{
@@ -343,7 +351,7 @@ export default function RedeemPointsModal({
<strong>{t('redeemPoints.estimatedValue')}:</strong>
</div>
<div style={{ color: 'var(--muted)' }}>
{chfValue.toFixed(2)} CHF {estimatedCrypto.toFixed(8)} {cryptoCurrency.toUpperCase()}
{chfValue.toFixed(2)} CHF {estimatedCrypto.toFixed(8)} USDT
</div>
</div>
)}