This commit is contained in:
root
2025-12-22 06:43:19 +01:00
parent a940d51475
commit 6f4ca75faf
25 changed files with 1350 additions and 221 deletions

View File

@@ -8,11 +8,15 @@ interface Buyer {
username: string username: string
email: string email: string
created_at?: string created_at?: string
referral_count?: number
hasWholesaleAccess?: boolean
hasInnerCircleAccess?: boolean
} }
export default function BuyersManagementPage() { export default function BuyersManagementPage() {
const router = useRouter() const router = useRouter()
const [buyers, setBuyers] = useState<Buyer[]>([]) const [buyers, setBuyers] = useState<Buyer[]>([])
const [filteredBuyers, setFilteredBuyers] = useState<Buyer[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [authenticated, setAuthenticated] = useState(false) const [authenticated, setAuthenticated] = useState(false)
const [editingBuyer, setEditingBuyer] = useState<Buyer | null>(null) const [editingBuyer, setEditingBuyer] = useState<Buyer | null>(null)
@@ -21,6 +25,10 @@ export default function BuyersManagementPage() {
email: '', email: '',
password: '', password: '',
}) })
const [filters, setFilters] = useState({
wholesaleOnly: false,
innerCircleOnly: false,
})
useEffect(() => { useEffect(() => {
// Check authentication // Check authentication
@@ -44,7 +52,9 @@ export default function BuyersManagementPage() {
const response = await fetch('/api/buyers') const response = await fetch('/api/buyers')
if (response.ok) { if (response.ok) {
const data = await response.json() const data = await response.json()
setBuyers(Array.isArray(data) ? data : []) const buyersList = Array.isArray(data) ? data : []
setBuyers(buyersList)
applyFilters(buyersList, filters)
} }
} catch (error) { } catch (error) {
console.error('Error fetching buyers:', error) console.error('Error fetching buyers:', error)
@@ -53,6 +63,24 @@ export default function BuyersManagementPage() {
} }
} }
const applyFilters = (buyersList: Buyer[], currentFilters: typeof filters) => {
let filtered = [...buyersList]
if (currentFilters.wholesaleOnly) {
filtered = filtered.filter(buyer => buyer.hasWholesaleAccess)
}
if (currentFilters.innerCircleOnly) {
filtered = filtered.filter(buyer => buyer.hasInnerCircleAccess)
}
setFilteredBuyers(filtered)
}
useEffect(() => {
applyFilters(buyers, filters)
}, [filters])
const handleEdit = (buyer: Buyer) => { const handleEdit = (buyer: Buyer) => {
setEditingBuyer(buyer) setEditingBuyer(buyer)
setFormData({ setFormData({
@@ -173,11 +201,62 @@ export default function BuyersManagementPage() {
</button> </button>
</div> </div>
{buyers.length === 0 ? ( <div style={{
marginBottom: '20px',
padding: '16px',
background: 'var(--card)',
borderRadius: '8px',
border: '1px solid var(--border)'
}}>
<h3 style={{ marginBottom: '12px', fontSize: '16px' }}>Filters</h3>
<div style={{ display: 'flex', gap: '20px', flexWrap: 'wrap' }}>
<label style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
cursor: 'pointer',
userSelect: 'none'
}}>
<input
type="checkbox"
checked={filters.wholesaleOnly}
onChange={(e) => setFilters({ ...filters, wholesaleOnly: e.target.checked })}
style={{ width: '16px', height: '16px', cursor: 'pointer' }}
/>
<span>Wholesale Access (3+ referrals)</span>
</label>
<label style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
cursor: 'pointer',
userSelect: 'none'
}}>
<input
type="checkbox"
checked={filters.innerCircleOnly}
onChange={(e) => setFilters({ ...filters, innerCircleOnly: e.target.checked })}
style={{ width: '16px', height: '16px', cursor: 'pointer' }}
/>
<span>Inner Circle Access (10+ referrals)</span>
</label>
</div>
{(filters.wholesaleOnly || filters.innerCircleOnly) && (
<p style={{
marginTop: '12px',
color: 'var(--muted)',
fontSize: '14px'
}}>
Showing {filteredBuyers.length} of {buyers.length} buyers
</p>
)}
</div>
{(filters.wholesaleOnly || filters.innerCircleOnly ? filteredBuyers : buyers).length === 0 ? (
<p style={{ color: 'var(--muted)' }}>No buyers found</p> <p style={{ color: 'var(--muted)' }}>No buyers found</p>
) : ( ) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
{buyers.map((buyer) => ( {(filters.wholesaleOnly || filters.innerCircleOnly ? filteredBuyers : buyers).map((buyer) => (
<div <div
key={buyer.id} key={buyer.id}
className="drop-card" className="drop-card"
@@ -273,10 +352,35 @@ export default function BuyersManagementPage() {
ID: {buyer.id} ID: {buyer.id}
</p> </p>
{buyer.created_at && ( {buyer.created_at && (
<p style={{ color: 'var(--muted)', fontSize: '12px' }}> <p style={{ color: 'var(--muted)', fontSize: '12px', marginBottom: '4px' }}>
Created: {new Date(buyer.created_at).toLocaleString()} Created: {new Date(buyer.created_at).toLocaleString()}
</p> </p>
)} )}
<div style={{
display: 'flex',
gap: '8px',
marginTop: '8px',
flexWrap: 'wrap'
}}>
<span style={{
padding: '4px 8px',
borderRadius: '4px',
fontSize: '12px',
background: buyer.hasWholesaleAccess ? '#10b981' : '#6b7280',
color: '#fff'
}}>
{buyer.referral_count || 0} referrals - Wholesale {buyer.hasWholesaleAccess ? '✓' : '✗'}
</span>
<span style={{
padding: '4px 8px',
borderRadius: '4px',
fontSize: '12px',
background: buyer.hasInnerCircleAccess ? '#8b5cf6' : '#6b7280',
color: '#fff'
}}>
Inner Circle {buyer.hasInnerCircleAccess ? '✓' : '✗'}
</span>
</div>
</div> </div>
<div style={{ display: 'flex', gap: '8px' }}> <div style={{ display: 'flex', gap: '8px' }}>
<button <button

View File

@@ -537,7 +537,7 @@ export default function DropsManagementPage() {
onChange={(e) => setFormData({ ...formData, ppu: e.target.value })} onChange={(e) => setFormData({ ...formData, ppu: e.target.value })}
required required
min="1" min="1"
placeholder="2500 = 2.50 CHF" placeholder="2500 = 2.50 EUR"
style={{ style={{
width: '100%', width: '100%',
padding: '8px', padding: '8px',
@@ -959,7 +959,7 @@ export default function DropsManagementPage() {
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<h3 style={{ marginBottom: '8px' }}>{drop.item}</h3> <h3 style={{ marginBottom: '8px' }}>{drop.item}</h3>
<p style={{ color: 'var(--muted)', fontSize: '14px', marginBottom: '4px' }}> <p style={{ color: 'var(--muted)', fontSize: '14px', marginBottom: '4px' }}>
ID: {drop.id} · Size: {drop.size}{drop.unit} · Price: {(drop.ppu / 1000).toFixed(2)} CHF / {drop.unit} ID: {drop.id} · Size: {drop.size}{drop.unit} · Price: {(drop.ppu / 1000).toFixed(2)} EUR / {drop.unit}
</p> </p>
<p style={{ color: 'var(--muted)', fontSize: '12px', marginBottom: '12px' }}> <p style={{ color: 'var(--muted)', fontSize: '12px', marginBottom: '12px' }}>
Created: {new Date(drop.created_at).toLocaleString()} Created: {new Date(drop.created_at).toLocaleString()}

View File

@@ -1,13 +1,37 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db' import pool from '@/lib/db'
// GET /api/buyers - Get all buyers // GET /api/buyers - Get all buyers with referral counts
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
const [rows] = await pool.execute( const [rows] = await pool.execute(
'SELECT id, username, email, created_at FROM buyers ORDER BY created_at DESC' `SELECT
b.id,
b.username,
b.email,
b.created_at,
COALESCE(ref_counts.referral_count, 0) as referral_count
FROM buyers b
LEFT JOIN (
SELECT referrer, COUNT(*) as referral_count
FROM referrals
GROUP BY referrer
) ref_counts ON b.id = ref_counts.referrer
ORDER BY b.created_at DESC`
) )
return NextResponse.json(rows)
// Add access status for each buyer
const buyersWithAccess = (rows as any[]).map((buyer: any) => {
const referralCount = parseInt(buyer.referral_count) || 0
return {
...buyer,
referral_count: referralCount,
hasWholesaleAccess: referralCount >= 3,
hasInnerCircleAccess: referralCount >= 10
}
})
return NextResponse.json(buyersWithAccess)
} catch (error) { } catch (error) {
console.error('Error fetching buyers:', error) console.error('Error fetching buyers:', error)
return NextResponse.json( return NextResponse.json(

View File

@@ -3,6 +3,8 @@ import { cookies } from 'next/headers'
import pool from '@/lib/db' import pool from '@/lib/db'
import { getNowPaymentsConfig } from '@/lib/nowpayments' import { getNowPaymentsConfig } from '@/lib/nowpayments'
import { ALLOWED_PAYMENT_CURRENCIES, isAllowedCurrency } from '@/lib/payment-currencies' import { ALLOWED_PAYMENT_CURRENCIES, isAllowedCurrency } from '@/lib/payment-currencies'
import { getCountryFromIp, calculateShippingFee } from '@/lib/geolocation'
import { getCurrencyForCountry, convertPriceForCountry } from '@/lib/currency'
// POST /api/payments/create-invoice - Create a NOWPayments payment // POST /api/payments/create-invoice - Create a NOWPayments payment
// Note: Endpoint name kept as "create-invoice" for backward compatibility // Note: Endpoint name kept as "create-invoice" for backward compatibility
@@ -129,23 +131,38 @@ export async function POST(request: NextRequest) {
) )
} }
// Check if user has unlocked wholesale prices // Check if user has unlocked wholesale prices (use transaction connection to avoid connection leak)
const [referralRows] = await pool.execute( const [referralRows] = await connection.execute(
'SELECT COUNT(*) as count FROM referrals WHERE referrer = ?', 'SELECT COUNT(*) as count FROM referrals WHERE referrer = ?',
[buyer_id] [buyer_id]
) )
const referralCount = (referralRows as any[])[0]?.count || 0 const referralCount = (referralRows as any[])[0]?.count || 0
const isWholesaleUnlocked = referralCount >= 3 const isWholesaleUnlocked = referralCount >= 3
// Calculate price // Get country from IP to determine currency
// ppu is stored as integer where 1000 = $1.00, so divide by 1000 to get actual price const countryCode = await getCountryFromIp(request)
const currency = getCurrencyForCountry(countryCode)
// Calculate price in EUR (database stores prices in EUR)
// ppu is stored as integer where 1000 = 1.00 EUR, so divide by 1000 to get actual price
// Assuming ppu is per gram // Assuming ppu is per gram
const pricePerGram = drop.ppu / 1000 const pricePerGramEur = drop.ppu / 1000
const priceToUse = isWholesaleUnlocked ? pricePerGram * 0.76 : pricePerGram const priceToUseEur = isWholesaleUnlocked ? pricePerGramEur * 0.76 : pricePerGramEur
const priceAmount = size * priceToUse const priceAmountEur = size * priceToUseEur
// Convert price to user's currency (CHF for Swiss, EUR for others)
const priceAmount = convertPriceForCountry(priceAmountEur, countryCode)
// Calculate shipping fee (already in correct currency: CHF for CH, EUR for others)
const shippingFee = calculateShippingFee(countryCode)
// Add shipping fee to total price
const totalPriceAmount = priceAmount + shippingFee
// Round to 2 decimal places // Round to 2 decimal places
const roundedPriceAmount = Math.round(priceAmount * 100) / 100 const roundedPriceAmount = Math.round(totalPriceAmount * 100) / 100
const roundedShippingFee = Math.round(shippingFee * 100) / 100
const roundedSubtotal = Math.round(priceAmount * 100) / 100
// Generate order ID // Generate order ID
const orderId = `SALE-${Date.now()}-${drop_id}-${buyer_id}` const orderId = `SALE-${Date.now()}-${drop_id}-${buyer_id}`
@@ -158,6 +175,10 @@ export async function POST(request: NextRequest) {
// Get NOWPayments config (testnet or production) // Get NOWPayments config (testnet or production)
const nowPaymentsConfig = getNowPaymentsConfig() const nowPaymentsConfig = getNowPaymentsConfig()
// Use currency based on user location (CHF for Swiss, EUR for others)
// Override the default currency from config with user's currency
const priceCurrency = currency.toLowerCase()
// Calculate expiration time (10 minutes from now) // Calculate expiration time (10 minutes from now)
const expiresAt = new Date() const expiresAt = new Date()
expiresAt.setMinutes(expiresAt.getMinutes() + 10) expiresAt.setMinutes(expiresAt.getMinutes() + 10)
@@ -180,7 +201,7 @@ export async function POST(request: NextRequest) {
}, },
body: JSON.stringify({ body: JSON.stringify({
price_amount: roundedPriceAmount, price_amount: roundedPriceAmount,
price_currency: nowPaymentsConfig.currency, price_currency: priceCurrency, // CHF for Swiss users, EUR for others
pay_currency: payCurrency, // Required: crypto currency (btc, eth, etc) pay_currency: payCurrency, // Required: crypto currency (btc, eth, etc)
order_id: orderId, order_id: orderId,
order_description: `${drop.item} - ${size}g`, order_description: `${drop.item} - ${size}g`,
@@ -206,7 +227,7 @@ export async function POST(request: NextRequest) {
// payment.payment_id is the NOWPayments payment ID // payment.payment_id is the NOWPayments payment ID
await connection.execute( await connection.execute(
'INSERT INTO pending_orders (payment_id, order_id, drop_id, buyer_id, buyer_data_id, size, price_amount, price_currency, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', 'INSERT INTO pending_orders (payment_id, order_id, drop_id, buyer_id, buyer_data_id, size, price_amount, price_currency, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
[payment.payment_id, orderId, drop_id, buyer_id, buyer_data_id, size, roundedPriceAmount, nowPaymentsConfig.currency, expiresAt] [payment.payment_id, orderId, drop_id, buyer_id, buyer_data_id, size, roundedPriceAmount, priceCurrency, expiresAt]
) )
// Commit transaction - inventory is now reserved // Commit transaction - inventory is now reserved
@@ -220,8 +241,10 @@ export async function POST(request: NextRequest) {
pay_address: payment.pay_address, // Address where customer sends payment pay_address: payment.pay_address, // Address where customer sends payment
pay_amount: payment.pay_amount, // Amount in crypto to pay pay_amount: payment.pay_amount, // Amount in crypto to pay
pay_currency: payment.pay_currency, // Crypto currency pay_currency: payment.pay_currency, // Crypto currency
price_amount: payment.price_amount, // Price in fiat price_amount: payment.price_amount, // Total price in fiat (includes shipping)
price_currency: payment.price_currency, // Fiat currency price_currency: payment.price_currency, // Fiat currency (CHF or EUR)
shipping_fee: roundedShippingFee, // Shipping fee in user's currency
subtotal: roundedSubtotal, // Product price without shipping in user's currency
order_id: orderId, order_id: orderId,
payin_extra_id: payment.payin_extra_id, // Memo/tag for certain currencies (XRP, XLM, etc) payin_extra_id: payment.payin_extra_id, // Memo/tag for certain currencies (XRP, XLM, etc)
expiration_estimate_date: payment.expiration_estimate_date, // When payment expires expiration_estimate_date: payment.expiration_estimate_date, // When payment expires

View File

@@ -14,7 +14,17 @@ export async function GET(request: NextRequest) {
referralCount: 0, referralCount: 0,
isUnlocked: false, isUnlocked: false,
referralsNeeded: 3, referralsNeeded: 3,
referralsRemaining: 3 referralsRemaining: 3,
wholesaleTier: {
referralsNeeded: 3,
referralsRemaining: 3,
isUnlocked: false
},
innerCircleTier: {
referralsNeeded: 10,
referralsRemaining: 10,
isUnlocked: false
}
}, },
{ status: 200 } { status: 200 }
) )
@@ -29,15 +39,29 @@ export async function GET(request: NextRequest) {
) )
const referralCount = (referralRows as any[])[0]?.count || 0 const referralCount = (referralRows as any[])[0]?.count || 0
const isUnlocked = referralCount >= 3 const isWholesaleUnlocked = referralCount >= 3
const referralsNeeded = 3 const isInnerCircleUnlocked = referralCount >= 10
const referralsRemaining = Math.max(0, referralsNeeded - referralCount)
// Determine which tier to show
const wholesaleTier = {
referralsNeeded: 3,
referralsRemaining: Math.max(0, 3 - referralCount),
isUnlocked: isWholesaleUnlocked
}
const innerCircleTier = {
referralsNeeded: 10,
referralsRemaining: Math.max(0, 10 - referralCount),
isUnlocked: isInnerCircleUnlocked
}
return NextResponse.json({ return NextResponse.json({
referralCount, referralCount,
isUnlocked, isUnlocked: isWholesaleUnlocked, // Keep for backward compatibility
referralsNeeded, referralsNeeded: isWholesaleUnlocked ? innerCircleTier.referralsNeeded : wholesaleTier.referralsNeeded,
referralsRemaining, referralsRemaining: isWholesaleUnlocked ? innerCircleTier.referralsRemaining : wholesaleTier.referralsRemaining,
wholesaleTier,
innerCircleTier,
}) })
} catch (error) { } catch (error) {
console.error('Error fetching referral status:', error) console.error('Error fetching referral status:', error)

View File

@@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from 'next/server'
import { getCountryFromIp, calculateShippingFee } from '@/lib/geolocation'
import { getCurrencyForCountry } from '@/lib/currency'
// GET /api/shipping-fee - Get shipping fee based on user's IP location
export async function GET(request: NextRequest) {
try {
const countryCode = await getCountryFromIp(request)
const shippingFee = calculateShippingFee(countryCode)
const currency = getCurrencyForCountry(countryCode)
return NextResponse.json({
shipping_fee: shippingFee,
country_code: countryCode,
currency: currency,
})
} catch (error) {
console.error('Error calculating shipping fee:', error)
// Default to 40 EUR if detection fails
return NextResponse.json({
shipping_fee: 40,
country_code: null,
currency: 'EUR',
})
}
}

View File

@@ -2,6 +2,7 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useSearchParams } from 'next/navigation' import { useSearchParams } from 'next/navigation'
import { useI18n } from '@/lib/i18n'
interface User { interface User {
id: number id: number
@@ -16,6 +17,7 @@ interface AuthModalProps {
} }
export default function AuthModal({ isOpen, onClose, onLogin }: AuthModalProps) { export default function AuthModal({ isOpen, onClose, onLogin }: AuthModalProps) {
const { t } = useI18n()
const searchParams = useSearchParams() const searchParams = useSearchParams()
const [isLogin, setIsLogin] = useState(true) const [isLogin, setIsLogin] = useState(true)
const [username, setUsername] = useState('') const [username, setUsername] = useState('')
@@ -83,7 +85,7 @@ export default function AuthModal({ isOpen, onClose, onLogin }: AuthModalProps)
const data = await response.json() const data = await response.json()
if (!response.ok) { if (!response.ok) {
setError(data.error || 'An error occurred') setError(data.error || t('auth.anErrorOccurred'))
setLoading(false) setLoading(false)
return return
} }
@@ -93,7 +95,7 @@ export default function AuthModal({ isOpen, onClose, onLogin }: AuthModalProps)
onClose() onClose()
} catch (error) { } catch (error) {
console.error('Auth error:', error) console.error('Auth error:', error)
setError('An unexpected error occurred') setError(t('auth.unexpectedError'))
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -131,7 +133,7 @@ export default function AuthModal({ isOpen, onClose, onLogin }: AuthModalProps)
> >
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
<h2 style={{ margin: 0 }}> <h2 style={{ margin: 0 }}>
{isLogin ? 'Login' : 'Register'} {isLogin ? t('auth.login') : t('auth.register')}
</h2> </h2>
<button <button
onClick={onClose} onClick={onClose}
@@ -165,7 +167,7 @@ export default function AuthModal({ isOpen, onClose, onLogin }: AuthModalProps)
color: 'var(--text)', color: 'var(--text)',
}} }}
> >
Email {t('auth.email')}
</label> </label>
<input <input
type="email" type="email"
@@ -182,7 +184,7 @@ export default function AuthModal({ isOpen, onClose, onLogin }: AuthModalProps)
color: 'var(--text)', color: 'var(--text)',
fontSize: '14px', fontSize: '14px',
}} }}
placeholder="your@email.com" placeholder={t('auth.email')}
/> />
</div> </div>
)} )}
@@ -197,7 +199,7 @@ export default function AuthModal({ isOpen, onClose, onLogin }: AuthModalProps)
color: 'var(--text)', color: 'var(--text)',
}} }}
> >
Username {t('auth.username')}
</label> </label>
<input <input
type="text" type="text"
@@ -215,7 +217,7 @@ export default function AuthModal({ isOpen, onClose, onLogin }: AuthModalProps)
color: 'var(--text)', color: 'var(--text)',
fontSize: '14px', fontSize: '14px',
}} }}
placeholder="username" placeholder={t('auth.username')}
/> />
</div> </div>
@@ -229,7 +231,7 @@ export default function AuthModal({ isOpen, onClose, onLogin }: AuthModalProps)
color: 'var(--text)', color: 'var(--text)',
}} }}
> >
Password {t('auth.password')}
</label> </label>
<input <input
type="password" type="password"
@@ -247,7 +249,7 @@ export default function AuthModal({ isOpen, onClose, onLogin }: AuthModalProps)
color: 'var(--text)', color: 'var(--text)',
fontSize: '14px', fontSize: '14px',
}} }}
placeholder="password" placeholder={t('auth.password')}
/> />
</div> </div>
@@ -262,7 +264,7 @@ export default function AuthModal({ isOpen, onClose, onLogin }: AuthModalProps)
color: 'var(--text)', color: 'var(--text)',
}} }}
> >
Referral ID <span style={{ color: 'var(--muted)', fontSize: '12px', fontWeight: 'normal' }}>(optional)</span> {t('auth.referralId')} <span style={{ color: 'var(--muted)', fontSize: '12px', fontWeight: 'normal' }}>({t('auth.optional')})</span>
</label> </label>
<input <input
type="text" type="text"
@@ -278,11 +280,11 @@ export default function AuthModal({ isOpen, onClose, onLogin }: AuthModalProps)
color: 'var(--text)', color: 'var(--text)',
fontSize: '14px', fontSize: '14px',
}} }}
placeholder="Enter referral ID" placeholder={t('auth.referralId')}
/> />
{searchParams?.get('ref') && referralId === searchParams.get('ref') && ( {searchParams?.get('ref') && referralId === searchParams.get('ref') && (
<small style={{ display: 'block', marginTop: '4px', fontSize: '12px', color: 'var(--accent)' }}> <small style={{ display: 'block', marginTop: '4px', fontSize: '12px', color: 'var(--accent)' }}>
Auto-filled from referral link {t('auth.autoFilled')}
</small> </small>
)} )}
</div> </div>
@@ -316,10 +318,10 @@ export default function AuthModal({ isOpen, onClose, onLogin }: AuthModalProps)
}} }}
> >
{loading {loading
? 'Processing...' ? t('common.processing')
: isLogin : isLogin
? 'Login' ? t('auth.login')
: 'Register'} : t('auth.register')}
</button> </button>
</form> </form>
@@ -333,7 +335,7 @@ export default function AuthModal({ isOpen, onClose, onLogin }: AuthModalProps)
> >
{isLogin ? ( {isLogin ? (
<> <>
Don't have an account?{' '} {t('auth.dontHaveAccount')}{' '}
<button <button
onClick={() => { onClick={() => {
setIsLogin(false) setIsLogin(false)
@@ -348,12 +350,12 @@ export default function AuthModal({ isOpen, onClose, onLogin }: AuthModalProps)
padding: 0, padding: 0,
}} }}
> >
Register {t('auth.register')}
</button> </button>
</> </>
) : ( ) : (
<> <>
Already have an account?{' '} {t('auth.alreadyHaveAccount')}{' '}
<button <button
onClick={() => { onClick={() => {
setIsLogin(true) setIsLogin(true)
@@ -368,7 +370,7 @@ export default function AuthModal({ isOpen, onClose, onLogin }: AuthModalProps)
padding: 0, padding: 0,
}} }}
> >
Login {t('auth.login')}
</button> </button>
</> </>
)} )}

View File

@@ -4,6 +4,7 @@ import { useState, useEffect, Suspense } from 'react'
import Image from 'next/image' import Image from 'next/image'
import AuthModal from './AuthModal' import AuthModal from './AuthModal'
import UnlockModal from './UnlockModal' import UnlockModal from './UnlockModal'
import { useI18n } from '@/lib/i18n'
interface DropData { interface DropData {
id: number id: number
@@ -28,6 +29,7 @@ interface User {
} }
export default function Drop() { export default function Drop() {
const { t } = useI18n()
const [drop, setDrop] = useState<DropData | null>(null) const [drop, setDrop] = useState<DropData | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [selectedSize, setSelectedSize] = useState(50) const [selectedSize, setSelectedSize] = useState(50)
@@ -52,11 +54,15 @@ export default function Drop() {
const [isWholesaleUnlocked, setIsWholesaleUnlocked] = useState(false) const [isWholesaleUnlocked, setIsWholesaleUnlocked] = useState(false)
const [showUnlockModal, setShowUnlockModal] = useState(false) const [showUnlockModal, setShowUnlockModal] = useState(false)
const [selectedImageIndex, setSelectedImageIndex] = useState(0) 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
useEffect(() => { useEffect(() => {
fetchActiveDrop() fetchActiveDrop()
checkAuth() checkAuth()
checkWholesaleStatus() checkWholesaleStatus()
fetchShippingFee() // Fetch currency info on mount
// Poll active drop every 30 seconds // Poll active drop every 30 seconds
const interval = setInterval(() => { const interval = setInterval(() => {
@@ -193,11 +199,12 @@ export default function Drop() {
const getMinimumGrams = () => { const getMinimumGrams = () => {
if (!drop) return 0 if (!drop) return 0
// Minimum price is 5 CHF // Minimum price is 5 in user's currency
// Calculate minimum grams needed for 5 CHF // Calculate minimum grams needed for 5 (EUR or CHF)
const pricePerGram = drop.ppu / 1000 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 // Use the higher price (standard) to ensure minimum is met
return Math.ceil(5 / pricePerGram) return Math.ceil(minPriceEur / pricePerGramEur)
} }
const handleCustomQuantityChange = (value: string) => { const handleCustomQuantityChange = (value: string) => {
@@ -224,17 +231,17 @@ export default function Drop() {
const minimum = getMinimumGrams() const minimum = getMinimumGrams()
if (isNaN(numValue) || numValue <= 0) { if (isNaN(numValue) || numValue <= 0) {
setQuantityError('Please enter a valid number') setQuantityError(t('drop.enterValidNumber'))
return false return false
} }
if (numValue < minimum) { if (numValue < minimum) {
setQuantityError(`Minimum ${minimum}g required (5 CHF minimum)`) setQuantityError(t('drop.minimumRequired', { minimum }))
return false return false
} }
if (numValue > remaining) { if (numValue > remaining) {
setQuantityError(`Maximum ${remaining}g available`) setQuantityError(t('drop.maximumAvailable', { maximum: remaining }))
return false return false
} }
@@ -311,6 +318,41 @@ export default function Drop() {
} }
} }
const fetchShippingFee = async () => {
setLoadingShippingFee(true)
try {
const response = await fetch('/api/shipping-fee', {
credentials: 'include',
})
if (response.ok) {
const data = await response.json()
setShippingFee(data.shipping_fee || 40)
setCurrency(data.currency || 'EUR')
} 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
}
return priceInEur
}
const handleJoinDrop = () => { const handleJoinDrop = () => {
// Validate custom quantity if entered // Validate custom quantity if entered
if (customQuantity && !validateCustomQuantity()) { if (customQuantity && !validateCustomQuantity()) {
@@ -322,9 +364,10 @@ export default function Drop() {
setShowAuthModal(true) setShowAuthModal(true)
return return
} }
// Fetch available currencies and buyer data when opening confirm modal // Fetch available currencies, buyer data, and shipping fee when opening confirm modal
fetchAvailableCurrencies() fetchAvailableCurrencies()
fetchBuyerData() fetchBuyerData()
fetchShippingFee()
setShowConfirmModal(true) setShowConfirmModal(true)
} }
@@ -334,6 +377,7 @@ export default function Drop() {
// After login, fetch buyer data and show the confirmation modal // After login, fetch buyer data and show the confirmation modal
fetchAvailableCurrencies() fetchAvailableCurrencies()
fetchBuyerData() fetchBuyerData()
fetchShippingFee()
setShowConfirmModal(true) setShowConfirmModal(true)
} }
@@ -342,7 +386,7 @@ export default function Drop() {
// Validate buyer data fields // Validate buyer data fields
if (!buyerFullname.trim() || !buyerAddress.trim() || !buyerPhone.trim()) { if (!buyerFullname.trim() || !buyerAddress.trim() || !buyerPhone.trim()) {
setErrorMessage('Please fill in all delivery information (full name, address, and phone)') setErrorMessage(t('drop.fillDeliveryInfo'))
setShowErrorModal(true) setShowErrorModal(true)
return return
} }
@@ -434,23 +478,43 @@ export default function Drop() {
const calculatePrice = () => { const calculatePrice = () => {
if (!drop) return 0 if (!drop) return 0
// ppu is stored as integer where 1000 = $1.00, so divide by 1000 to get actual price // 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 // Assuming ppu is per gram
const pricePerGram = drop.ppu / 1000 const pricePerGramEur = drop.ppu / 1000
const priceToUse = isWholesaleUnlocked ? pricePerGram * 0.76 : pricePerGram const priceToUseEur = isWholesaleUnlocked ? pricePerGramEur * 0.76 : pricePerGramEur
return selectedSize * priceToUse const priceEur = selectedSize * priceToUseEur
// Convert to user's currency
return convertPrice(priceEur)
} }
const calculateStandardPrice = () => { const calculateStandardPrice = () => {
if (!drop) return 0 if (!drop) return 0
const pricePerGram = drop.ppu / 1000 const pricePerGramEur = drop.ppu / 1000
return selectedSize * pricePerGram const priceEur = selectedSize * pricePerGramEur
// Convert to user's currency
return convertPrice(priceEur)
} }
const calculateWholesalePrice = () => { const calculateWholesalePrice = () => {
if (!drop) return 0 if (!drop) return 0
const pricePerGram = drop.ppu / 1000 const pricePerGramEur = drop.ppu / 1000
return selectedSize * pricePerGram * 0.76 const priceEur = selectedSize * pricePerGramEur * 0.76
// Convert to user's currency
return convertPrice(priceEur)
}
// Get price per gram in user's currency
const getPricePerGram = () => {
if (!drop) return 0
const pricePerGramEur = drop.ppu / 1000
return convertPrice(pricePerGramEur)
}
// Get wholesale price per gram in user's currency
const getWholesalePricePerGram = () => {
if (!drop) return 0
const pricePerGramEur = drop.ppu / 1000
return convertPrice(pricePerGramEur * 0.76)
} }
const getTimeUntilStart = () => { const getTimeUntilStart = () => {
@@ -467,11 +531,16 @@ export default function Drop() {
const diffMinutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60)) const diffMinutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60))
if (diffDays > 0) { if (diffDays > 0) {
return `${diffDays} day${diffDays > 1 ? 's' : ''}${diffHours > 0 ? ` ${diffHours} hour${diffHours > 1 ? 's' : ''}` : ''}` const dayText = diffDays === 1 ? t('drop.day') : t('drop.days')
const hourText = diffHours > 0 ? (diffHours === 1 ? t('drop.hour') : t('drop.hours')) : ''
return `${diffDays} ${dayText}${diffHours > 0 ? ` ${diffHours} ${hourText}` : ''}`
} else if (diffHours > 0) { } else if (diffHours > 0) {
return `${diffHours} hour${diffHours > 1 ? 's' : ''}${diffMinutes > 0 ? ` ${diffMinutes} minute${diffMinutes > 1 ? 's' : ''}` : ''}` const hourText = diffHours === 1 ? t('drop.hour') : t('drop.hours')
const minuteText = diffMinutes > 0 ? (diffMinutes === 1 ? t('drop.minute') : t('drop.minutes')) : ''
return `${diffHours} ${hourText}${diffMinutes > 0 ? ` ${diffMinutes} ${minuteText}` : ''}`
} else { } else {
return `${diffMinutes} minute${diffMinutes > 1 ? 's' : ''}` const minuteText = diffMinutes === 1 ? t('drop.minute') : t('drop.minutes')
return `${diffMinutes} ${minuteText}`
} }
} }
@@ -495,7 +564,7 @@ export default function Drop() {
return ( return (
<div className="drop"> <div className="drop">
<div style={{ gridColumn: '1 / -1', textAlign: 'center', padding: '40px' }}> <div style={{ gridColumn: '1 / -1', textAlign: 'center', padding: '40px' }}>
<p style={{ color: 'var(--muted)' }}>Loading...</p> <p style={{ color: 'var(--muted)' }}>{t('drop.loading')}</p>
</div> </div>
</div> </div>
) )
@@ -505,12 +574,12 @@ export default function Drop() {
return ( return (
<div className="drop"> <div className="drop">
<div style={{ gridColumn: '1 / -1', textAlign: 'center', padding: '60px' }}> <div style={{ gridColumn: '1 / -1', textAlign: 'center', padding: '60px' }}>
<h2 style={{ marginBottom: '16px' }}>Drop Sold Out</h2> <h2 style={{ marginBottom: '16px' }}>{t('drop.dropSoldOut')}</h2>
<p style={{ color: 'var(--muted)', marginBottom: '20px' }}> <p style={{ color: 'var(--muted)', marginBottom: '20px' }}>
The current collective drop has been fully reserved. {t('drop.fullyReserved')}
</p> </p>
<p style={{ color: 'var(--muted)' }}> <p style={{ color: 'var(--muted)' }}>
Next collective drop coming soon. {t('drop.nextDropComingSoon')}
</p> </p>
</div> </div>
</div> </div>
@@ -601,27 +670,26 @@ export default function Drop() {
color: 'var(--muted)', color: 'var(--muted)',
}} }}
> >
No Image {t('common.noImage')}
</div> </div>
)} )}
<div> <div>
<h2>{drop.item}</h2> <h2>{drop.item}</h2>
<div className="meta"> <div className="meta">
{formatSize(drop.size, drop.unit)} batch {formatSize(drop.size, drop.unit)} {t('drop.batch')}
</div> </div>
<div className="price"> <div className="price">
{(() => { {(() => {
// ppu is stored as integer where 1000 = $1.00 // Get prices in user's currency
// Assuming ppu is always per gram for display purposes const pricePerGram = getPricePerGram();
const pricePerGram = drop.ppu / 1000; const wholesalePricePerGram = getWholesalePricePerGram();
const wholesalePricePerGram = pricePerGram * 0.76;
if (isWholesaleUnlocked) { if (isWholesaleUnlocked) {
return ( return (
<> <>
<strong>Wholesale price: {wholesalePricePerGram.toFixed(2)} CHF / g</strong> <strong>{t('drop.wholesalePriceLabel')} {wholesalePricePerGram.toFixed(2)} {currency} / g</strong>
<span className="muted" style={{ display: 'block', marginTop: '6px', fontSize: '14px' }}> <span className="muted" style={{ display: 'block', marginTop: '6px', fontSize: '14px' }}>
Standard: {pricePerGram.toFixed(2)} CHF / g {t('drop.standard')}: {pricePerGram.toFixed(2)} {currency} / g
</span> </span>
</> </>
); );
@@ -629,11 +697,11 @@ export default function Drop() {
return ( return (
<> <>
<strong>Standard price: {pricePerGram.toFixed(2)} CHF / g</strong> <strong>{t('drop.standardPriceLabel')} {pricePerGram.toFixed(2)} {currency} / g</strong>
<span className="muted"> <span className="muted">
Wholesale: {wholesalePricePerGram.toFixed(2)} CHF / g 🔒 <a href="#unlock" onClick={(e) => { e.preventDefault(); setShowUnlockModal(true); }}>unlock</a> {t('drop.wholesale')}: {wholesalePricePerGram.toFixed(2)} {currency} / g 🔒 <a href="#unlock" onClick={(e) => { e.preventDefault(); setShowUnlockModal(true); }}>{t('drop.unlock')}</a>
</span> </span>
<div className="hint">Unlock once. Keep wholesale forever.</div> <div className="hint">{t('drop.unlockOnce')}</div>
</> </>
); );
})()} })()}
@@ -642,7 +710,7 @@ export default function Drop() {
{isUpcoming ? ( {isUpcoming ? (
<div style={{ marginTop: '30px', padding: '20px', background: 'var(--bg-soft)', borderRadius: '12px', textAlign: 'center' }}> <div style={{ marginTop: '30px', padding: '20px', background: 'var(--bg-soft)', borderRadius: '12px', textAlign: 'center' }}>
<p style={{ margin: 0, color: 'var(--muted)', fontSize: '16px' }}> <p style={{ margin: 0, color: 'var(--muted)', fontSize: '16px' }}>
Drop starts in <strong>{timeUntilStart}</strong> {t('drop.dropStartsIn')} <strong>{timeUntilStart}</strong>
</p> </p>
</div> </div>
) : ( ) : (
@@ -654,7 +722,7 @@ export default function Drop() {
{(() => { {(() => {
const fillDisplay = drop.unit === 'kg' ? Math.round(drop.fill * 1000) : Math.round(drop.fill); const fillDisplay = drop.unit === 'kg' ? Math.round(drop.fill * 1000) : Math.round(drop.fill);
const sizeDisplay = drop.unit === 'kg' ? Math.round(drop.size * 1000) : drop.size; const sizeDisplay = drop.unit === 'kg' ? Math.round(drop.size * 1000) : drop.size;
return `${fillDisplay}g of ${sizeDisplay}g reserved`; return `${fillDisplay}g ${t('drop.of')} ${sizeDisplay}g ${t('drop.reserved')}`;
})()} })()}
</div> </div>
{(() => { {(() => {
@@ -663,7 +731,7 @@ export default function Drop() {
return pendingFill > 0 && ( return pendingFill > 0 && (
<div className="meta" style={{ fontSize: '12px', color: 'var(--muted)', marginTop: '4px' }}> <div className="meta" style={{ fontSize: '12px', color: 'var(--muted)', marginTop: '4px' }}>
{drop.unit === 'kg' ? pendingFill.toFixed(2) : Math.round(pendingFill)} {drop.unit === 'kg' ? pendingFill.toFixed(2) : Math.round(pendingFill)}
{drop.unit} on hold (10 min checkout window) {drop.unit} {t('drop.onHold')}
</div> </div>
) )
})()} })()}
@@ -692,7 +760,7 @@ export default function Drop() {
value={customQuantity} value={customQuantity}
onChange={(e) => handleCustomQuantityChange(e.target.value)} onChange={(e) => handleCustomQuantityChange(e.target.value)}
onBlur={validateCustomQuantity} onBlur={validateCustomQuantity}
placeholder="Custom (g)" placeholder={t('drop.custom')}
min={getMinimumGrams()} min={getMinimumGrams()}
max={getRemainingInGrams()} max={getRemainingInGrams()}
style={{ style={{
@@ -712,7 +780,7 @@ export default function Drop() {
)} )}
{!quantityError && customQuantity && ( {!quantityError && customQuantity && (
<div style={{ marginTop: '6px', fontSize: '12px', color: 'var(--muted)' }}> <div style={{ marginTop: '6px', fontSize: '12px', color: 'var(--muted)' }}>
Min: {getMinimumGrams()}g · Max: {getRemainingInGrams()}g {t('drop.min')}: {getMinimumGrams()}g · {t('drop.max')}: {getRemainingInGrams()}g
</div> </div>
)} )}
</div> </div>
@@ -722,42 +790,42 @@ export default function Drop() {
{isWholesaleUnlocked ? ( {isWholesaleUnlocked ? (
<> <>
<div style={{ fontSize: '18px', fontWeight: 500, marginBottom: '8px' }}> <div style={{ fontSize: '18px', fontWeight: 500, marginBottom: '8px' }}>
Total: {calculatePrice().toFixed(2)} CHF {t('drop.total')}: {calculatePrice().toFixed(2)} {currency}
</div> </div>
<div style={{ fontSize: '14px', color: 'var(--muted)' }}> <div style={{ fontSize: '14px', color: 'var(--muted)' }}>
Standard total: {calculateStandardPrice().toFixed(2)} CHF {t('drop.standardTotal')}: {calculateStandardPrice().toFixed(2)} {currency}
</div> </div>
</> </>
) : ( ) : (
<> <>
<div style={{ fontSize: '18px', fontWeight: 500, marginBottom: '8px' }}> <div style={{ fontSize: '18px', fontWeight: 500, marginBottom: '8px' }}>
Total: {calculateStandardPrice().toFixed(2)} CHF {t('drop.total')}: {calculateStandardPrice().toFixed(2)} {currency}
</div> </div>
<div style={{ fontSize: '14px', color: 'var(--muted)', display: 'flex', alignItems: 'center', gap: '6px' }}> <div style={{ fontSize: '14px', color: 'var(--muted)', display: 'flex', alignItems: 'center', gap: '6px' }}>
Wholesale total: {calculateWholesalePrice().toFixed(2)} CHF 🔒 {t('drop.wholesaleTotal')}: {calculateWholesalePrice().toFixed(2)} {currency} 🔒
</div> </div>
</> </>
)} )}
</div> </div>
<button className="cta" onClick={handleJoinDrop}> <button className="cta" onClick={handleJoinDrop}>
Join the drop {t('drop.joinTheDrop')}
</button> </button>
<div className="cta-note">No subscription · No obligation</div> <div className="cta-note">{t('drop.noSubscription')}</div>
</> </>
)} )}
{hasRemaining && availableSizes.length === 0 && ( {hasRemaining && availableSizes.length === 0 && (
<div style={{ marginTop: '30px', padding: '20px', background: 'var(--bg-soft)', borderRadius: '12px', textAlign: 'center' }}> <div style={{ marginTop: '30px', padding: '20px', background: 'var(--bg-soft)', borderRadius: '12px', textAlign: 'center' }}>
<p style={{ margin: 0, color: 'var(--muted)' }}> <p style={{ margin: 0, color: 'var(--muted)' }}>
Less than 50{drop.unit} remaining. This drop is almost fully reserved. {t('drop.lessThanRemaining', { amount: 50, unit: drop.unit })}
</p> </p>
</div> </div>
)} )}
{!hasRemaining && ( {!hasRemaining && (
<div style={{ marginTop: '30px', padding: '20px', background: 'var(--bg-soft)', borderRadius: '12px', textAlign: 'center' }}> <div style={{ marginTop: '30px', padding: '20px', background: 'var(--bg-soft)', borderRadius: '12px', textAlign: 'center' }}>
<p style={{ margin: 0, color: 'var(--muted)' }}>This drop is fully reserved</p> <p style={{ margin: 0, color: 'var(--muted)' }}>{t('drop.fullyReservedText')}</p>
</div> </div>
)} )}
</div> </div>
@@ -792,34 +860,34 @@ export default function Drop() {
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<h2 style={{ marginTop: 0, marginBottom: '20px' }}> <h2 style={{ marginTop: 0, marginBottom: '20px' }}>
Confirm Purchase {t('drop.confirmPurchase')}
</h2> </h2>
<div style={{ marginBottom: '24px' }}> <div style={{ marginBottom: '24px' }}>
<p style={{ marginBottom: '12px', color: 'var(--muted)' }}> <p style={{ marginBottom: '12px', color: 'var(--muted)' }}>
<strong>Item:</strong> {drop.item} <strong>{t('drop.item')}:</strong> {drop.item}
</p> </p>
<p style={{ marginBottom: '12px', color: 'var(--muted)' }}> <p style={{ marginBottom: '12px', color: 'var(--muted)' }}>
<strong>Quantity:</strong> {selectedSize}g <strong>{t('drop.quantity')}:</strong> {selectedSize}g
</p> </p>
<p style={{ marginBottom: '12px', color: 'var(--muted)' }}> <p style={{ marginBottom: '12px', color: 'var(--muted)' }}>
<strong>Price per {drop.unit}:</strong> {(drop.ppu / 1000).toFixed(2)} CHF <strong>{t('drop.pricePerUnit', { unit: drop.unit })}:</strong> {getPricePerGram().toFixed(2)} {currency}
</p> </p>
{/* Delivery Information */} {/* Delivery Information */}
<div style={{ marginTop: '24px', marginBottom: '16px' }}> <div style={{ marginTop: '24px', marginBottom: '16px' }}>
<h3 style={{ marginBottom: '16px', fontSize: '16px', color: 'var(--text)' }}> <h3 style={{ marginBottom: '16px', fontSize: '16px', color: 'var(--text)' }}>
Delivery Information {t('drop.deliveryInformation')}
</h3> </h3>
<div style={{ marginBottom: '12px' }}> <div style={{ marginBottom: '12px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', color: 'var(--muted)' }}> <label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', color: 'var(--muted)' }}>
<strong>Full Name *</strong> <strong>{t('drop.fullNameRequired')}</strong>
</label> </label>
<input <input
type="text" type="text"
value={buyerFullname} value={buyerFullname}
onChange={(e) => setBuyerFullname(e.target.value)} onChange={(e) => setBuyerFullname(e.target.value)}
placeholder="Enter your full name" placeholder={t('drop.enterFullName')}
required required
style={{ style={{
width: '100%', width: '100%',
@@ -836,12 +904,12 @@ export default function Drop() {
<div style={{ marginBottom: '12px' }}> <div style={{ marginBottom: '12px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', color: 'var(--muted)' }}> <label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', color: 'var(--muted)' }}>
<strong>Address *</strong> <strong>{t('drop.addressRequired')}</strong>
</label> </label>
<textarea <textarea
value={buyerAddress} value={buyerAddress}
onChange={(e) => setBuyerAddress(e.target.value)} onChange={(e) => setBuyerAddress(e.target.value)}
placeholder="Enter your delivery address" placeholder={t('drop.enterAddress')}
required required
rows={3} rows={3}
style={{ style={{
@@ -861,13 +929,13 @@ export default function Drop() {
<div style={{ marginBottom: '12px' }}> <div style={{ marginBottom: '12px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', color: 'var(--muted)' }}> <label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', color: 'var(--muted)' }}>
<strong>Phone Number *</strong> <strong>{t('drop.phoneRequired')}</strong>
</label> </label>
<input <input
type="tel" type="tel"
value={buyerPhone} value={buyerPhone}
onChange={(e) => setBuyerPhone(e.target.value)} onChange={(e) => setBuyerPhone(e.target.value)}
placeholder="Enter your phone number" placeholder={t('drop.enterPhone')}
required required
style={{ style={{
width: '100%', width: '100%',
@@ -886,10 +954,10 @@ export default function Drop() {
{/* Currency Selection */} {/* Currency Selection */}
<div style={{ marginTop: '24px', marginBottom: '16px' }}> <div style={{ marginTop: '24px', marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', color: 'var(--muted)' }}> <label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', color: 'var(--muted)' }}>
<strong>Payment Currency:</strong> <strong>{t('drop.paymentCurrency')}:</strong>
</label> </label>
{loadingCurrencies ? ( {loadingCurrencies ? (
<p style={{ color: 'var(--muted)', fontSize: '14px' }}>Loading currencies...</p> <p style={{ color: 'var(--muted)', fontSize: '14px' }}>{t('drop.loadingCurrencies')}</p>
) : ( ) : (
<select <select
value={selectedCurrency} value={selectedCurrency}
@@ -941,9 +1009,32 @@ export default function Drop() {
marginTop: '16px', marginTop: '16px',
}} }}
> >
<p style={{ margin: 0, fontSize: '18px', fontWeight: 'bold' }}> <div style={{ marginBottom: '12px' }}>
Total: {calculatePrice().toFixed(2)} CHF <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '8px' }}>
</p> <span style={{ color: 'var(--muted)', fontSize: '14px' }}>{t('drop.subtotal')}:</span>
<span style={{ fontWeight: 500, fontSize: '14px' }}>
{calculatePrice().toFixed(2)} {currency}
</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '8px' }}>
<span style={{ color: 'var(--muted)', fontSize: '14px' }}>{t('drop.shippingFee')}:</span>
<span style={{ fontWeight: 500, fontSize: '14px' }}>
{loadingShippingFee ? '...' : (shippingFee !== null ? shippingFee.toFixed(2) : '40.00')} {currency}
</span>
</div>
<div style={{
display: 'flex',
justifyContent: 'space-between',
marginTop: '12px',
paddingTop: '12px',
borderTop: '1px solid var(--border)'
}}>
<span style={{ fontSize: '18px', fontWeight: 'bold' }}>{t('drop.total')}:</span>
<span style={{ fontSize: '18px', fontWeight: 'bold' }}>
{loadingShippingFee ? '...' : ((calculatePrice() + (shippingFee || 40)).toFixed(2))} {currency}
</span>
</div>
</div>
<p <p
style={{ style={{
margin: '4px 0 0 0', margin: '4px 0 0 0',
@@ -951,7 +1042,7 @@ export default function Drop() {
color: 'var(--muted)', color: 'var(--muted)',
}} }}
> >
incl. 2.5% VAT {t('drop.inclVat')}
</p> </p>
<p <p
style={{ style={{
@@ -960,7 +1051,7 @@ export default function Drop() {
color: 'var(--muted)', color: 'var(--muted)',
}} }}
> >
Pay with: {String(selectedCurrency || '').toUpperCase()} {t('drop.payWith')}: {String(selectedCurrency || '').toUpperCase()}
</p> </p>
</div> </div>
</div> </div>
@@ -989,7 +1080,7 @@ export default function Drop() {
display: 'inline-block', display: 'inline-block',
}} }}
> >
Cancel {t('common.cancel')}
</button> </button>
<button <button
onClick={handleConfirmPurchase} onClick={handleConfirmPurchase}
@@ -1009,7 +1100,7 @@ export default function Drop() {
display: 'inline-block', display: 'inline-block',
}} }}
> >
{processing ? 'Processing...' : 'Confirm Purchase'} {processing ? t('common.processing') : t('drop.confirmPurchase')}
</button> </button>
</div> </div>
</div> </div>
@@ -1061,14 +1152,14 @@ export default function Drop() {
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<h2 style={{ marginTop: 0, marginBottom: '20px', color: '#0a7931' }}> <h2 style={{ marginTop: 0, marginBottom: '20px', color: '#0a7931' }}>
Payment confirmed {t('drop.paymentConfirmed')}
</h2> </h2>
<p style={{ marginBottom: '16px', color: 'var(--text)' }}> <p style={{ marginBottom: '16px', color: 'var(--text)' }}>
Your order has been successfully processed and is now reserved in this drop. {t('drop.orderProcessed')}
</p> </p>
<div style={{ marginBottom: '24px' }}> <div style={{ marginBottom: '24px' }}>
<p style={{ marginBottom: '12px', fontWeight: '600', color: 'var(--text)' }}> <p style={{ marginBottom: '12px', fontWeight: '600', color: 'var(--text)' }}>
What happens next: {t('drop.whatHappensNext')}:
</p> </p>
<ul style={{ <ul style={{
margin: 0, margin: 0,
@@ -1076,13 +1167,13 @@ export default function Drop() {
color: 'var(--muted)', color: 'var(--muted)',
lineHeight: '1.8' lineHeight: '1.8'
}}> }}>
<li>Your order will be processed within 24 hours</li> <li>{t('drop.orderProcessed24h')}</li>
<li>Shipped via express delivery</li> <li>{t('drop.shippedExpress')}</li>
<li>You'll receive a shipping confirmation and tracking link by email</li> <li>{t('drop.shippingConfirmation')}</li>
</ul> </ul>
</div> </div>
<p style={{ marginBottom: '24px', color: 'var(--muted)', fontStyle: 'italic' }}> <p style={{ marginBottom: '24px', color: 'var(--muted)', fontStyle: 'italic' }}>
Thank you for being part of the collective. {t('drop.thankYouCollective')}
</p> </p>
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}> <div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
<button <button
@@ -1104,7 +1195,7 @@ export default function Drop() {
display: 'inline-block', display: 'inline-block',
}} }}
> >
Close {t('common.close')}
</button> </button>
</div> </div>
</div> </div>
@@ -1145,7 +1236,7 @@ export default function Drop() {
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<h2 style={{ marginTop: 0, marginBottom: '20px', color: '#dc2626' }}> <h2 style={{ marginTop: 0, marginBottom: '20px', color: '#dc2626' }}>
Error {t('drop.error')}
</h2> </h2>
<p style={{ marginBottom: '24px', color: 'var(--muted)' }}> <p style={{ marginBottom: '24px', color: 'var(--muted)' }}>
{errorMessage} {errorMessage}
@@ -1172,7 +1263,7 @@ export default function Drop() {
display: 'inline-block', display: 'inline-block',
}} }}
> >
Close {t('common.close')}
</button> </button>
</div> </div>
</div> </div>
@@ -1240,20 +1331,50 @@ export default function Drop() {
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<h2 style={{ marginTop: 0, marginBottom: '20px' }}> <h2 style={{ marginTop: 0, marginBottom: '20px' }}>
Complete Payment {t('drop.completePayment')}
</h2> </h2>
<div style={{ marginBottom: '24px' }}> <div style={{ marginBottom: '24px' }}>
<p style={{ marginBottom: '12px', color: 'var(--muted)' }}> <p style={{ marginBottom: '12px', color: 'var(--muted)' }}>
<strong>Amount to Pay:</strong> {paymentData.pay_amount} {paymentData.pay_currency.toUpperCase()} <strong>{t('drop.amountToPay')}:</strong> {paymentData.pay_amount} {paymentData.pay_currency.toUpperCase()}
</p>
<p style={{ marginBottom: '12px', color: 'var(--muted)' }}>
<strong>Price:</strong> {paymentData.price_amount} {paymentData.price_currency.toUpperCase()}
</p> </p>
<div style={{
marginTop: '20px',
marginBottom: '20px',
padding: '16px',
background: 'var(--bg-soft)',
borderRadius: '8px'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '8px' }}>
<span style={{ color: 'var(--muted)' }}>{t('drop.subtotal')}:</span>
<span style={{ fontWeight: 500 }}>
{paymentData.subtotal?.toFixed(2) || (paymentData.price_amount - (paymentData.shipping_fee || 0)).toFixed(2)} {paymentData.price_currency.toUpperCase()}
</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '8px' }}>
<span style={{ color: 'var(--muted)' }}>{t('drop.shippingFee')}:</span>
<span style={{ fontWeight: 500 }}>
{paymentData.shipping_fee?.toFixed(2) || '0.00'} {paymentData.price_currency.toUpperCase()}
</span>
</div>
<div style={{
display: 'flex',
justifyContent: 'space-between',
marginTop: '12px',
paddingTop: '12px',
borderTop: '1px solid var(--border)'
}}>
<span style={{ fontWeight: 600, fontSize: '16px' }}>{t('drop.total')}:</span>
<span style={{ fontWeight: 600, fontSize: '16px' }}>
{paymentData.price_amount} {paymentData.price_currency.toUpperCase()}
</span>
</div>
</div>
<div style={{ marginTop: '20px', marginBottom: '20px' }}> <div style={{ marginTop: '20px', marginBottom: '20px' }}>
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', color: 'var(--muted)' }}> <label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', color: 'var(--muted)' }}>
Send payment to this address: {t('drop.sendPaymentTo')}
</label> </label>
<div <div
style={{ style={{
@@ -1288,14 +1409,14 @@ export default function Drop() {
fontSize: '14px', fontSize: '14px',
}} }}
> >
Copy Address {t('drop.copyAddress')}
</button> </button>
</div> </div>
{paymentData.payin_extra_id && ( {paymentData.payin_extra_id && (
<div style={{ marginTop: '20px', marginBottom: '20px' }}> <div style={{ marginTop: '20px', marginBottom: '20px' }}>
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', color: 'var(--muted)' }}> <label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', color: 'var(--muted)' }}>
Memo / Destination Tag (Required): {t('drop.memoRequired')}:
</label> </label>
<div <div
style={{ style={{
@@ -1318,34 +1439,34 @@ export default function Drop() {
button.textContent = 'Copied!' button.textContent = 'Copied!'
setTimeout(() => { setTimeout(() => {
if (button) button.textContent = originalText if (button) button.textContent = originalText
}, 2000) }, 2000)
}} }}
style={{ style={{
padding: '8px 16px', padding: '8px 16px',
background: 'var(--bg-soft)', background: 'var(--bg-soft)',
border: '1px solid var(--border)', border: '1px solid var(--border)',
borderRadius: '8px', borderRadius: '8px',
cursor: 'pointer', cursor: 'pointer',
fontSize: '14px', fontSize: '14px',
}} }}
> >
Copy Memo {t('drop.copyMemo')}
</button> </button>
</div> </div>
)} )}
{paymentData.expiration_estimate_date && ( {paymentData.expiration_estimate_date && (
<p style={{ marginTop: '16px', fontSize: '12px', color: 'var(--muted)' }}> <p style={{ marginTop: '16px', fontSize: '12px', color: 'var(--muted)' }}>
Payment expires: {new Date(paymentData.expiration_estimate_date).toLocaleString()} {t('drop.paymentExpires')}: {new Date(paymentData.expiration_estimate_date).toLocaleString()}
</p> </p>
)} )}
<div style={{ marginTop: '24px', padding: '16px', background: 'var(--bg-soft)', borderRadius: '8px' }}> <div style={{ marginTop: '24px', padding: '16px', background: 'var(--bg-soft)', borderRadius: '8px' }}>
<p style={{ margin: 0, fontSize: '14px', color: 'var(--muted)' }}> <p style={{ margin: 0, fontSize: '14px', color: 'var(--muted)' }}>
<strong>Status:</strong> {paymentData.payment_status} <strong>{t('drop.status')}:</strong> {paymentData.payment_status}
</p> </p>
<p style={{ margin: '12px 0 0 0', fontSize: '12px', color: '#dc2626', fontWeight: 500 }}> <p style={{ margin: '12px 0 0 0', fontSize: '12px', color: '#dc2626', fontWeight: 500 }}>
Closing this window will cancel your reservation and free up the inventory. {t('drop.closingWarning')}
</p> </p>
</div> </div>
</div> </div>
@@ -1398,7 +1519,7 @@ export default function Drop() {
display: 'inline-block', display: 'inline-block',
}} }}
> >
Close {t('common.close')}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -1,7 +1,13 @@
'use client'
import { useI18n } from '@/lib/i18n'
export default function Footer() { export default function Footer() {
const { t } = useI18n()
return ( return (
<footer> <footer>
© 2025 420Deals.ch · CBD &lt; 1% THC · Sale from 18 years · Switzerland {t('footer.text')}
</footer> </footer>
) )
} }

View File

@@ -1,26 +1,23 @@
'use client'
import { useI18n } from '@/lib/i18n'
export default function InfoBox() { export default function InfoBox() {
const { t } = useI18n()
return ( return (
<div className="info-box"> <div className="info-box">
<div> <div>
<h3>Why so cheap?</h3> <h3>{t('infoBox.whyCheap')}</h3>
<p> <p>{t('infoBox.whyCheapText')}</p>
Retail prices are around 10 CHF/g. Through collective
bulk orders, we buy like wholesalers without
intermediaries.
</p>
</div> </div>
<div> <div>
<h3>Taxes & Legal</h3> <h3>{t('infoBox.taxesLegal')}</h3>
<p> <p>{t('infoBox.taxesLegalText')}</p>
Bulk sale with 2.5% VAT. No retail packaging, no
tobacco tax.
</p>
</div> </div>
<div> <div>
<h3>Drop Model</h3> <h3>{t('infoBox.dropModel')}</h3>
<p> <p>{t('infoBox.dropModelText')}</p>
One variety per drop. Only when sold out then the next drop.
</p>
</div> </div>
</div> </div>
) )

View File

@@ -0,0 +1,45 @@
'use client'
import { useI18n } from '@/lib/i18n'
export default function LanguageSwitcher() {
const { language, setLanguage } = useI18n()
return (
<div style={{ display: 'flex', gap: '8px', alignItems: 'center', marginLeft: '16px' }}>
<button
onClick={() => setLanguage('en')}
style={{
padding: '6px 12px',
fontSize: '13px',
background: language === 'en' ? 'var(--accent)' : 'transparent',
color: language === 'en' ? '#000' : 'var(--muted)',
border: `1px solid ${language === 'en' ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: '6px',
cursor: 'pointer',
fontWeight: language === 'en' ? 500 : 400,
transition: 'all 0.2s',
}}
>
EN
</button>
<button
onClick={() => setLanguage('de')}
style={{
padding: '6px 12px',
fontSize: '13px',
background: language === 'de' ? 'var(--accent)' : 'transparent',
color: language === 'de' ? '#000' : 'var(--muted)',
border: `1px solid ${language === 'de' ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: '6px',
cursor: 'pointer',
fontWeight: language === 'de' ? 500 : 400,
transition: 'all 0.2s',
}}
>
DE
</button>
</div>
)
}

View File

@@ -2,6 +2,8 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import AuthModal from './AuthModal' import AuthModal from './AuthModal'
import LanguageSwitcher from './LanguageSwitcher'
import { useI18n } from '@/lib/i18n'
interface User { interface User {
id: number id: number
@@ -10,6 +12,7 @@ interface User {
} }
export default function Nav() { export default function Nav() {
const { t } = useI18n()
const [user, setUser] = useState<User | null>(null) const [user, setUser] = useState<User | null>(null)
const [showAuthModal, setShowAuthModal] = useState(false) const [showAuthModal, setShowAuthModal] = useState(false)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@@ -72,9 +75,10 @@ export default function Nav() {
</button> </button>
</div> </div>
<div className={`links ${mobileMenuOpen ? 'mobile-open' : ''}`}> <div className={`links ${mobileMenuOpen ? 'mobile-open' : ''}`}>
<a href="#drop" onClick={() => setMobileMenuOpen(false)}>Drop</a> <a href="#drop" onClick={() => setMobileMenuOpen(false)}>{t('nav.drop')}</a>
<a href="#past" onClick={() => setMobileMenuOpen(false)}>Past Drops</a> <a href="#past" onClick={() => setMobileMenuOpen(false)}>{t('nav.pastDrops')}</a>
<a href="#community" onClick={() => setMobileMenuOpen(false)}>Community</a> <a href="#community" onClick={() => setMobileMenuOpen(false)}>{t('nav.community')}</a>
<LanguageSwitcher />
{!loading && ( {!loading && (
user ? ( user ? (
<> <>
@@ -99,7 +103,7 @@ export default function Nav() {
cursor: 'pointer', cursor: 'pointer',
}} }}
> >
Orders {t('nav.orders')}
</a> </a>
<button <button
onClick={() => { onClick={() => {
@@ -120,7 +124,7 @@ export default function Nav() {
display: 'inline-block', display: 'inline-block',
}} }}
> >
Logout {t('nav.logout')}
</button> </button>
</> </>
) : ( ) : (
@@ -144,7 +148,7 @@ export default function Nav() {
display: 'inline-block', display: 'inline-block',
}} }}
> >
Login {t('nav.login')}
</button> </button>
) )
)} )}

View File

@@ -2,6 +2,7 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import Image from 'next/image' import Image from 'next/image'
import { useI18n } from '@/lib/i18n'
interface PastDrop { interface PastDrop {
id: number id: number
@@ -22,6 +23,7 @@ interface PastDropsProps {
} }
export default function PastDrops({ limit, showMoreLink = false }: PastDropsProps = {}) { export default function PastDrops({ limit, showMoreLink = false }: PastDropsProps = {}) {
const { t } = useI18n()
const [drops, setDrops] = useState<PastDrop[]>([]) const [drops, setDrops] = useState<PastDrop[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@@ -55,18 +57,20 @@ export default function PastDrops({ limit, showMoreLink = false }: PastDropsProp
const formatSoldOutTime = (hours: number) => { const formatSoldOutTime = (hours: number) => {
if (hours < 1) { if (hours < 1) {
return 'Sold out in less than 1h' return `${t('pastDrops.soldOutIn')} ${t('pastDrops.lessThan1h')}`
} else if (hours === 1) { } else if (hours === 1) {
return 'Sold out in 1h' return `${t('pastDrops.soldOutIn')} ${t('pastDrops.1h')}`
} else if (hours < 24) { } else if (hours < 24) {
return `Sold out in ${hours}h` return `${t('pastDrops.soldOutIn')} ${t('pastDrops.hours', { hours })}`
} else { } else {
const days = Math.floor(hours / 24) const days = Math.floor(hours / 24)
const remainingHours = hours % 24 const remainingHours = hours % 24
if (remainingHours === 0) { if (remainingHours === 0) {
return days === 1 ? 'Sold out in 1 day' : `Sold out in ${days} days` return days === 1
? `${t('pastDrops.soldOutIn')} ${t('pastDrops.1day')}`
: `${t('pastDrops.soldOutIn')} ${t('pastDrops.days', { days })}`
} else { } else {
return `Sold out in ${days}d ${remainingHours}h` return `${t('pastDrops.soldOutIn')} ${t('pastDrops.daysHours', { days, hours: remainingHours })}`
} }
} }
} }
@@ -88,7 +92,7 @@ export default function PastDrops({ limit, showMoreLink = false }: PastDropsProp
if (loading) { if (loading) {
return ( return (
<div className="past"> <div className="past">
<p style={{ color: 'var(--muted)', textAlign: 'center' }}>Loading past drops...</p> <p style={{ color: 'var(--muted)', textAlign: 'center' }}>{t('pastDrops.loading')}</p>
</div> </div>
) )
} }
@@ -97,7 +101,7 @@ export default function PastDrops({ limit, showMoreLink = false }: PastDropsProp
return ( return (
<div className="past"> <div className="past">
<p style={{ color: 'var(--muted)', textAlign: 'center' }}> <p style={{ color: 'var(--muted)', textAlign: 'center' }}>
No past drops yet. Check back soon! {t('pastDrops.noDrops')}
</p> </p>
</div> </div>
) )
@@ -149,7 +153,7 @@ export default function PastDrops({ limit, showMoreLink = false }: PastDropsProp
color: 'var(--muted)', color: 'var(--muted)',
}} }}
> >
No Image {t('common.noImage')}
</div> </div>
)} )}
<strong>{drop.item}</strong> <strong>{drop.item}</strong>
@@ -174,7 +178,7 @@ export default function PastDrops({ limit, showMoreLink = false }: PastDropsProp
fontWeight: 500, fontWeight: 500,
}} }}
> >
More {t('pastDrops.more')}
</a> </a>
</div> </div>
)} )}

View File

@@ -1,8 +1,10 @@
'use client' 'use client'
import { useState } from 'react' import { useState } from 'react'
import { useI18n } from '@/lib/i18n'
export default function Signup() { export default function Signup() {
const { t } = useI18n()
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [whatsapp, setWhatsapp] = useState('') const [whatsapp, setWhatsapp] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
@@ -60,19 +62,19 @@ export default function Signup() {
return ( return (
<> <>
<div className="signup"> <div className="signup">
<h2>Drop Notifications</h2> <h2>{t('signup.title')}</h2>
<p>Receive updates about new drops via email or WhatsApp.</p> <p>{t('signup.subtitle')}</p>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<input <input
type="email" type="email"
placeholder="E-Mail" placeholder={t('signup.email')}
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
disabled={loading} disabled={loading}
/> />
<input <input
type="text" type="text"
placeholder="WhatsApp Number" placeholder={t('signup.whatsapp')}
value={whatsapp} value={whatsapp}
onChange={(e) => setWhatsapp(e.target.value)} onChange={(e) => setWhatsapp(e.target.value)}
disabled={loading} disabled={loading}
@@ -80,7 +82,7 @@ export default function Signup() {
<br /> <br />
{error && <div style={{ color: '#ff4444', marginTop: '10px', fontSize: '14px' }}>{error}</div>} {error && <div style={{ color: '#ff4444', marginTop: '10px', fontSize: '14px' }}>{error}</div>}
<button type="submit" disabled={loading}> <button type="submit" disabled={loading}>
{loading ? 'Subscribing...' : 'Get Notified'} {loading ? t('signup.subscribing') : t('signup.getNotified')}
</button> </button>
</form> </form>
</div> </div>
@@ -103,7 +105,7 @@ export default function Signup() {
}} }}
> >
<p style={{ margin: 0, fontSize: '16px', color: '#eaeaea' }}> <p style={{ margin: 0, fontSize: '16px', color: '#eaeaea' }}>
You will receive a notification as soon as a new drop drops. {t('signup.successMessage')}
</p> </p>
<button <button
onClick={() => setShowPopup(false)} onClick={() => setShowPopup(false)}
@@ -118,7 +120,7 @@ export default function Signup() {
fontSize: '14px', fontSize: '14px',
}} }}
> >
OK {t('common.ok')}
</button> </button>
</div> </div>
)} )}

View File

@@ -2,15 +2,25 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import UnlockModal from './UnlockModal' import UnlockModal from './UnlockModal'
import { useI18n } from '@/lib/i18n'
interface ReferralTier {
referralsNeeded: number
referralsRemaining: number
isUnlocked: boolean
}
interface ReferralStatus { interface ReferralStatus {
referralCount: number referralCount: number
isUnlocked: boolean isUnlocked: boolean
referralsNeeded: number referralsNeeded: number
referralsRemaining: number referralsRemaining: number
wholesaleTier?: ReferralTier
innerCircleTier?: ReferralTier
} }
export default function UnlockBar() { export default function UnlockBar() {
const { t } = useI18n()
const [referralStatus, setReferralStatus] = useState<ReferralStatus | null>(null) const [referralStatus, setReferralStatus] = useState<ReferralStatus | null>(null)
const [showModal, setShowModal] = useState(false) const [showModal, setShowModal] = useState(false)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@@ -34,6 +44,16 @@ export default function UnlockBar() {
isUnlocked: false, isUnlocked: false,
referralsNeeded: 3, referralsNeeded: 3,
referralsRemaining: 3, referralsRemaining: 3,
wholesaleTier: {
referralsNeeded: 3,
referralsRemaining: 3,
isUnlocked: false
},
innerCircleTier: {
referralsNeeded: 10,
referralsRemaining: 10,
isUnlocked: false
}
}) })
} finally { } finally {
setLoading(false) setLoading(false)
@@ -54,27 +74,72 @@ export default function UnlockBar() {
isUnlocked: false, isUnlocked: false,
referralsNeeded: 3, referralsNeeded: 3,
referralsRemaining: 3, referralsRemaining: 3,
wholesaleTier: {
referralsNeeded: 3,
referralsRemaining: 3,
isUnlocked: false
},
innerCircleTier: {
referralsNeeded: 10,
referralsRemaining: 10,
isUnlocked: false
}
} }
// If unlocked, show different message or hide bar const wholesaleTier = status.wholesaleTier || {
if (status.isUnlocked) { referralsNeeded: 3,
referralsRemaining: Math.max(0, 3 - status.referralCount),
isUnlocked: status.isUnlocked
}
const innerCircleTier = status.innerCircleTier || {
referralsNeeded: 10,
referralsRemaining: Math.max(0, 10 - status.referralCount),
isUnlocked: status.referralCount >= 10
}
// If wholesale is unlocked but inner circle is not, show inner circle as next level
if (wholesaleTier.isUnlocked && !innerCircleTier.isUnlocked) {
return (
<>
<div className="unlock-bar" style={{ background: 'var(--accent)', color: '#000' }}>
<div>
{t('unlockBar.unlocked')} <strong>{t('unlockBar.unlockedText')}</strong>
</div>
</div>
<div className="unlock-bar">
<div>
{t('unlockBar.innerCircleLocked')} <strong>{t('unlockBar.referralsCompleted', { count: status.referralCount, needed: innerCircleTier.referralsNeeded })}</strong> · {t('unlockBar.toGo', { remaining: innerCircleTier.referralsRemaining })}
<br />
<small>{t('unlockBar.innerCircleUnlockText', { needed: innerCircleTier.referralsNeeded })}</small>
<a href="#unlock" onClick={handleUnlockClick}>{t('unlockBar.unlockNow')}</a>
</div>
</div>
<UnlockModal isOpen={showModal} onClose={() => setShowModal(false)} />
</>
)
}
// If both are unlocked, show success message
if (wholesaleTier.isUnlocked && innerCircleTier.isUnlocked) {
return ( return (
<div className="unlock-bar" style={{ background: 'var(--accent)', color: '#000' }}> <div className="unlock-bar" style={{ background: 'var(--accent)', color: '#000' }}>
<div> <div>
Wholesale prices unlocked <strong>You have access to wholesale pricing!</strong> {t('unlockBar.unlocked')} <strong>{t('unlockBar.unlockedText')}</strong> · {t('unlockBar.innerCircleUnlocked')}
</div> </div>
</div> </div>
) )
} }
// Show wholesale unlock progress
return ( return (
<> <>
<div className="unlock-bar"> <div className="unlock-bar">
<div> <div>
🔒 Wholesale prices locked <strong>{status.referralCount} / {status.referralsNeeded} referrals completed</strong> · {status.referralsRemaining} to go {t('unlockBar.locked')} <strong>{t('unlockBar.referralsCompleted', { count: status.referralCount, needed: wholesaleTier.referralsNeeded })}</strong> · {t('unlockBar.toGo', { remaining: wholesaleTier.referralsRemaining })}
<br /> <br />
<small>{status.referralsNeeded} verified sign-ups unlock wholesale prices forever.</small> <small>{t('unlockBar.unlockText', { needed: wholesaleTier.referralsNeeded })}</small>
<a href="#unlock" onClick={handleUnlockClick}>Unlock now</a> <a href="#unlock" onClick={handleUnlockClick}>{t('unlockBar.unlockNow')}</a>
</div> </div>
</div> </div>
<UnlockModal isOpen={showModal} onClose={() => setShowModal(false)} /> <UnlockModal isOpen={showModal} onClose={() => setShowModal(false)} />

View File

@@ -2,6 +2,7 @@
import { useState, useEffect, Suspense } from 'react' import { useState, useEffect, Suspense } from 'react'
import AuthModal from './AuthModal' import AuthModal from './AuthModal'
import { useI18n } from '@/lib/i18n'
interface UnlockModalProps { interface UnlockModalProps {
isOpen: boolean isOpen: boolean
@@ -22,6 +23,7 @@ interface ReferralStatus {
} }
export default function UnlockModal({ isOpen, onClose }: UnlockModalProps) { export default function UnlockModal({ isOpen, onClose }: UnlockModalProps) {
const { t } = useI18n()
const [referralStatus, setReferralStatus] = useState<ReferralStatus | null>(null) const [referralStatus, setReferralStatus] = useState<ReferralStatus | null>(null)
const [referralLink, setReferralLink] = useState<string>('') const [referralLink, setReferralLink] = useState<string>('')
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@@ -115,7 +117,7 @@ export default function UnlockModal({ isOpen, onClose }: UnlockModalProps) {
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
<h2 style={{ margin: 0 }}>Unlock wholesale prices</h2> <h2 style={{ margin: 0 }}>{t('unlockModal.title')}</h2>
<button <button
onClick={onClose} onClick={onClose}
style={{ style={{
@@ -137,17 +139,17 @@ export default function UnlockModal({ isOpen, onClose }: UnlockModalProps) {
</div> </div>
{loading ? ( {loading ? (
<p style={{ color: 'var(--muted)', textAlign: 'center' }}>Loading...</p> <p style={{ color: 'var(--muted)', textAlign: 'center' }}>{t('common.loading')}</p>
) : ( ) : (
<> <>
<div style={{ marginBottom: '24px', textAlign: 'center' }}> <div style={{ marginBottom: '24px', textAlign: 'center' }}>
<div style={{ fontSize: '18px', marginBottom: '8px' }}> <div style={{ fontSize: '18px', marginBottom: '8px' }}>
🔒 {status.referralCount} of {status.referralsNeeded} referrals completed 🔒 {t('unlockModal.referralsCompleted', { count: status.referralCount, needed: status.referralsNeeded })}
</div> </div>
<p style={{ color: 'var(--muted)', fontSize: '14px', margin: '8px 0' }}> <p style={{ color: 'var(--muted)', fontSize: '14px', margin: '8px 0' }}>
Invite {status.referralsNeeded} friends to sign up. {t('unlockModal.inviteFriends', { needed: status.referralsNeeded })}
<br /> <br />
Once they do, wholesale prices unlock forever. {t('unlockModal.unlockForever')}
</p> </p>
</div> </div>
@@ -162,7 +164,7 @@ export default function UnlockModal({ isOpen, onClose }: UnlockModalProps) {
fontWeight: 500, fontWeight: 500,
}} }}
> >
Your referral link {t('unlockModal.yourReferralLink')}
</label> </label>
<div style={{ display: 'flex', gap: '8px' }}> <div style={{ display: 'flex', gap: '8px' }}>
<input <input
@@ -194,7 +196,7 @@ export default function UnlockModal({ isOpen, onClose }: UnlockModalProps) {
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
}} }}
> >
{copied ? 'Copied!' : 'Copy link'} {copied ? t('unlockModal.copied') : t('unlockModal.copyLink')}
</button> </button>
</div> </div>
</div> </div>
@@ -209,7 +211,7 @@ export default function UnlockModal({ isOpen, onClose }: UnlockModalProps) {
}} }}
> >
<p style={{ color: 'var(--muted)', fontSize: '14px', margin: '0 0 12px 0' }}> <p style={{ color: 'var(--muted)', fontSize: '14px', margin: '0 0 12px 0' }}>
Please log in to get your referral link {t('unlockModal.yourReferralLink')}
</p> </p>
<button <button
onClick={() => setShowAuthModal(true)} onClick={() => setShowAuthModal(true)}
@@ -224,7 +226,7 @@ export default function UnlockModal({ isOpen, onClose }: UnlockModalProps) {
fontWeight: 500, fontWeight: 500,
}} }}
> >
Login {t('auth.login')}
</button> </button>
</div> </div>
)} )}
@@ -240,7 +242,7 @@ export default function UnlockModal({ isOpen, onClose }: UnlockModalProps) {
textAlign: 'center', textAlign: 'center',
}} }}
> >
Friends must sign up to count. {t('unlockModal.friendsMustSignUp')}
</div> </div>
<div <div
@@ -252,7 +254,10 @@ export default function UnlockModal({ isOpen, onClose }: UnlockModalProps) {
marginBottom: '24px', marginBottom: '24px',
}} }}
> >
{status.referralsRemaining} referral{status.referralsRemaining !== 1 ? 's' : ''} to go {status.referralsRemaining === 1
? t('unlockModal.referralsToGoSingular', { remaining: status.referralsRemaining })
: t('unlockModal.referralsToGoPlural', { remaining: status.referralsRemaining })
}
</div> </div>
<button <button
@@ -269,7 +274,7 @@ export default function UnlockModal({ isOpen, onClose }: UnlockModalProps) {
fontWeight: 500, fontWeight: 500,
}} }}
> >
Close {t('common.close')}
</button> </button>
</> </>
)} )}

View File

@@ -1,6 +1,7 @@
import type { Metadata } from 'next' import type { Metadata } from 'next'
import { Inter } from 'next/font/google' import { Inter } from 'next/font/google'
import './globals.css' import './globals.css'
import { I18nProvider } from '@/lib/i18n'
const inter = Inter({ subsets: ['latin'], weight: ['300', '400', '500', '600'] }) const inter = Inter({ subsets: ['latin'], weight: ['300', '400', '500', '600'] })
@@ -16,7 +17,9 @@ export default function RootLayout({
}) { }) {
return ( return (
<html lang="en"> <html lang="en">
<body className={inter.className}>{children}</body> <body className={inter.className}>
<I18nProvider>{children}</I18nProvider>
</body>
</html> </html>
) )
} }

View File

@@ -9,9 +9,11 @@ import Signup from './components/Signup'
import PastDrops from './components/PastDrops' import PastDrops from './components/PastDrops'
import Footer from './components/Footer' import Footer from './components/Footer'
import UnlockBar from './components/UnlockBar' import UnlockBar from './components/UnlockBar'
import { useI18n } from '@/lib/i18n'
function PaymentHandler() { function PaymentHandler() {
const searchParams = useSearchParams() const searchParams = useSearchParams()
const { t } = useI18n()
useEffect(() => { useEffect(() => {
const payment = searchParams.get('payment') const payment = searchParams.get('payment')
@@ -21,16 +23,17 @@ function PaymentHandler() {
// Clean up URL - IPN is handled by external service // Clean up URL - IPN is handled by external service
window.history.replaceState({}, '', window.location.pathname) window.history.replaceState({}, '', window.location.pathname)
} else if (payment === 'cancelled') { } else if (payment === 'cancelled') {
alert('Payment was cancelled.') alert(t('payment.cancelled'))
// Clean up URL // Clean up URL
window.history.replaceState({}, '', window.location.pathname) window.history.replaceState({}, '', window.location.pathname)
} }
}, [searchParams]) }, [searchParams, t])
return null return null
} }
export default function Home() { export default function Home() {
const { t } = useI18n()
return ( return (
<> <>
@@ -40,11 +43,8 @@ export default function Home() {
<Nav /> <Nav />
<UnlockBar /> <UnlockBar />
<header className="container"> <header className="container">
<h1>Shop together. Wholesale prices for private buyers.</h1> <h1>{t('header.title')}</h1>
<p> <p>{t('header.subtitle')}</p>
Limited CBD drops directly from Swiss producers. No retail.
No markup. Just collective bulk prices.
</p>
</header> </header>
<section className="container" id="drop"> <section className="container" id="drop">
@@ -57,7 +57,7 @@ export default function Home() {
</section> </section>
<section className="container" id="past"> <section className="container" id="past">
<h2>Past Drops</h2> <h2>{t('pastDrops.title')}</h2>
<PastDrops limit={3} showMoreLink={true} /> <PastDrops limit={3} showMoreLink={true} />
</section> </section>

51
lib/currency.ts Normal file
View File

@@ -0,0 +1,51 @@
/**
* Currency conversion utilities
* Database stores prices in EUR
* Countries in CHF_COUNTRIES see CHF (converted from EUR)
* All other countries see EUR
*/
// List of country codes that use CHF currency
// Add or remove country codes here to change which countries get CHF pricing
export const CHF_COUNTRIES = ['CH'] as const
// EUR to CHF exchange rate
// Using a fixed rate - in production, you might want to fetch this from an API
// Current approximate rate: 1 EUR ≈ 0.97 CHF (as of 2025)
// Note: This is approximate. For production, consider using a real-time exchange rate API
const EUR_TO_CHF_RATE = 0.97
/**
* Convert EUR amount to CHF
*/
export function convertEurToChf(eurAmount: number): number {
return eurAmount * EUR_TO_CHF_RATE
}
/**
* Get the currency to use based on country code
* Returns 'CHF' for countries in CHF_COUNTRIES, 'EUR' for all other countries
*/
export function getCurrencyForCountry(countryCode: string | null): 'CHF' | 'EUR' {
return countryCode && CHF_COUNTRIES.includes(countryCode as any) ? 'CHF' : 'EUR'
}
/**
* Convert price based on country
* If country is in CHF_COUNTRIES, convert EUR to CHF
* Otherwise, return EUR amount as-is
*/
export function convertPriceForCountry(priceInEur: number, countryCode: string | null): number {
if (countryCode && CHF_COUNTRIES.includes(countryCode as any)) {
return convertEurToChf(priceInEur)
}
return priceInEur
}
/**
* Get currency symbol for display
*/
export function getCurrencySymbol(currency: 'CHF' | 'EUR'): string {
return currency === 'CHF' ? 'CHF' : 'EUR'
}

View File

@@ -7,8 +7,10 @@ const pool = mysql.createPool({
password: process.env.DB_PASSWORD || '', password: process.env.DB_PASSWORD || '',
database: process.env.DB_NAME || 'cbd420', database: process.env.DB_NAME || 'cbd420',
waitForConnections: true, waitForConnections: true,
connectionLimit: 10, connectionLimit: 50, // Increased from 10 to handle more concurrent requests
queueLimit: 0, queueLimit: 0,
connectTimeout: 60000, // 60 seconds timeout for establishing connection
idleTimeout: 600000, // 10 minutes - close idle connections after this time
}) })
export default pool export default pool

92
lib/geolocation.ts Normal file
View File

@@ -0,0 +1,92 @@
import { NextRequest } from 'next/server'
import { CHF_COUNTRIES } from '@/lib/currency'
/**
* Get client IP address from request headers
*/
function getClientIp(request: NextRequest): string | null {
// Check various headers that might contain the real IP
const forwardedFor = request.headers.get('x-forwarded-for')
if (forwardedFor) {
// x-forwarded-for can contain multiple IPs, take the first one
return forwardedFor.split(',')[0].trim()
}
const realIp = request.headers.get('x-real-ip')
if (realIp) {
return realIp.trim()
}
const cfConnectingIp = request.headers.get('cf-connecting-ip') // Cloudflare
if (cfConnectingIp) {
return cfConnectingIp.trim()
}
// Fallback to remote address if available
const remoteAddress = request.headers.get('remote-addr')
if (remoteAddress) {
return remoteAddress.trim()
}
return null
}
/**
* Get country code from IP address using ip-api.com (free, no API key required)
* Returns country code (e.g., 'CH' for Switzerland, 'SG' for Singapore), or null if detection fails
*/
export async function getCountryFromIp(request: NextRequest): Promise<string | null> {
try {
const ip = getClientIp(request)
if (!ip) {
console.warn('Could not determine client IP')
return null
}
// Skip localhost/private IPs
if (ip === '127.0.0.1' || ip === '::1' || ip.startsWith('192.168.') || ip.startsWith('10.') || ip.startsWith('172.')) {
// For local development, default to Switzerland
return 'CH'
}
// Use ip-api.com free service (no API key required, rate limited)
// Returns JSON with country code
const response = await fetch(`http://ip-api.com/json/${ip}?fields=countryCode`, {
method: 'GET',
headers: {
'Accept': 'application/json',
},
})
if (!response.ok) {
console.warn(`Failed to fetch geolocation: ${response.status}`)
return null
}
const data = await response.json()
if (data.countryCode) {
return data.countryCode
}
return null
} catch (error) {
console.error('Error detecting country from IP:', error)
return null
}
}
/**
* Calculate shipping fee based on country
* Returns shipping fee in the appropriate currency
* 15 CHF for countries in CHF_COUNTRIES, 40 EUR for all other countries
*/
export function calculateShippingFee(countryCode: string | null): number {
// 15 CHF for countries in CHF_COUNTRIES, 40 EUR for all other countries
if (countryCode && CHF_COUNTRIES.includes(countryCode as any)) {
return 15
}
return 40
}

109
lib/i18n.tsx Normal file
View File

@@ -0,0 +1,109 @@
'use client'
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'
import enTranslations from './translations/en.json'
import deTranslations from './translations/de.json'
type Language = 'en' | 'de'
type TranslationKey = string
type Translations = typeof enTranslations
interface I18nContextType {
language: Language
setLanguage: (lang: Language) => void
t: (key: TranslationKey, params?: Record<string, string | number>) => string
}
const I18nContext = createContext<I18nContextType | undefined>(undefined)
const translations: Record<Language, Translations> = {
en: enTranslations,
de: deTranslations,
}
export function I18nProvider({ children }: { children: ReactNode }) {
const [language, setLanguageState] = useState<Language>('en')
// Load language from localStorage on mount
useEffect(() => {
const savedLanguage = localStorage.getItem('language') as Language
if (savedLanguage && (savedLanguage === 'en' || savedLanguage === 'de')) {
setLanguageState(savedLanguage)
} else {
// Detect browser language
const browserLang = navigator.language.split('-')[0]
if (browserLang === 'de') {
setLanguageState('de')
} else {
setLanguageState('en')
}
}
}, [])
const setLanguage = (lang: Language) => {
setLanguageState(lang)
localStorage.setItem('language', lang)
// Update HTML lang attribute
if (typeof document !== 'undefined') {
document.documentElement.lang = lang
}
}
const t = (key: TranslationKey, params?: Record<string, string | number>): string => {
const keys = key.split('.')
let value: any = translations[language]
for (const k of keys) {
if (value && typeof value === 'object' && k in value) {
value = value[k]
} else {
// Fallback to English if key not found
value = translations.en
for (const fallbackKey of keys) {
if (value && typeof value === 'object' && fallbackKey in value) {
value = value[fallbackKey]
} else {
return key // Return key if translation not found
}
}
break
}
}
if (typeof value !== 'string') {
return key
}
// Replace parameters in the translation string
if (params) {
return value.replace(/\{(\w+)\}/g, (match, paramKey) => {
return params[paramKey]?.toString() || match
})
}
return value
}
// Update HTML lang attribute when language changes
useEffect(() => {
if (typeof document !== 'undefined') {
document.documentElement.lang = language
}
}, [language])
return (
<I18nContext.Provider value={{ language, setLanguage, t }}>
{children}
</I18nContext.Provider>
)
}
export function useI18n() {
const context = useContext(I18nContext)
if (context === undefined) {
throw new Error('useI18n must be used within an I18nProvider')
}
return context
}

211
lib/translations/de.json Normal file
View File

@@ -0,0 +1,211 @@
{
"common": {
"loading": "Lädt...",
"error": "Ein Fehler ist aufgetreten",
"ok": "OK",
"cancel": "Abbrechen",
"close": "Schließen",
"save": "Speichern",
"delete": "Löschen",
"edit": "Bearbeiten",
"submit": "Absenden",
"processing": "Wird verarbeitet...",
"noImage": "Kein Bild"
},
"nav": {
"drop": "Drop",
"pastDrops": "Vergangene Drops",
"community": "Community",
"orders": "Bestellungen",
"login": "Anmelden",
"logout": "Abmelden"
},
"header": {
"title": "Gemeinsam einkaufen. Wholesale-Preise für private Käufer.",
"subtitle": "Limitierte CBD Drops direkt von Schweizer Produzenten. Kein Retail. Kein Marketing-Aufschlag. Nur kollektive Mengenpreise."
},
"drop": {
"loading": "Lädt...",
"soldOut": "Drop ausverkauft",
"nextDropComing": "Nächster kollektiver Drop kommt bald",
"joinDrop": "Am Drop teilnehmen",
"reserved": "reserviert",
"of": "von",
"batch": "Batch",
"indoor": "Indoor",
"switzerland": "Schweiz",
"inclVat": "inkl. 2.5% MWST",
"perGram": "pro Gramm",
"selectQuantity": "Menge auswählen",
"customQuantity": "Individuelle Menge",
"minimumRequired": "Mindestens {minimum}g erforderlich (5 CHF Minimum)",
"maximumAvailable": "Maximal {maximum}g verfügbar",
"enterValidNumber": "Bitte geben Sie eine gültige Zahl ein",
"fillDeliveryInfo": "Bitte füllen Sie alle Lieferinformationen aus (Vollständiger Name, Adresse und Telefon)",
"fullName": "Vollständiger Name",
"address": "Adresse",
"phone": "Telefon",
"confirmPurchase": "Kauf bestätigen",
"totalPrice": "Gesamtpreis",
"standardPrice": "Standardpreis",
"wholesalePrice": "Großhandelspreis",
"paymentCurrency": "Zahlungswährung",
"selectCurrency": "Währung auswählen",
"upcomingIn": "Kommt in",
"day": "Tag",
"days": "Tage",
"hour": "Stunde",
"hours": "Stunden",
"minute": "Minute",
"minutes": "Minuten",
"paymentAddress": "Zahlungsadresse",
"paymentAmount": "Zahlungsbetrag",
"paymentId": "Zahlungs-ID",
"copyAddress": "Adresse kopieren",
"copied": "Kopiert!",
"paymentInstructions": "Senden Sie genau {amount} {currency} an die oben angegebene Adresse. Die Zahlung läuft in 20 Minuten ab.",
"paymentExpired": "Zahlung abgelaufen. Bitte versuchen Sie es erneut.",
"paymentPending": "Zahlung ausstehend...",
"paymentSuccess": "Zahlung erfolgreich!",
"paymentFailed": "Zahlung fehlgeschlagen. Bitte versuchen Sie es erneut.",
"orderConfirmed": "Bestellung bestätigt!",
"orderFailed": "Bestellung fehlgeschlagen. Bitte versuchen Sie es erneut.",
"dropSoldOut": "Drop ausverkauft",
"fullyReserved": "Der aktuelle kollektive Drop wurde vollständig reserviert.",
"nextDropComingSoon": "Nächster kollektiver Drop kommt bald.",
"batch": "Batch",
"reserved": "reserviert",
"wholesalePriceLabel": "Großhandelspreis:",
"standardPriceLabel": "Standardpreis:",
"standard": "Standard",
"wholesale": "Großhandel",
"unlock": "freischalten",
"unlockOnce": "Einmal freischalten. Großhandelspreis für immer behalten.",
"dropStartsIn": "Drop startet in",
"onHold": "in Wartestellung (10 Minuten Checkout-Fenster)",
"custom": "Individuell (g)",
"min": "Min",
"max": "Max",
"total": "Gesamt",
"standardTotal": "Standard gesamt",
"wholesaleTotal": "Großhandel gesamt",
"joinTheDrop": "Am Drop teilnehmen",
"noSubscription": "Kein Abonnement · Keine Verpflichtung",
"lessThanRemaining": "Weniger als {amount}{unit} verbleibend. Dieser Drop ist fast vollständig reserviert.",
"fullyReservedText": "Dieser Drop ist vollständig reserviert",
"item": "Artikel",
"quantity": "Menge",
"pricePerUnit": "Preis pro {unit}",
"deliveryInformation": "Lieferinformationen",
"fullNameRequired": "Vollständiger Name *",
"enterFullName": "Geben Sie Ihren vollständigen Namen ein",
"addressRequired": "Adresse *",
"enterAddress": "Geben Sie Ihre Lieferadresse ein",
"phoneRequired": "Telefonnummer *",
"enterPhone": "Geben Sie Ihre Telefonnummer ein",
"loadingCurrencies": "Lädt Währungen...",
"payWith": "Zahlen mit",
"completePayment": "Zahlung abschließen",
"amountToPay": "Zu zahlender Betrag",
"price": "Preis",
"subtotal": "Zwischensumme",
"shippingFee": "Versandgebühr",
"sendPaymentTo": "Senden Sie die Zahlung an diese Adresse",
"copyAddress": "Adresse kopieren",
"memoRequired": "Memo / Ziel-Tag (Erforderlich)",
"copyMemo": "Memo kopieren",
"paymentExpires": "Zahlung läuft ab",
"status": "Status",
"closingWarning": "⚠️ Das Schließen dieses Fensters wird Ihre Reservierung stornieren und den Bestand freigeben.",
"paymentConfirmed": "Zahlung bestätigt ✔️",
"orderProcessed": "Ihre Bestellung wurde erfolgreich verarbeitet und ist jetzt in diesem Drop reserviert.",
"whatHappensNext": "Was als Nächstes passiert",
"orderProcessed24h": "Ihre Bestellung wird innerhalb von 24 Stunden bearbeitet",
"shippedExpress": "Versand per Express-Lieferung",
"shippingConfirmation": "Sie erhalten eine Versandbestätigung und Tracking-Link per E-Mail",
"thankYouCollective": "Vielen Dank, dass Sie Teil des Kollektivs sind.",
"error": "⚠️ Fehler"
},
"infoBox": {
"whyCheap": "Warum so günstig?",
"whyCheapText": "Retailpreise liegen bei ca. 10 CHF/g. Durch kollektive Sammelbestellungen kaufen wir wie Grosshändler ein ohne Zwischenstufen.",
"taxesLegal": "Steuern & Recht",
"taxesLegalText": "Bulk-Verkauf mit 2.5% MWST. Keine Retail-Verpackung, keine Tabaksteuer.",
"dropModel": "Drop-Modell",
"dropModelText": "Pro Drop nur eine Sorte. Erst ausverkauft dann der nächste Drop."
},
"signup": {
"title": "Drop-Benachrichtigungen",
"subtitle": "Erhalte Updates zu neuen Drops per E-Mail oder WhatsApp.",
"email": "E-Mail",
"whatsapp": "WhatsApp Nummer",
"getNotified": "Benachrichtigen lassen",
"subscribing": "Wird abonniert...",
"successMessage": "Du erhältst eine Benachrichtigung, sobald ein neuer Drop verfügbar ist."
},
"pastDrops": {
"title": "Vergangene Drops",
"loading": "Lädt vergangene Drops...",
"noDrops": "Noch keine vergangenen Drops. Schauen Sie bald wieder vorbei!",
"soldOutIn": "Ausverkauft in",
"lessThan1h": "weniger als 1h",
"1h": "1h",
"hours": "{hours}h",
"1day": "1 Tag",
"days": "{days} Tage",
"daysHours": "{days}T {hours}h",
"more": "Mehr →"
},
"footer": {
"text": "© 2025 420Deals.ch · CBD < 1% THC · Verkauf ab 18 Jahren · Schweiz"
},
"auth": {
"login": "Anmelden",
"register": "Registrieren",
"username": "Benutzername",
"password": "Passwort",
"email": "E-Mail",
"referralId": "Empfehlungs-ID",
"optional": "optional",
"autoFilled": "✓ Automatisch von Empfehlungslink ausgefüllt",
"dontHaveAccount": "Haben Sie noch kein Konto?",
"alreadyHaveAccount": "Haben Sie bereits ein Konto?",
"anErrorOccurred": "Ein Fehler ist aufgetreten",
"unexpectedError": "Ein unerwarteter Fehler ist aufgetreten"
},
"unlockBar": {
"unlocked": "✅ Großhandelspreise freigeschaltet —",
"unlockedText": "Sie haben Zugang zu Großhandelspreisen!",
"locked": "🔒 Großhandelspreise gesperrt —",
"referralsCompleted": "{count} / {needed} Empfehlungen abgeschlossen",
"toGo": "{remaining} verbleibend",
"unlockText": "{needed} verifizierte Anmeldungen schalten Großhandelspreise 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",
"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.",
"yourReferralLink": "Ihr Empfehlungslink",
"copyLink": "Link kopieren",
"copied": "Kopiert!",
"shareVia": "Teilen über",
"email": "E-Mail",
"whatsapp": "WhatsApp",
"referralStats": "Empfehlungsstatistiken",
"totalReferrals": "Gesamt Empfehlungen",
"verifiedReferrals": "Verifizierte Empfehlungen",
"pendingReferrals": "Ausstehende Empfehlungen",
"friendsMustSignUp": "Freunde müssen sich anmelden, damit es zählt.",
"referralsToGoSingular": "{remaining} Empfehlung verbleibend",
"referralsToGoPlural": "{remaining} Empfehlungen verbleibend"
},
"payment": {
"cancelled": "Zahlung wurde abgebrochen."
}
}

208
lib/translations/en.json Normal file
View File

@@ -0,0 +1,208 @@
{
"common": {
"loading": "Loading...",
"error": "An error occurred",
"ok": "OK",
"cancel": "Cancel",
"close": "Close",
"save": "Save",
"delete": "Delete",
"edit": "Edit",
"submit": "Submit",
"processing": "Processing...",
"noImage": "No Image"
},
"nav": {
"drop": "Drop",
"pastDrops": "Past Drops",
"community": "Community",
"orders": "Orders",
"login": "Login",
"logout": "Logout"
},
"header": {
"title": "Shop together. Wholesale prices for private buyers.",
"subtitle": "Limited CBD drops directly from Swiss producers. No retail. No markup. Just collective bulk prices."
},
"drop": {
"loading": "Loading...",
"soldOut": "Drop sold out",
"nextDropComing": "Next collective drop coming soon",
"joinDrop": "Join the Drop",
"reserved": "reserved",
"of": "of",
"batch": "Batch",
"indoor": "Indoor",
"switzerland": "Switzerland",
"inclVat": "incl. 2.5% VAT",
"perGram": "per gram",
"selectQuantity": "Select quantity",
"customQuantity": "Custom quantity",
"minimumRequired": "Minimum {minimum}g required (5 CHF minimum)",
"maximumAvailable": "Maximum {maximum}g available",
"enterValidNumber": "Please enter a valid number",
"fillDeliveryInfo": "Please fill in all delivery information (full name, address, and phone)",
"fullName": "Full Name",
"address": "Address",
"phone": "Phone",
"confirmPurchase": "Confirm Purchase",
"totalPrice": "Total Price",
"standardPrice": "Standard Price",
"wholesalePrice": "Wholesale Price",
"paymentCurrency": "Payment Currency",
"selectCurrency": "Select currency",
"upcomingIn": "Upcoming in",
"day": "day",
"days": "days",
"hour": "hour",
"hours": "hours",
"minute": "minute",
"minutes": "minutes",
"paymentAddress": "Payment Address",
"paymentAmount": "Payment Amount",
"paymentId": "Payment ID",
"copyAddress": "Copy Address",
"copied": "Copied!",
"paymentInstructions": "Send exactly {amount} {currency} to the address above. Payment expires in 20 minutes.",
"paymentExpired": "Payment expired. Please try again.",
"paymentPending": "Payment pending...",
"paymentSuccess": "Payment successful!",
"paymentFailed": "Payment failed. Please try again.",
"orderConfirmed": "Order confirmed!",
"orderFailed": "Order failed. Please try again.",
"dropSoldOut": "Drop Sold Out",
"fullyReserved": "The current collective drop has been fully reserved.",
"nextDropComingSoon": "Next collective drop coming soon.",
"wholesalePriceLabel": "Wholesale price:",
"standardPriceLabel": "Standard price:",
"standard": "Standard",
"wholesale": "Wholesale",
"unlock": "unlock",
"unlockOnce": "Unlock once. Keep wholesale forever.",
"dropStartsIn": "Drop starts in",
"onHold": "on hold (10 min checkout window)",
"custom": "Custom (g)",
"min": "Min",
"max": "Max",
"total": "Total",
"standardTotal": "Standard total",
"wholesaleTotal": "Wholesale total",
"joinTheDrop": "Join the drop",
"noSubscription": "No subscription · No obligation",
"lessThanRemaining": "Less than {amount}{unit} remaining. This drop is almost fully reserved.",
"fullyReservedText": "This drop is fully reserved",
"item": "Item",
"quantity": "Quantity",
"pricePerUnit": "Price per {unit}",
"deliveryInformation": "Delivery Information",
"fullNameRequired": "Full Name *",
"enterFullName": "Enter your full name",
"addressRequired": "Address *",
"enterAddress": "Enter your delivery address",
"phoneRequired": "Phone Number *",
"enterPhone": "Enter your phone number",
"loadingCurrencies": "Loading currencies...",
"payWith": "Pay with",
"completePayment": "Complete Payment",
"amountToPay": "Amount to Pay",
"price": "Price",
"subtotal": "Subtotal",
"shippingFee": "Shipping Fee",
"sendPaymentTo": "Send payment to this address",
"memoRequired": "Memo / Destination Tag (Required)",
"copyMemo": "Copy Memo",
"paymentExpires": "Payment expires",
"status": "Status",
"closingWarning": "⚠️ Closing this window will cancel your reservation and free up the inventory.",
"paymentConfirmed": "Payment confirmed ✔️",
"orderProcessed": "Your order has been successfully processed and is now reserved in this drop.",
"whatHappensNext": "What happens next",
"orderProcessed24h": "Your order will be processed within 24 hours",
"shippedExpress": "Shipped via express delivery",
"shippingConfirmation": "You'll receive a shipping confirmation and tracking link by email",
"thankYouCollective": "Thank you for being part of the collective.",
"error": "⚠️ Error"
},
"infoBox": {
"whyCheap": "Why so cheap?",
"whyCheapText": "Retail prices are around 10 CHF/g. Through collective bulk orders, we buy like wholesalers without intermediaries.",
"taxesLegal": "Taxes & Legal",
"taxesLegalText": "Bulk sale with 2.5% VAT. No retail packaging, no tobacco tax.",
"dropModel": "Drop Model",
"dropModelText": "One variety per drop. Only when sold out then the next drop."
},
"signup": {
"title": "Drop Notifications",
"subtitle": "Receive updates about new drops via email or WhatsApp.",
"email": "E-Mail",
"whatsapp": "WhatsApp Number",
"getNotified": "Get Notified",
"subscribing": "Subscribing...",
"successMessage": "You will receive a notification as soon as a new drop drops."
},
"pastDrops": {
"title": "Past Drops",
"loading": "Loading past drops...",
"noDrops": "No past drops yet. Check back soon!",
"soldOutIn": "Sold out in",
"lessThan1h": "less than 1h",
"1h": "1h",
"hours": "{hours}h",
"1day": "1 day",
"days": "{days} days",
"daysHours": "{days}d {hours}h",
"more": "More →"
},
"footer": {
"text": "© 2025 420Deals.ch · CBD < 1% THC · Sale from 18 years · Switzerland"
},
"auth": {
"login": "Login",
"register": "Register",
"username": "Username",
"password": "Password",
"email": "Email",
"referralId": "Referral ID",
"optional": "optional",
"autoFilled": "✓ Auto-filled from referral link",
"dontHaveAccount": "Don't have an account?",
"alreadyHaveAccount": "Already have an account?",
"anErrorOccurred": "An error occurred",
"unexpectedError": "An unexpected error occurred"
},
"unlockBar": {
"unlocked": "✅ Wholesale prices unlocked —",
"unlockedText": "You have access to wholesale pricing!",
"locked": "🔒 Wholesale prices locked —",
"referralsCompleted": "{count} / {needed} referrals completed",
"toGo": "{remaining} to go",
"unlockText": "{needed} verified sign-ups unlock wholesale 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",
"referralsCompleted": "{count} of {needed} referrals completed",
"inviteFriends": "Invite {needed} friends to sign up.",
"unlockForever": "Once they do, wholesale prices unlock forever.",
"yourReferralLink": "Your referral link",
"copyLink": "Copy Link",
"copied": "Copied!",
"shareVia": "Share via",
"email": "Email",
"whatsapp": "WhatsApp",
"referralStats": "Referral Stats",
"totalReferrals": "Total Referrals",
"verifiedReferrals": "Verified Referrals",
"pendingReferrals": "Pending Referrals",
"friendsMustSignUp": "Friends must sign up to count.",
"referralsToGoSingular": "{remaining} referral to go",
"referralsToGoPlural": "{remaining} referrals to go"
},
"payment": {
"cancelled": "Payment was cancelled."
}
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB