Mobile-first UI overhaul for Souq, Wallet, Forum pages

- Souq: bottom sheets, emoji category chips, skeleton loading, toast notifications
- Wallet: gradient balance card with GBP preview, status badges, clean history
- Forum: back nav, skeleton loading, bottom sheet create, thread author avatars
- All: pb-24 clearance, rounded-2xl, active: press states, no hover: classes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
FalahMobile
2026-06-15 09:19:45 +01:00
parent e2365e29fe
commit 1834370e40
3 changed files with 513 additions and 164 deletions
+167 -49
View File
@@ -2,73 +2,162 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useAuth } from '@/lib/AuthContext' import { useAuth } from '@/lib/AuthContext'
import { Plus, ChevronRight } from 'lucide-react' import { Plus, ChevronRight, MessageCircle, X, ArrowLeft } from 'lucide-react'
interface Category { id: string; name: string; description: string; icon: string } interface Category { id: string; name: string; description: string; icon: string }
interface Thread { id: string; title: string; content: string; author: { name: string }; category: { name: string }; _count: { posts: number }; createdAt: string } interface Thread {
id: string; title: string; content: string
author: { name: string }; category: { name: string }
_count: { posts: number }; createdAt: string
}
export default function ForumPage() { export default function ForumPage() {
const { token } = useAuth() const { token } = useAuth()
const [categories, setCategories] = useState<Category[]>([]) const [categories, setCategories] = useState<Category[]>([])
const [threads, setThreads] = useState<Thread[]>([]) const [threads, setThreads] = useState<Thread[]>([])
const [selectedCat, setSelectedCat] = useState<string | null>(null) const [selectedCat, setSelectedCat] = useState<Category | null>(null)
const [view, setView] = useState<'categories' | 'threads'>('categories') const [view, setView] = useState<'categories' | 'threads'>('categories')
const [showCreate, setShowCreate] = useState(false) const [showCreate, setShowCreate] = useState(false)
const [loading, setLoading] = useState(true)
const [toast, setToast] = useState('')
useEffect(() => { fetch('/api/forum/categories').then(r => r.json()).then(d => setCategories(d.categories || [])) }, []) useEffect(() => {
fetch('/api/forum/categories')
.then(r => r.json())
.then(d => setCategories(d.categories || []))
.finally(() => setLoading(false))
}, [])
const loadThreads = async (catId?: string) => { const loadThreads = async (catId: string) => {
const params = catId ? `?categoryId=${catId}` : '' setLoading(true)
const res = await fetch(`/api/forum/threads${params}`) const res = await fetch(`/api/forum/threads?categoryId=${catId}`)
const data = await res.json() const data = await res.json()
setThreads(data.threads || []) setThreads(data.threads || [])
setLoading(false)
} }
const showToast = (msg: string) => { setToast(msg); setTimeout(() => setToast(''), 3000) }
return ( return (
<div className="max-w-4xl mx-auto p-4 sm:p-6 space-y-6"> <div className="min-h-dvh bg-[#0a0a0f] pb-24">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-[#D4AF37]">Forum</h1> {/* Header */}
<div className="px-5 pt-6 pb-4 flex items-center justify-between">
<div className="flex items-center gap-3">
{view === 'threads' && (
<button onClick={() => { setView('categories'); setSelectedCat(null) }} className="w-9 h-9 flex items-center justify-center rounded-xl bg-gray-800/60 active:opacity-60">
<ArrowLeft size={18} className="text-gray-400" />
</button>
)}
<div>
<h1 className="text-xl font-bold text-white">{view === 'threads' && selectedCat ? selectedCat.name : 'Forum'}</h1>
<p className="text-[11px] text-gray-600 mt-0.5">
{view === 'categories' ? 'Community discussions' : `${threads.length} threads`}
</p>
</div>
</div>
{token && ( {token && (
<button onClick={() => setShowCreate(true)} <button
className="flex items-center gap-1 bg-[#D4AF37] text-[#0a0a0f] px-3 py-2 rounded text-sm font-semibold hover:bg-[#C9A84C] transition"><Plus size={16} /> New Thread</button> onClick={() => setShowCreate(true)}
className="flex items-center gap-1.5 bg-[#D4AF37] text-[#0a0a0f] px-4 py-2.5 rounded-xl font-bold text-sm active:scale-[0.96] transition-transform"
>
<Plus size={16} /> Post
</button>
)} )}
</div> </div>
{view === 'categories' ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> {/* Loading */}
{categories.map(c => ( {loading && (
<button key={c.id} onClick={() => { setSelectedCat(c.id); setView('threads'); loadThreads(c.id) }} <div className="px-5 space-y-3">
className="bg-[#111118] border border-gray-800 rounded-lg p-4 text-left hover:border-gray-700 transition"> {[...Array(4)].map((_, i) => (
<div key={i} className="h-20 rounded-2xl bg-gray-900/60 animate-pulse" />
))}
</div>
)}
{/* Categories view */}
{!loading && view === 'categories' && (
<div className="px-5 space-y-3">
{categories.length === 0 ? (
<div className="text-center py-16">
<div className="text-4xl mb-3">🕌</div>
<p className="text-gray-500 text-sm">No categories yet</p>
</div>
) : categories.map(c => (
<button
key={c.id}
onClick={() => { setSelectedCat(c); setView('threads'); loadThreads(c.id) }}
className="w-full text-left bg-[#111118] border border-gray-800/60 rounded-2xl p-4 active:scale-[0.99] transition-transform"
>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-2xl">{c.icon}</span> <span className="text-2xl w-10 h-10 flex items-center justify-center bg-gray-800/60 rounded-xl shrink-0">
<div className="flex-1"> {c.icon || '💬'}
<h3 className="font-semibold">{c.name}</h3> </span>
<p className="text-xs text-gray-500">{c.description}</p> <div className="flex-1 min-w-0">
<h3 className="font-semibold text-white text-sm">{c.name}</h3>
<p className="text-xs text-gray-500 mt-0.5 line-clamp-1">{c.description}</p>
</div> </div>
<ChevronRight size={16} className="text-gray-600" /> <ChevronRight size={16} className="text-gray-700 shrink-0" />
</div> </div>
</button> </button>
))} ))}
</div> </div>
) : ( )}
<div className="space-y-3">
<button onClick={() => setView('categories')} className="text-sm text-[#D4AF37] hover:underline mb-2 inline-block">&larr; Back to categories</button> {/* Threads view */}
{threads.map(t => ( {!loading && view === 'threads' && (
<div key={t.id} className="bg-[#111118] border border-gray-800 rounded-lg p-4 space-y-1"> <div className="px-5 space-y-3">
<h3 className="font-semibold">{t.title}</h3> {threads.length === 0 ? (
<p className="text-sm text-gray-400 line-clamp-2">{t.content}</p> <div className="text-center py-16">
<div className="flex items-center gap-3 text-xs text-gray-500 pt-1"> <div className="text-4xl mb-3">💬</div>
<span>{t.author.name}</span><span>{t.category.name}</span><span>{t._count.posts} replies</span><span>{new Date(t.createdAt).toLocaleDateString()}</span> <p className="text-white font-semibold mb-1">No threads yet</p>
<p className="text-gray-500 text-sm">Start the first discussion</p>
</div>
) : threads.map(t => (
<div key={t.id} className="bg-[#111118] border border-gray-800/60 rounded-2xl p-4">
<h3 className="font-semibold text-white text-sm leading-snug mb-1">{t.title}</h3>
<p className="text-xs text-gray-500 line-clamp-2 mb-3">{t.content}</p>
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<div className="w-5 h-5 rounded-full bg-[#D4AF37]/15 flex items-center justify-center">
<span className="text-[9px] text-[#D4AF37] font-bold">{t.author.name[0]}</span>
</div>
<span className="text-[11px] text-gray-600">{t.author.name}</span>
</div>
<div className="flex items-center gap-1 text-[11px] text-gray-700">
<MessageCircle size={12} />
{t._count.posts}
</div>
</div> </div>
</div> </div>
))} ))}
</div> </div>
)} )}
{showCreate && token && <CreateThreadModal token={token} categories={categories} onClose={() => setShowCreate(false)} onCreated={() => { setShowCreate(false); if (selectedCat) loadThreads(selectedCat) }} />}
{/* Toast */}
{toast && (
<div className="fixed bottom-24 left-5 right-5 bg-[#1a1a26] border border-gray-700 rounded-2xl px-4 py-3 text-sm text-white text-center z-50 shadow-xl">
{toast}
</div>
)}
{/* Create thread bottom sheet */}
{showCreate && token && (
<CreateThreadSheet
token={token}
categories={categories}
onClose={() => setShowCreate(false)}
onCreated={() => { if (selectedCat) loadThreads(selectedCat.id); showToast('Thread posted!') }}
showToast={showToast}
/>
)}
</div> </div>
) )
} }
function CreateThreadModal({ token, categories, onClose, onCreated }: { token: string; categories: Category[]; onClose: () => void; onCreated: () => void }) { function CreateThreadSheet({ token, categories, onClose, onCreated, showToast }: {
token: string; categories: Category[]; onClose: () => void; onCreated: () => void; showToast: (m: string) => void
}) {
const [title, setTitle] = useState('') const [title, setTitle] = useState('')
const [content, setContent] = useState('') const [content, setContent] = useState('')
const [categoryId, setCategoryId] = useState(categories[0]?.id || '') const [categoryId, setCategoryId] = useState(categories[0]?.id || '')
@@ -76,29 +165,58 @@ function CreateThreadModal({ token, categories, onClose, onCreated }: { token: s
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
if (!categoryId) return
setLoading(true) setLoading(true)
const res = await fetch('/api/forum/threads', { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ title, content, categoryId }) }) try {
const res = await fetch('/api/forum/threads', {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ title, content, categoryId }),
})
const data = await res.json()
if (res.ok) { onCreated(); onClose() }
else showToast(data.error || 'Failed to post')
} catch { showToast('Something went wrong') }
setLoading(false) setLoading(false)
if (res.ok) { onCreated() } else { const d = await res.json(); alert(d.error) }
} }
return ( return (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center p-4 z-50"> <div className="fixed inset-0 z-50 flex flex-col justify-end" onClick={onClose}>
<form onSubmit={handleSubmit} className="bg-[#111118] border border-gray-800 rounded-lg p-6 max-w-md w-full space-y-4"> <div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
<h2 className="text-lg font-bold">New Thread</h2> <form
<select value={categoryId} onChange={e => setCategoryId(e.target.value)} onSubmit={handleSubmit}
className="w-full bg-[#0a0a0f] border border-gray-700 rounded px-3 py-2 text-sm focus:border-[#D4AF37] outline-none"> className="relative bg-[#0f0f18] border border-gray-800/60 rounded-t-3xl p-6 pb-10 space-y-4"
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)} onClick={e => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-1">
<div className="w-10 h-1 bg-gray-700 rounded-full mx-auto absolute left-1/2 -translate-x-1/2 top-3" />
<h2 className="text-lg font-bold text-white mt-2">New Thread</h2>
<button type="button" onClick={onClose} className="w-8 h-8 flex items-center justify-center rounded-full bg-gray-800 mt-2">
<X size={15} className="text-gray-400" />
</button>
</div>
<select
value={categoryId} onChange={e => setCategoryId(e.target.value)}
className="w-full bg-[#0a0a0f] border border-gray-800 rounded-2xl px-4 py-4 text-sm text-white focus:border-[#D4AF37]/50 focus:outline-none"
>
{categories.map(c => <option key={c.id} value={c.id}>{c.icon} {c.name}</option>)}
</select> </select>
<input type="text" placeholder="Title" value={title} onChange={e => setTitle(e.target.value)} required
className="w-full bg-[#0a0a0f] border border-gray-700 rounded px-3 py-2 text-sm focus:border-[#D4AF37] outline-none" /> <input
<textarea placeholder="Content" value={content} onChange={e => setContent(e.target.value)} required rows={4} type="text" placeholder="Thread title" value={title} onChange={e => setTitle(e.target.value)} required
className="w-full bg-[#0a0a0f] border border-gray-700 rounded px-3 py-2 text-sm focus:border-[#D4AF37] outline-none" /> className="w-full bg-[#0a0a0f] border border-gray-800 rounded-2xl px-4 py-4 text-sm text-white placeholder-gray-600 focus:border-[#D4AF37]/50 focus:outline-none"
<button type="submit" disabled={loading} />
className="w-full bg-[#D4AF37] text-[#0a0a0f] py-2 rounded font-semibold disabled:opacity-50 hover:bg-[#C9A84C] transition"> <textarea
{loading ? 'Posting...' : 'Create Thread'} placeholder="Share your thoughts…" value={content} onChange={e => setContent(e.target.value)} required rows={4}
className="w-full bg-[#0a0a0f] border border-gray-800 rounded-2xl px-4 py-3 text-sm text-white placeholder-gray-600 focus:border-[#D4AF37]/50 focus:outline-none resize-none"
/>
<button
type="submit" disabled={loading}
className="w-full bg-[#D4AF37] text-[#0a0a0f] py-4 rounded-2xl font-bold disabled:opacity-50 active:scale-[0.98] transition-transform"
>
{loading ? 'Posting…' : 'Post Thread'}
</button> </button>
<button type="button" onClick={onClose} className="w-full text-gray-500 text-sm hover:text-white">Cancel</button>
</form> </form>
</div> </div>
) )
+226 -84
View File
@@ -1,29 +1,47 @@
'use client' 'use client'
import { useState, useEffect } from 'react' import { useState, useEffect, useRef } from 'react'
import { useAuth } from '@/lib/AuthContext' import { useAuth } from '@/lib/AuthContext'
import { Search, Plus, ShoppingCart, Zap } from 'lucide-react' import { Search, Plus, ShoppingCart, Zap, X, ChevronRight, Package } from 'lucide-react'
interface Listing { id: string; sellerId: string; title: string; category: string; price_flh: number; seller: string; description: string; status: string; featured: boolean; featuredUntil: string | null; fileType: string | null; createdAt: string } interface Listing {
id: string; sellerId: string; title: string; category: string
price_flh: number; seller: string; description: string; status: string
featured: boolean; featuredUntil: string | null; fileType: string | null; createdAt: string
}
const CATEGORIES = ['All', 'E-Books', 'Courses', 'Design', 'Audio', 'Video', 'Software', 'Other'] const CATEGORIES = ['All', 'E-Books', 'Courses', 'Design', 'Audio', 'Video', 'Software', 'Other']
const CATEGORY_ICONS: Record<string, string> = {
'All': '🏪', 'E-Books': '📚', 'Courses': '🎓', 'Design': '🎨',
'Audio': '🎵', 'Video': '🎬', 'Software': '💻', 'Other': '📦',
}
export default function SouqPage() { export default function SouqPage() {
const { token, user, loading: authLoading } = useAuth() const { token, user } = useAuth()
const [listings, setListings] = useState<Listing[]>([]) const [listings, setListings] = useState<Listing[]>([])
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [category, setCategory] = useState('All') const [category, setCategory] = useState('All')
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [showCreate, setShowCreate] = useState(false) const [showCreate, setShowCreate] = useState(false)
const [selectedListing, setSelectedListing] = useState<Listing | null>(null) const [selectedListing, setSelectedListing] = useState<Listing | null>(null)
const [toast, setToast] = useState('')
const fetchListings = async () => { const fetchListings = async () => {
try { const res = await fetch('/api/marketplace/listings'); const data = await res.json(); setListings(data.listings || []) } try {
catch (e) { console.error(e) } finally { setLoading(false) } const res = await fetch('/api/marketplace/listings')
const data = await res.json()
setListings(data.listings || [])
} catch { } finally { setLoading(false) }
} }
useEffect(() => { fetchListings() }, []) useEffect(() => { fetchListings() }, [])
const showToast = (msg: string) => {
setToast(msg)
setTimeout(() => setToast(''), 3000)
}
const filtered = listings.filter(l => { const filtered = listings.filter(l => {
if (category !== 'All' && l.category !== category) return false if (category !== 'All' && l.category !== category) return false
if (search && !l.title.toLowerCase().includes(search.toLowerCase())) return false if (search && !l.title.toLowerCase().includes(search.toLowerCase())) return false
@@ -32,93 +50,190 @@ export default function SouqPage() {
const handlePurchase = async (listingId: string) => { const handlePurchase = async (listingId: string) => {
if (!token) return if (!token) return
const res = await fetch('/api/marketplace/purchase', { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ listingId }) }) const res = await fetch('/api/marketplace/purchase', {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ listingId }),
})
const data = await res.json() const data = await res.json()
if (res.ok) { fetchListings() } else { alert(data.error) } if (res.ok) { fetchListings(); showToast('Purchase successful!'); setSelectedListing(null) }
else showToast(data.error || 'Purchase failed')
} }
if (authLoading) return <div className="p-8 text-center text-gray-500">Loading...</div>
return ( return (
<div className="max-w-6xl mx-auto p-4 sm:p-6 space-y-6"> <div className="min-h-dvh bg-[#0a0a0f] pb-24 select-none">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<h1 className="text-2xl font-bold text-[#D4AF37]">Souq</h1> {/* Header */}
<div className="flex gap-2"> <div className="px-5 pt-6 pb-4">
<div className="relative flex-1 sm:flex-none"> <div className="flex items-center justify-between mb-4">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" /> <div>
<input type="text" placeholder="Search..." value={search} onChange={e => setSearch(e.target.value)} <h1 className="text-xl font-bold text-white">Souq</h1>
className="w-full sm:w-48 bg-[#111118] border border-gray-700 rounded pl-9 pr-3 py-2 text-sm focus:border-[#D4AF37] outline-none" /> <p className="text-[11px] text-gray-600 mt-0.5">Islamic digital marketplace</p>
</div> </div>
{token && ( {token && (
<button onClick={() => setShowCreate(true)} <button
className="flex items-center gap-1 bg-[#D4AF37] text-[#0a0a0f] px-3 py-2 rounded text-sm font-semibold hover:bg-[#C9A84C] transition"> onClick={() => setShowCreate(true)}
<Plus size={16} /> Create className="flex items-center gap-1.5 bg-[#D4AF37] text-[#0a0a0f] px-4 py-2.5 rounded-xl font-bold text-sm active:scale-[0.96] transition-transform"
>
<Plus size={16} /> Sell
</button> </button>
)} )}
</div> </div>
{/* Search */}
<div className="relative">
<Search size={16} className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-600" />
<input
type="search"
placeholder="Search products…"
value={search}
onChange={e => setSearch(e.target.value)}
className="w-full bg-[#111118] border border-gray-800 rounded-2xl pl-11 pr-4 py-3.5 text-sm text-white placeholder-gray-600 focus:border-[#D4AF37]/50 focus:outline-none"
/>
</div>
</div> </div>
<div className="flex gap-2 overflow-x-auto pb-2">
{/* Category chips */}
<div className="flex gap-2 px-5 pb-4 overflow-x-auto scrollbar-none" style={{ scrollbarWidth: 'none' }}>
{CATEGORIES.map(c => ( {CATEGORIES.map(c => (
<button key={c} onClick={() => setCategory(c)} <button
className={`px-3 py-1 rounded-full text-xs whitespace-nowrap transition ${category === c ? 'bg-[#D4AF37] text-[#0a0a0f] font-semibold' : 'bg-gray-800 text-gray-400 hover:text-white'}`}>{c}</button> key={c}
onClick={() => setCategory(c)}
className={`flex items-center gap-1.5 px-3.5 py-2 rounded-xl text-xs font-semibold whitespace-nowrap transition-all active:scale-95 ${
category === c
? 'bg-[#D4AF37] text-[#0a0a0f]'
: 'bg-gray-900 text-gray-500 border border-gray-800'
}`}
>
<span>{CATEGORY_ICONS[c]}</span>
{c}
</button>
))} ))}
</div> </div>
{loading ? (
<div className="text-center text-gray-500 py-12">Loading listings...</div> {/* Loading */}
) : filtered.length === 0 ? ( {loading && (
<div className="text-center text-gray-500 py-12">No listings found</div> <div className="px-5 space-y-3">
) : ( {[...Array(4)].map((_, i) => (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"> <div key={i} className="h-28 rounded-2xl bg-gray-900/60 animate-pulse" />
{filtered.map(l => (
<div key={l.id} className="bg-[#111118] border border-gray-800 rounded-lg p-4 space-y-2 hover:border-gray-700 transition cursor-pointer" onClick={() => setSelectedListing(l)}>
<div className="flex items-start justify-between">
<span className="text-xs text-gray-500 bg-gray-800 px-2 py-0.5 rounded">{l.category}</span>
{l.featured && <Zap size={14} className="text-[#D4AF37]" />}
</div>
<h3 className="font-semibold">{l.title}</h3>
<p className="text-sm text-gray-400 line-clamp-2">{l.description}</p>
<div className="flex items-center justify-between pt-2">
<span className="text-[#D4AF37] font-bold">{l.price_flh.toLocaleString()} FLH</span>
<span className="text-xs text-gray-500">{l.seller}</span>
</div>
{token && l.sellerId !== user?.id && (
<button onClick={e => { e.stopPropagation(); handlePurchase(l.id) }}
className="w-full flex items-center justify-center gap-1 bg-[#D4AF37]/10 text-[#D4AF37] border border-[#D4AF37]/30 rounded py-1.5 text-sm hover:bg-[#D4AF37]/20 transition">
<ShoppingCart size={14} /> Buy
</button>
)}
</div>
))} ))}
</div> </div>
)} )}
{/* Empty */}
{!loading && filtered.length === 0 && (
<div className="flex flex-col items-center justify-center pt-20 px-6 text-center">
<div className="text-5xl mb-4">🏪</div>
<p className="text-white font-semibold mb-1">Nothing here yet</p>
<p className="text-gray-600 text-sm">Be the first to list a product</p>
</div>
)}
{/* Listings */}
{!loading && filtered.length > 0 && (
<div className="px-5 space-y-3">
{filtered.map(l => (
<button
key={l.id}
onClick={() => setSelectedListing(l)}
className="w-full text-left bg-[#111118] border border-gray-800/60 rounded-2xl p-4 active:scale-[0.99] transition-transform"
>
<div className="flex items-start gap-3">
{/* Icon block */}
<div className="w-12 h-12 rounded-xl bg-gray-800/60 flex items-center justify-center shrink-0 text-xl">
{CATEGORY_ICONS[l.category] || '📦'}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<h3 className="font-semibold text-white text-sm leading-tight line-clamp-1">{l.title}</h3>
{l.featured && <Zap size={13} className="text-[#D4AF37] shrink-0 mt-0.5" />}
</div>
<p className="text-xs text-gray-500 mt-0.5 line-clamp-1">{l.description}</p>
<div className="flex items-center justify-between mt-2">
<span className="text-[#D4AF37] font-bold text-sm">{l.price_flh.toLocaleString()} FLH</span>
<span className="text-[11px] text-gray-700">{l.seller}</span>
</div>
</div>
<ChevronRight size={16} className="text-gray-700 shrink-0 mt-1" />
</div>
</button>
))}
</div>
)}
{/* Toast */}
{toast && (
<div className="fixed bottom-24 left-5 right-5 bg-[#1a1a26] border border-gray-700 rounded-2xl px-4 py-3 text-sm text-white text-center z-50 shadow-xl">
{toast}
</div>
)}
{/* Detail bottom sheet */}
{selectedListing && ( {selectedListing && (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center p-4 z-50" onClick={() => setSelectedListing(null)}> <div className="fixed inset-0 z-50 flex flex-col justify-end" onClick={() => setSelectedListing(null)}>
<div className="bg-[#111118] border border-gray-800 rounded-lg p-6 max-w-lg w-full space-y-4" onClick={e => e.stopPropagation()}> <div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
<div className="flex items-start justify-between"> <div
<h2 className="text-xl font-bold">{selectedListing.title}</h2> className="relative bg-[#0f0f18] border border-gray-800/60 rounded-t-3xl p-6 pb-10 max-h-[80dvh] overflow-y-auto"
<button onClick={() => setSelectedListing(null)} className="text-gray-500 hover:text-white"></button> onClick={e => e.stopPropagation()}
>
{/* Handle */}
<div className="w-10 h-1 bg-gray-700 rounded-full mx-auto mb-5" />
<div className="flex items-start gap-3 mb-4">
<div className="w-14 h-14 rounded-2xl bg-gray-800/60 flex items-center justify-center text-2xl shrink-0">
{CATEGORY_ICONS[selectedListing.category] || '📦'}
</div>
<div className="flex-1 min-w-0">
<h2 className="text-lg font-bold text-white leading-tight">{selectedListing.title}</h2>
<p className="text-xs text-gray-500 mt-0.5">by {selectedListing.seller}</p>
</div>
<button onClick={() => setSelectedListing(null)} className="w-8 h-8 flex items-center justify-center rounded-full bg-gray-800">
<X size={15} className="text-gray-400" />
</button>
</div> </div>
<p className="text-sm text-gray-400">{selectedListing.description}</p>
<div className="flex gap-2 text-sm"> <span className="inline-block text-[11px] bg-gray-800 text-gray-400 px-3 py-1 rounded-full mb-3">{selectedListing.category}</span>
<span className="bg-gray-800 px-2 py-0.5 rounded">{selectedListing.category}</span> <p className="text-sm text-gray-400 leading-relaxed mb-5">{selectedListing.description}</p>
<span className="text-gray-500">by {selectedListing.seller}</span>
<div className="flex items-center justify-between mb-5">
<p className="text-2xl font-bold text-[#D4AF37]">{selectedListing.price_flh.toLocaleString()} FLH</p>
{user && <p className="text-xs text-gray-600">Your balance: {user.flhBalance?.toLocaleString() || 0} FLH</p>}
</div> </div>
<p className="text-2xl font-bold text-[#D4AF37]">{selectedListing.price_flh.toLocaleString()} FLH</p>
{token && selectedListing.sellerId !== user?.id && ( {token && selectedListing.sellerId !== user?.id ? (
<button onClick={() => { handlePurchase(selectedListing.id); setSelectedListing(null) }} <button
className="w-full bg-[#D4AF37] text-[#0a0a0f] py-2 rounded font-semibold hover:bg-[#C9A84C] transition"> onClick={() => handlePurchase(selectedListing.id)}
Purchase className="w-full flex items-center justify-center gap-2 bg-[#D4AF37] text-[#0a0a0f] py-4 rounded-2xl font-bold text-base active:scale-[0.98] transition-transform"
>
<ShoppingCart size={18} /> Purchase Now
</button>
) : selectedListing.sellerId === user?.id ? (
<div className="w-full text-center py-4 rounded-2xl bg-gray-900 text-gray-500 text-sm">Your listing</div>
) : (
<button onClick={() => setSelectedListing(null)} className="w-full text-center py-4 rounded-2xl bg-gray-900 text-gray-400 text-sm">
Sign in to purchase
</button> </button>
)} )}
</div> </div>
</div> </div>
)} )}
{showCreate && token && <CreateListingModal token={token} userId={user!.id} onClose={() => setShowCreate(false)} onCreated={fetchListings} />}
{/* Create listing bottom sheet */}
{showCreate && token && (
<CreateListingSheet
token={token}
onClose={() => setShowCreate(false)}
onCreated={fetchListings}
showToast={showToast}
/>
)}
</div> </div>
) )
} }
function CreateListingModal({ token, onClose, onCreated }: { token: string; userId: string; onClose: () => void; onCreated: () => void }) { function CreateListingSheet({ token, onClose, onCreated, showToast }: {
token: string; onClose: () => void; onCreated: () => void; showToast: (m: string) => void
}) {
const [title, setTitle] = useState('') const [title, setTitle] = useState('')
const [description, setDescription] = useState('') const [description, setDescription] = useState('')
const [category, setCategory] = useState('E-Books') const [category, setCategory] = useState('E-Books')
@@ -128,31 +243,58 @@ function CreateListingModal({ token, onClose, onCreated }: { token: string; user
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
setLoading(true) setLoading(true)
const res = await fetch('/api/marketplace/listings', { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ title, description, category, priceFlh: parseInt(priceFlh) }) }) try {
const data = await res.json() const res = await fetch('/api/marketplace/listings', {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ title, description, category, priceFlh: parseInt(priceFlh) }),
})
const data = await res.json()
if (res.ok) { onCreated(); onClose(); showToast('Listing created!') }
else showToast(data.error || 'Failed to create')
} catch { showToast('Something went wrong') }
setLoading(false) setLoading(false)
if (res.ok) { onCreated(); onClose() } else { alert(data.error) }
} }
return ( return (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center p-4 z-50"> <div className="fixed inset-0 z-50 flex flex-col justify-end" onClick={onClose}>
<form onSubmit={handleSubmit} className="bg-[#111118] border border-gray-800 rounded-lg p-6 max-w-md w-full space-y-4"> <div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
<h2 className="text-lg font-bold">Create Listing</h2> <form
<input type="text" placeholder="Title" value={title} onChange={e => setTitle(e.target.value)} required onSubmit={handleSubmit}
className="w-full bg-[#0a0a0f] border border-gray-700 rounded px-3 py-2 text-sm focus:border-[#D4AF37] outline-none" /> className="relative bg-[#0f0f18] border border-gray-800/60 rounded-t-3xl p-6 pb-10 space-y-4"
<textarea placeholder="Description" value={description} onChange={e => setDescription(e.target.value)} required rows={3} onClick={e => e.stopPropagation()}
className="w-full bg-[#0a0a0f] border border-gray-700 rounded px-3 py-2 text-sm focus:border-[#D4AF37] outline-none" /> >
<select value={category} onChange={e => setCategory(e.target.value)} <div className="w-10 h-1 bg-gray-700 rounded-full mx-auto mb-2" />
className="w-full bg-[#0a0a0f] border border-gray-700 rounded px-3 py-2 text-sm focus:border-[#D4AF37] outline-none"> <h2 className="text-lg font-bold text-white">New Listing</h2>
{['E-Books', 'Courses', 'Design', 'Audio', 'Video', 'Software', 'Other'].map(c => <option key={c} value={c}>{c}</option>)}
<input
type="text" placeholder="Product title" value={title} onChange={e => setTitle(e.target.value)} required
className="w-full bg-[#0a0a0f] border border-gray-800 rounded-2xl px-4 py-4 text-sm text-white placeholder-gray-600 focus:border-[#D4AF37]/50 focus:outline-none"
/>
<textarea
placeholder="Description" value={description} onChange={e => setDescription(e.target.value)} required rows={3}
className="w-full bg-[#0a0a0f] border border-gray-800 rounded-2xl px-4 py-3 text-sm text-white placeholder-gray-600 focus:border-[#D4AF37]/50 focus:outline-none resize-none"
/>
<select
value={category} onChange={e => setCategory(e.target.value)}
className="w-full bg-[#0a0a0f] border border-gray-800 rounded-2xl px-4 py-4 text-sm text-white focus:border-[#D4AF37]/50 focus:outline-none"
>
{['E-Books', 'Courses', 'Design', 'Audio', 'Video', 'Software', 'Other'].map(c => <option key={c}>{c}</option>)}
</select> </select>
<input type="number" placeholder="Price (FLH)" value={priceFlh} onChange={e => setPriceFlh(e.target.value)} required min={1} <input
className="w-full bg-[#0a0a0f] border border-gray-700 rounded px-3 py-2 text-sm focus:border-[#D4AF37] outline-none" /> type="number" placeholder="Price in FLH" value={priceFlh} onChange={e => setPriceFlh(e.target.value)} required min={1}
<button type="submit" disabled={loading} inputMode="numeric"
className="w-full bg-[#D4AF37] text-[#0a0a0f] py-2 rounded font-semibold disabled:opacity-50 hover:bg-[#C9A84C] transition"> className="w-full bg-[#0a0a0f] border border-gray-800 rounded-2xl px-4 py-4 text-sm text-white placeholder-gray-600 focus:border-[#D4AF37]/50 focus:outline-none"
{loading ? 'Creating...' : 'Create Listing'} />
<button
type="submit" disabled={loading}
className="w-full bg-[#D4AF37] text-[#0a0a0f] py-4 rounded-2xl font-bold disabled:opacity-50 active:scale-[0.98] transition-transform"
>
{loading ? 'Creating…' : 'Create Listing'}
</button>
<button type="button" onClick={onClose} className="w-full text-gray-600 text-sm py-2">
Cancel
</button> </button>
<button type="button" onClick={onClose} className="w-full text-gray-500 text-sm hover:text-white">Cancel</button>
</form> </form>
</div> </div>
) )
+120 -31
View File
@@ -2,8 +2,8 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useAuth } from '@/lib/AuthContext' import { useAuth } from '@/lib/AuthContext'
import { ArrowUpRight, History } from 'lucide-react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { ArrowDownLeft, Clock, CheckCircle2, XCircle, Wallet } from 'lucide-react'
export default function WalletPage() { export default function WalletPage() {
const { token, user, loading: authLoading } = useAuth() const { token, user, loading: authLoading } = useAuth()
@@ -11,54 +11,143 @@ export default function WalletPage() {
const [amount, setAmount] = useState('') const [amount, setAmount] = useState('')
const [cashouts, setCashouts] = useState<any[]>([]) const [cashouts, setCashouts] = useState<any[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [toast, setToast] = useState('')
useEffect(() => { if (!authLoading && !token) router.push('/login') }, [token, authLoading, router]) useEffect(() => { if (!authLoading && !token) router.push('/login') }, [token, authLoading, router])
useEffect(() => { if (!token) return; fetch('/api/wallet', { headers: { 'Authorization': `Bearer ${token}` } }).then(r => r.json()).then(d => setCashouts(d.requests || [])).catch(() => {}) }, [token]) useEffect(() => {
if (!token) return
fetch('/api/wallet', { headers: { Authorization: `Bearer ${token}` } })
.then(r => r.json()).then(d => setCashouts(d.requests || [])).catch(() => { })
}, [token])
const showToast = (msg: string) => { setToast(msg); setTimeout(() => setToast(''), 3000) }
const handleCashout = async () => { const handleCashout = async () => {
if (!token || !amount) return if (!token || !amount || parseInt(amount) < 100) return
setLoading(true) setLoading(true)
const res = await fetch('/api/wallet', { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ amountFlh: parseInt(amount) }) }) const res = await fetch('/api/wallet', {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ amountFlh: parseInt(amount) }),
})
setLoading(false) setLoading(false)
const data = await res.json() const data = await res.json()
if (res.ok) { setAmount(''); alert('Cashout submitted!') } else { alert(data.error) } if (res.ok) { setAmount(''); showToast('Cashout request submitted!') }
else showToast(data.error || 'Something went wrong')
} }
if (authLoading) return <div className="p-8 text-center text-gray-500">Loading...</div> if (authLoading) return (
<div className="min-h-dvh flex items-center justify-center bg-[#0a0a0f] pb-20">
<div className="w-8 h-8 rounded-full border-2 border-[#D4AF37]/30 border-t-[#D4AF37] animate-spin" />
</div>
)
if (!token) return null if (!token) return null
const fiatValue = amount ? ((parseInt(amount) || 0) * 0.008).toFixed(2) : '0.00'
return ( return (
<div className="max-w-3xl mx-auto p-4 sm:p-6 space-y-6"> <div className="min-h-dvh bg-[#0a0a0f] pb-24">
<h1 className="text-2xl font-bold text-[#D4AF37]">Wallet</h1>
<div className="bg-gradient-to-r from-[#D4AF37]/10 to-[#0a0a0f] border border-[#D4AF37]/20 rounded-lg p-6"> {/* Header */}
<p className="text-sm text-gray-400 mb-1">Balance</p> <div className="px-5 pt-6 pb-5">
<p className="text-4xl font-bold text-[#D4AF37]">{user?.flhBalance?.toLocaleString() || 0} <span className="text-lg text-gray-500">FLH</span></p> <h1 className="text-xl font-bold text-white">Wallet</h1>
<p className="text-[11px] text-gray-600 mt-0.5">Manage your FLH balance</p>
</div> </div>
<div className="bg-[#111118] border border-gray-800 rounded-lg p-6 space-y-4">
<h2 className="font-semibold flex items-center gap-2"><ArrowUpRight size={16} className="text-[#D4AF37]" /> Cash Out</h2> {/* Balance card */}
<p className="text-xs text-gray-500">Rate: 100 FLH = £0.80 (20% platform spread). Minimum 100 FLH.</p> <div className="mx-5 mb-5 rounded-3xl overflow-hidden" style={{ background: 'linear-gradient(135deg, #1a1508 0%, #0f0f18 100%)', border: '1px solid rgba(212,175,55,0.2)' }}>
<div className="flex gap-2"> <div className="px-6 pt-6 pb-5">
<input type="number" placeholder="Amount (FLH)" value={amount} onChange={e => setAmount(e.target.value)} min={100} <div className="flex items-center gap-2 mb-4">
className="flex-1 bg-[#0a0a0f] border border-gray-700 rounded px-3 py-2 text-sm focus:border-[#D4AF37] outline-none" /> <Wallet size={16} className="text-[#D4AF37]" />
<button onClick={handleCashout} disabled={loading || !amount || parseInt(amount) < 100} <span className="text-xs text-[#D4AF37]/70 font-medium uppercase tracking-wider">FLH Balance</span>
className="bg-[#D4AF37] text-[#0a0a0f] px-4 py-2 rounded text-sm font-semibold disabled:opacity-50 hover:bg-[#C9A84C] transition"> </div>
{loading ? 'Processing...' : 'Cash Out'} <p className="text-5xl font-bold text-white tabular-nums leading-none">
</button> {(user?.flhBalance || 0).toLocaleString()}
</p>
<p className="text-[#D4AF37] font-semibold mt-1 text-base">FLH Tokens</p>
<p className="text-xs text-gray-600 mt-3">
£{((user?.flhBalance || 0) * 0.008).toFixed(2)} GBP at current rate
</p>
</div> </div>
</div> </div>
<div className="bg-[#111118] border border-gray-800 rounded-lg p-6 space-y-3">
<h2 className="font-semibold flex items-center gap-2"><History size={16} className="text-[#D4AF37]" /> History</h2> {/* Cash Out */}
{cashouts.length === 0 ? ( <div className="mx-5 mb-5 bg-[#111118] border border-gray-800/60 rounded-3xl p-5">
<p className="text-sm text-gray-500">No cashout requests yet</p> <div className="flex items-center gap-2 mb-1">
) : ( <ArrowDownLeft size={16} className="text-[#D4AF37]" />
cashouts.map(c => ( <h2 className="font-bold text-white text-sm">Cash Out</h2>
<div key={c.id} className="flex items-center justify-between text-sm"> </div>
<span className="text-gray-400">{c.amountFlh} FLH £{c.fiatAmount.toFixed(2)}</span> <p className="text-[11px] text-gray-600 mb-4">100 FLH = £0.80 · Minimum 100 FLH</p>
<span className={`text-xs ${c.status === 'pending' ? 'text-yellow-400' : 'text-green-400'}`}>{c.status}</span>
<div className="space-y-3">
<input
type="number"
placeholder="Amount in FLH"
value={amount}
onChange={e => setAmount(e.target.value)}
min={100}
inputMode="numeric"
className="w-full bg-[#0d0d14] border border-gray-800 rounded-2xl px-4 py-4 text-white placeholder-gray-700 text-sm focus:border-[#D4AF37]/50 focus:outline-none"
/>
{amount && parseInt(amount) > 0 && (
<div className="flex items-center justify-between px-1">
<span className="text-xs text-gray-600">{parseInt(amount).toLocaleString()} FLH</span>
<span className="text-xs text-[#D4AF37] font-semibold">£{fiatValue} GBP</span>
</div> </div>
)) )}
<button
onClick={handleCashout}
disabled={loading || !amount || parseInt(amount) < 100}
className="w-full bg-[#D4AF37] text-[#0a0a0f] py-4 rounded-2xl font-bold text-sm disabled:opacity-40 active:scale-[0.98] transition-all"
>
{loading ? 'Submitting…' : 'Request Cash Out'}
</button>
{amount && parseInt(amount) < 100 && (
<p className="text-xs text-red-400 text-center">Minimum 100 FLH</p>
)}
</div>
</div>
{/* History */}
<div className="mx-5 bg-[#111118] border border-gray-800/60 rounded-3xl p-5">
<h2 className="font-bold text-white text-sm mb-4">History</h2>
{cashouts.length === 0 ? (
<div className="text-center py-6">
<p className="text-gray-600 text-sm">No cashout requests yet</p>
</div>
) : (
<div className="space-y-3">
{cashouts.map(c => (
<div key={c.id} className="flex items-center gap-3 py-2">
<div className="w-9 h-9 rounded-full bg-gray-800 flex items-center justify-center shrink-0">
{c.status === 'pending'
? <Clock size={16} className="text-yellow-400" />
: c.status === 'approved'
? <CheckCircle2 size={16} className="text-emerald-400" />
: <XCircle size={16} className="text-red-400" />
}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-white font-medium">{c.amountFlh.toLocaleString()} FLH</p>
<p className="text-xs text-gray-600">£{c.fiatAmount.toFixed(2)} GBP</p>
</div>
<span className={`text-xs font-semibold capitalize px-2.5 py-1 rounded-full ${
c.status === 'pending' ? 'bg-yellow-400/10 text-yellow-400'
: c.status === 'approved' ? 'bg-emerald-400/10 text-emerald-400'
: 'bg-red-400/10 text-red-400'
}`}>{c.status}</span>
</div>
))}
</div>
)} )}
</div> </div>
{/* Toast */}
{toast && (
<div className="fixed bottom-24 left-5 right-5 bg-[#1a1a26] border border-gray-700 rounded-2xl px-4 py-3 text-sm text-white text-center z-50 shadow-xl">
{toast}
</div>
)}
</div> </div>
) )
} }