final
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
|
||||
interface User {
|
||||
id: number
|
||||
@@ -15,10 +16,12 @@ interface AuthModalProps {
|
||||
}
|
||||
|
||||
export default function AuthModal({ isOpen, onClose, onLogin }: AuthModalProps) {
|
||||
const searchParams = useSearchParams()
|
||||
const [isLogin, setIsLogin] = useState(true)
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [referralId, setReferralId] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
@@ -30,8 +33,26 @@ export default function AuthModal({ isOpen, onClose, onLogin }: AuthModalProps)
|
||||
setEmail('')
|
||||
setError('')
|
||||
setIsLogin(true)
|
||||
|
||||
// Auto-fill referral ID from URL if present
|
||||
const refFromUrl = searchParams?.get('ref')
|
||||
if (refFromUrl) {
|
||||
setReferralId(refFromUrl)
|
||||
} else {
|
||||
setReferralId('')
|
||||
}
|
||||
}
|
||||
}, [isOpen])
|
||||
}, [isOpen, searchParams])
|
||||
|
||||
// Update referral ID when switching to register mode and URL has ref parameter
|
||||
useEffect(() => {
|
||||
if (!isLogin) {
|
||||
const refFromUrl = searchParams?.get('ref')
|
||||
if (refFromUrl && !referralId) {
|
||||
setReferralId(refFromUrl)
|
||||
}
|
||||
}
|
||||
}, [isLogin, searchParams, referralId])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
@@ -40,9 +61,15 @@ export default function AuthModal({ isOpen, onClose, onLogin }: AuthModalProps)
|
||||
|
||||
try {
|
||||
const endpoint = isLogin ? '/api/auth/login' : '/api/auth/register'
|
||||
|
||||
// Use referral ID from input field, or fall back to URL parameter
|
||||
const referralIdToUse = !isLogin && referralId.trim()
|
||||
? referralId.trim()
|
||||
: (!isLogin ? searchParams?.get('ref') : null)
|
||||
|
||||
const body = isLogin
|
||||
? { username, password }
|
||||
: { username, password, email }
|
||||
: { username, password, email, referral_id: referralIdToUse }
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
@@ -192,7 +219,7 @@ export default function AuthModal({ isOpen, onClose, onLogin }: AuthModalProps)
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label
|
||||
htmlFor="password"
|
||||
style={{
|
||||
@@ -224,6 +251,43 @@ export default function AuthModal({ isOpen, onClose, onLogin }: AuthModalProps)
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isLogin && (
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<label
|
||||
htmlFor="referralId"
|
||||
style={{
|
||||
display: 'block',
|
||||
marginBottom: '8px',
|
||||
fontSize: '14px',
|
||||
color: 'var(--text)',
|
||||
}}
|
||||
>
|
||||
Referral ID <span style={{ color: 'var(--muted)', fontSize: '12px', fontWeight: 'normal' }}>(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="referralId"
|
||||
value={referralId}
|
||||
onChange={(e) => setReferralId(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--bg-soft)',
|
||||
color: 'var(--text)',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
placeholder="Enter referral ID"
|
||||
/>
|
||||
{searchParams?.get('ref') && referralId === searchParams.get('ref') && (
|
||||
<small style={{ display: 'block', marginTop: '4px', fontSize: '12px', color: 'var(--accent)' }}>
|
||||
✓ Auto-filled from referral link
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, Suspense } from 'react'
|
||||
import Image from 'next/image'
|
||||
import AuthModal from './AuthModal'
|
||||
import UnlockModal from './UnlockModal'
|
||||
|
||||
interface DropData {
|
||||
id: number
|
||||
@@ -45,12 +46,29 @@ export default function Drop() {
|
||||
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)
|
||||
|
||||
useEffect(() => {
|
||||
fetchActiveDrop()
|
||||
checkAuth()
|
||||
checkWholesaleStatus()
|
||||
}, [])
|
||||
|
||||
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
|
||||
@@ -333,11 +351,10 @@ 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
|
||||
const pricePerUnit = drop.ppu / 1000
|
||||
if (drop.unit === 'kg') {
|
||||
return (selectedSize / 1000) * pricePerUnit
|
||||
}
|
||||
return selectedSize * pricePerUnit
|
||||
// Assuming ppu is per gram
|
||||
const pricePerGram = drop.ppu / 1000
|
||||
const priceToUse = isWholesaleUnlocked ? pricePerGram * 0.76 : pricePerGram
|
||||
return selectedSize * priceToUse
|
||||
}
|
||||
|
||||
const getTimeUntilStart = () => {
|
||||
@@ -426,10 +443,36 @@ export default function Drop() {
|
||||
<div>
|
||||
<h2>{drop.item}</h2>
|
||||
<div className="meta">
|
||||
{formatSize(drop.size, drop.unit)} Batch
|
||||
{formatSize(drop.size, drop.unit)} batch
|
||||
</div>
|
||||
<div className="price">
|
||||
{(drop.ppu / 1000).toFixed(2)} CHF / {drop.unit} · incl. 2.5% VAT
|
||||
{(() => {
|
||||
// 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 ? (
|
||||
@@ -444,9 +487,11 @@ export default function Drop() {
|
||||
<span style={{ width: `${progressPercentage}%` }}></span>
|
||||
</div>
|
||||
<div className="meta">
|
||||
{drop.unit === 'kg' ? drop.fill.toFixed(2) : Math.round(drop.fill)}
|
||||
{drop.unit} of {drop.size}
|
||||
{drop.unit} reserved
|
||||
{(() => {
|
||||
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;
|
||||
@@ -476,8 +521,9 @@ export default function Drop() {
|
||||
</div>
|
||||
|
||||
<button className="cta" onClick={handleJoinDrop}>
|
||||
Join Drop
|
||||
Join the drop
|
||||
</button>
|
||||
<div className="cta-note">No subscription · No obligation</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -751,10 +797,18 @@ export default function Drop() {
|
||||
)}
|
||||
|
||||
{/* Auth Modal */}
|
||||
<AuthModal
|
||||
isOpen={showAuthModal}
|
||||
onClose={() => setShowAuthModal(false)}
|
||||
onLogin={handleLogin}
|
||||
<Suspense fallback={null}>
|
||||
<AuthModal
|
||||
isOpen={showAuthModal}
|
||||
onClose={() => setShowAuthModal(false)}
|
||||
onLogin={handleLogin}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
{/* Unlock Modal */}
|
||||
<UnlockModal
|
||||
isOpen={showUnlockModal}
|
||||
onClose={() => setShowUnlockModal(false)}
|
||||
/>
|
||||
|
||||
{/* Success Modal */}
|
||||
|
||||
@@ -69,14 +69,33 @@ export default function Nav() {
|
||||
<span style={{ color: 'var(--muted)', fontSize: '14px', marginLeft: '48px' }}>
|
||||
{user.username}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
<a
|
||||
href="/orders"
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: '1px solid var(--border)',
|
||||
color: 'var(--text)',
|
||||
padding: '8px 16px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
marginLeft: '12px',
|
||||
lineHeight: '1',
|
||||
boxSizing: 'border-box',
|
||||
display: 'inline-block',
|
||||
textDecoration: 'none',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Orders
|
||||
</a>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: '1px solid #e57373',
|
||||
color: '#e57373',
|
||||
padding: '8px 16px',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
marginLeft: '12px',
|
||||
|
||||
87
app/components/UnlockBar.tsx
Normal file
87
app/components/UnlockBar.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import UnlockModal from './UnlockModal'
|
||||
|
||||
interface ReferralStatus {
|
||||
referralCount: number
|
||||
isUnlocked: boolean
|
||||
referralsNeeded: number
|
||||
referralsRemaining: number
|
||||
}
|
||||
|
||||
export default function UnlockBar() {
|
||||
const [referralStatus, setReferralStatus] = useState<ReferralStatus | null>(null)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
fetchReferralStatus()
|
||||
}, [])
|
||||
|
||||
const fetchReferralStatus = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/referrals/status', {
|
||||
credentials: 'include',
|
||||
})
|
||||
const data = await response.json()
|
||||
setReferralStatus(data)
|
||||
} catch (error) {
|
||||
console.error('Error fetching referral status:', error)
|
||||
// Set default values on error
|
||||
setReferralStatus({
|
||||
referralCount: 0,
|
||||
isUnlocked: false,
|
||||
referralsNeeded: 3,
|
||||
referralsRemaining: 3,
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUnlockClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
e.preventDefault()
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="unlock-bar">
|
||||
🔒 Wholesale prices locked — <strong>Loading...</strong>
|
||||
<br />
|
||||
<small>3 verified sign-ups unlock wholesale prices forever.</small>
|
||||
<a href="#unlock" onClick={handleUnlockClick}>Unlock now</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const status = referralStatus || {
|
||||
referralCount: 0,
|
||||
isUnlocked: false,
|
||||
referralsNeeded: 3,
|
||||
referralsRemaining: 3,
|
||||
}
|
||||
|
||||
// If unlocked, show different message or hide bar
|
||||
if (status.isUnlocked) {
|
||||
return (
|
||||
<div className="unlock-bar" style={{ background: 'var(--accent)', color: '#000' }}>
|
||||
✅ Wholesale prices unlocked — <strong>You have access to wholesale pricing!</strong>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="unlock-bar">
|
||||
🔒 Wholesale prices locked — <strong>{status.referralCount} / {status.referralsNeeded} referrals completed</strong> · {status.referralsRemaining} to go
|
||||
<br />
|
||||
<small>{status.referralsNeeded} verified sign-ups unlock wholesale prices forever.</small>
|
||||
<a href="#unlock" onClick={handleUnlockClick}>Unlock now</a>
|
||||
</div>
|
||||
<UnlockModal isOpen={showModal} onClose={() => setShowModal(false)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
251
app/components/UnlockModal.tsx
Normal file
251
app/components/UnlockModal.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
interface UnlockModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
interface ReferralStatus {
|
||||
referralCount: number
|
||||
isUnlocked: boolean
|
||||
referralsNeeded: number
|
||||
referralsRemaining: number
|
||||
}
|
||||
|
||||
export default function UnlockModal({ isOpen, onClose }: UnlockModalProps) {
|
||||
const [referralStatus, setReferralStatus] = useState<ReferralStatus | null>(null)
|
||||
const [referralLink, setReferralLink] = useState<string>('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
fetchReferralData()
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
const fetchReferralData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
// Fetch referral status
|
||||
const statusResponse = await fetch('/api/referrals/status', {
|
||||
credentials: 'include',
|
||||
})
|
||||
const statusData = await statusResponse.json()
|
||||
setReferralStatus(statusData)
|
||||
|
||||
// Fetch referral link if user is logged in
|
||||
const linkResponse = await fetch('/api/referrals/link', {
|
||||
credentials: 'include',
|
||||
})
|
||||
if (linkResponse.ok) {
|
||||
const linkData = await linkResponse.json()
|
||||
setReferralLink(linkData.referralLink)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching referral data:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyLink = async () => {
|
||||
if (!referralLink) return
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(referralLink)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch (error) {
|
||||
console.error('Failed to copy link:', error)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const status = referralStatus || {
|
||||
referralCount: 0,
|
||||
isUnlocked: false,
|
||||
referralsNeeded: 3,
|
||||
referralsRemaining: 3,
|
||||
}
|
||||
|
||||
return (
|
||||
<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={onClose}
|
||||
>
|
||||
<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()}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
||||
<h2 style={{ margin: 0 }}>Unlock wholesale prices</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
fontSize: '24px',
|
||||
cursor: 'pointer',
|
||||
color: 'var(--muted)',
|
||||
padding: 0,
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p style={{ color: 'var(--muted)', textAlign: 'center' }}>Loading...</p>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ marginBottom: '24px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '18px', marginBottom: '8px' }}>
|
||||
🔒 {status.referralCount} of {status.referralsNeeded} referrals completed
|
||||
</div>
|
||||
<p style={{ color: 'var(--muted)', fontSize: '14px', margin: '8px 0' }}>
|
||||
Invite {status.referralsNeeded} friends to sign up.
|
||||
<br />
|
||||
Once they do, wholesale prices unlock forever.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{referralLink ? (
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
marginBottom: '8px',
|
||||
fontSize: '14px',
|
||||
color: 'var(--muted)',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Your referral link
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={referralLink}
|
||||
readOnly
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '12px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--bg-soft)',
|
||||
color: 'var(--text)',
|
||||
fontSize: '14px',
|
||||
fontFamily: 'monospace',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={handleCopyLink}
|
||||
style={{
|
||||
padding: '12px 20px',
|
||||
background: copied ? 'var(--accent)' : 'var(--bg-soft)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '8px',
|
||||
color: copied ? '#000' : 'var(--text)',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{copied ? 'Copied!' : 'Copy link'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
padding: '16px',
|
||||
background: 'var(--bg-soft)',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '24px',
|
||||
textAlign: 'center',
|
||||
color: 'var(--muted)',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Please log in to get your referral link
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
padding: '12px',
|
||||
background: 'var(--bg-soft)',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '24px',
|
||||
fontSize: '13px',
|
||||
color: 'var(--muted)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
Friends must sign up to count.
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
fontSize: '16px',
|
||||
fontWeight: 500,
|
||||
color: 'var(--text)',
|
||||
marginBottom: '24px',
|
||||
}}
|
||||
>
|
||||
{status.referralsRemaining} referral{status.referralsRemaining !== 1 ? 's' : ''} to go
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 24px',
|
||||
background: 'var(--accent)',
|
||||
color: '#000',
|
||||
border: 'none',
|
||||
borderRadius: '14px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '15px',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user