rc 1.0
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { useI18n } from '@/lib/i18n'
|
||||
|
||||
interface User {
|
||||
id: number
|
||||
@@ -16,6 +17,7 @@ interface AuthModalProps {
|
||||
}
|
||||
|
||||
export default function AuthModal({ isOpen, onClose, onLogin }: AuthModalProps) {
|
||||
const { t } = useI18n()
|
||||
const searchParams = useSearchParams()
|
||||
const [isLogin, setIsLogin] = useState(true)
|
||||
const [username, setUsername] = useState('')
|
||||
@@ -83,7 +85,7 @@ export default function AuthModal({ isOpen, onClose, onLogin }: AuthModalProps)
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
setError(data.error || 'An error occurred')
|
||||
setError(data.error || t('auth.anErrorOccurred'))
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
@@ -93,7 +95,7 @@ export default function AuthModal({ isOpen, onClose, onLogin }: AuthModalProps)
|
||||
onClose()
|
||||
} catch (error) {
|
||||
console.error('Auth error:', error)
|
||||
setError('An unexpected error occurred')
|
||||
setError(t('auth.unexpectedError'))
|
||||
} finally {
|
||||
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' }}>
|
||||
<h2 style={{ margin: 0 }}>
|
||||
{isLogin ? 'Login' : 'Register'}
|
||||
{isLogin ? t('auth.login') : t('auth.register')}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
@@ -165,7 +167,7 @@ export default function AuthModal({ isOpen, onClose, onLogin }: AuthModalProps)
|
||||
color: 'var(--text)',
|
||||
}}
|
||||
>
|
||||
Email
|
||||
{t('auth.email')}
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
@@ -182,7 +184,7 @@ export default function AuthModal({ isOpen, onClose, onLogin }: AuthModalProps)
|
||||
color: 'var(--text)',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
placeholder="your@email.com"
|
||||
placeholder={t('auth.email')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -197,7 +199,7 @@ export default function AuthModal({ isOpen, onClose, onLogin }: AuthModalProps)
|
||||
color: 'var(--text)',
|
||||
}}
|
||||
>
|
||||
Username
|
||||
{t('auth.username')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -215,7 +217,7 @@ export default function AuthModal({ isOpen, onClose, onLogin }: AuthModalProps)
|
||||
color: 'var(--text)',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
placeholder="username"
|
||||
placeholder={t('auth.username')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -229,7 +231,7 @@ export default function AuthModal({ isOpen, onClose, onLogin }: AuthModalProps)
|
||||
color: 'var(--text)',
|
||||
}}
|
||||
>
|
||||
Password
|
||||
{t('auth.password')}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
@@ -247,7 +249,7 @@ export default function AuthModal({ isOpen, onClose, onLogin }: AuthModalProps)
|
||||
color: 'var(--text)',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
placeholder="password"
|
||||
placeholder={t('auth.password')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -262,7 +264,7 @@ export default function AuthModal({ isOpen, onClose, onLogin }: AuthModalProps)
|
||||
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>
|
||||
<input
|
||||
type="text"
|
||||
@@ -278,11 +280,11 @@ export default function AuthModal({ isOpen, onClose, onLogin }: AuthModalProps)
|
||||
color: 'var(--text)',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
placeholder="Enter referral ID"
|
||||
placeholder={t('auth.referralId')}
|
||||
/>
|
||||
{searchParams?.get('ref') && referralId === searchParams.get('ref') && (
|
||||
<small style={{ display: 'block', marginTop: '4px', fontSize: '12px', color: 'var(--accent)' }}>
|
||||
✓ Auto-filled from referral link
|
||||
{t('auth.autoFilled')}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
@@ -316,10 +318,10 @@ export default function AuthModal({ isOpen, onClose, onLogin }: AuthModalProps)
|
||||
}}
|
||||
>
|
||||
{loading
|
||||
? 'Processing...'
|
||||
? t('common.processing')
|
||||
: isLogin
|
||||
? 'Login'
|
||||
: 'Register'}
|
||||
? t('auth.login')
|
||||
: t('auth.register')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -333,7 +335,7 @@ export default function AuthModal({ isOpen, onClose, onLogin }: AuthModalProps)
|
||||
>
|
||||
{isLogin ? (
|
||||
<>
|
||||
Don't have an account?{' '}
|
||||
{t('auth.dontHaveAccount')}{' '}
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsLogin(false)
|
||||
@@ -348,12 +350,12 @@ export default function AuthModal({ isOpen, onClose, onLogin }: AuthModalProps)
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
Register
|
||||
{t('auth.register')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Already have an account?{' '}
|
||||
{t('auth.alreadyHaveAccount')}{' '}
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsLogin(true)
|
||||
@@ -368,7 +370,7 @@ export default function AuthModal({ isOpen, onClose, onLogin }: AuthModalProps)
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
Login
|
||||
{t('auth.login')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect, Suspense } from 'react'
|
||||
import Image from 'next/image'
|
||||
import AuthModal from './AuthModal'
|
||||
import UnlockModal from './UnlockModal'
|
||||
import { useI18n } from '@/lib/i18n'
|
||||
|
||||
interface DropData {
|
||||
id: number
|
||||
@@ -28,6 +29,7 @@ interface User {
|
||||
}
|
||||
|
||||
export default function Drop() {
|
||||
const { t } = useI18n()
|
||||
const [drop, setDrop] = useState<DropData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [selectedSize, setSelectedSize] = useState(50)
|
||||
@@ -52,11 +54,15 @@ export default function Drop() {
|
||||
const [isWholesaleUnlocked, setIsWholesaleUnlocked] = useState(false)
|
||||
const [showUnlockModal, setShowUnlockModal] = useState(false)
|
||||
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(() => {
|
||||
fetchActiveDrop()
|
||||
checkAuth()
|
||||
checkWholesaleStatus()
|
||||
fetchShippingFee() // Fetch currency info on mount
|
||||
|
||||
// Poll active drop every 30 seconds
|
||||
const interval = setInterval(() => {
|
||||
@@ -193,11 +199,12 @@ export default function Drop() {
|
||||
|
||||
const getMinimumGrams = () => {
|
||||
if (!drop) return 0
|
||||
// Minimum price is 5 CHF
|
||||
// Calculate minimum grams needed for 5 CHF
|
||||
const pricePerGram = drop.ppu / 1000
|
||||
// Minimum price is 5 in user's currency
|
||||
// Calculate minimum grams needed for 5 (EUR or CHF)
|
||||
const pricePerGramEur = drop.ppu / 1000
|
||||
const minPriceEur = currency === 'CHF' ? 5 / 0.97 : 5 // Convert min CHF to EUR equivalent
|
||||
// Use the higher price (standard) to ensure minimum is met
|
||||
return Math.ceil(5 / pricePerGram)
|
||||
return Math.ceil(minPriceEur / pricePerGramEur)
|
||||
}
|
||||
|
||||
const handleCustomQuantityChange = (value: string) => {
|
||||
@@ -224,17 +231,17 @@ export default function Drop() {
|
||||
const minimum = getMinimumGrams()
|
||||
|
||||
if (isNaN(numValue) || numValue <= 0) {
|
||||
setQuantityError('Please enter a valid number')
|
||||
setQuantityError(t('drop.enterValidNumber'))
|
||||
return false
|
||||
}
|
||||
|
||||
if (numValue < minimum) {
|
||||
setQuantityError(`Minimum ${minimum}g required (5 CHF minimum)`)
|
||||
setQuantityError(t('drop.minimumRequired', { minimum }))
|
||||
return false
|
||||
}
|
||||
|
||||
if (numValue > remaining) {
|
||||
setQuantityError(`Maximum ${remaining}g available`)
|
||||
setQuantityError(t('drop.maximumAvailable', { maximum: remaining }))
|
||||
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 = () => {
|
||||
// Validate custom quantity if entered
|
||||
if (customQuantity && !validateCustomQuantity()) {
|
||||
@@ -322,9 +364,10 @@ export default function Drop() {
|
||||
setShowAuthModal(true)
|
||||
return
|
||||
}
|
||||
// Fetch available currencies and buyer data when opening confirm modal
|
||||
// Fetch available currencies, buyer data, and shipping fee when opening confirm modal
|
||||
fetchAvailableCurrencies()
|
||||
fetchBuyerData()
|
||||
fetchShippingFee()
|
||||
setShowConfirmModal(true)
|
||||
}
|
||||
|
||||
@@ -334,6 +377,7 @@ export default function Drop() {
|
||||
// After login, fetch buyer data and show the confirmation modal
|
||||
fetchAvailableCurrencies()
|
||||
fetchBuyerData()
|
||||
fetchShippingFee()
|
||||
setShowConfirmModal(true)
|
||||
}
|
||||
|
||||
@@ -342,7 +386,7 @@ export default function Drop() {
|
||||
|
||||
// Validate buyer data fields
|
||||
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)
|
||||
return
|
||||
}
|
||||
@@ -434,23 +478,43 @@ export default function Drop() {
|
||||
|
||||
const calculatePrice = () => {
|
||||
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
|
||||
const pricePerGram = drop.ppu / 1000
|
||||
const priceToUse = isWholesaleUnlocked ? pricePerGram * 0.76 : pricePerGram
|
||||
return selectedSize * priceToUse
|
||||
const pricePerGramEur = drop.ppu / 1000
|
||||
const priceToUseEur = isWholesaleUnlocked ? pricePerGramEur * 0.76 : pricePerGramEur
|
||||
const priceEur = selectedSize * priceToUseEur
|
||||
// Convert to user's currency
|
||||
return convertPrice(priceEur)
|
||||
}
|
||||
|
||||
const calculateStandardPrice = () => {
|
||||
if (!drop) return 0
|
||||
const pricePerGram = drop.ppu / 1000
|
||||
return selectedSize * pricePerGram
|
||||
const pricePerGramEur = drop.ppu / 1000
|
||||
const priceEur = selectedSize * pricePerGramEur
|
||||
// Convert to user's currency
|
||||
return convertPrice(priceEur)
|
||||
}
|
||||
|
||||
const calculateWholesalePrice = () => {
|
||||
if (!drop) return 0
|
||||
const pricePerGram = drop.ppu / 1000
|
||||
return selectedSize * pricePerGram * 0.76
|
||||
const pricePerGramEur = drop.ppu / 1000
|
||||
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 = () => {
|
||||
@@ -467,11 +531,16 @@ export default function Drop() {
|
||||
const diffMinutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60))
|
||||
|
||||
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) {
|
||||
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 {
|
||||
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 (
|
||||
<div className="drop">
|
||||
<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>
|
||||
)
|
||||
@@ -505,12 +574,12 @@ export default function Drop() {
|
||||
return (
|
||||
<div className="drop">
|
||||
<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' }}>
|
||||
The current collective drop has been fully reserved.
|
||||
{t('drop.fullyReserved')}
|
||||
</p>
|
||||
<p style={{ color: 'var(--muted)' }}>
|
||||
Next collective drop coming soon.
|
||||
{t('drop.nextDropComingSoon')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -601,27 +670,26 @@ export default function Drop() {
|
||||
color: 'var(--muted)',
|
||||
}}
|
||||
>
|
||||
No Image
|
||||
{t('common.noImage')}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h2>{drop.item}</h2>
|
||||
<div className="meta">
|
||||
{formatSize(drop.size, drop.unit)} batch
|
||||
{formatSize(drop.size, drop.unit)} {t('drop.batch')}
|
||||
</div>
|
||||
<div className="price">
|
||||
{(() => {
|
||||
// ppu is stored as integer where 1000 = $1.00
|
||||
// Assuming ppu is always per gram for display purposes
|
||||
const pricePerGram = drop.ppu / 1000;
|
||||
const wholesalePricePerGram = pricePerGram * 0.76;
|
||||
// Get prices in user's currency
|
||||
const pricePerGram = getPricePerGram();
|
||||
const wholesalePricePerGram = getWholesalePricePerGram();
|
||||
|
||||
if (isWholesaleUnlocked) {
|
||||
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' }}>
|
||||
Standard: {pricePerGram.toFixed(2)} CHF / g
|
||||
{t('drop.standard')}: {pricePerGram.toFixed(2)} {currency} / g
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
@@ -629,11 +697,11 @@ export default function Drop() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<strong>Standard price: {pricePerGram.toFixed(2)} CHF / g</strong>
|
||||
<strong>{t('drop.standardPriceLabel')} {pricePerGram.toFixed(2)} {currency} / g</strong>
|
||||
<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>
|
||||
<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 ? (
|
||||
<div style={{ marginTop: '30px', padding: '20px', background: 'var(--bg-soft)', borderRadius: '12px', textAlign: 'center' }}>
|
||||
<p style={{ margin: 0, color: 'var(--muted)', fontSize: '16px' }}>
|
||||
Drop starts in <strong>{timeUntilStart}</strong>
|
||||
{t('drop.dropStartsIn')} <strong>{timeUntilStart}</strong>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -654,7 +722,7 @@ export default function Drop() {
|
||||
{(() => {
|
||||
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;
|
||||
return `${fillDisplay}g of ${sizeDisplay}g reserved`;
|
||||
return `${fillDisplay}g ${t('drop.of')} ${sizeDisplay}g ${t('drop.reserved')}`;
|
||||
})()}
|
||||
</div>
|
||||
{(() => {
|
||||
@@ -663,7 +731,7 @@ export default function Drop() {
|
||||
return pendingFill > 0 && (
|
||||
<div className="meta" style={{ fontSize: '12px', color: 'var(--muted)', marginTop: '4px' }}>
|
||||
{drop.unit === 'kg' ? pendingFill.toFixed(2) : Math.round(pendingFill)}
|
||||
{drop.unit} on hold (10 min checkout window)
|
||||
{drop.unit} {t('drop.onHold')}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
@@ -692,7 +760,7 @@ export default function Drop() {
|
||||
value={customQuantity}
|
||||
onChange={(e) => handleCustomQuantityChange(e.target.value)}
|
||||
onBlur={validateCustomQuantity}
|
||||
placeholder="Custom (g)"
|
||||
placeholder={t('drop.custom')}
|
||||
min={getMinimumGrams()}
|
||||
max={getRemainingInGrams()}
|
||||
style={{
|
||||
@@ -712,7 +780,7 @@ export default function Drop() {
|
||||
)}
|
||||
{!quantityError && customQuantity && (
|
||||
<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>
|
||||
@@ -722,42 +790,42 @@ export default function Drop() {
|
||||
{isWholesaleUnlocked ? (
|
||||
<>
|
||||
<div style={{ fontSize: '18px', fontWeight: 500, marginBottom: '8px' }}>
|
||||
Total: {calculatePrice().toFixed(2)} CHF
|
||||
{t('drop.total')}: {calculatePrice().toFixed(2)} {currency}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: 'var(--muted)' }}>
|
||||
Standard total: {calculateStandardPrice().toFixed(2)} CHF
|
||||
{t('drop.standardTotal')}: {calculateStandardPrice().toFixed(2)} {currency}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ fontSize: '18px', fontWeight: 500, marginBottom: '8px' }}>
|
||||
Total: {calculateStandardPrice().toFixed(2)} CHF
|
||||
{t('drop.total')}: {calculateStandardPrice().toFixed(2)} {currency}
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<button className="cta" onClick={handleJoinDrop}>
|
||||
Join the drop
|
||||
{t('drop.joinTheDrop')}
|
||||
</button>
|
||||
<div className="cta-note">No subscription · No obligation</div>
|
||||
<div className="cta-note">{t('drop.noSubscription')}</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasRemaining && availableSizes.length === 0 && (
|
||||
<div style={{ marginTop: '30px', padding: '20px', background: 'var(--bg-soft)', borderRadius: '12px', textAlign: 'center' }}>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasRemaining && (
|
||||
<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>
|
||||
@@ -792,34 +860,34 @@ export default function Drop() {
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 style={{ marginTop: 0, marginBottom: '20px' }}>
|
||||
Confirm Purchase
|
||||
{t('drop.confirmPurchase')}
|
||||
</h2>
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<p style={{ marginBottom: '12px', color: 'var(--muted)' }}>
|
||||
<strong>Item:</strong> {drop.item}
|
||||
<strong>{t('drop.item')}:</strong> {drop.item}
|
||||
</p>
|
||||
<p style={{ marginBottom: '12px', color: 'var(--muted)' }}>
|
||||
<strong>Quantity:</strong> {selectedSize}g
|
||||
<strong>{t('drop.quantity')}:</strong> {selectedSize}g
|
||||
</p>
|
||||
<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>
|
||||
|
||||
{/* Delivery Information */}
|
||||
<div style={{ marginTop: '24px', marginBottom: '16px' }}>
|
||||
<h3 style={{ marginBottom: '16px', fontSize: '16px', color: 'var(--text)' }}>
|
||||
Delivery Information
|
||||
{t('drop.deliveryInformation')}
|
||||
</h3>
|
||||
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', color: 'var(--muted)' }}>
|
||||
<strong>Full Name *</strong>
|
||||
<strong>{t('drop.fullNameRequired')}</strong>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={buyerFullname}
|
||||
onChange={(e) => setBuyerFullname(e.target.value)}
|
||||
placeholder="Enter your full name"
|
||||
placeholder={t('drop.enterFullName')}
|
||||
required
|
||||
style={{
|
||||
width: '100%',
|
||||
@@ -836,12 +904,12 @@ export default function Drop() {
|
||||
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', color: 'var(--muted)' }}>
|
||||
<strong>Address *</strong>
|
||||
<strong>{t('drop.addressRequired')}</strong>
|
||||
</label>
|
||||
<textarea
|
||||
value={buyerAddress}
|
||||
onChange={(e) => setBuyerAddress(e.target.value)}
|
||||
placeholder="Enter your delivery address"
|
||||
placeholder={t('drop.enterAddress')}
|
||||
required
|
||||
rows={3}
|
||||
style={{
|
||||
@@ -861,13 +929,13 @@ export default function Drop() {
|
||||
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', color: 'var(--muted)' }}>
|
||||
<strong>Phone Number *</strong>
|
||||
<strong>{t('drop.phoneRequired')}</strong>
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={buyerPhone}
|
||||
onChange={(e) => setBuyerPhone(e.target.value)}
|
||||
placeholder="Enter your phone number"
|
||||
placeholder={t('drop.enterPhone')}
|
||||
required
|
||||
style={{
|
||||
width: '100%',
|
||||
@@ -886,10 +954,10 @@ export default function Drop() {
|
||||
{/* Currency Selection */}
|
||||
<div style={{ marginTop: '24px', marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', color: 'var(--muted)' }}>
|
||||
<strong>Payment Currency:</strong>
|
||||
<strong>{t('drop.paymentCurrency')}:</strong>
|
||||
</label>
|
||||
{loadingCurrencies ? (
|
||||
<p style={{ color: 'var(--muted)', fontSize: '14px' }}>Loading currencies...</p>
|
||||
<p style={{ color: 'var(--muted)', fontSize: '14px' }}>{t('drop.loadingCurrencies')}</p>
|
||||
) : (
|
||||
<select
|
||||
value={selectedCurrency}
|
||||
@@ -941,9 +1009,32 @@ export default function Drop() {
|
||||
marginTop: '16px',
|
||||
}}
|
||||
>
|
||||
<p style={{ margin: 0, fontSize: '18px', fontWeight: 'bold' }}>
|
||||
Total: {calculatePrice().toFixed(2)} CHF
|
||||
</p>
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '8px' }}>
|
||||
<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
|
||||
style={{
|
||||
margin: '4px 0 0 0',
|
||||
@@ -951,7 +1042,7 @@ export default function Drop() {
|
||||
color: 'var(--muted)',
|
||||
}}
|
||||
>
|
||||
incl. 2.5% VAT
|
||||
{t('drop.inclVat')}
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
@@ -960,7 +1051,7 @@ export default function Drop() {
|
||||
color: 'var(--muted)',
|
||||
}}
|
||||
>
|
||||
Pay with: {String(selectedCurrency || '').toUpperCase()}
|
||||
{t('drop.payWith')}: {String(selectedCurrency || '').toUpperCase()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -989,7 +1080,7 @@ export default function Drop() {
|
||||
display: 'inline-block',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirmPurchase}
|
||||
@@ -1009,7 +1100,7 @@ export default function Drop() {
|
||||
display: 'inline-block',
|
||||
}}
|
||||
>
|
||||
{processing ? 'Processing...' : 'Confirm Purchase'}
|
||||
{processing ? t('common.processing') : t('drop.confirmPurchase')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1061,14 +1152,14 @@ export default function Drop() {
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 style={{ marginTop: 0, marginBottom: '20px', color: '#0a7931' }}>
|
||||
Payment confirmed ✔️
|
||||
{t('drop.paymentConfirmed')}
|
||||
</h2>
|
||||
<p style={{ marginBottom: '16px', color: 'var(--text)' }}>
|
||||
Your order has been successfully processed and is now reserved in this drop.
|
||||
{t('drop.orderProcessed')}
|
||||
</p>
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<p style={{ marginBottom: '12px', fontWeight: '600', color: 'var(--text)' }}>
|
||||
What happens next:
|
||||
{t('drop.whatHappensNext')}:
|
||||
</p>
|
||||
<ul style={{
|
||||
margin: 0,
|
||||
@@ -1076,13 +1167,13 @@ export default function Drop() {
|
||||
color: 'var(--muted)',
|
||||
lineHeight: '1.8'
|
||||
}}>
|
||||
<li>Your order will be processed within 24 hours</li>
|
||||
<li>Shipped via express delivery</li>
|
||||
<li>You'll receive a shipping confirmation and tracking link by email</li>
|
||||
<li>{t('drop.orderProcessed24h')}</li>
|
||||
<li>{t('drop.shippedExpress')}</li>
|
||||
<li>{t('drop.shippingConfirmation')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p style={{ marginBottom: '24px', color: 'var(--muted)', fontStyle: 'italic' }}>
|
||||
Thank you for being part of the collective.
|
||||
{t('drop.thankYouCollective')}
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
@@ -1104,7 +1195,7 @@ export default function Drop() {
|
||||
display: 'inline-block',
|
||||
}}
|
||||
>
|
||||
Close
|
||||
{t('common.close')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1145,7 +1236,7 @@ export default function Drop() {
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 style={{ marginTop: 0, marginBottom: '20px', color: '#dc2626' }}>
|
||||
⚠️ Error
|
||||
{t('drop.error')}
|
||||
</h2>
|
||||
<p style={{ marginBottom: '24px', color: 'var(--muted)' }}>
|
||||
{errorMessage}
|
||||
@@ -1172,7 +1263,7 @@ export default function Drop() {
|
||||
display: 'inline-block',
|
||||
}}
|
||||
>
|
||||
Close
|
||||
{t('common.close')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1240,20 +1331,50 @@ export default function Drop() {
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 style={{ marginTop: 0, marginBottom: '20px' }}>
|
||||
Complete Payment
|
||||
{t('drop.completePayment')}
|
||||
</h2>
|
||||
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<p style={{ marginBottom: '12px', color: 'var(--muted)' }}>
|
||||
<strong>Amount to Pay:</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()}
|
||||
<strong>{t('drop.amountToPay')}:</strong> {paymentData.pay_amount} {paymentData.pay_currency.toUpperCase()}
|
||||
</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' }}>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', color: 'var(--muted)' }}>
|
||||
Send payment to this address:
|
||||
{t('drop.sendPaymentTo')}
|
||||
</label>
|
||||
<div
|
||||
style={{
|
||||
@@ -1288,14 +1409,14 @@ export default function Drop() {
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Copy Address
|
||||
{t('drop.copyAddress')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{paymentData.payin_extra_id && (
|
||||
<div style={{ marginTop: '20px', marginBottom: '20px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', color: 'var(--muted)' }}>
|
||||
Memo / Destination Tag (Required):
|
||||
{t('drop.memoRequired')}:
|
||||
</label>
|
||||
<div
|
||||
style={{
|
||||
@@ -1318,34 +1439,34 @@ export default function Drop() {
|
||||
button.textContent = 'Copied!'
|
||||
setTimeout(() => {
|
||||
if (button) button.textContent = originalText
|
||||
}, 2000)
|
||||
}}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: 'var(--bg-soft)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Copy Memo
|
||||
</button>
|
||||
}, 2000)
|
||||
}}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: 'var(--bg-soft)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
{t('drop.copyMemo')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{paymentData.expiration_estimate_date && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: '24px', padding: '16px', background: 'var(--bg-soft)', borderRadius: '8px' }}>
|
||||
<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 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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1398,7 +1519,7 @@ export default function Drop() {
|
||||
display: 'inline-block',
|
||||
}}
|
||||
>
|
||||
Close
|
||||
{t('common.close')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { useI18n } from '@/lib/i18n'
|
||||
|
||||
export default function Footer() {
|
||||
const { t } = useI18n()
|
||||
|
||||
return (
|
||||
<footer>
|
||||
© 2025 420Deals.ch · CBD < 1% THC · Sale from 18 years · Switzerland
|
||||
{t('footer.text')}
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,26 +1,23 @@
|
||||
'use client'
|
||||
|
||||
import { useI18n } from '@/lib/i18n'
|
||||
|
||||
export default function InfoBox() {
|
||||
const { t } = useI18n()
|
||||
|
||||
return (
|
||||
<div className="info-box">
|
||||
<div>
|
||||
<h3>Why so cheap?</h3>
|
||||
<p>
|
||||
Retail prices are around 10 CHF/g. Through collective
|
||||
bulk orders, we buy like wholesalers – without
|
||||
intermediaries.
|
||||
</p>
|
||||
<h3>{t('infoBox.whyCheap')}</h3>
|
||||
<p>{t('infoBox.whyCheapText')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Taxes & Legal</h3>
|
||||
<p>
|
||||
Bulk sale with 2.5% VAT. No retail packaging, no
|
||||
tobacco tax.
|
||||
</p>
|
||||
<h3>{t('infoBox.taxesLegal')}</h3>
|
||||
<p>{t('infoBox.taxesLegalText')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Drop Model</h3>
|
||||
<p>
|
||||
One variety per drop. Only when sold out – then the next drop.
|
||||
</p>
|
||||
<h3>{t('infoBox.dropModel')}</h3>
|
||||
<p>{t('infoBox.dropModelText')}</p>
|
||||
</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 AuthModal from './AuthModal'
|
||||
import LanguageSwitcher from './LanguageSwitcher'
|
||||
import { useI18n } from '@/lib/i18n'
|
||||
|
||||
interface User {
|
||||
id: number
|
||||
@@ -10,6 +12,7 @@ interface User {
|
||||
}
|
||||
|
||||
export default function Nav() {
|
||||
const { t } = useI18n()
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [showAuthModal, setShowAuthModal] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -72,9 +75,10 @@ export default function Nav() {
|
||||
</button>
|
||||
</div>
|
||||
<div className={`links ${mobileMenuOpen ? 'mobile-open' : ''}`}>
|
||||
<a href="#drop" onClick={() => setMobileMenuOpen(false)}>Drop</a>
|
||||
<a href="#past" onClick={() => setMobileMenuOpen(false)}>Past Drops</a>
|
||||
<a href="#community" onClick={() => setMobileMenuOpen(false)}>Community</a>
|
||||
<a href="#drop" onClick={() => setMobileMenuOpen(false)}>{t('nav.drop')}</a>
|
||||
<a href="#past" onClick={() => setMobileMenuOpen(false)}>{t('nav.pastDrops')}</a>
|
||||
<a href="#community" onClick={() => setMobileMenuOpen(false)}>{t('nav.community')}</a>
|
||||
<LanguageSwitcher />
|
||||
{!loading && (
|
||||
user ? (
|
||||
<>
|
||||
@@ -99,7 +103,7 @@ export default function Nav() {
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Orders
|
||||
{t('nav.orders')}
|
||||
</a>
|
||||
<button
|
||||
onClick={() => {
|
||||
@@ -120,7 +124,7 @@ export default function Nav() {
|
||||
display: 'inline-block',
|
||||
}}
|
||||
>
|
||||
Logout
|
||||
{t('nav.logout')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
@@ -144,7 +148,7 @@ export default function Nav() {
|
||||
display: 'inline-block',
|
||||
}}
|
||||
>
|
||||
Login
|
||||
{t('nav.login')}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import Image from 'next/image'
|
||||
import { useI18n } from '@/lib/i18n'
|
||||
|
||||
interface PastDrop {
|
||||
id: number
|
||||
@@ -22,6 +23,7 @@ interface PastDropsProps {
|
||||
}
|
||||
|
||||
export default function PastDrops({ limit, showMoreLink = false }: PastDropsProps = {}) {
|
||||
const { t } = useI18n()
|
||||
const [drops, setDrops] = useState<PastDrop[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
@@ -55,18 +57,20 @@ export default function PastDrops({ limit, showMoreLink = false }: PastDropsProp
|
||||
|
||||
const formatSoldOutTime = (hours: number) => {
|
||||
if (hours < 1) {
|
||||
return 'Sold out in less than 1h'
|
||||
return `${t('pastDrops.soldOutIn')} ${t('pastDrops.lessThan1h')}`
|
||||
} else if (hours === 1) {
|
||||
return 'Sold out in 1h'
|
||||
return `${t('pastDrops.soldOutIn')} ${t('pastDrops.1h')}`
|
||||
} else if (hours < 24) {
|
||||
return `Sold out in ${hours}h`
|
||||
return `${t('pastDrops.soldOutIn')} ${t('pastDrops.hours', { hours })}`
|
||||
} else {
|
||||
const days = Math.floor(hours / 24)
|
||||
const remainingHours = hours % 24
|
||||
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 {
|
||||
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) {
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -97,7 +101,7 @@ export default function PastDrops({ limit, showMoreLink = false }: PastDropsProp
|
||||
return (
|
||||
<div className="past">
|
||||
<p style={{ color: 'var(--muted)', textAlign: 'center' }}>
|
||||
No past drops yet. Check back soon!
|
||||
{t('pastDrops.noDrops')}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
@@ -149,7 +153,7 @@ export default function PastDrops({ limit, showMoreLink = false }: PastDropsProp
|
||||
color: 'var(--muted)',
|
||||
}}
|
||||
>
|
||||
No Image
|
||||
{t('common.noImage')}
|
||||
</div>
|
||||
)}
|
||||
<strong>{drop.item}</strong>
|
||||
@@ -174,7 +178,7 @@ export default function PastDrops({ limit, showMoreLink = false }: PastDropsProp
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
More →
|
||||
{t('pastDrops.more')}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useI18n } from '@/lib/i18n'
|
||||
|
||||
export default function Signup() {
|
||||
const { t } = useI18n()
|
||||
const [email, setEmail] = useState('')
|
||||
const [whatsapp, setWhatsapp] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -60,19 +62,19 @@ export default function Signup() {
|
||||
return (
|
||||
<>
|
||||
<div className="signup">
|
||||
<h2>Drop Notifications</h2>
|
||||
<p>Receive updates about new drops via email or WhatsApp.</p>
|
||||
<h2>{t('signup.title')}</h2>
|
||||
<p>{t('signup.subtitle')}</p>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="E-Mail"
|
||||
placeholder={t('signup.email')}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={loading}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="WhatsApp Number"
|
||||
placeholder={t('signup.whatsapp')}
|
||||
value={whatsapp}
|
||||
onChange={(e) => setWhatsapp(e.target.value)}
|
||||
disabled={loading}
|
||||
@@ -80,7 +82,7 @@ export default function Signup() {
|
||||
<br />
|
||||
{error && <div style={{ color: '#ff4444', marginTop: '10px', fontSize: '14px' }}>{error}</div>}
|
||||
<button type="submit" disabled={loading}>
|
||||
{loading ? 'Subscribing...' : 'Get Notified'}
|
||||
{loading ? t('signup.subscribing') : t('signup.getNotified')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -103,7 +105,7 @@ export default function Signup() {
|
||||
}}
|
||||
>
|
||||
<p style={{ margin: 0, fontSize: '16px', color: '#eaeaea' }}>
|
||||
You will receive a notification as soon as a new drop drops.
|
||||
{t('signup.successMessage')}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowPopup(false)}
|
||||
@@ -118,7 +120,7 @@ export default function Signup() {
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
OK
|
||||
{t('common.ok')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2,15 +2,25 @@
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import UnlockModal from './UnlockModal'
|
||||
import { useI18n } from '@/lib/i18n'
|
||||
|
||||
interface ReferralTier {
|
||||
referralsNeeded: number
|
||||
referralsRemaining: number
|
||||
isUnlocked: boolean
|
||||
}
|
||||
|
||||
interface ReferralStatus {
|
||||
referralCount: number
|
||||
isUnlocked: boolean
|
||||
referralsNeeded: number
|
||||
referralsRemaining: number
|
||||
wholesaleTier?: ReferralTier
|
||||
innerCircleTier?: ReferralTier
|
||||
}
|
||||
|
||||
export default function UnlockBar() {
|
||||
const { t } = useI18n()
|
||||
const [referralStatus, setReferralStatus] = useState<ReferralStatus | null>(null)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -34,6 +44,16 @@ export default function UnlockBar() {
|
||||
isUnlocked: false,
|
||||
referralsNeeded: 3,
|
||||
referralsRemaining: 3,
|
||||
wholesaleTier: {
|
||||
referralsNeeded: 3,
|
||||
referralsRemaining: 3,
|
||||
isUnlocked: false
|
||||
},
|
||||
innerCircleTier: {
|
||||
referralsNeeded: 10,
|
||||
referralsRemaining: 10,
|
||||
isUnlocked: false
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
@@ -54,27 +74,72 @@ export default function UnlockBar() {
|
||||
isUnlocked: false,
|
||||
referralsNeeded: 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
|
||||
if (status.isUnlocked) {
|
||||
const wholesaleTier = status.wholesaleTier || {
|
||||
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 (
|
||||
<div className="unlock-bar" style={{ background: 'var(--accent)', color: '#000' }}>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
// Show wholesale unlock progress
|
||||
return (
|
||||
<>
|
||||
<div className="unlock-bar">
|
||||
<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 />
|
||||
<small>{status.referralsNeeded} verified sign-ups unlock wholesale prices forever.</small>
|
||||
<a href="#unlock" onClick={handleUnlockClick}>Unlock now</a>
|
||||
<small>{t('unlockBar.unlockText', { needed: wholesaleTier.referralsNeeded })}</small>
|
||||
<a href="#unlock" onClick={handleUnlockClick}>{t('unlockBar.unlockNow')}</a>
|
||||
</div>
|
||||
</div>
|
||||
<UnlockModal isOpen={showModal} onClose={() => setShowModal(false)} />
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useEffect, Suspense } from 'react'
|
||||
import AuthModal from './AuthModal'
|
||||
import { useI18n } from '@/lib/i18n'
|
||||
|
||||
interface UnlockModalProps {
|
||||
isOpen: boolean
|
||||
@@ -22,6 +23,7 @@ interface ReferralStatus {
|
||||
}
|
||||
|
||||
export default function UnlockModal({ isOpen, onClose }: UnlockModalProps) {
|
||||
const { t } = useI18n()
|
||||
const [referralStatus, setReferralStatus] = useState<ReferralStatus | null>(null)
|
||||
const [referralLink, setReferralLink] = useState<string>('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -115,7 +117,7 @@ export default function UnlockModal({ isOpen, onClose }: UnlockModalProps) {
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<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
|
||||
onClick={onClose}
|
||||
style={{
|
||||
@@ -137,17 +139,17 @@ export default function UnlockModal({ isOpen, onClose }: UnlockModalProps) {
|
||||
</div>
|
||||
|
||||
{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={{ fontSize: '18px', marginBottom: '8px' }}>
|
||||
🔒 {status.referralCount} of {status.referralsNeeded} referrals completed
|
||||
🔒 {t('unlockModal.referralsCompleted', { count: status.referralCount, needed: status.referralsNeeded })}
|
||||
</div>
|
||||
<p style={{ color: 'var(--muted)', fontSize: '14px', margin: '8px 0' }}>
|
||||
Invite {status.referralsNeeded} friends to sign up.
|
||||
{t('unlockModal.inviteFriends', { needed: status.referralsNeeded })}
|
||||
<br />
|
||||
Once they do, wholesale prices unlock forever.
|
||||
{t('unlockModal.unlockForever')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -162,7 +164,7 @@ export default function UnlockModal({ isOpen, onClose }: UnlockModalProps) {
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Your referral link
|
||||
{t('unlockModal.yourReferralLink')}
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<input
|
||||
@@ -194,7 +196,7 @@ export default function UnlockModal({ isOpen, onClose }: UnlockModalProps) {
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{copied ? 'Copied!' : 'Copy link'}
|
||||
{copied ? t('unlockModal.copied') : t('unlockModal.copyLink')}
|
||||
</button>
|
||||
</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' }}>
|
||||
Please log in to get your referral link
|
||||
{t('unlockModal.yourReferralLink')}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowAuthModal(true)}
|
||||
@@ -224,7 +226,7 @@ export default function UnlockModal({ isOpen, onClose }: UnlockModalProps) {
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Login
|
||||
{t('auth.login')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -240,7 +242,7 @@ export default function UnlockModal({ isOpen, onClose }: UnlockModalProps) {
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
Friends must sign up to count.
|
||||
{t('unlockModal.friendsMustSignUp')}
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -252,7 +254,10 @@ export default function UnlockModal({ isOpen, onClose }: UnlockModalProps) {
|
||||
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>
|
||||
|
||||
<button
|
||||
@@ -269,7 +274,7 @@ export default function UnlockModal({ isOpen, onClose }: UnlockModalProps) {
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Close
|
||||
{t('common.close')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user