This commit is contained in:
root
2025-12-21 12:46:27 +01:00
parent 5e65144934
commit bb1c5b43d6
18 changed files with 1375 additions and 691 deletions

View File

@@ -6,7 +6,7 @@ import bcrypt from 'bcrypt'
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { username, password, email } = body
const { username, password, email, referral_id } = body
// Validate required fields
if (!username || !password || !email) {
@@ -84,6 +84,27 @@ export async function POST(request: NextRequest) {
const buyer = (rows as any[])[0]
// Handle referral if provided
if (referral_id) {
const referrerId = parseInt(referral_id, 10)
// Validate that referrer exists and is not the same as the new user
if (referrerId && referrerId !== buyer.id) {
const [referrerRows] = await pool.execute(
'SELECT id FROM buyers WHERE id = ?',
[referrerId]
)
if ((referrerRows as any[]).length > 0) {
// Create referral record
await pool.execute(
'INSERT INTO referrals (referrer, referree) VALUES (?, ?)',
[referrerId, buyer.id]
)
}
}
}
// Create session cookie
const response = NextResponse.json(
{

54
app/api/orders/route.ts Normal file
View File

@@ -0,0 +1,54 @@
import { NextRequest, NextResponse } from 'next/server'
import { cookies } from 'next/headers'
import pool from '@/lib/db'
// GET /api/orders - Get all orders (sales) for the current user
export async function GET(request: NextRequest) {
try {
// Get buyer_id from session cookie
const cookieStore = await cookies()
const buyerIdCookie = cookieStore.get('buyer_id')?.value
if (!buyerIdCookie) {
return NextResponse.json(
{ error: 'Authentication required' },
{ status: 401 }
)
}
const buyer_id = parseInt(buyerIdCookie, 10)
// Get all sales for this buyer with drop and buyer_data information
const [rows] = await pool.execute(
`SELECT
s.id,
s.drop_id,
s.buyer_id,
s.size,
s.payment_id,
s.created_at,
d.item as drop_item,
d.unit as drop_unit,
d.ppu as drop_ppu,
d.image_url as drop_image_url,
bd.fullname as buyer_fullname,
bd.address as buyer_address,
bd.phone as buyer_phone
FROM sales s
LEFT JOIN drops d ON s.drop_id = d.id
LEFT JOIN buyer_data bd ON s.buyer_data_id = bd.id
WHERE s.buyer_id = ?
ORDER BY s.created_at DESC`,
[buyer_id]
)
return NextResponse.json(rows)
} catch (error) {
console.error('Error fetching orders:', error)
return NextResponse.json(
{ error: 'Failed to fetch orders' },
{ status: 500 }
)
}
}

View File

@@ -119,18 +119,23 @@ export async function POST(request: NextRequest) {
)
}
// Check if user has unlocked wholesale prices
const [referralRows] = await pool.execute(
'SELECT COUNT(*) as count FROM referrals WHERE referrer = ?',
[buyer_id]
)
const referralCount = (referralRows as any[])[0]?.count || 0
const isWholesaleUnlocked = referralCount >= 3
// Calculate price
// ppu is stored as integer where 1000 = $1.00, so divide by 1000 to get actual price
const pricePerUnit = drop.ppu / 1000
let priceAmount = 0
if (drop.unit === 'kg') {
priceAmount = (size / 1000) * pricePerUnit
} else {
priceAmount = size * pricePerUnit
}
// Assuming ppu is per gram
const pricePerGram = drop.ppu / 1000
const priceToUse = isWholesaleUnlocked ? pricePerGram * 0.76 : pricePerGram
const priceAmount = size * priceToUse
// Round to 2 decimal places
priceAmount = Math.round(priceAmount * 100) / 100
const roundedPriceAmount = Math.round(priceAmount * 100) / 100
// Generate order ID
const orderId = `SALE-${Date.now()}-${drop_id}-${buyer_id}`
@@ -163,7 +168,7 @@ export async function POST(request: NextRequest) {
'Content-Type': 'application/json',
},
body: JSON.stringify({
price_amount: priceAmount,
price_amount: roundedPriceAmount,
price_currency: nowPaymentsConfig.currency,
pay_currency: payCurrency, // Required: crypto currency (btc, eth, etc)
order_id: orderId,
@@ -190,7 +195,7 @@ export async function POST(request: NextRequest) {
// payment.payment_id is the NOWPayments payment ID
await connection.execute(
'INSERT INTO pending_orders (payment_id, order_id, drop_id, buyer_id, buyer_data_id, size, price_amount, price_currency, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
[payment.payment_id, orderId, drop_id, buyer_id, buyer_data_id, size, priceAmount, nowPaymentsConfig.currency, expiresAt]
[payment.payment_id, orderId, drop_id, buyer_id, buyer_data_id, size, roundedPriceAmount, nowPaymentsConfig.currency, expiresAt]
)
// Commit transaction - inventory is now reserved

View File

@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from 'next/server'
import { cookies } from 'next/headers'
// GET /api/referrals/link - Get referral link for current user
export async function GET(request: NextRequest) {
try {
const cookieStore = cookies()
const buyerIdCookie = cookieStore.get('buyer_id')?.value
if (!buyerIdCookie) {
return NextResponse.json(
{ error: 'Authentication required' },
{ status: 401 }
)
}
const buyer_id = parseInt(buyerIdCookie, 10)
// Get base URL
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ||
request.headers.get('origin') ||
'http://localhost:3000'
// Create referral link with buyer_id as referral parameter
const referralLink = `${baseUrl}?ref=${buyer_id}`
return NextResponse.json({
referralLink,
referralId: buyer_id,
})
} catch (error) {
console.error('Error generating referral link:', error)
return NextResponse.json(
{ error: 'Failed to generate referral link' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,50 @@
import { NextRequest, NextResponse } from 'next/server'
import { cookies } from 'next/headers'
import pool from '@/lib/db'
// GET /api/referrals/status - Get referral count and unlock status for current user
export async function GET(request: NextRequest) {
try {
const cookieStore = cookies()
const buyerIdCookie = cookieStore.get('buyer_id')?.value
if (!buyerIdCookie) {
return NextResponse.json(
{
referralCount: 0,
isUnlocked: false,
referralsNeeded: 3,
referralsRemaining: 3
},
{ status: 200 }
)
}
const buyer_id = parseInt(buyerIdCookie, 10)
// Count referrals for this user
const [referralRows] = await pool.execute(
'SELECT COUNT(*) as count FROM referrals WHERE referrer = ?',
[buyer_id]
)
const referralCount = (referralRows as any[])[0]?.count || 0
const isUnlocked = referralCount >= 3
const referralsNeeded = 3
const referralsRemaining = Math.max(0, referralsNeeded - referralCount)
return NextResponse.json({
referralCount,
isUnlocked,
referralsNeeded,
referralsRemaining,
})
} catch (error) {
console.error('Error fetching referral status:', error)
return NextResponse.json(
{ error: 'Failed to fetch referral status' },
{ status: 500 }
)
}
}

View File

@@ -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={{

View File

@@ -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 */}

View File

@@ -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',

View 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)} />
</>
)
}

View 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>
)
}

View File

@@ -54,6 +54,25 @@ nav .links a:hover {
color: var(--text);
}
.unlock-bar {
background: var(--bg-soft);
border-bottom: 1px solid var(--border);
padding: 12px 20px;
font-size: 14px;
color: var(--muted);
text-align: center;
}
.unlock-bar strong {
color: var(--text);
}
.unlock-bar a {
margin-left: 10px;
color: var(--accent);
font-weight: 500;
}
.container {
max-width: 1200px;
margin: 0 auto;
@@ -111,12 +130,30 @@ header p {
margin-bottom: 20px;
}
.price .muted {
display: block;
margin-top: 6px;
font-size: 14px;
color: var(--muted);
}
.price .hint {
font-size: 13px;
color: var(--muted);
margin-top: 4px;
}
.price a {
color: var(--accent);
font-weight: 500;
}
.progress {
background: var(--bg-soft);
border-radius: 10px;
height: 10px;
overflow: hidden;
margin-bottom: 20px;
margin-bottom: 12px;
}
.progress span {
@@ -146,8 +183,8 @@ header p {
.cta {
margin-top: 30px;
padding: 16px 28px;
background: #0a7931;
color: #fff;
background: var(--accent);
color: #000;
border: none;
border-radius: 14px;
font-size: 15px;
@@ -155,6 +192,12 @@ header p {
cursor: pointer;
}
.cta-note {
margin-top: 8px;
font-size: 13px;
color: var(--muted);
}
.info-box {
margin-top: 60px;
background: var(--card);

291
app/orders/page.tsx Normal file
View File

@@ -0,0 +1,291 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import Image from 'next/image'
import Nav from '../components/Nav'
interface Order {
id: number
drop_id: number
buyer_id: number
size: number
payment_id: string
created_at: string
drop_item: string
drop_unit: string
drop_ppu: number
drop_image_url: string | null
buyer_fullname: string
buyer_address: string
buyer_phone: string
}
export default function OrdersPage() {
const router = useRouter()
const [orders, setOrders] = useState<Order[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [user, setUser] = useState<any>(null)
useEffect(() => {
checkAuth()
fetchOrders()
}, [])
const checkAuth = async () => {
try {
const response = await fetch('/api/auth/session', {
credentials: 'include',
})
if (response.ok) {
const data = await response.json()
setUser(data.user)
} else {
// Not authenticated, redirect to home
router.push('/')
}
} catch (error) {
console.error('Error checking auth:', error)
router.push('/')
}
}
const fetchOrders = async () => {
try {
const response = await fetch('/api/orders', {
credentials: 'include',
})
if (response.status === 401) {
router.push('/')
return
}
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to fetch orders')
}
const data = await response.json()
setOrders(data)
} catch (error) {
console.error('Error fetching orders:', error)
setError(error instanceof Error ? error.message : 'Failed to load orders')
} finally {
setLoading(false)
}
}
const formatDate = (dateString: string) => {
const date = new Date(dateString)
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
const formatSize = (size: number, unit: string) => {
if (unit === 'kg' && size >= 1000) {
return `${(size / 1000).toFixed(1)}kg`
}
return `${size}${unit}`
}
const calculatePrice = (order: Order) => {
// ppu is stored as integer where 1000 = $1.00
const pricePerGram = order.drop_ppu / 1000
// Size is in grams
return (order.size * pricePerGram).toFixed(2)
}
if (loading) {
return (
<>
<Nav />
<div className="container" style={{ paddingTop: '120px', textAlign: 'center' }}>
<p style={{ color: 'var(--muted)' }}>Loading orders...</p>
</div>
</>
)
}
return (
<>
<Nav />
<div className="container" style={{ paddingTop: '120px' }}>
<button
onClick={() => router.push('/')}
style={{
background: 'transparent',
border: '1px solid var(--border)',
color: 'var(--text)',
padding: '8px 16px',
borderRadius: '8px',
cursor: 'pointer',
fontSize: '14px',
marginBottom: '24px',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
>
Back
</button>
<h1 style={{ marginBottom: '40px' }}>My Orders</h1>
{error && (
<div
style={{
padding: '16px',
background: 'rgba(255, 0, 0, 0.1)',
border: '1px solid rgba(255, 0, 0, 0.3)',
borderRadius: '12px',
color: '#ff4444',
marginBottom: '24px',
}}
>
{error}
</div>
)}
{orders.length === 0 ? (
<div
style={{
padding: '60px 20px',
textAlign: 'center',
background: 'var(--card)',
borderRadius: '16px',
border: '1px solid var(--border)',
}}
>
<p style={{ color: 'var(--muted)', fontSize: '18px', marginBottom: '12px' }}>
No orders yet
</p>
<p style={{ color: 'var(--muted)', fontSize: '14px' }}>
Your purchase history will appear here once you make your first order.
</p>
<a
href="/"
style={{
display: 'inline-block',
marginTop: '24px',
padding: '12px 24px',
background: 'var(--accent)',
color: '#000',
borderRadius: '12px',
textDecoration: 'none',
fontWeight: 500,
}}
>
Browse Drops
</a>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
{orders.map((order) => (
<div
key={order.id}
style={{
background: 'var(--card)',
borderRadius: '16px',
padding: '24px',
border: '1px solid var(--border)',
display: 'grid',
gridTemplateColumns: '120px 1fr',
gap: '24px',
}}
>
{order.drop_image_url ? (
<Image
src={order.drop_image_url}
alt={order.drop_item}
width={120}
height={120}
style={{
width: '100%',
height: '120px',
borderRadius: '12px',
objectFit: 'cover',
}}
/>
) : (
<div
style={{
width: '100%',
height: '120px',
background: 'var(--bg-soft)',
borderRadius: '12px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--muted)',
fontSize: '14px',
}}
>
No Image
</div>
)}
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: '12px' }}>
<div>
<h3 style={{ margin: 0, marginBottom: '8px', fontSize: '20px' }}>
{order.drop_item}
</h3>
<p style={{ margin: 0, color: 'var(--muted)', fontSize: '14px' }}>
Order #{order.id} · {formatDate(order.created_at)}
</p>
</div>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: '18px', fontWeight: 500, marginBottom: '4px' }}>
{calculatePrice(order)} CHF
</div>
<div style={{ fontSize: '14px', color: 'var(--muted)' }}>
{formatSize(order.size, 'g')}
</div>
</div>
</div>
<div
style={{
padding: '16px',
background: 'var(--bg-soft)',
borderRadius: '8px',
marginTop: '16px',
}}
>
<div style={{ fontSize: '13px', color: 'var(--muted)', marginBottom: '8px' }}>
Delivery Information:
</div>
<div style={{ fontSize: '14px' }}>
<div style={{ marginBottom: '4px' }}>
<strong>{order.buyer_fullname}</strong>
</div>
<div style={{ color: 'var(--muted)', marginBottom: '4px' }}>
{order.buyer_address}
</div>
<div style={{ color: 'var(--muted)' }}>
{order.buyer_phone}
</div>
</div>
</div>
{order.payment_id && (
<div style={{ marginTop: '12px', fontSize: '12px', color: 'var(--muted)' }}>
Payment ID: {order.payment_id}
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
</>
)
}

View File

@@ -8,6 +8,7 @@ import InfoBox from './components/InfoBox'
import Signup from './components/Signup'
import PastDrops from './components/PastDrops'
import Footer from './components/Footer'
import UnlockBar from './components/UnlockBar'
function PaymentHandler() {
const searchParams = useSearchParams()
@@ -37,6 +38,7 @@ export default function Home() {
<PaymentHandler />
</Suspense>
<Nav />
<UnlockBar />
<header className="container">
<h1>Shop together. Wholesale prices for private buyers.</h1>
<p>