rc 1.0
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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()}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
27
app/api/shipping-fee/route.ts
Normal file
27
app/api/shipping-fee/route.ts
Normal 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',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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={{
|
||||||
@@ -1329,23 +1450,23 @@ export default function Drop() {
|
|||||||
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>
|
||||||
|
|||||||
@@ -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 < 1% THC · Sale from 18 years · Switzerland
|
{t('footer.text')}
|
||||||
</footer>
|
</footer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
45
app/components/LanguageSwitcher.tsx
Normal file
45
app/components/LanguageSwitcher.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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)} />
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
16
app/page.tsx
16
app/page.tsx
@@ -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
51
lib/currency.ts
Normal 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'
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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
92
lib/geolocation.ts
Normal 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
109
lib/i18n.tsx
Normal 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
211
lib/translations/de.json
Normal 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
208
lib/translations/en.json
Normal 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
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Reference in New Issue
Block a user