final
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
let pointsToEur = parseFloat(settings.find(s => s.setting_key === 'points_to_eur')?.setting_value || '0')
|
||||
|
||||
// Calculate discount in CHF, then convert to user's currency
|
||||
const discountChf = pointsToUse / pointsToChf
|
||||
// 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
|
||||
}
|
||||
|
||||
// Convert discount based on user's currency
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: 500 }}>
|
||||
{t('redeemPoints.selectCrypto')} *
|
||||
{t('redeemPoints.cryptoCurrency')}
|
||||
</label>
|
||||
<select
|
||||
value={cryptoCurrency}
|
||||
onChange={(e) => setCryptoCurrency(e.target.value)}
|
||||
style={{
|
||||
<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,6 +300,7 @@ export default function RedeemPointsModal({
|
||||
({t('redeemPoints.min')}: {minRedemptionPoints})
|
||||
</span>
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: '8px', marginBottom: '16px' }}>
|
||||
<input
|
||||
type="number"
|
||||
value={pointsToRedeem}
|
||||
@@ -319,17 +310,34 @@ export default function RedeemPointsModal({
|
||||
step="1"
|
||||
placeholder={minRedemptionPoints.toString()}
|
||||
style={{
|
||||
width: '100%',
|
||||
flex: 1,
|
||||
padding: '12px',
|
||||
background: 'var(--bg-soft)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
color: 'var(--text)',
|
||||
marginBottom: '16px',
|
||||
}}
|
||||
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>
|
||||
)}
|
||||
|
||||
268
cbd420.sql
268
cbd420.sql
@@ -1,11 +1,11 @@
|
||||
-- phpMyAdmin SQL Dump
|
||||
-- version 5.2.1deb1+deb12u1
|
||||
-- version 5.2.3-1.el9.remi
|
||||
-- https://www.phpmyadmin.net/
|
||||
--
|
||||
-- Host: localhost:3306
|
||||
-- Generation Time: Dec 28, 2025 at 01:36 AM
|
||||
-- Server version: 10.11.14-MariaDB-0+deb12u2
|
||||
-- PHP Version: 8.2.29
|
||||
-- Host: localhost
|
||||
-- Generation Time: Dec 31, 2025 at 05:36 PM
|
||||
-- Server version: 10.5.29-MariaDB
|
||||
-- PHP Version: 8.2.28
|
||||
|
||||
SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO";
|
||||
START TRANSACTION;
|
||||
@@ -72,11 +72,16 @@ CREATE TABLE `deliveries` (
|
||||
CREATE TABLE `drops` (
|
||||
`id` int(11) NOT NULL,
|
||||
`item` text NOT NULL,
|
||||
`description` text DEFAULT NULL,
|
||||
`size` int(11) NOT NULL DEFAULT 100,
|
||||
`fill` int(11) NOT NULL DEFAULT 0,
|
||||
`unit` varchar(12) NOT NULL DEFAULT 'g',
|
||||
`image_url` varchar(255) DEFAULT NULL,
|
||||
`ppu` int(11) NOT NULL DEFAULT 1,
|
||||
`price_chf` decimal(10,4) DEFAULT NULL,
|
||||
`price_eur` decimal(10,4) DEFAULT NULL,
|
||||
`wholesale_price_chf` decimal(10,4) DEFAULT NULL,
|
||||
`wholesale_price_eur` decimal(10,4) DEFAULT NULL,
|
||||
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
|
||||
`start_time` datetime DEFAULT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
@@ -130,6 +135,38 @@ CREATE TABLE `pending_orders` (
|
||||
|
||||
-- --------------------------------------------------------
|
||||
|
||||
--
|
||||
-- Table structure for table `point_redemptions`
|
||||
--
|
||||
|
||||
CREATE TABLE `point_redemptions` (
|
||||
`id` int(11) NOT NULL,
|
||||
`buyer_id` int(11) NOT NULL,
|
||||
`points` decimal(10,2) NOT NULL,
|
||||
`crypto_currency` varchar(20) NOT NULL,
|
||||
`wallet_address` varchar(255) NOT NULL,
|
||||
`crypto_amount` decimal(20,8) DEFAULT NULL,
|
||||
`status` enum('pending','processing','completed','failed','cancelled') NOT NULL DEFAULT 'pending',
|
||||
`transaction_hash` varchar(255) DEFAULT NULL,
|
||||
`error_message` text DEFAULT NULL,
|
||||
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
|
||||
`updated_at` datetime NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp()
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
|
||||
-- --------------------------------------------------------
|
||||
|
||||
--
|
||||
-- Table structure for table `referrals`
|
||||
--
|
||||
|
||||
CREATE TABLE `referrals` (
|
||||
`id` int(11) NOT NULL,
|
||||
`referrer` int(11) NOT NULL,
|
||||
`referree` int(11) NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
|
||||
-- --------------------------------------------------------
|
||||
|
||||
--
|
||||
-- Table structure for table `referral_point_transactions`
|
||||
--
|
||||
@@ -138,7 +175,7 @@ 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,
|
||||
`type` enum('earned','spent','redeemed') NOT NULL,
|
||||
`sale_id` int(11) DEFAULT NULL,
|
||||
`pending_order_id` int(11) DEFAULT NULL,
|
||||
`description` text DEFAULT NULL,
|
||||
@@ -161,18 +198,6 @@ CREATE TABLE `referral_settings` (
|
||||
|
||||
-- --------------------------------------------------------
|
||||
|
||||
--
|
||||
-- Table structure for table `referrals`
|
||||
--
|
||||
|
||||
CREATE TABLE `referrals` (
|
||||
`id` int(11) NOT NULL,
|
||||
`referrer` int(11) NOT NULL,
|
||||
`referree` int(11) NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
|
||||
-- --------------------------------------------------------
|
||||
|
||||
--
|
||||
-- Table structure for table `sales`
|
||||
--
|
||||
@@ -247,6 +272,22 @@ ALTER TABLE `pending_orders`
|
||||
ADD KEY `idx_expires_at` (`expires_at`),
|
||||
ADD KEY `buyer_data_id` (`buyer_data_id`);
|
||||
|
||||
--
|
||||
-- Indexes for table `point_redemptions`
|
||||
--
|
||||
ALTER TABLE `point_redemptions`
|
||||
ADD PRIMARY KEY (`id`),
|
||||
ADD KEY `buyer_id` (`buyer_id`),
|
||||
ADD KEY `status` (`status`);
|
||||
|
||||
--
|
||||
-- Indexes for table `referrals`
|
||||
--
|
||||
ALTER TABLE `referrals`
|
||||
ADD PRIMARY KEY (`id`),
|
||||
ADD KEY `referree` (`referree`),
|
||||
ADD KEY `referrer` (`referrer`);
|
||||
|
||||
--
|
||||
-- Indexes for table `referral_point_transactions`
|
||||
--
|
||||
@@ -263,14 +304,6 @@ ALTER TABLE `referral_settings`
|
||||
ADD PRIMARY KEY (`id`),
|
||||
ADD UNIQUE KEY `setting_key` (`setting_key`);
|
||||
|
||||
--
|
||||
-- Indexes for table `referrals`
|
||||
--
|
||||
ALTER TABLE `referrals`
|
||||
ADD PRIMARY KEY (`id`),
|
||||
ADD KEY `referree` (`referree`),
|
||||
ADD KEY `referrer` (`referrer`);
|
||||
|
||||
--
|
||||
-- Indexes for table `sales`
|
||||
--
|
||||
@@ -320,6 +353,18 @@ ALTER TABLE `drop_images`
|
||||
ALTER TABLE `pending_orders`
|
||||
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;
|
||||
|
||||
--
|
||||
-- AUTO_INCREMENT for table `point_redemptions`
|
||||
--
|
||||
ALTER TABLE `point_redemptions`
|
||||
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;
|
||||
|
||||
--
|
||||
-- AUTO_INCREMENT for table `referrals`
|
||||
--
|
||||
ALTER TABLE `referrals`
|
||||
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;
|
||||
|
||||
--
|
||||
-- AUTO_INCREMENT for table `referral_point_transactions`
|
||||
--
|
||||
@@ -332,12 +377,6 @@ ALTER TABLE `referral_point_transactions`
|
||||
ALTER TABLE `referral_settings`
|
||||
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;
|
||||
|
||||
--
|
||||
-- AUTO_INCREMENT for table `referrals`
|
||||
--
|
||||
ALTER TABLE `referrals`
|
||||
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;
|
||||
|
||||
--
|
||||
-- AUTO_INCREMENT for table `sales`
|
||||
--
|
||||
@@ -381,12 +420,10 @@ ALTER TABLE `pending_orders`
|
||||
ADD CONSTRAINT `pending_orders_ibfk_3` FOREIGN KEY (`buyer_data_id`) REFERENCES `buyer_data` (`id`);
|
||||
|
||||
--
|
||||
-- Constraints for table `referral_point_transactions`
|
||||
-- Constraints for table `point_redemptions`
|
||||
--
|
||||
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;
|
||||
ALTER TABLE `point_redemptions`
|
||||
ADD CONSTRAINT `point_redemptions_ibfk_1` FOREIGN KEY (`buyer_id`) REFERENCES `buyers` (`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
--
|
||||
-- Constraints for table `referrals`
|
||||
@@ -395,6 +432,14 @@ ALTER TABLE `referrals`
|
||||
ADD CONSTRAINT `referrals_ibfk_1` FOREIGN KEY (`referree`) REFERENCES `buyers` (`id`),
|
||||
ADD CONSTRAINT `referrals_ibfk_2` FOREIGN KEY (`referrer`) REFERENCES `buyers` (`id`);
|
||||
|
||||
--
|
||||
-- Constraints for table `referral_point_transactions`
|
||||
--
|
||||
ALTER TABLE `referral_point_transactions`
|
||||
ADD CONSTRAINT `referral_point_transactions_ibfk_1` FOREIGN KEY (`buyer_id`) REFERENCES `buyers` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
ADD CONSTRAINT `referral_point_transactions_ibfk_2` FOREIGN KEY (`sale_id`) REFERENCES `sales` (`id`) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
ADD CONSTRAINT `referral_point_transactions_ibfk_3` FOREIGN KEY (`pending_order_id`) REFERENCES `pending_orders` (`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
--
|
||||
-- Constraints for table `sales`
|
||||
--
|
||||
@@ -402,155 +447,6 @@ 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 */;
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
"confirmPurchase": "Kauf bestätigen",
|
||||
"totalPrice": "Gesamtpreis",
|
||||
"standardPrice": "Standardpreis",
|
||||
"wholesalePrice": "Großhandelspreis",
|
||||
"wholesalePrice": "420deals-Memberpreis",
|
||||
"paymentCurrency": "Zahlungswährung",
|
||||
"selectCurrency": "Währung auswählen",
|
||||
"upcomingIn": "Kommt in",
|
||||
@@ -75,10 +75,10 @@
|
||||
"nextDropComingSoon": "Nächster kollektiver Drop kommt bald.",
|
||||
"batch": "Batch",
|
||||
"reserved": "reserviert",
|
||||
"wholesalePriceLabel": "Großhandelspreis:",
|
||||
"wholesalePriceLabel": "420deals-Memberpreis:",
|
||||
"standardPriceLabel": "Standardpreis:",
|
||||
"standard": "Standard",
|
||||
"wholesale": "Großhandel",
|
||||
"wholesale": "420deals-Memberpreis",
|
||||
"unlock": "freischalten",
|
||||
"unlockOnce": "Einmal freischalten. Großhandelspreis für immer behalten.",
|
||||
"dropStartsIn": "Drop startet in",
|
||||
@@ -88,7 +88,7 @@
|
||||
"max": "Max",
|
||||
"total": "Gesamt",
|
||||
"standardTotal": "Standard gesamt",
|
||||
"wholesaleTotal": "Großhandel gesamt",
|
||||
"wholesaleTotal": "420deals-Memberpreis gesamt",
|
||||
"joinTheDrop": "Am Drop teilnehmen",
|
||||
"noSubscription": "Kein Abonnement · Keine Verpflichtung",
|
||||
"lessThanRemaining": "Weniger als {amount}{unit} verbleibend. Dieser Drop ist fast vollständig reserviert.",
|
||||
@@ -176,23 +176,23 @@
|
||||
"unexpectedError": "Ein unerwarteter Fehler ist aufgetreten"
|
||||
},
|
||||
"unlockBar": {
|
||||
"unlocked": "✅ Großhandelspreise freigeschaltet —",
|
||||
"unlockedText": "Sie haben Zugang zu Großhandelspreisen!",
|
||||
"locked": "🔒 Großhandelspreise gesperrt —",
|
||||
"unlocked": "✅ 420deals-Memberpreise freigeschaltet —",
|
||||
"unlockedText": "Sie haben Zugang zu 420deals-Memberpreisen!",
|
||||
"locked": "🔒 420deals-Memberpreise gesperrt —",
|
||||
"referralsCompleted": "{count} / {needed} Empfehlungen abgeschlossen",
|
||||
"toGo": "{remaining} verbleibend",
|
||||
"unlockText": "{needed} verifizierte Anmeldungen schalten Großhandelspreise für immer frei.",
|
||||
"unlockText": "{needed} verifizierte Anmeldungen schalten 420deals-Memberpreise für immer frei.",
|
||||
"unlockNow": "Jetzt freischalten",
|
||||
"innerCircleLocked": "🔒 Inner Circle Chat gesperrt —",
|
||||
"innerCircleUnlockText": "{needed} verifizierte Anmeldungen schalten den Zugang zu unserem Inner Circle Chat für immer frei.",
|
||||
"innerCircleUnlocked": "Inner Circle Chat freigeschaltet!"
|
||||
},
|
||||
"unlockModal": {
|
||||
"title": "Großhandelspreise freischalten",
|
||||
"title": "420deals-Memberpreise freischalten",
|
||||
"innerCircleTitle": "Inner Circle Chat freischalten",
|
||||
"referralsCompleted": "{count} von {needed} Empfehlungen abgeschlossen",
|
||||
"inviteFriends": "Laden Sie {needed} Freunde zur Anmeldung ein.",
|
||||
"unlockForever": "Sobald sie sich anmelden, werden die Großhandelspreise für immer freigeschaltet.",
|
||||
"unlockForever": "Sobald sie sich anmelden, werden die 420deals-Memberpreise für immer freigeschaltet.",
|
||||
"innerCircleUnlockForever": "Sobald sie sich anmelden, wird der Inner Circle Chat für immer freigeschaltet.",
|
||||
"yourReferralLink": "Ihr Empfehlungslink",
|
||||
"copyLink": "Link kopieren",
|
||||
@@ -216,6 +216,7 @@
|
||||
"currentBalance": "Aktueller Kontostand",
|
||||
"points": "Punkte",
|
||||
"selectCrypto": "Kryptowährung auswählen",
|
||||
"cryptoCurrency": "Kryptowährung",
|
||||
"walletAddress": "Wallet-Adresse",
|
||||
"walletAddressPlaceholder": "Geben Sie Ihre Krypto-Wallet-Adresse ein",
|
||||
"pointsToRedeem": "Einzulösende Punkte",
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
"confirmPurchase": "Confirm Purchase",
|
||||
"totalPrice": "Total Price",
|
||||
"standardPrice": "Standard Price",
|
||||
"wholesalePrice": "Wholesale Price",
|
||||
"wholesalePrice": "420deals Member Price",
|
||||
"paymentCurrency": "Payment Currency",
|
||||
"selectCurrency": "Select currency",
|
||||
"upcomingIn": "Upcoming in",
|
||||
@@ -73,10 +73,10 @@
|
||||
"dropSoldOut": "Drop Sold Out",
|
||||
"fullyReserved": "The current collective drop has been fully reserved.",
|
||||
"nextDropComingSoon": "Next collective drop coming soon.",
|
||||
"wholesalePriceLabel": "Wholesale price:",
|
||||
"wholesalePriceLabel": "420deals Member Price:",
|
||||
"standardPriceLabel": "Standard price:",
|
||||
"standard": "Standard",
|
||||
"wholesale": "Wholesale",
|
||||
"wholesale": "420deals Member Price",
|
||||
"unlock": "unlock",
|
||||
"unlockOnce": "Unlock once. Keep wholesale forever.",
|
||||
"dropStartsIn": "Drop starts in",
|
||||
@@ -86,7 +86,7 @@
|
||||
"max": "Max",
|
||||
"total": "Total",
|
||||
"standardTotal": "Standard total",
|
||||
"wholesaleTotal": "Wholesale total",
|
||||
"wholesaleTotal": "420deals Member Price total",
|
||||
"joinTheDrop": "Join the drop",
|
||||
"noSubscription": "No subscription · No obligation",
|
||||
"lessThanRemaining": "Less than {amount}{unit} remaining. This drop is almost fully reserved.",
|
||||
@@ -173,23 +173,23 @@
|
||||
"unexpectedError": "An unexpected error occurred"
|
||||
},
|
||||
"unlockBar": {
|
||||
"unlocked": "✅ Wholesale prices unlocked —",
|
||||
"unlockedText": "You have access to wholesale pricing!",
|
||||
"locked": "🔒 Wholesale prices locked —",
|
||||
"unlocked": "✅ 420deals Member prices unlocked —",
|
||||
"unlockedText": "You have access to 420deals Member pricing!",
|
||||
"locked": "🔒 420deals Member prices locked —",
|
||||
"referralsCompleted": "{count} / {needed} referrals completed",
|
||||
"toGo": "{remaining} to go",
|
||||
"unlockText": "{needed} verified sign-ups unlock wholesale prices forever.",
|
||||
"unlockText": "{needed} verified sign-ups unlock 420deals Member prices forever.",
|
||||
"unlockNow": "Unlock now",
|
||||
"innerCircleLocked": "🔒 Inner circle chat locked —",
|
||||
"innerCircleUnlockText": "{needed} verified sign-ups unlock access to our Inner circle chat forever.",
|
||||
"innerCircleUnlocked": "Inner circle chat unlocked!"
|
||||
},
|
||||
"unlockModal": {
|
||||
"title": "Unlock Wholesale Prices",
|
||||
"title": "Unlock 420deals Member Prices",
|
||||
"innerCircleTitle": "Unlock Inner chat circle",
|
||||
"referralsCompleted": "{count} of {needed} referrals completed",
|
||||
"inviteFriends": "Invite {needed} friends to sign up.",
|
||||
"unlockForever": "Once they do, wholesale prices unlock forever.",
|
||||
"unlockForever": "Once they do, 420deals Member prices unlock forever.",
|
||||
"innerCircleUnlockForever": "Once they do, Inner chat circle unlocks forever.",
|
||||
"yourReferralLink": "Your referral link",
|
||||
"copyLink": "Copy Link",
|
||||
@@ -213,6 +213,7 @@
|
||||
"currentBalance": "Current Balance",
|
||||
"points": "points",
|
||||
"selectCrypto": "Select Cryptocurrency",
|
||||
"cryptoCurrency": "Cryptocurrency",
|
||||
"walletAddress": "Wallet Address",
|
||||
"walletAddressPlaceholder": "Enter your crypto wallet address",
|
||||
"pointsToRedeem": "Points to Redeem",
|
||||
|
||||
14
migrations/add_drop_description.sql
Normal file
14
migrations/add_drop_description.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
-- Migration to add description field to drops table
|
||||
-- Date: 2025-01-XX
|
||||
|
||||
SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO";
|
||||
START TRANSACTION;
|
||||
SET time_zone = "+00:00";
|
||||
|
||||
-- Add description column to drops table
|
||||
ALTER TABLE `drops`
|
||||
ADD COLUMN `description` text DEFAULT NULL AFTER `item`;
|
||||
|
||||
COMMIT;
|
||||
|
||||
|
||||
18
migrations/add_eur_referral_points.sql
Normal file
18
migrations/add_eur_referral_points.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
-- Migration to add EUR-based referral points settings
|
||||
-- This makes referral points universal across currencies by using EUR as the base
|
||||
-- Date: 2025-01-XX
|
||||
|
||||
SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO";
|
||||
START TRANSACTION;
|
||||
SET time_zone = "+00:00";
|
||||
|
||||
-- Add EUR-based referral point settings
|
||||
INSERT INTO `referral_settings` (`setting_key`, `setting_value`, `description`) VALUES
|
||||
('points_per_eur', '10', 'Number of referral points earned per 1 EUR purchase by referred user'),
|
||||
('points_to_eur', '100', 'Number of referral points required to redeem 1 EUR discount')
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`description` = VALUES(`description`);
|
||||
|
||||
COMMIT;
|
||||
|
||||
|
||||
30
migrations/add_multi_currency_prices.sql
Normal file
30
migrations/add_multi_currency_prices.sql
Normal file
@@ -0,0 +1,30 @@
|
||||
-- Migration to add multi-currency pricing support for drops
|
||||
-- Date: 2025-01-XX
|
||||
-- Adds support for CHF and EUR prices, both regular and wholesale
|
||||
|
||||
SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO";
|
||||
START TRANSACTION;
|
||||
SET time_zone = "+00:00";
|
||||
|
||||
-- Add price columns to drops table
|
||||
-- Prices are stored as decimal per gram
|
||||
ALTER TABLE `drops`
|
||||
ADD COLUMN `price_chf` decimal(10,4) DEFAULT NULL AFTER `ppu`,
|
||||
ADD COLUMN `price_eur` decimal(10,4) DEFAULT NULL AFTER `price_chf`,
|
||||
ADD COLUMN `wholesale_price_chf` decimal(10,4) DEFAULT NULL AFTER `price_eur`,
|
||||
ADD COLUMN `wholesale_price_eur` decimal(10,4) DEFAULT NULL AFTER `wholesale_price_chf`;
|
||||
|
||||
-- Migrate existing ppu data to new price fields
|
||||
-- ppu is stored as integer where 1000 = 1.00 EUR per gram
|
||||
-- Convert to decimal format and set both EUR and CHF prices
|
||||
UPDATE `drops`
|
||||
SET
|
||||
`price_eur` = `ppu` / 1000.0,
|
||||
`price_chf` = (`ppu` / 1000.0) * 0.97, -- Convert EUR to CHF (1 EUR ≈ 0.97 CHF)
|
||||
`wholesale_price_eur` = (`ppu` / 1000.0) * 0.76, -- 76% of regular price
|
||||
`wholesale_price_chf` = (`ppu` / 1000.0) * 0.76 * 0.97 -- 76% of regular price in CHF
|
||||
WHERE `ppu` IS NOT NULL AND `ppu` > 0;
|
||||
|
||||
COMMIT;
|
||||
|
||||
|
||||
BIN
public/icon_ref_points.png
Normal file
BIN
public/icon_ref_points.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
Reference in New Issue
Block a user