Files
cbd420/app/components/Drop.tsx
2025-12-21 17:36:44 +01:00

1411 lines
49 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useState, useEffect, Suspense } from 'react'
import Image from 'next/image'
import AuthModal from './AuthModal'
import UnlockModal from './UnlockModal'
interface DropData {
id: number
item: string
size: number
fill: number
unit: string
ppu: number
image_url: string | null
images?: string[] // Array of image URLs (up to 4)
created_at: string
start_time: string | null
is_upcoming?: boolean
sales_fill?: number // Only confirmed sales
pending_fill?: number // Items on hold (pending orders)
}
interface User {
id: number
username: string
email: string
}
export default function Drop() {
const [drop, setDrop] = useState<DropData | null>(null)
const [loading, setLoading] = useState(true)
const [selectedSize, setSelectedSize] = useState(50)
const [customQuantity, setCustomQuantity] = useState<string>('')
const [quantityError, setQuantityError] = useState<string>('')
const [selectedCurrency, setSelectedCurrency] = useState<string>('btc')
const [availableCurrencies, setAvailableCurrencies] = useState<string[]>([])
const [loadingCurrencies, setLoadingCurrencies] = useState(false)
const [buyerFullname, setBuyerFullname] = useState<string>('')
const [buyerAddress, setBuyerAddress] = useState<string>('')
const [buyerPhone, setBuyerPhone] = useState<string>('')
const [showConfirmModal, setShowConfirmModal] = useState(false)
const [showAuthModal, setShowAuthModal] = useState(false)
const [showPaymentModal, setShowPaymentModal] = useState(false)
const [showSuccessModal, setShowSuccessModal] = useState(false)
const [showErrorModal, setShowErrorModal] = useState(false)
const [errorMessage, setErrorMessage] = useState<string>('')
const [paymentData, setPaymentData] = useState<any>(null)
const [processing, setProcessing] = useState(false)
const [user, setUser] = useState<User | null>(null)
const [checkingAuth, setCheckingAuth] = useState(true)
const [isWholesaleUnlocked, setIsWholesaleUnlocked] = useState(false)
const [showUnlockModal, setShowUnlockModal] = useState(false)
const [selectedImageIndex, setSelectedImageIndex] = useState(0)
useEffect(() => {
fetchActiveDrop()
checkAuth()
checkWholesaleStatus()
// Poll active drop every 30 seconds
const interval = setInterval(() => {
fetchActiveDrop()
}, 30000) // 30 seconds
return () => clearInterval(interval)
}, [])
const checkWholesaleStatus = async () => {
try {
const response = await fetch('/api/referrals/status', {
credentials: 'include',
})
if (response.ok) {
const data = await response.json()
setIsWholesaleUnlocked(data.isUnlocked || false)
}
} catch (error) {
console.error('Error checking wholesale status:', error)
}
}
// Poll payment status when payment modal is open
useEffect(() => {
if (!showPaymentModal || !paymentData?.payment_id) return
const checkPaymentStatus = async () => {
try {
const response = await fetch(`/api/payments/check-status?payment_id=${paymentData.payment_id}`, {
credentials: 'include',
})
if (response.ok) {
const status = await response.json()
// If pending order is gone and sale exists, payment was processed
if (!status.has_pending_order && status.has_sale) {
// Close payment modal
setShowPaymentModal(false)
// Show success modal
setShowSuccessModal(true)
// Refresh drop data
await fetchActiveDrop()
}
}
} catch (error) {
console.error('Error checking payment status:', error)
}
}
// Check immediately, then poll every 3 seconds
checkPaymentStatus()
const interval = setInterval(checkPaymentStatus, 3000)
return () => clearInterval(interval)
}, [showPaymentModal, paymentData?.payment_id])
const checkAuth = async () => {
try {
const response = await fetch('/api/auth/session', {
credentials: 'include',
})
if (response.ok) {
const data = await response.json()
setUser(data.user)
}
} catch (error) {
console.error('Error checking auth:', error)
} finally {
setCheckingAuth(false)
}
}
const fetchActiveDrop = async () => {
try {
const response = await fetch('/api/drops/active', {
// Add cache control to prevent stale data
cache: 'no-store',
})
if (response.ok) {
const data = await response.json()
// Handle both null response and actual drop data
setDrop(data) // data can be null if no active drop
} else {
// If response is not ok, log the error
const errorData = await response.json().catch(() => ({ error: 'Unknown error' }))
console.error('Error fetching active drop:', errorData)
setDrop(null)
}
} catch (error) {
console.error('Error fetching active drop:', error)
setDrop(null)
} finally {
setLoading(false)
}
}
const getProgressPercentage = (fill: number, size: number) => {
return Math.min((fill / size) * 100, 100)
}
const formatSize = (size: number, unit: string) => {
if (unit === 'g' && size >= 1000) {
return `${(size / 1000).toFixed(1)}kg`
}
return `${size}${unit}`
}
const getAvailableSizes = () => {
if (!drop) return []
const sizes = [50, 100, 250] // Always in grams
// Calculate remaining inventory in grams
let remainingInGrams = 0
if (drop.unit === 'kg') {
remainingInGrams = (drop.size - drop.fill) * 1000
} else {
// For 'g' or any other unit, assume same unit
remainingInGrams = drop.size - drop.fill
}
// Only show sizes that don't exceed remaining inventory
return sizes.filter((size) => size <= remainingInGrams)
}
const getRemainingInGrams = () => {
if (!drop) return 0
if (drop.unit === 'kg') {
return (drop.size - drop.fill) * 1000
}
return drop.size - drop.fill
}
const getMinimumGrams = () => {
if (!drop) return 0
// Minimum price is 5 CHF
// Calculate minimum grams needed for 5 CHF
const pricePerGram = drop.ppu / 1000
// Use the higher price (standard) to ensure minimum is met
return Math.ceil(5 / pricePerGram)
}
const handleCustomQuantityChange = (value: string) => {
setCustomQuantity(value)
setQuantityError('')
// Clear selected preset size when custom input is used
if (value.trim() !== '') {
const numValue = parseInt(value, 10)
if (!isNaN(numValue) && numValue > 0) {
setSelectedSize(numValue)
}
}
}
const validateCustomQuantity = () => {
if (!drop || !customQuantity.trim()) {
setQuantityError('')
return true
}
const numValue = parseInt(customQuantity, 10)
const remaining = getRemainingInGrams()
const minimum = getMinimumGrams()
if (isNaN(numValue) || numValue <= 0) {
setQuantityError('Please enter a valid number')
return false
}
if (numValue < minimum) {
setQuantityError(`Minimum ${minimum}g required (5 CHF minimum)`)
return false
}
if (numValue > remaining) {
setQuantityError(`Maximum ${remaining}g available`)
return false
}
setQuantityError('')
return true
}
const handleQuantityButtonClick = (size: number) => {
setSelectedSize(size)
setCustomQuantity('')
setQuantityError('')
}
const fetchAvailableCurrencies = async () => {
setLoadingCurrencies(true)
try {
const response = await fetch('/api/payments/currencies')
if (response.ok) {
const data = await response.json()
// When fixed_rate=true, API returns objects with { currency, min_amount, max_amount }
// When fixed_rate=false, API returns array of strings
const currencies: string[] = []
if (Array.isArray(data.currencies)) {
data.currencies.forEach((c: any) => {
let currencyCode: string | null = null
// Handle object format (when fixed_rate=true)
if (typeof c === 'object' && c !== null && c.currency) {
currencyCode = String(c.currency).trim().toLowerCase()
}
// Handle string format (when fixed_rate=false)
else if (typeof c === 'string') {
currencyCode = c.trim().toLowerCase()
}
// Add to array if valid
if (currencyCode && currencyCode.length > 0) {
currencies.push(currencyCode)
}
})
}
setAvailableCurrencies(currencies)
// Set default to BTC if available, otherwise first currency
if (currencies.length > 0) {
const defaultCurrency = currencies.includes('btc') ? 'btc' : currencies[0]
setSelectedCurrency(defaultCurrency)
}
}
} catch (error) {
console.error('Error fetching currencies:', error)
} finally {
setLoadingCurrencies(false)
}
}
const fetchBuyerData = async () => {
try {
const response = await fetch('/api/buyer-data', {
credentials: 'include',
})
if (response.ok) {
const data = await response.json()
if (data.buyer_data) {
// Autofill form fields with existing buyer data
setBuyerFullname(data.buyer_data.fullname || '')
setBuyerAddress(data.buyer_data.address || '')
setBuyerPhone(data.buyer_data.phone || '')
}
}
} catch (error) {
console.error('Error fetching buyer data:', error)
}
}
const handleJoinDrop = () => {
// Validate custom quantity if entered
if (customQuantity && !validateCustomQuantity()) {
return
}
// Check if user is logged in
if (!user) {
setShowAuthModal(true)
return
}
// Fetch available currencies and buyer data when opening confirm modal
fetchAvailableCurrencies()
fetchBuyerData()
setShowConfirmModal(true)
}
const handleLogin = (loggedInUser: User) => {
setUser(loggedInUser)
setShowAuthModal(false)
// After login, fetch buyer data and show the confirmation modal
fetchAvailableCurrencies()
fetchBuyerData()
setShowConfirmModal(true)
}
const handleConfirmPurchase = async () => {
if (!drop) return
// Validate buyer data fields
if (!buyerFullname.trim() || !buyerAddress.trim() || !buyerPhone.trim()) {
setErrorMessage('Please fill in all delivery information (full name, address, and phone)')
setShowErrorModal(true)
return
}
setProcessing(true)
try {
// First, get or create buyer_data
const buyerDataResponse = await fetch('/api/buyer-data/get-or-create', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
fullname: buyerFullname.trim(),
address: buyerAddress.trim(),
phone: buyerPhone.trim(),
}),
})
if (!buyerDataResponse.ok) {
const error = await buyerDataResponse.json()
setErrorMessage(error.error || 'Failed to save delivery information')
setShowErrorModal(true)
setProcessing(false)
return
}
const buyerData = await buyerDataResponse.json()
// Create NOWPayments payment
const response = await fetch('/api/payments/create-invoice', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include', // Important for cookies
body: JSON.stringify({
drop_id: drop.id,
size: selectedSize, // Size in grams
pay_currency: selectedCurrency, // Selected payment currency
buyer_data_id: buyerData.buyer_data_id, // Buyer delivery data ID
}),
})
if (!response.ok) {
const error = await response.json()
if (response.status === 401) {
// User not authenticated - show login modal
setShowConfirmModal(false)
setShowAuthModal(true)
setProcessing(false)
return
}
// Show error modal instead of alert
setErrorMessage(error.error || 'Failed to create payment')
setShowErrorModal(true)
setShowConfirmModal(false)
setProcessing(false)
return
}
const data = await response.json()
// Close confirmation modal
setShowConfirmModal(false)
setProcessing(false)
// Show payment modal with payment details
if (data.pay_address) {
setPaymentData(data)
setShowPaymentModal(true)
} else {
setErrorMessage('Payment created but no payment address received')
setShowErrorModal(true)
await fetchActiveDrop()
}
} catch (error) {
console.error('Error creating payment invoice:', error)
setErrorMessage('Failed to create payment invoice. Please try again.')
setShowErrorModal(true)
setProcessing(false)
}
}
const handleCancelPurchase = () => {
setShowConfirmModal(false)
}
const calculatePrice = () => {
if (!drop) return 0
// ppu is stored as integer where 1000 = $1.00, so divide by 1000 to get actual price
// Assuming ppu is per gram
const pricePerGram = drop.ppu / 1000
const priceToUse = isWholesaleUnlocked ? pricePerGram * 0.76 : pricePerGram
return selectedSize * priceToUse
}
const calculateStandardPrice = () => {
if (!drop) return 0
const pricePerGram = drop.ppu / 1000
return selectedSize * pricePerGram
}
const calculateWholesalePrice = () => {
if (!drop) return 0
const pricePerGram = drop.ppu / 1000
return selectedSize * pricePerGram * 0.76
}
const getTimeUntilStart = () => {
if (!drop || !drop.is_upcoming || !drop.start_time) return null
const startTime = new Date(drop.start_time)
const now = new Date()
const diffMs = startTime.getTime() - now.getTime()
if (diffMs <= 0) return null
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
const diffHours = Math.floor((diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
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' : ''}` : ''}`
} else if (diffHours > 0) {
return `${diffHours} hour${diffHours > 1 ? 's' : ''}${diffMinutes > 0 ? ` ${diffMinutes} minute${diffMinutes > 1 ? 's' : ''}` : ''}`
} else {
return `${diffMinutes} minute${diffMinutes > 1 ? 's' : ''}`
}
}
// Get images array (prioritize new images array, fallback to legacy image_url)
// Must be defined before early returns to maintain hook order
const images = drop?.images && drop.images.length > 0
? drop.images
: (drop?.image_url ? [drop.image_url] : [])
// Reset selected image index when images change
// Must be called before early returns to maintain hook order
useEffect(() => {
if (images.length > 0 && selectedImageIndex >= images.length) {
setSelectedImageIndex(0)
} else if (images.length === 0) {
setSelectedImageIndex(0)
}
}, [images, selectedImageIndex])
if (loading) {
return (
<div className="drop">
<div style={{ gridColumn: '1 / -1', textAlign: 'center', padding: '40px' }}>
<p style={{ color: 'var(--muted)' }}>Loading...</p>
</div>
</div>
)
}
if (!drop) {
return (
<div className="drop">
<div style={{ gridColumn: '1 / -1', textAlign: 'center', padding: '60px' }}>
<h2 style={{ marginBottom: '16px' }}>Drop Sold Out</h2>
<p style={{ color: 'var(--muted)', marginBottom: '20px' }}>
The current collective drop has been fully reserved.
</p>
<p style={{ color: 'var(--muted)' }}>
Next collective drop coming soon.
</p>
</div>
</div>
)
}
const progressPercentage = getProgressPercentage(drop.fill, drop.size)
const availableSizes = getAvailableSizes()
const timeUntilStart = getTimeUntilStart()
const isUpcoming = drop.is_upcoming && timeUntilStart
// Calculate remaining in the drop's unit
const remaining = drop.size - drop.fill
const hasRemaining = remaining > 0
return (
<div className="drop">
{images.length > 0 ? (
<div>
{/* Main large image */}
<div style={{ marginBottom: '12px' }}>
<Image
src={images[selectedImageIndex]}
alt={`${drop.item} - Image ${selectedImageIndex + 1}`}
width={420}
height={420}
style={{
width: '100%',
height: 'auto',
borderRadius: '16px',
objectFit: 'cover',
aspectRatio: '1 / 1'
}}
/>
</div>
{/* Thumbnails */}
{images.length > 1 && (
<div style={{
display: 'grid',
gridTemplateColumns: `repeat(${Math.min(images.length, 4)}, 1fr)`,
gap: '8px'
}}>
{images.slice(0, 4).map((imgUrl, index) => (
<button
key={index}
onClick={() => setSelectedImageIndex(index)}
style={{
padding: 0,
border: selectedImageIndex === index
? '2px solid var(--accent)'
: '2px solid transparent',
borderRadius: '8px',
background: 'transparent',
cursor: 'pointer',
overflow: 'hidden',
aspectRatio: '1 / 1'
}}
>
<Image
src={imgUrl}
alt={`${drop.item} - Thumbnail ${index + 1}`}
width={100}
height={100}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
opacity: selectedImageIndex === index ? 1 : 0.7,
transition: 'opacity 0.2s'
}}
/>
</button>
))}
</div>
)}
</div>
) : (
<div
style={{
width: '100%',
aspectRatio: '1 / 1',
background: 'var(--bg-soft)',
borderRadius: '16px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--muted)',
}}
>
No Image
</div>
)}
<div>
<h2>{drop.item}</h2>
<div className="meta">
{formatSize(drop.size, drop.unit)} 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;
if (isWholesaleUnlocked) {
return (
<>
<strong>Wholesale price: {wholesalePricePerGram.toFixed(2)} CHF / g</strong>
<span className="muted" style={{ display: 'block', marginTop: '6px', fontSize: '14px' }}>
Standard: {pricePerGram.toFixed(2)} CHF / g
</span>
</>
);
}
return (
<>
<strong>Standard price: {pricePerGram.toFixed(2)} CHF / g</strong>
<span className="muted">
Wholesale: {wholesalePricePerGram.toFixed(2)} CHF / g 🔒 <a href="#unlock" onClick={(e) => { e.preventDefault(); setShowUnlockModal(true); }}>unlock</a>
</span>
<div className="hint">Unlock once. Keep wholesale forever.</div>
</>
);
})()}
</div>
{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>
</p>
</div>
) : (
<>
<div className="progress">
<span style={{ width: `${progressPercentage}%` }}></span>
</div>
<div className="meta">
{(() => {
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`;
})()}
</div>
{(() => {
const pendingFill = Number(drop.pending_fill) || 0;
console.log(`pending fill:${pendingFill}`)
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)
</div>
)
})()}
</>
)}
{!isUpcoming && hasRemaining && (
<>
<div style={{ display: 'flex', gap: '12px', alignItems: 'flex-start', flexWrap: 'wrap' }}>
{availableSizes.length > 0 && (
<div className="options" style={{ flex: '1', minWidth: '200px' }}>
{availableSizes.map((size) => (
<button
key={size}
className={selectedSize === size && !customQuantity ? 'active' : ''}
onClick={() => handleQuantityButtonClick(size)}
>
{size}g
</button>
))}
</div>
)}
<div style={{ flex: availableSizes.length > 0 ? '1' : '100%', minWidth: '150px' }}>
<input
type="number"
value={customQuantity}
onChange={(e) => handleCustomQuantityChange(e.target.value)}
onBlur={validateCustomQuantity}
placeholder="Custom (g)"
min={getMinimumGrams()}
max={getRemainingInGrams()}
style={{
width: '100%',
padding: '14px',
borderRadius: '12px',
border: `1px solid ${quantityError ? '#dc2626' : 'var(--border)'}`,
background: 'var(--bg-soft)',
color: 'var(--text)',
fontSize: '14px',
}}
/>
{quantityError && (
<div style={{ marginTop: '6px', fontSize: '12px', color: '#dc2626' }}>
{quantityError}
</div>
)}
{!quantityError && customQuantity && (
<div style={{ marginTop: '6px', fontSize: '12px', color: 'var(--muted)' }}>
Min: {getMinimumGrams()}g · Max: {getRemainingInGrams()}g
</div>
)}
</div>
</div>
<div style={{ marginTop: '24px', marginBottom: '16px' }}>
{isWholesaleUnlocked ? (
<>
<div style={{ fontSize: '18px', fontWeight: 500, marginBottom: '8px' }}>
Total: {calculatePrice().toFixed(2)} CHF
</div>
<div style={{ fontSize: '14px', color: 'var(--muted)' }}>
Standard total: {calculateStandardPrice().toFixed(2)} CHF
</div>
</>
) : (
<>
<div style={{ fontSize: '18px', fontWeight: 500, marginBottom: '8px' }}>
Total: {calculateStandardPrice().toFixed(2)} CHF
</div>
<div style={{ fontSize: '14px', color: 'var(--muted)', display: 'flex', alignItems: 'center', gap: '6px' }}>
Wholesale total: {calculateWholesalePrice().toFixed(2)} CHF 🔒
</div>
</>
)}
</div>
<button className="cta" onClick={handleJoinDrop}>
Join the drop
</button>
<div className="cta-note">No subscription · No obligation</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.
</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>
</div>
)}
</div>
{/* Confirmation Modal */}
{showConfirmModal && drop && (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0, 0, 0, 0.7)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
padding: '20px',
}}
onClick={handleCancelPurchase}
>
<div
style={{
background: 'var(--card)',
borderRadius: '16px',
padding: '32px',
maxWidth: '500px',
width: '100%',
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.3)',
}}
onClick={(e) => e.stopPropagation()}
>
<h2 style={{ marginTop: 0, marginBottom: '20px' }}>
Confirm Purchase
</h2>
<div style={{ marginBottom: '24px' }}>
<p style={{ marginBottom: '12px', color: 'var(--muted)' }}>
<strong>Item:</strong> {drop.item}
</p>
<p style={{ marginBottom: '12px', color: 'var(--muted)' }}>
<strong>Quantity:</strong> {selectedSize}g
</p>
<p style={{ marginBottom: '12px', color: 'var(--muted)' }}>
<strong>Price per {drop.unit}:</strong> {(drop.ppu / 1000).toFixed(2)} CHF
</p>
{/* Delivery Information */}
<div style={{ marginTop: '24px', marginBottom: '16px' }}>
<h3 style={{ marginBottom: '16px', fontSize: '16px', color: 'var(--text)' }}>
Delivery Information
</h3>
<div style={{ marginBottom: '12px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', color: 'var(--muted)' }}>
<strong>Full Name *</strong>
</label>
<input
type="text"
value={buyerFullname}
onChange={(e) => setBuyerFullname(e.target.value)}
placeholder="Enter your full name"
required
style={{
width: '100%',
padding: '12px',
background: 'var(--bg-soft)',
border: '1px solid var(--border)',
borderRadius: '8px',
fontSize: '14px',
color: 'var(--text)',
boxSizing: 'border-box',
}}
/>
</div>
<div style={{ marginBottom: '12px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', color: 'var(--muted)' }}>
<strong>Address *</strong>
</label>
<textarea
value={buyerAddress}
onChange={(e) => setBuyerAddress(e.target.value)}
placeholder="Enter your delivery address"
required
rows={3}
style={{
width: '100%',
padding: '12px',
background: 'var(--bg-soft)',
border: '1px solid var(--border)',
borderRadius: '8px',
fontSize: '14px',
color: 'var(--text)',
fontFamily: 'inherit',
resize: 'vertical',
boxSizing: 'border-box',
}}
/>
</div>
<div style={{ marginBottom: '12px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', color: 'var(--muted)' }}>
<strong>Phone Number *</strong>
</label>
<input
type="tel"
value={buyerPhone}
onChange={(e) => setBuyerPhone(e.target.value)}
placeholder="Enter your phone number"
required
style={{
width: '100%',
padding: '12px',
background: 'var(--bg-soft)',
border: '1px solid var(--border)',
borderRadius: '8px',
fontSize: '14px',
color: 'var(--text)',
boxSizing: 'border-box',
}}
/>
</div>
</div>
{/* Currency Selection */}
<div style={{ marginTop: '24px', marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', color: 'var(--muted)' }}>
<strong>Payment Currency:</strong>
</label>
{loadingCurrencies ? (
<p style={{ color: 'var(--muted)', fontSize: '14px' }}>Loading currencies...</p>
) : (
<select
value={selectedCurrency}
onChange={(e) => setSelectedCurrency(e.target.value)}
style={{
width: '100%',
padding: '12px',
background: 'var(--bg-soft)',
border: '1px solid var(--border)',
borderRadius: '8px',
fontSize: '14px',
color: 'var(--text)',
cursor: 'pointer',
}}
>
{availableCurrencies.map((currency, index) => {
// Ensure currency is a string - handle both string and object cases
let currencyStr: string
if (typeof currency === 'string') {
currencyStr = currency.trim().toLowerCase()
} else if (currency && typeof currency === 'object') {
// If it's an object, try to extract a string value
currencyStr = (currency as any).value || (currency as any).code || String(currency).trim().toLowerCase()
} else {
currencyStr = String(currency || '').trim().toLowerCase()
}
// Fallback if we still don't have a valid string
if (!currencyStr || currencyStr === '[object object]' || currencyStr === 'null' || currencyStr === 'undefined') {
console.warn('Invalid currency value:', currency, 'at index', index)
return null
}
return (
<option key={`${currencyStr}-${index}`} value={currencyStr}>
{currencyStr.toUpperCase()}
</option>
)
}).filter(Boolean)}
</select>
)}
</div>
<div
style={{
padding: '16px',
background: 'var(--bg-soft)',
borderRadius: '8px',
marginTop: '16px',
}}
>
<p style={{ margin: 0, fontSize: '18px', fontWeight: 'bold' }}>
Total: {calculatePrice().toFixed(2)} CHF
</p>
<p
style={{
margin: '4px 0 0 0',
fontSize: '14px',
color: 'var(--muted)',
}}
>
incl. 2.5% VAT
</p>
<p
style={{
margin: '8px 0 0 0',
fontSize: '12px',
color: 'var(--muted)',
}}
>
Pay with: {String(selectedCurrency || '').toUpperCase()}
</p>
</div>
</div>
<div
style={{
display: 'flex',
gap: '12px',
justifyContent: 'flex-end',
}}
>
<button
onClick={handleCancelPurchase}
disabled={processing}
style={{
padding: '12px 24px',
background: '#dc2626',
border: 'none',
borderRadius: '14px',
cursor: processing ? 'not-allowed' : 'pointer',
color: '#fff',
fontSize: '15px',
fontWeight: 500,
opacity: processing ? 0.6 : 1,
lineHeight: '1.5',
boxSizing: 'border-box',
display: 'inline-block',
}}
>
Cancel
</button>
<button
onClick={handleConfirmPurchase}
disabled={processing}
style={{
padding: '12px 24px',
background: '#0a7931',
color: '#fff',
border: 'none',
borderRadius: '14px',
cursor: processing ? 'not-allowed' : 'pointer',
fontSize: '15px',
fontWeight: 500,
opacity: processing ? 0.6 : 1,
lineHeight: '1.5',
boxSizing: 'border-box',
display: 'inline-block',
}}
>
{processing ? 'Processing...' : 'Confirm Purchase'}
</button>
</div>
</div>
</div>
)}
{/* Auth Modal */}
<Suspense fallback={null}>
<AuthModal
isOpen={showAuthModal}
onClose={() => setShowAuthModal(false)}
onLogin={handleLogin}
/>
</Suspense>
{/* Unlock Modal */}
<UnlockModal
isOpen={showUnlockModal}
onClose={() => setShowUnlockModal(false)}
/>
{/* Success Modal */}
{showSuccessModal && (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0, 0, 0, 0.7)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1001,
padding: '20px',
}}
onClick={() => setShowSuccessModal(false)}
>
<div
style={{
background: 'var(--card)',
borderRadius: '16px',
padding: '32px',
maxWidth: '500px',
width: '100%',
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.3)',
}}
onClick={(e) => e.stopPropagation()}
>
<h2 style={{ marginTop: 0, marginBottom: '20px', color: '#0a7931' }}>
Payment confirmed
</h2>
<p style={{ marginBottom: '16px', color: 'var(--text)' }}>
Your order has been successfully processed and is now reserved in this drop.
</p>
<div style={{ marginBottom: '24px' }}>
<p style={{ marginBottom: '12px', fontWeight: '600', color: 'var(--text)' }}>
What happens next:
</p>
<ul style={{
margin: 0,
paddingLeft: '20px',
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>
</ul>
</div>
<p style={{ marginBottom: '24px', color: 'var(--muted)', fontStyle: 'italic' }}>
Thank you for being part of the collective.
</p>
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
<button
onClick={() => {
setShowSuccessModal(false)
setPaymentData(null)
}}
style={{
padding: '12px 24px',
background: '#0a7931',
color: '#fff',
border: 'none',
borderRadius: '14px',
cursor: 'pointer',
fontSize: '15px',
fontWeight: 500,
lineHeight: '1.5',
boxSizing: 'border-box',
display: 'inline-block',
}}
>
Close
</button>
</div>
</div>
</div>
)}
{/* Error Modal */}
{showErrorModal && (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0, 0, 0, 0.7)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1001,
padding: '20px',
}}
onClick={() => {
setShowErrorModal(false)
setErrorMessage('')
// Don't refresh - just return to drop view as is
}}
>
<div
style={{
background: 'var(--card)',
borderRadius: '16px',
padding: '32px',
maxWidth: '500px',
width: '100%',
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.3)',
}}
onClick={(e) => e.stopPropagation()}
>
<h2 style={{ marginTop: 0, marginBottom: '20px', color: '#dc2626' }}>
Error
</h2>
<p style={{ marginBottom: '24px', color: 'var(--muted)' }}>
{errorMessage}
</p>
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
<button
onClick={() => {
setShowErrorModal(false)
setErrorMessage('')
// Don't refresh - just return to drop view as is
// The drop should still be visible with current inventory state
}}
style={{
padding: '12px 24px',
background: '#dc2626',
color: '#fff',
border: 'none',
borderRadius: '14px',
cursor: 'pointer',
fontSize: '15px',
fontWeight: 500,
lineHeight: '1.5',
boxSizing: 'border-box',
display: 'inline-block',
}}
>
Close
</button>
</div>
</div>
</div>
)}
{/* Payment Modal */}
{showPaymentModal && paymentData && (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0, 0, 0, 0.7)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
padding: '20px',
}}
onClick={async () => {
// Check if payment was already processed before cancelling
if (paymentData?.payment_id) {
try {
const statusResponse = await fetch(`/api/payments/check-status?payment_id=${paymentData.payment_id}`, {
credentials: 'include',
})
if (statusResponse.ok) {
const status = await statusResponse.json()
// Only cancel if payment hasn't been processed yet
if (status.has_pending_order && !status.has_sale) {
await fetch('/api/payments/cancel-pending', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
payment_id: paymentData.payment_id,
}),
})
}
}
} catch (error) {
console.error('Error checking/cancelling pending order:', error)
}
}
setShowPaymentModal(false)
// Refresh drop data to show updated inventory
await fetchActiveDrop()
}}
>
<div
style={{
background: 'var(--card)',
borderRadius: '16px',
padding: '32px',
maxWidth: '600px',
width: '100%',
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.3)',
}}
onClick={(e) => e.stopPropagation()}
>
<h2 style={{ marginTop: 0, marginBottom: '20px' }}>
Complete Payment
</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()}
</p>
<div style={{ marginTop: '20px', marginBottom: '20px' }}>
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', color: 'var(--muted)' }}>
Send payment to this address:
</label>
<div
style={{
padding: '12px',
background: 'var(--bg-soft)',
borderRadius: '8px',
fontFamily: 'monospace',
fontSize: '14px',
wordBreak: 'break-all',
marginBottom: '12px',
}}
>
{paymentData.pay_address}
</div>
<button
onClick={(e) => {
navigator.clipboard.writeText(paymentData.pay_address)
// Show brief success feedback
const button = e.currentTarget as HTMLButtonElement
const originalText = button.textContent
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 Address
</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):
</label>
<div
style={{
padding: '12px',
background: 'var(--bg-soft)',
borderRadius: '8px',
fontFamily: 'monospace',
fontSize: '14px',
marginBottom: '12px',
}}
>
{paymentData.payin_extra_id}
</div>
<button
onClick={(e) => {
navigator.clipboard.writeText(paymentData.payin_extra_id)
// Show brief success feedback
const button = e.currentTarget as HTMLButtonElement
const originalText = button.textContent
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>
</div>
)}
{paymentData.expiration_estimate_date && (
<p style={{ marginTop: '16px', fontSize: '12px', color: 'var(--muted)' }}>
Payment expires: {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}
</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.
</p>
</div>
</div>
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
<button
onClick={async () => {
// Check if payment was already processed before cancelling
if (paymentData?.payment_id) {
try {
const statusResponse = await fetch(`/api/payments/check-status?payment_id=${paymentData.payment_id}`, {
credentials: 'include',
})
if (statusResponse.ok) {
const status = await statusResponse.json()
// Only cancel if payment hasn't been processed yet
if (status.has_pending_order && !status.has_sale) {
await fetch('/api/payments/cancel-pending', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
payment_id: paymentData.payment_id,
}),
})
}
}
} catch (error) {
console.error('Error checking/cancelling pending order:', error)
}
}
setShowPaymentModal(false)
// Refresh drop data to show updated inventory
await fetchActiveDrop()
}}
style={{
padding: '12px 24px',
background: '#dc2626',
border: 'none',
borderRadius: '14px',
cursor: 'pointer',
color: '#fff',
fontSize: '15px',
fontWeight: 500,
lineHeight: '1.5',
boxSizing: 'border-box',
display: 'inline-block',
}}
>
Close
</button>
</div>
</div>
</div>
)}
</div>
)
}