easier fund management and navigations
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m26s
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m26s
This commit is contained in:
@@ -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>
|
||||||
|
) : 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>
|
||||||
)}
|
)}
|
||||||
|
</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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
+42
-26
@@ -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 handleSearch(e: React.FormEvent) {
|
function navigate(tag: string) {
|
||||||
e.preventDefault()
|
const url = fundSlug ? `/hashtag/${tag}?fund=${encodeURIComponent(fundSlug)}` : `/hashtag/${tag}`
|
||||||
const tag = normalizeTag(query)
|
router.push(url)
|
||||||
if (tag) {
|
|
||||||
router.push(`/hashtag/${tag}`)
|
|
||||||
setQuery('')
|
setQuery('')
|
||||||
setSuggestions([])
|
setSuggestions([])
|
||||||
setShowSuggestions(false)
|
setShowSuggestions(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleSearch(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
const tag = normalizeTag(query)
|
||||||
|
if (tag) navigate(tag)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleQueryChange(e: React.ChangeEvent<HTMLInputElement>) {
|
function handleQueryChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
@@ -52,16 +56,6 @@ export function Navbar() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<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="flex items-center justify-between h-14 gap-4">
|
|
||||||
{/* Logo */}
|
|
||||||
<Link href="/" className="flex items-center gap-2 shrink-0">
|
|
||||||
<TrendingUp className="h-6 w-6 text-indigo-500" />
|
|
||||||
<span className="font-bold text-lg hidden sm:block">HashEx</span>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{/* Search */}
|
|
||||||
<form onSubmit={handleSearch} className="flex-1 max-w-md">
|
<form onSubmit={handleSearch} className="flex-1 max-w-md">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
|
||||||
@@ -71,7 +65,7 @@ export function Navbar() {
|
|||||||
onChange={handleQueryChange}
|
onChange={handleQueryChange}
|
||||||
onBlur={() => setTimeout(() => setShowSuggestions(false), 150)}
|
onBlur={() => setTimeout(() => setShowSuggestions(false), 150)}
|
||||||
onFocus={() => suggestions.length > 0 && setShowSuggestions(true)}
|
onFocus={() => suggestions.length > 0 && setShowSuggestions(true)}
|
||||||
placeholder="#hashtag"
|
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"
|
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 && (
|
{showSuggestions && suggestions.length > 0 && (
|
||||||
@@ -80,12 +74,7 @@ export function Navbar() {
|
|||||||
<button
|
<button
|
||||||
key={s.tag}
|
key={s.tag}
|
||||||
type="button"
|
type="button"
|
||||||
onMouseDown={() => {
|
onMouseDown={() => navigate(s.tag)}
|
||||||
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"
|
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="font-medium">#{s.displayTag}</span>
|
||||||
@@ -96,6 +85,33 @@ export function Navbar() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Navbar() {
|
||||||
|
const { data: session } = useSession()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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="flex items-center justify-between h-14 gap-4">
|
||||||
|
{/* Logo */}
|
||||||
|
<Link href="/" className="flex items-center gap-2 shrink-0">
|
||||||
|
<TrendingUp className="h-6 w-6 text-indigo-500" />
|
||||||
|
<span className="font-bold text-lg hidden sm:block">HashEx</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Search — NavSearchInner uses useSearchParams() to preserve ?fund= context */}
|
||||||
|
<Suspense fallback={
|
||||||
|
<div 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 disabled placeholder="#hashtag" className="w-full bg-surface border border-surface-border rounded-lg pl-9 pr-3 py-1.5 text-sm" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
<NavSearchInner />
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
{/* Right section */}
|
{/* Right section */}
|
||||||
<div className="flex items-center gap-3 shrink-0">
|
<div className="flex items-center gap-3 shrink-0">
|
||||||
|
|||||||
Reference in New Issue
Block a user