diff --git a/app/admin/buyers/page.tsx b/app/admin/buyers/page.tsx index 13c75ac..c0e887f 100644 --- a/app/admin/buyers/page.tsx +++ b/app/admin/buyers/page.tsx @@ -8,11 +8,15 @@ interface Buyer { username: string email: string created_at?: string + referral_count?: number + hasWholesaleAccess?: boolean + hasInnerCircleAccess?: boolean } export default function BuyersManagementPage() { const router = useRouter() const [buyers, setBuyers] = useState([]) + const [filteredBuyers, setFilteredBuyers] = useState([]) const [loading, setLoading] = useState(true) const [authenticated, setAuthenticated] = useState(false) const [editingBuyer, setEditingBuyer] = useState(null) @@ -21,6 +25,10 @@ export default function BuyersManagementPage() { email: '', password: '', }) + const [filters, setFilters] = useState({ + wholesaleOnly: false, + innerCircleOnly: false, + }) useEffect(() => { // Check authentication @@ -44,7 +52,9 @@ export default function BuyersManagementPage() { const response = await fetch('/api/buyers') if (response.ok) { const data = await response.json() - setBuyers(Array.isArray(data) ? data : []) + const buyersList = Array.isArray(data) ? data : [] + setBuyers(buyersList) + applyFilters(buyersList, filters) } } catch (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) => { setEditingBuyer(buyer) setFormData({ @@ -173,11 +201,62 @@ export default function BuyersManagementPage() { - {buyers.length === 0 ? ( +
+

Filters

+
+ + +
+ {(filters.wholesaleOnly || filters.innerCircleOnly) && ( +

+ Showing {filteredBuyers.length} of {buyers.length} buyers +

+ )} +
+ + {(filters.wholesaleOnly || filters.innerCircleOnly ? filteredBuyers : buyers).length === 0 ? (

No buyers found

) : (
- {buyers.map((buyer) => ( + {(filters.wholesaleOnly || filters.innerCircleOnly ? filteredBuyers : buyers).map((buyer) => (
{buyer.created_at && ( -

+

Created: {new Date(buyer.created_at).toLocaleString()}

)} +
+ + {buyer.referral_count || 0} referrals - Wholesale {buyer.hasWholesaleAccess ? '✓' : '✗'} + + + Inner Circle {buyer.hasInnerCircleAccess ? '✓' : '✗'} + +
)} @@ -197,7 +199,7 @@ export default function AuthModal({ isOpen, onClose, onLogin }: AuthModalProps) color: 'var(--text)', }} > - Username + {t('auth.username')}
@@ -229,7 +231,7 @@ export default function AuthModal({ isOpen, onClose, onLogin }: AuthModalProps) color: 'var(--text)', }} > - Password + {t('auth.password')} @@ -262,7 +264,7 @@ export default function AuthModal({ isOpen, onClose, onLogin }: AuthModalProps) color: 'var(--text)', }} > - Referral ID (optional) + {t('auth.referralId')} ({t('auth.optional')}) {searchParams?.get('ref') && referralId === searchParams.get('ref') && ( - ✓ Auto-filled from referral link + {t('auth.autoFilled')} )} @@ -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')} @@ -333,7 +335,7 @@ export default function AuthModal({ isOpen, onClose, onLogin }: AuthModalProps) > {isLogin ? ( <> - Don't have an account?{' '} + {t('auth.dontHaveAccount')}{' '} ) : ( <> - Already have an account?{' '} + {t('auth.alreadyHaveAccount')}{' '} )} diff --git a/app/components/Drop.tsx b/app/components/Drop.tsx index 43ad811..695acb4 100644 --- a/app/components/Drop.tsx +++ b/app/components/Drop.tsx @@ -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(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(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 (
-

Loading...

+

{t('drop.loading')}

) @@ -505,12 +574,12 @@ export default function Drop() { return (
-

Drop Sold Out

+

{t('drop.dropSoldOut')}

- The current collective drop has been fully reserved. + {t('drop.fullyReserved')}

- Next collective drop coming soon. + {t('drop.nextDropComingSoon')}

@@ -601,27 +670,26 @@ export default function Drop() { color: 'var(--muted)', }} > - No Image + {t('common.noImage')} )}

{drop.item}

- {formatSize(drop.size, drop.unit)} batch + {formatSize(drop.size, drop.unit)} {t('drop.batch')}
{(() => { - // 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 ( <> - Wholesale price: {wholesalePricePerGram.toFixed(2)} CHF / g + {t('drop.wholesalePriceLabel')} {wholesalePricePerGram.toFixed(2)} {currency} / g - Standard: {pricePerGram.toFixed(2)} CHF / g + {t('drop.standard')}: {pricePerGram.toFixed(2)} {currency} / g ); @@ -629,11 +697,11 @@ export default function Drop() { return ( <> - Standard price: {pricePerGram.toFixed(2)} CHF / g + {t('drop.standardPriceLabel')} {pricePerGram.toFixed(2)} {currency} / g - Wholesale: {wholesalePricePerGram.toFixed(2)} CHF / g 🔒 { e.preventDefault(); setShowUnlockModal(true); }}>unlock + {t('drop.wholesale')}: {wholesalePricePerGram.toFixed(2)} {currency} / g 🔒 { e.preventDefault(); setShowUnlockModal(true); }}>{t('drop.unlock')} -
Unlock once. Keep wholesale forever.
+
{t('drop.unlockOnce')}
); })()} @@ -642,7 +710,7 @@ export default function Drop() { {isUpcoming ? (

- Drop starts in {timeUntilStart} + {t('drop.dropStartsIn')} {timeUntilStart}

) : ( @@ -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')}`; })()}
{(() => { @@ -663,7 +731,7 @@ export default function Drop() { return pendingFill > 0 && (
{drop.unit === 'kg' ? pendingFill.toFixed(2) : Math.round(pendingFill)} - {drop.unit} on hold (10 min checkout window) + {drop.unit} {t('drop.onHold')}
) })()} @@ -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 && (
- Min: {getMinimumGrams()}g · Max: {getRemainingInGrams()}g + {t('drop.min')}: {getMinimumGrams()}g · {t('drop.max')}: {getRemainingInGrams()}g
)}
@@ -722,42 +790,42 @@ export default function Drop() { {isWholesaleUnlocked ? ( <>
- Total: {calculatePrice().toFixed(2)} CHF + {t('drop.total')}: {calculatePrice().toFixed(2)} {currency}
- Standard total: {calculateStandardPrice().toFixed(2)} CHF + {t('drop.standardTotal')}: {calculateStandardPrice().toFixed(2)} {currency}
) : ( <>
- Total: {calculateStandardPrice().toFixed(2)} CHF + {t('drop.total')}: {calculateStandardPrice().toFixed(2)} {currency}
- Wholesale total: {calculateWholesalePrice().toFixed(2)} CHF 🔒 + {t('drop.wholesaleTotal')}: {calculateWholesalePrice().toFixed(2)} {currency} 🔒
)} -
No subscription · No obligation
+
{t('drop.noSubscription')}
)} {hasRemaining && availableSizes.length === 0 && (

- Less than 50{drop.unit} remaining. This drop is almost fully reserved. + {t('drop.lessThanRemaining', { amount: 50, unit: drop.unit })}

)} {!hasRemaining && (
-

This drop is fully reserved

+

{t('drop.fullyReservedText')}

)} @@ -792,34 +860,34 @@ export default function Drop() { onClick={(e) => e.stopPropagation()} >

- Confirm Purchase + {t('drop.confirmPurchase')}

- Item: {drop.item} + {t('drop.item')}: {drop.item}

- Quantity: {selectedSize}g + {t('drop.quantity')}: {selectedSize}g

- Price per {drop.unit}: {(drop.ppu / 1000).toFixed(2)} CHF + {t('drop.pricePerUnit', { unit: drop.unit })}: {getPricePerGram().toFixed(2)} {currency}

{/* Delivery Information */}

- Delivery Information + {t('drop.deliveryInformation')}

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() {