easier fund management and navigations
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m26s

This commit is contained in:
2026-03-22 01:20:34 -04:00
parent d68bc99817
commit 100f149c53
3 changed files with 120 additions and 50 deletions
+49 -6
View File
@@ -2,6 +2,7 @@
import { useState } from 'react' import { useState } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { formatCurrency, formatNumber } from '@/lib/utils' import { formatCurrency, formatNumber } from '@/lib/utils'
interface Props { interface Props {
@@ -11,18 +12,20 @@ interface Props {
shortPosition: { shares: number; avgBuyPrice: number } | null shortPosition: { shares: number; avgBuyPrice: number } | null
fundId?: string fundId?: string
fundName?: string fundName?: string
managedFunds?: { slug: string; name: string }[]
maxPositionShares: number maxPositionShares: number
maxPositionValue: number maxPositionValue: number
} }
type Tab = 'BUY_LONG' | 'SELL_LONG' | 'BUY_SHORT' | 'SELL_SHORT' type Tab = 'BUY_LONG' | 'SELL_LONG' | 'BUY_SHORT' | 'SELL_SHORT'
export function TradePanel({ hashtag, balance, longPosition, shortPosition, fundId, fundName, maxPositionShares, maxPositionValue }: Props) { export function TradePanel({ hashtag, balance, longPosition, shortPosition, fundId, fundName, managedFunds, maxPositionShares, maxPositionValue }: Props) {
const router = useRouter() const router = useRouter()
const [tab, setTab] = useState<Tab>('BUY_LONG') const [tab, setTab] = useState<Tab>('BUY_LONG')
const [shares, setShares] = useState('') const [shares, setShares] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const [showFundMenu, setShowFundMenu] = useState(false)
const sharesNum = parseFloat(shares) || 0 const sharesNum = parseFloat(shares) || 0
const cost = sharesNum * hashtag.currentPrice const cost = sharesNum * hashtag.currentPrice
@@ -65,13 +68,53 @@ export function TradePanel({ hashtag, balance, longPosition, shortPosition, fund
return ( return (
<div className="bg-surface-card border border-surface-border rounded-xl p-6 space-y-5"> <div className="bg-surface-card border border-surface-border rounded-xl p-6 space-y-5">
{fundName && ( {fundName ? (
<div className="flex items-center gap-2 text-xs bg-indigo-500/10 border border-indigo-500/30 rounded-lg px-3 py-2 text-indigo-300"> <div className="flex items-center gap-2 text-xs bg-indigo-500/10 border border-indigo-500/30 rounded-lg px-3 py-2 text-indigo-300">
<span className="text-lg">🏦</span> <span className="text-base">🏦</span>
Trading as <span className="font-semibold">{fundName}</span> <span>Trading as <span className="font-semibold">{fundName}</span></span>
<span className="text-indigo-500 ml-auto">Fund mode</span> <Link
href={`/hashtag/${hashtag.tag}`}
className="ml-auto text-indigo-400 hover:text-indigo-200 transition-colors"
>
Exit fund mode ×
</Link>
</div> </div>
)} ) : managedFunds && managedFunds.length === 1 ? (
<Link
href={`/hashtag/${hashtag.tag}?fund=${encodeURIComponent(managedFunds[0].slug)}`}
className="flex items-center gap-2 text-xs border border-surface-border rounded-lg px-3 py-2 text-slate-400 hover:text-slate-200 hover:bg-surface transition-colors"
>
<span className="text-base">🏦</span>
<span>Trade as <span className="font-medium text-slate-200">{managedFunds[0].name}</span></span>
<span className="ml-auto"></span>
</Link>
) : managedFunds && managedFunds.length > 1 ? (
<div className="text-xs border border-surface-border rounded-lg overflow-hidden">
<button
type="button"
onClick={() => setShowFundMenu((v) => !v)}
className="w-full flex items-center gap-2 px-3 py-2 text-slate-400 hover:text-slate-200 hover:bg-surface transition-colors"
>
<span className="text-base">🏦</span>
<span>Trade as a fund</span>
<span className="ml-auto text-slate-600">{showFundMenu ? '▲' : '▼'}</span>
</button>
{showFundMenu && (
<div className="border-t border-surface-border divide-y divide-surface-border">
{managedFunds.map((f) => (
<Link
key={f.slug}
href={`/hashtag/${hashtag.tag}?fund=${encodeURIComponent(f.slug)}`}
className="flex items-center justify-between px-3 py-2 font-medium text-indigo-300 hover:text-indigo-200 hover:bg-surface transition-colors"
>
<span>{f.name}</span>
<span className="text-slate-500"></span>
</Link>
))}
</div>
)}
</div>
) : null}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="font-semibold">Trade #{hashtag.displayTag}</h2> <h2 className="font-semibold">Trade #{hashtag.displayTag}</h2>
<span className="text-sm text-slate-400"> <span className="text-sm text-slate-400">
+11
View File
@@ -84,6 +84,16 @@ export default async function HashtagPage({ params, searchParams }: Props) {
} }
} }
// When not in fund mode, fetch funds this user manages for the fund-mode switcher
let managedFunds: { slug: string; name: string }[] = []
if (session && !fundContext) {
const managerships = await prisma.fundManager.findMany({
where: { userId: session.user.id },
include: { fund: { select: { slug: true, name: true } } },
})
managedFunds = managerships.map((m) => ({ slug: m.fund.slug, name: m.fund.name }))
}
// Unknown hashtag — show research panel // Unknown hashtag — show research panel
if (!hashtag || !hashtag.isActive) { if (!hashtag || !hashtag.isActive) {
return ( return (
@@ -199,6 +209,7 @@ export default async function HashtagPage({ params, searchParams }: Props) {
fundName={fundContext?.name} fundName={fundContext?.name}
maxPositionShares={fundContext ? FUND_MAX_POSITION_SHARES : MAX_POSITION_SHARES} maxPositionShares={fundContext ? FUND_MAX_POSITION_SHARES : MAX_POSITION_SHARES}
maxPositionValue={fundContext ? FUND_MAX_POSITION_VALUE : MAX_POSITION_VALUE} maxPositionValue={fundContext ? FUND_MAX_POSITION_VALUE : MAX_POSITION_VALUE}
managedFunds={managedFunds}
/> />
) : ( ) : (
<div className="bg-surface-card border border-surface-border rounded-xl p-6 text-center"> <div className="bg-surface-card border border-surface-border rounded-xl p-6 text-center">
+60 -44
View File
@@ -3,30 +3,34 @@
import Link from 'next/link' import Link from 'next/link'
import { useSession, signOut } from 'next-auth/react' import { useSession, signOut } from 'next-auth/react'
import { TrendingUp, Search, User, LogOut, Shield, Trophy } from 'lucide-react' import { TrendingUp, Search, User, LogOut, Shield, Trophy } from 'lucide-react'
import { useState, useRef, useEffect } from 'react' import { useState, useRef, useEffect, Suspense } from 'react'
import { useRouter } from 'next/navigation' import { useRouter, useSearchParams } from 'next/navigation'
import { formatCurrency } from '@/lib/utils' import { formatCurrency } from '@/lib/utils'
import { normalizeTag } from '@/lib/utils' import { normalizeTag } from '@/lib/utils'
type Suggestion = { tag: string; displayTag: string; currentPrice: number } type Suggestion = { tag: string; displayTag: string; currentPrice: number }
export function Navbar() { function NavSearchInner() {
const { data: session } = useSession()
const router = useRouter() const router = useRouter()
const searchParams = useSearchParams()
const fundSlug = searchParams.get('fund')
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const [suggestions, setSuggestions] = useState<Suggestion[]>([]) const [suggestions, setSuggestions] = useState<Suggestion[]>([])
const [showSuggestions, setShowSuggestions] = useState(false) const [showSuggestions, setShowSuggestions] = useState(false)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null) const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
function navigate(tag: string) {
const url = fundSlug ? `/hashtag/${tag}?fund=${encodeURIComponent(fundSlug)}` : `/hashtag/${tag}`
router.push(url)
setQuery('')
setSuggestions([])
setShowSuggestions(false)
}
function handleSearch(e: React.FormEvent) { function handleSearch(e: React.FormEvent) {
e.preventDefault() e.preventDefault()
const tag = normalizeTag(query) const tag = normalizeTag(query)
if (tag) { if (tag) navigate(tag)
router.push(`/hashtag/${tag}`)
setQuery('')
setSuggestions([])
setShowSuggestions(false)
}
} }
function handleQueryChange(e: React.ChangeEvent<HTMLInputElement>) { function handleQueryChange(e: React.ChangeEvent<HTMLInputElement>) {
@@ -51,6 +55,42 @@ export function Navbar() {
}, 300) }, 300)
} }
return (
<form onSubmit={handleSearch} className="flex-1 max-w-md">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
<input
type="text"
value={query}
onChange={handleQueryChange}
onBlur={() => setTimeout(() => setShowSuggestions(false), 150)}
onFocus={() => suggestions.length > 0 && setShowSuggestions(true)}
placeholder={fundSlug ? `#hashtag (as ${fundSlug})` : '#hashtag'}
className="w-full bg-surface border border-surface-border rounded-lg pl-9 pr-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
{showSuggestions && suggestions.length > 0 && (
<div className="absolute top-full left-0 right-0 mt-1 bg-surface-card border border-surface-border rounded-lg shadow-xl z-50 overflow-hidden">
{suggestions.map((s) => (
<button
key={s.tag}
type="button"
onMouseDown={() => navigate(s.tag)}
className="w-full flex items-center justify-between px-3 py-2 text-sm hover:bg-surface-border transition-colors"
>
<span className="font-medium">#{s.displayTag}</span>
<span className="text-slate-400 text-xs">{formatCurrency(s.currentPrice)}</span>
</button>
))}
</div>
)}
</div>
</form>
)
}
export function Navbar() {
const { data: session } = useSession()
return ( return (
<nav className="border-b border-surface-border bg-surface-card"> <nav className="border-b border-surface-border bg-surface-card">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
@@ -61,41 +101,17 @@ export function Navbar() {
<span className="font-bold text-lg hidden sm:block">HashEx</span> <span className="font-bold text-lg hidden sm:block">HashEx</span>
</Link> </Link>
{/* Search */} {/* Search — NavSearchInner uses useSearchParams() to preserve ?fund= context */}
<form onSubmit={handleSearch} className="flex-1 max-w-md"> <Suspense fallback={
<div className="relative"> <div className="flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" /> <div className="relative">
<input <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
type="text" <input disabled placeholder="#hashtag" className="w-full bg-surface border border-surface-border rounded-lg pl-9 pr-3 py-1.5 text-sm" />
value={query} </div>
onChange={handleQueryChange}
onBlur={() => setTimeout(() => setShowSuggestions(false), 150)}
onFocus={() => suggestions.length > 0 && setShowSuggestions(true)}
placeholder="#hashtag"
className="w-full bg-surface border border-surface-border rounded-lg pl-9 pr-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
{showSuggestions && suggestions.length > 0 && (
<div className="absolute top-full left-0 right-0 mt-1 bg-surface-card border border-surface-border rounded-lg shadow-xl z-50 overflow-hidden">
{suggestions.map((s) => (
<button
key={s.tag}
type="button"
onMouseDown={() => {
router.push(`/hashtag/${s.tag}`)
setQuery('')
setSuggestions([])
setShowSuggestions(false)
}}
className="w-full flex items-center justify-between px-3 py-2 text-sm hover:bg-surface-border transition-colors"
>
<span className="font-medium">#{s.displayTag}</span>
<span className="text-slate-400 text-xs">{formatCurrency(s.currentPrice)}</span>
</button>
))}
</div>
)}
</div> </div>
</form> }>
<NavSearchInner />
</Suspense>
{/* Right section */} {/* Right section */}
<div className="flex items-center gap-3 shrink-0"> <div className="flex items-center gap-3 shrink-0">