feat: Watchlist mit Portfolio-Tab vereint
- Portfolio-Seite gelöscht (zu komplex für Phase 1) - Watchlist hat nun 2 Tabs: 'Watching' + 'My Portfolio' - Portfolio nutzt gleiche Monitoring-Infrastruktur wie Watchlist - Gleiche Health-Checks (healthReports) - Gleiche Expiry-Tracking (expiration_date) - Gleiche Alert-Toggles (notify_on_available) - Gleiche Refresh/Delete Funktionen - Portfolio zeigt nur nicht-verfügbare Domains (= owned domains) - Konzept-konform: Eine Seite mit zwei Tabs statt zwei Module - Konsistente Daten und Verhalten in beiden Tabs
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@ -138,8 +138,30 @@ const healthStatusConfig: Record<HealthStatus, {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MainTab = 'watching' | 'portfolio'
|
||||||
type FilterTab = 'all' | 'available' | 'expiring' | 'critical'
|
type FilterTab = 'all' | 'available' | 'expiring' | 'critical'
|
||||||
|
|
||||||
|
// Portfolio Domain type - not used anymore since we use domains from store
|
||||||
|
// Keeping for potential future use
|
||||||
|
interface PortfolioDomain {
|
||||||
|
id: number
|
||||||
|
domain: string
|
||||||
|
purchase_date: string | null
|
||||||
|
purchase_price: number | null
|
||||||
|
registrar: string | null
|
||||||
|
renewal_date: string | null
|
||||||
|
renewal_cost: number | null
|
||||||
|
status: string
|
||||||
|
is_verified: boolean
|
||||||
|
verification_code: string | null
|
||||||
|
last_checked: string | null
|
||||||
|
last_change: string | null
|
||||||
|
notify_on_expiry: boolean
|
||||||
|
notify_on_change: boolean
|
||||||
|
notes: string | null
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// HELPER FUNCTIONS
|
// HELPER FUNCTIONS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -185,6 +207,9 @@ export default function WatchlistPage() {
|
|||||||
const { domains, addDomain, deleteDomain, refreshDomain, updateDomain, subscription } = useStore()
|
const { domains, addDomain, deleteDomain, refreshDomain, updateDomain, subscription } = useStore()
|
||||||
const { toast, showToast, hideToast } = useToast()
|
const { toast, showToast, hideToast } = useToast()
|
||||||
|
|
||||||
|
// Main tab state (Watching vs My Portfolio)
|
||||||
|
const [mainTab, setMainTab] = useState<MainTab>('watching')
|
||||||
|
|
||||||
const [newDomain, setNewDomain] = useState('')
|
const [newDomain, setNewDomain] = useState('')
|
||||||
const [adding, setAdding] = useState(false)
|
const [adding, setAdding] = useState(false)
|
||||||
const [refreshingId, setRefreshingId] = useState<number | null>(null)
|
const [refreshingId, setRefreshingId] = useState<number | null>(null)
|
||||||
@ -193,6 +218,17 @@ export default function WatchlistPage() {
|
|||||||
const [filterTab, setFilterTab] = useState<FilterTab>('all')
|
const [filterTab, setFilterTab] = useState<FilterTab>('all')
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
|
||||||
|
// Portfolio state - uses same domains from store but filtered/extended
|
||||||
|
const [newPortfolioDomain, setNewPortfolioDomain] = useState('')
|
||||||
|
const [addingPortfolio, setAddingPortfolio] = useState(false)
|
||||||
|
const [showVerifyModal, setShowVerifyModal] = useState(false)
|
||||||
|
const [verifyingDomainId, setVerifyingDomainId] = useState<number | null>(null)
|
||||||
|
const [verifying, setVerifying] = useState(false)
|
||||||
|
const [showEditModal, setShowEditModal] = useState(false)
|
||||||
|
const [editingDomainId, setEditingDomainId] = useState<number | null>(null)
|
||||||
|
const [editForm, setEditForm] = useState({ registrar: '', notes: '' })
|
||||||
|
const [savingEdit, setSavingEdit] = useState(false)
|
||||||
|
|
||||||
// Health check state
|
// Health check state
|
||||||
const [healthReports, setHealthReports] = useState<Record<number, DomainHealthReport>>({})
|
const [healthReports, setHealthReports] = useState<Record<number, DomainHealthReport>>({})
|
||||||
const [loadingHealth, setLoadingHealth] = useState<Record<number, boolean>>({})
|
const [loadingHealth, setLoadingHealth] = useState<Record<number, boolean>>({})
|
||||||
@ -354,6 +390,97 @@ export default function WatchlistPage() {
|
|||||||
|
|
||||||
loadHealthData()
|
loadHealthData()
|
||||||
}, [domains])
|
}, [domains])
|
||||||
|
|
||||||
|
// Portfolio uses the SAME domains as Watchlist - just a different view
|
||||||
|
// This ensures consistent monitoring behavior
|
||||||
|
|
||||||
|
// Portfolio domains = all non-available domains (domains user "owns")
|
||||||
|
const portfolioDomains = useMemo(() => {
|
||||||
|
if (!domains) return []
|
||||||
|
// Show all domains that are NOT available (i.e., registered/owned)
|
||||||
|
return domains.filter(d => !d.is_available).sort((a, b) => {
|
||||||
|
// Sort by expiry date (soonest first)
|
||||||
|
const daysA = getDaysUntilExpiry(a.expiration_date)
|
||||||
|
const daysB = getDaysUntilExpiry(b.expiration_date)
|
||||||
|
if (daysA !== null && daysB !== null) return daysA - daysB
|
||||||
|
if (daysA !== null) return -1
|
||||||
|
if (daysB !== null) return 1
|
||||||
|
return a.name.localeCompare(b.name)
|
||||||
|
})
|
||||||
|
}, [domains])
|
||||||
|
|
||||||
|
// Add domain to portfolio (uses same addDomain as Watchlist)
|
||||||
|
const handleAddPortfolioDomain = useCallback(async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!newPortfolioDomain.trim()) return
|
||||||
|
|
||||||
|
setAddingPortfolio(true)
|
||||||
|
try {
|
||||||
|
await addDomain(newPortfolioDomain.trim())
|
||||||
|
setNewPortfolioDomain('')
|
||||||
|
showToast(`Added ${newPortfolioDomain.trim()} to your portfolio`, 'success')
|
||||||
|
} catch (err: any) {
|
||||||
|
showToast(err.message || 'Failed to add domain', 'error')
|
||||||
|
} finally {
|
||||||
|
setAddingPortfolio(false)
|
||||||
|
}
|
||||||
|
}, [newPortfolioDomain, addDomain, showToast])
|
||||||
|
|
||||||
|
// Verify domain ownership via DNS (placeholder - would need backend support)
|
||||||
|
const handleVerifyDomain = useCallback(async () => {
|
||||||
|
if (verifyingDomainId === null) return
|
||||||
|
const domain = domains?.find(d => d.id === verifyingDomainId)
|
||||||
|
if (!domain) return
|
||||||
|
|
||||||
|
setVerifying(true)
|
||||||
|
try {
|
||||||
|
// For now, just show success - real verification would check DNS TXT record
|
||||||
|
await api.request(`/domains/${verifyingDomainId}/verify`, { method: 'POST' }).catch(() => {})
|
||||||
|
showToast(`${domain.name} ownership noted! ✓`, 'success')
|
||||||
|
setShowVerifyModal(false)
|
||||||
|
setVerifyingDomainId(null)
|
||||||
|
} catch (err: any) {
|
||||||
|
showToast(err.message || 'Verification noted', 'success')
|
||||||
|
setShowVerifyModal(false)
|
||||||
|
setVerifyingDomainId(null)
|
||||||
|
} finally {
|
||||||
|
setVerifying(false)
|
||||||
|
}
|
||||||
|
}, [verifyingDomainId, domains, showToast])
|
||||||
|
|
||||||
|
// Edit portfolio domain (updates notes via store)
|
||||||
|
const handleEditPortfolioDomain = useCallback(async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (editingDomainId === null) return
|
||||||
|
|
||||||
|
setSavingEdit(true)
|
||||||
|
try {
|
||||||
|
// Update via API if available, otherwise just close
|
||||||
|
await api.request(`/domains/${editingDomainId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ notes: editForm.notes }),
|
||||||
|
}).catch(() => {})
|
||||||
|
showToast('Domain updated', 'success')
|
||||||
|
setShowEditModal(false)
|
||||||
|
setEditingDomainId(null)
|
||||||
|
} catch (err: any) {
|
||||||
|
showToast('Updated locally', 'success')
|
||||||
|
setShowEditModal(false)
|
||||||
|
setEditingDomainId(null)
|
||||||
|
} finally {
|
||||||
|
setSavingEdit(false)
|
||||||
|
}
|
||||||
|
}, [editingDomainId, editForm, showToast])
|
||||||
|
|
||||||
|
// Open edit modal for portfolio domain
|
||||||
|
const openEditModal = useCallback((domain: typeof domains[0]) => {
|
||||||
|
setEditingDomainId(domain.id)
|
||||||
|
setEditForm({
|
||||||
|
registrar: domain.registrar || '',
|
||||||
|
notes: '',
|
||||||
|
})
|
||||||
|
setShowEditModal(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TerminalLayout hideHeaderSearch={true}>
|
<TerminalLayout hideHeaderSearch={true}>
|
||||||
@ -370,90 +497,99 @@ export default function WatchlistPage() {
|
|||||||
|
|
||||||
{/* Header Section */}
|
{/* Header Section */}
|
||||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-end gap-6 border-b border-white/5 pb-8">
|
<div className="flex flex-col md:flex-row justify-between items-start md:items-end gap-6 border-b border-white/5 pb-8">
|
||||||
<div className="space-y-2">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="h-8 w-1 bg-emerald-500 rounded-full shadow-[0_0_10px_rgba(16,185,129,0.5)]" />
|
<div className="h-8 w-1 bg-emerald-500 rounded-full shadow-[0_0_10px_rgba(16,185,129,0.5)]" />
|
||||||
<h1 className="text-3xl font-bold tracking-tight text-white">Watchlist</h1>
|
<h1 className="text-3xl font-bold tracking-tight text-white">Watchlist</h1>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-zinc-400 max-w-lg">
|
|
||||||
Monitor external domains you care about and manage your own portfolio in one place.
|
{/* Main Tab Switcher (Watching | My Portfolio) */}
|
||||||
</p>
|
<div className="flex items-center gap-1 bg-zinc-900/60 border border-white/10 p-1 rounded-lg w-fit">
|
||||||
|
<button
|
||||||
|
onClick={() => setMainTab('watching')}
|
||||||
|
className={clsx(
|
||||||
|
"px-4 py-2 rounded-md text-sm font-medium transition-all flex items-center gap-2",
|
||||||
|
mainTab === 'watching'
|
||||||
|
? "bg-emerald-500/20 text-emerald-400 border border-emerald-500/30"
|
||||||
|
: "text-zinc-400 hover:text-white hover:bg-white/5"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
Watching
|
||||||
|
{stats.total > 0 && (
|
||||||
|
<span className="px-1.5 py-0.5 rounded text-[10px] font-bold bg-white/10">{stats.total}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setMainTab('portfolio')}
|
||||||
|
className={clsx(
|
||||||
|
"px-4 py-2 rounded-md text-sm font-medium transition-all flex items-center gap-2",
|
||||||
|
mainTab === 'portfolio'
|
||||||
|
? "bg-emerald-500/20 text-emerald-400 border border-emerald-500/30"
|
||||||
|
: "text-zinc-400 hover:text-white hover:bg-white/5"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Shield className="w-4 h-4" />
|
||||||
|
My Portfolio
|
||||||
|
{portfolioDomains.length > 0 && (
|
||||||
|
<span className="px-1.5 py-0.5 rounded text-[10px] font-bold bg-white/10">{portfolioDomains.length}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs + Quick Stats */}
|
{/* Quick Stats Pills */}
|
||||||
<div className="flex flex-col items-end gap-3">
|
<div className="flex gap-2">
|
||||||
{/* View Tabs: Watching vs My Portfolio */}
|
{mainTab === 'watching' && stats.available > 0 && (
|
||||||
<div className="flex items-center gap-1 bg-white/5 p-1 rounded-lg">
|
<div className="px-3 py-1.5 rounded-full bg-emerald-500/10 border border-emerald-500/20 flex items-center gap-2 text-xs font-medium text-emerald-400 animate-pulse">
|
||||||
<Link
|
<Sparkles className="w-3.5 h-3.5" />
|
||||||
href="/terminal/watchlist"
|
{stats.available} Available!
|
||||||
className={clsx(
|
|
||||||
"px-3 py-1.5 rounded-md text-xs font-medium transition-all",
|
|
||||||
"flex items-center gap-2",
|
|
||||||
"bg-zinc-800 text-white shadow-sm"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Eye className="w-3.5 h-3.5" />
|
|
||||||
Watching
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/terminal/portfolio"
|
|
||||||
className={clsx(
|
|
||||||
"px-3 py-1.5 rounded-md text-xs font-medium transition-all flex items-center gap-2 text-zinc-400 hover:text-zinc-200 hover:bg-white/5"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Shield className="w-3.5 h-3.5" />
|
|
||||||
My Portfolio
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Quick Stats Pills */}
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{stats.available > 0 && (
|
|
||||||
<div className="px-3 py-1.5 rounded-full bg-emerald-500/10 border border-emerald-500/20 flex items-center gap-2 text-xs font-medium text-emerald-400 animate-pulse">
|
|
||||||
<Sparkles className="w-3.5 h-3.5" />
|
|
||||||
{stats.available} Available!
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="px-3 py-1.5 rounded-full bg-white/5 border border-white/10 flex items-center gap-2 text-xs font-medium text-zinc-300">
|
|
||||||
<Activity className="w-3.5 h-3.5 text-blue-400" />
|
|
||||||
Auto-Monitoring
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="px-3 py-1.5 rounded-full bg-white/5 border border-white/10 flex items-center gap-2 text-xs font-medium text-zinc-300">
|
||||||
|
<Activity className="w-3.5 h-3.5 text-blue-400" />
|
||||||
|
{mainTab === 'watching' ? 'Auto-Monitoring' : 'Verified Domains'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Metric Grid */}
|
{/* ══════════════════════════════════════════════════════════════════ */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
{/* WATCHING TAB CONTENT */}
|
||||||
<StatCard
|
{/* ══════════════════════════════════════════════════════════════════ */}
|
||||||
label="Total Tracked"
|
{mainTab === 'watching' && (
|
||||||
value={stats.total}
|
<>
|
||||||
subValue={stats.limit === -1 ? '/ ∞' : `/ ${stats.limit}`}
|
{/* Metric Grid */}
|
||||||
icon={Eye}
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
highlight={stats.available > 0}
|
<StatCard
|
||||||
trend={stats.available > 0 ? 'up' : 'active'}
|
label="Total Tracked"
|
||||||
/>
|
value={stats.total}
|
||||||
<StatCard
|
subValue={stats.limit === -1 ? '/ ∞' : `/ ${stats.limit}`}
|
||||||
label="Available Now"
|
icon={Eye}
|
||||||
value={stats.available}
|
highlight={stats.available > 0}
|
||||||
subValue="Ready to buy"
|
trend={stats.available > 0 ? 'up' : 'active'}
|
||||||
icon={Sparkles}
|
/>
|
||||||
trend={stats.available > 0 ? 'up' : 'neutral'}
|
<StatCard
|
||||||
/>
|
label="Available Now"
|
||||||
<StatCard
|
value={stats.available}
|
||||||
label="Expiring Soon"
|
subValue="Ready to buy"
|
||||||
value={stats.expiringSoon}
|
icon={Sparkles}
|
||||||
subValue="< 30 days"
|
trend={stats.available > 0 ? 'up' : 'neutral'}
|
||||||
icon={Clock}
|
/>
|
||||||
trend={stats.expiringSoon > 0 ? 'down' : 'neutral'}
|
<StatCard
|
||||||
/>
|
label="Expiring Soon"
|
||||||
<StatCard
|
value={stats.expiringSoon}
|
||||||
label="Health Issues"
|
subValue="< 30 days"
|
||||||
value={stats.critical}
|
icon={Clock}
|
||||||
subValue="Need attention"
|
trend={stats.expiringSoon > 0 ? 'down' : 'neutral'}
|
||||||
icon={AlertTriangle}
|
/>
|
||||||
trend={stats.critical > 0 ? 'down' : 'neutral'}
|
<StatCard
|
||||||
/>
|
label="Health Issues"
|
||||||
</div>
|
value={stats.critical}
|
||||||
|
subValue="Need attention"
|
||||||
|
icon={AlertTriangle}
|
||||||
|
trend={stats.critical > 0 ? 'down' : 'neutral'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Control Bar */}
|
{/* Control Bar */}
|
||||||
<div className="sticky top-4 z-30 bg-black/80 backdrop-blur-md border border-white/10 rounded-xl p-2 flex flex-col md:flex-row gap-4 items-center justify-between shadow-2xl">
|
<div className="sticky top-4 z-30 bg-black/80 backdrop-blur-md border border-white/10 rounded-xl p-2 flex flex-col md:flex-row gap-4 items-center justify-between shadow-2xl">
|
||||||
@ -772,6 +908,383 @@ export default function WatchlistPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ══════════════════════════════════════════════════════════════════ */}
|
||||||
|
{/* MY PORTFOLIO TAB CONTENT */}
|
||||||
|
{/* ══════════════════════════════════════════════════════════════════ */}
|
||||||
|
{mainTab === 'portfolio' && (
|
||||||
|
<>
|
||||||
|
{/* Portfolio Metric Grid - uses same data as Watching */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<StatCard
|
||||||
|
label="My Domains"
|
||||||
|
value={portfolioDomains.length}
|
||||||
|
subValue="Owned"
|
||||||
|
icon={Shield}
|
||||||
|
highlight={true}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Online"
|
||||||
|
value={Object.values(healthReports).filter(h => h.status === 'healthy').length}
|
||||||
|
subValue="Healthy"
|
||||||
|
icon={CheckCircle2}
|
||||||
|
trend="up"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Issues"
|
||||||
|
value={Object.values(healthReports).filter(h => h.status === 'critical' || h.status === 'weakening').length}
|
||||||
|
subValue="Need attention"
|
||||||
|
icon={AlertTriangle}
|
||||||
|
trend={Object.values(healthReports).filter(h => h.status === 'critical' || h.status === 'weakening').length > 0 ? 'down' : 'neutral'}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Expiring Soon"
|
||||||
|
value={portfolioDomains.filter(d => {
|
||||||
|
const days = getDaysUntilExpiry(d.expiration_date)
|
||||||
|
return days !== null && days <= 30 && days > 0
|
||||||
|
}).length}
|
||||||
|
subValue="< 30 days"
|
||||||
|
icon={Calendar}
|
||||||
|
trend={portfolioDomains.filter(d => {
|
||||||
|
const days = getDaysUntilExpiry(d.expiration_date)
|
||||||
|
return days !== null && days <= 30 && days > 0
|
||||||
|
}).length > 0 ? 'down' : 'neutral'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Control Bar - Add Domain */}
|
||||||
|
<div className="sticky top-4 z-30 bg-black/80 backdrop-blur-md border border-white/10 rounded-xl p-2 flex flex-col md:flex-row gap-4 items-center justify-between shadow-2xl">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-zinc-400">
|
||||||
|
<Shield className="w-4 h-4 text-emerald-400" />
|
||||||
|
<span>Add domains you own to track expiry & health</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add Domain Input */}
|
||||||
|
<form onSubmit={handleAddPortfolioDomain} className="flex-1 max-w-md w-full relative group">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newPortfolioDomain}
|
||||||
|
onChange={(e) => setNewPortfolioDomain(e.target.value)}
|
||||||
|
placeholder="Add your domain (e.g. mydomain.com)..."
|
||||||
|
className="w-full bg-black/50 border border-white/10 rounded-lg pl-10 pr-12 py-2 text-sm text-white placeholder:text-zinc-600 focus:outline-none focus:border-emerald-500/50 focus:ring-1 focus:ring-emerald-500/50 transition-all"
|
||||||
|
/>
|
||||||
|
<Plus className="absolute left-3 top-2.5 w-4 h-4 text-zinc-500 group-focus-within:text-emerald-500 transition-colors" />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={addingPortfolio || !newPortfolioDomain.trim()}
|
||||||
|
className="absolute right-2 top-1.5 p-1 hover:bg-emerald-500/20 rounded text-zinc-500 hover:text-emerald-400 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{addingPortfolio ? <Loader2 className="w-4 h-4 animate-spin" /> : <ArrowUpRight className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Portfolio Data Grid - uses same domains/health as Watching */}
|
||||||
|
<div className="bg-zinc-900/40 border border-white/5 rounded-xl overflow-hidden backdrop-blur-sm">
|
||||||
|
{portfolioDomains.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||||
|
<div className="w-16 h-16 rounded-full bg-white/5 flex items-center justify-center mb-4">
|
||||||
|
<Shield className="w-8 h-8 text-zinc-600" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-medium text-white mb-1">No domains in portfolio</h3>
|
||||||
|
<p className="text-zinc-500 text-sm max-w-xs mx-auto mb-6">
|
||||||
|
Add domains you own to track renewals, health status, and changes.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => document.querySelector<HTMLInputElement>('input[placeholder*="mydomain"]')?.focus()}
|
||||||
|
className="text-emerald-400 text-sm hover:text-emerald-300 transition-colors flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" /> Add your first domain
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Desktop Table - IDENTICAL to Watching tab */}
|
||||||
|
<div className="hidden md:block overflow-x-auto">
|
||||||
|
<div className="min-w-[900px]">
|
||||||
|
{/* Table Header */}
|
||||||
|
<div className="grid grid-cols-12 gap-4 px-6 py-3 bg-white/[0.02] border-b border-white/5 text-[11px] font-semibold text-zinc-500 uppercase tracking-wider">
|
||||||
|
<div className="col-span-3">Domain</div>
|
||||||
|
<div className="col-span-2 text-center">Health</div>
|
||||||
|
<div className="col-span-2 text-center">Expiry</div>
|
||||||
|
<div className="col-span-2 text-center">Last Update</div>
|
||||||
|
<div className="col-span-1 text-center">Alerts</div>
|
||||||
|
<div className="col-span-2 text-right">Actions</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table Rows - uses same data/logic as Watching */}
|
||||||
|
<div className="divide-y divide-white/5">
|
||||||
|
{portfolioDomains.map((domain) => {
|
||||||
|
const expiryDays = getDaysUntilExpiry(domain.expiration_date)
|
||||||
|
const isExpiringSoon = expiryDays !== null && expiryDays <= 30 && expiryDays > 0
|
||||||
|
const health = healthReports[domain.id]
|
||||||
|
const healthConfig = health ? healthStatusConfig[health.status] : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={domain.id}
|
||||||
|
className="grid grid-cols-12 gap-4 px-6 py-4 items-center hover:bg-white/[0.02] transition-colors group"
|
||||||
|
>
|
||||||
|
{/* Domain */}
|
||||||
|
<div className="col-span-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center">
|
||||||
|
<Shield className="w-4 h-4 text-emerald-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-white">{domain.name}</div>
|
||||||
|
<div className="text-[10px] text-zinc-500 mt-0.5">
|
||||||
|
{domain.registrar || 'Unknown registrar'} • Added {getTimeAgo(domain.created_at)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Health - uses SAME healthReports as Watching */}
|
||||||
|
<div className="col-span-2 flex justify-center">
|
||||||
|
<Tooltip content={healthConfig?.description || 'Click to check health'}>
|
||||||
|
<button
|
||||||
|
onClick={() => handleHealthCheck(domain.id)}
|
||||||
|
disabled={loadingHealth[domain.id]}
|
||||||
|
className={clsx(
|
||||||
|
"w-8 h-8 rounded-full flex items-center justify-center border transition-all",
|
||||||
|
loadingHealth[domain.id] && "animate-pulse",
|
||||||
|
healthConfig
|
||||||
|
? healthConfig.bgColor
|
||||||
|
: "bg-zinc-800 border-zinc-700 hover:border-zinc-600"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{loadingHealth[domain.id] ? (
|
||||||
|
<Loader2 className="w-4 h-4 text-zinc-400 animate-spin" />
|
||||||
|
) : healthConfig ? (
|
||||||
|
<healthConfig.icon className={clsx("w-4 h-4", healthConfig.color)} />
|
||||||
|
) : (
|
||||||
|
<Activity className="w-4 h-4 text-zinc-500" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expiry - uses expiration_date like Watching */}
|
||||||
|
<div className="col-span-2 flex justify-center">
|
||||||
|
{domain.expiration_date ? (
|
||||||
|
<Tooltip content={`Expires: ${formatExpiryDate(domain.expiration_date)}`}>
|
||||||
|
<div className={clsx(
|
||||||
|
"flex items-center gap-1.5 px-2 py-1 rounded border text-xs font-medium",
|
||||||
|
expiryDays !== null && expiryDays <= 7
|
||||||
|
? "bg-rose-500/10 text-rose-400 border-rose-500/20"
|
||||||
|
: isExpiringSoon
|
||||||
|
? "bg-amber-500/10 text-amber-400 border-amber-500/20"
|
||||||
|
: "bg-zinc-800/50 text-zinc-400 border-zinc-700"
|
||||||
|
)}>
|
||||||
|
<Calendar className="w-3 h-3" />
|
||||||
|
{expiryDays !== null ? `${expiryDays}d` : formatExpiryDate(domain.expiration_date)}
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<span className="text-zinc-600 text-xs">Unknown</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Last Update */}
|
||||||
|
<div className="col-span-2 flex justify-center">
|
||||||
|
<span className="text-xs text-zinc-500">
|
||||||
|
{domain.last_checked ? getTimeAgo(domain.last_checked) : 'Never'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Alerts - uses SAME handleToggleNotify as Watching */}
|
||||||
|
<div className="col-span-1 flex justify-center">
|
||||||
|
<Tooltip content={domain.notify_on_available ? 'Alerts ON' : 'Alerts OFF'}>
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggleNotify(domain.id, domain.notify_on_available)}
|
||||||
|
disabled={togglingNotifyId === domain.id}
|
||||||
|
className={clsx(
|
||||||
|
"p-1.5 rounded border transition-all",
|
||||||
|
domain.notify_on_available
|
||||||
|
? "bg-emerald-500/10 border-emerald-500/20 text-emerald-400"
|
||||||
|
: "bg-zinc-800/50 border-zinc-700 text-zinc-500 hover:text-zinc-400"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{togglingNotifyId === domain.id
|
||||||
|
? <Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
: domain.notify_on_available
|
||||||
|
? <Bell className="w-3.5 h-3.5" />
|
||||||
|
: <BellOff className="w-3.5 h-3.5" />
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions - uses SAME handleRefresh/handleDelete as Watching */}
|
||||||
|
<div className="col-span-2 flex justify-end gap-2">
|
||||||
|
<Tooltip content="Refresh">
|
||||||
|
<button
|
||||||
|
onClick={() => handleRefresh(domain.id)}
|
||||||
|
disabled={refreshingId === domain.id}
|
||||||
|
className="p-1.5 rounded border bg-zinc-800/50 border-zinc-700 text-zinc-400 hover:text-white hover:border-zinc-600 transition-all disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{refreshingId === domain.id
|
||||||
|
? <Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
: <RefreshCw className="w-3.5 h-3.5" />
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip content="Remove">
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(domain.id, domain.name)}
|
||||||
|
disabled={deletingId === domain.id}
|
||||||
|
className="p-1.5 rounded border bg-zinc-800/50 border-zinc-700 text-zinc-400 hover:text-rose-400 hover:border-rose-500/30 transition-all disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{deletingId === domain.id
|
||||||
|
? <Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
: <Trash2 className="w-3.5 h-3.5" />
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
<Link
|
||||||
|
href="/terminal/listing"
|
||||||
|
className="px-3 py-1.5 bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 rounded-lg text-[11px] font-medium hover:bg-emerald-500/20 transition-colors"
|
||||||
|
>
|
||||||
|
Sell
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Cards - IDENTICAL logic to Watching */}
|
||||||
|
<div className="md:hidden divide-y divide-white/5">
|
||||||
|
{portfolioDomains.map((domain) => {
|
||||||
|
const expiryDays = getDaysUntilExpiry(domain.expiration_date)
|
||||||
|
const isExpiringSoon = expiryDays !== null && expiryDays <= 30 && expiryDays > 0
|
||||||
|
const health = healthReports[domain.id]
|
||||||
|
const healthConfig = health ? healthStatusConfig[health.status] : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={domain.id} className="p-4 space-y-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center">
|
||||||
|
<Shield className="w-5 h-5 text-emerald-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-bold text-white">{domain.name}</div>
|
||||||
|
<div className="text-xs text-zinc-500">{domain.registrar || 'Unknown registrar'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Grid - uses same data as Watching */}
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div className="bg-white/[0.02] rounded-lg p-2 text-center">
|
||||||
|
<div className="text-[10px] text-zinc-500 uppercase mb-1">Health</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleHealthCheck(domain.id)}
|
||||||
|
className={clsx(
|
||||||
|
"w-8 h-8 rounded-full flex items-center justify-center border mx-auto",
|
||||||
|
healthConfig ? healthConfig.bgColor : "bg-zinc-800 border-zinc-700"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{loadingHealth[domain.id] ? (
|
||||||
|
<Loader2 className="w-4 h-4 text-zinc-400 animate-spin" />
|
||||||
|
) : healthConfig ? (
|
||||||
|
<healthConfig.icon className={clsx("w-4 h-4", healthConfig.color)} />
|
||||||
|
) : (
|
||||||
|
<Activity className="w-4 h-4 text-zinc-500" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white/[0.02] rounded-lg p-2 text-center">
|
||||||
|
<div className="text-[10px] text-zinc-500 uppercase mb-1">Expiry</div>
|
||||||
|
<div className={clsx(
|
||||||
|
"text-sm font-bold",
|
||||||
|
isExpiringSoon ? "text-amber-400" : "text-white"
|
||||||
|
)}>
|
||||||
|
{expiryDays !== null ? `${expiryDays}d` : '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white/[0.02] rounded-lg p-2 text-center">
|
||||||
|
<div className="text-[10px] text-zinc-500 uppercase mb-1">Checked</div>
|
||||||
|
<div className="text-sm text-zinc-400">
|
||||||
|
{domain.last_checked ? getTimeAgo(domain.last_checked) : '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions - uses SAME functions as Watching */}
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggleNotify(domain.id, domain.notify_on_available)}
|
||||||
|
disabled={togglingNotifyId === domain.id}
|
||||||
|
className={clsx(
|
||||||
|
"p-2 rounded border text-xs flex items-center gap-1.5",
|
||||||
|
domain.notify_on_available
|
||||||
|
? "bg-emerald-500/10 border-emerald-500/20 text-emerald-400"
|
||||||
|
: "bg-zinc-800/50 border-zinc-700 text-zinc-500"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{togglingNotifyId === domain.id
|
||||||
|
? <Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
: <Bell className="w-3.5 h-3.5" />
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRefresh(domain.id)}
|
||||||
|
disabled={refreshingId === domain.id}
|
||||||
|
className="p-2 rounded border bg-zinc-800/50 border-zinc-700 text-zinc-400"
|
||||||
|
>
|
||||||
|
{refreshingId === domain.id
|
||||||
|
? <Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
: <RefreshCw className="w-3.5 h-3.5" />
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(domain.id, domain.name)}
|
||||||
|
disabled={deletingId === domain.id}
|
||||||
|
className="p-2 rounded border bg-zinc-800/50 border-zinc-700 text-zinc-400 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{deletingId === domain.id
|
||||||
|
? <Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
: <Trash2 className="w-3.5 h-3.5" />
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/terminal/listing"
|
||||||
|
className="px-4 py-2 bg-emerald-500 text-white rounded-lg text-xs font-bold uppercase tracking-wider"
|
||||||
|
>
|
||||||
|
Sell
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Portfolio Footer */}
|
||||||
|
<div className="flex items-center justify-center gap-4 text-[10px] text-zinc-700 py-3">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
Same monitoring as Watching tab
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Bell className="w-3 h-3" />
|
||||||
|
Get alerts for changes
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Health Report Modal */}
|
{/* Health Report Modal */}
|
||||||
|
|||||||
@ -21,7 +21,6 @@ import {
|
|||||||
X,
|
X,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Tag,
|
Tag,
|
||||||
Briefcase,
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
|||||||
@ -52,7 +52,7 @@ export function TerminalLayout({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authCheckedRef.current) {
|
if (!authCheckedRef.current) {
|
||||||
authCheckedRef.current = true
|
authCheckedRef.current = true
|
||||||
checkAuth()
|
checkAuth()
|
||||||
}
|
}
|
||||||
}, [checkAuth])
|
}, [checkAuth])
|
||||||
|
|
||||||
@ -131,104 +131,104 @@ export function TerminalLayout({
|
|||||||
<div className="flex items-center gap-2 sm:gap-3 shrink-0 ml-4">
|
<div className="flex items-center gap-2 sm:gap-3 shrink-0 ml-4">
|
||||||
{!hideHeaderSearch && (
|
{!hideHeaderSearch && (
|
||||||
<>
|
<>
|
||||||
{/* Quick Search */}
|
{/* Quick Search */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setSearchOpen(true)}
|
onClick={() => setSearchOpen(true)}
|
||||||
className="hidden md:flex items-center gap-2 h-9 px-3 bg-foreground/5 hover:bg-foreground/8
|
className="hidden md:flex items-center gap-2 h-9 px-3 bg-foreground/5 hover:bg-foreground/8
|
||||||
border border-border/40 rounded-lg text-sm text-foreground-muted
|
border border-border/40 rounded-lg text-sm text-foreground-muted
|
||||||
hover:text-foreground transition-all duration-200 hover:border-border/60"
|
hover:text-foreground transition-all duration-200 hover:border-border/60"
|
||||||
>
|
>
|
||||||
<Search className="w-4 h-4" />
|
<Search className="w-4 h-4" />
|
||||||
<span className="hidden lg:inline">Search</span>
|
<span className="hidden lg:inline">Search</span>
|
||||||
<kbd className="hidden xl:inline-flex items-center h-5 px-1.5 bg-background border border-border/60
|
<kbd className="hidden xl:inline-flex items-center h-5 px-1.5 bg-background border border-border/60
|
||||||
rounded text-[10px] text-foreground-subtle font-mono">⌘K</kbd>
|
rounded text-[10px] text-foreground-subtle font-mono">⌘K</kbd>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Mobile Search */}
|
{/* Mobile Search */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setSearchOpen(true)}
|
onClick={() => setSearchOpen(true)}
|
||||||
className="md:hidden flex items-center justify-center w-9 h-9 text-foreground-muted
|
className="md:hidden flex items-center justify-center w-9 h-9 text-foreground-muted
|
||||||
hover:text-foreground hover:bg-foreground/5 rounded-lg transition-colors"
|
hover:text-foreground hover:bg-foreground/5 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
<Search className="w-5 h-5" />
|
<Search className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Notifications */}
|
{/* Notifications */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
onClick={() => setNotificationsOpen(!notificationsOpen)}
|
onClick={() => setNotificationsOpen(!notificationsOpen)}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"relative flex items-center justify-center w-9 h-9 rounded-lg transition-all duration-200",
|
"relative flex items-center justify-center w-9 h-9 rounded-lg transition-all duration-200",
|
||||||
notificationsOpen
|
notificationsOpen
|
||||||
? "bg-foreground/10 text-foreground"
|
? "bg-foreground/10 text-foreground"
|
||||||
: "text-foreground-muted hover:text-foreground hover:bg-foreground/5"
|
: "text-foreground-muted hover:text-foreground hover:bg-foreground/5"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Bell className="w-5 h-5" />
|
<Bell className="w-5 h-5" />
|
||||||
{hasNotifications && (
|
{hasNotifications && (
|
||||||
<span className="absolute top-1.5 right-1.5 w-2 h-2 bg-accent rounded-full">
|
<span className="absolute top-1.5 right-1.5 w-2 h-2 bg-accent rounded-full">
|
||||||
<span className="absolute inset-0 rounded-full bg-accent animate-ping opacity-50" />
|
<span className="absolute inset-0 rounded-full bg-accent animate-ping opacity-50" />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Notifications Dropdown */}
|
{/* Notifications Dropdown */}
|
||||||
{notificationsOpen && (
|
{notificationsOpen && (
|
||||||
<div className="absolute right-0 top-full mt-2 w-80 bg-zinc-900 border border-zinc-800
|
<div className="absolute right-0 top-full mt-2 w-80 bg-zinc-900 border border-zinc-800
|
||||||
rounded-xl shadow-2xl overflow-hidden z-50">
|
rounded-xl shadow-2xl overflow-hidden z-50">
|
||||||
<div className="p-4 border-b border-zinc-800 flex items-center justify-between">
|
<div className="p-4 border-b border-zinc-800 flex items-center justify-between">
|
||||||
<h3 className="text-sm font-medium text-white">Notifications</h3>
|
<h3 className="text-sm font-medium text-white">Notifications</h3>
|
||||||
<button
|
<button
|
||||||
onClick={() => setNotificationsOpen(false)}
|
onClick={() => setNotificationsOpen(false)}
|
||||||
className="text-zinc-500 hover:text-white"
|
className="text-zinc-500 hover:text-white"
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-80 overflow-y-auto">
|
<div className="max-h-80 overflow-y-auto">
|
||||||
{availableDomains.length > 0 ? (
|
{availableDomains.length > 0 ? (
|
||||||
<div className="p-2">
|
<div className="p-2">
|
||||||
{availableDomains.slice(0, 5).map((domain) => (
|
{availableDomains.slice(0, 5).map((domain) => (
|
||||||
<Link
|
<Link
|
||||||
key={domain.id}
|
key={domain.id}
|
||||||
href="/terminal/watchlist"
|
href="/terminal/watchlist"
|
||||||
onClick={() => setNotificationsOpen(false)}
|
onClick={() => setNotificationsOpen(false)}
|
||||||
className="flex items-start gap-3 p-3 hover:bg-white/5 rounded-lg transition-colors"
|
className="flex items-start gap-3 p-3 hover:bg-white/5 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
<div className="w-8 h-8 bg-emerald-500/10 rounded-lg flex items-center justify-center shrink-0">
|
<div className="w-8 h-8 bg-emerald-500/10 rounded-lg flex items-center justify-center shrink-0">
|
||||||
<span className="w-2 h-2 bg-emerald-500 rounded-full animate-pulse" />
|
<span className="w-2 h-2 bg-emerald-500 rounded-full animate-pulse" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium text-white truncate">{domain.name}</p>
|
<p className="text-sm font-medium text-white truncate">{domain.name}</p>
|
||||||
<p className="text-xs text-emerald-400">Available now!</p>
|
<p className="text-xs text-emerald-400">Available now!</p>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-8 text-center">
|
<div className="p-8 text-center">
|
||||||
<Bell className="w-8 h-8 text-zinc-700 mx-auto mb-3" />
|
<Bell className="w-8 h-8 text-zinc-700 mx-auto mb-3" />
|
||||||
<p className="text-sm text-zinc-500">No notifications</p>
|
<p className="text-sm text-zinc-500">No notifications</p>
|
||||||
<p className="text-xs text-zinc-600 mt-1">
|
<p className="text-xs text-zinc-600 mt-1">
|
||||||
We'll notify you when domains become available
|
We'll notify you when domains become available
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Keyboard Shortcuts Hint */}
|
{/* Keyboard Shortcuts Hint */}
|
||||||
<button
|
<button
|
||||||
onClick={() => {}}
|
onClick={() => {}}
|
||||||
className="hidden sm:flex items-center gap-1.5 px-2 py-1.5 text-xs text-foreground-subtle hover:text-foreground
|
className="hidden sm:flex items-center gap-1.5 px-2 py-1.5 text-xs text-foreground-subtle hover:text-foreground
|
||||||
bg-foreground/5 rounded-lg border border-border/40 hover:border-border/60 transition-all"
|
bg-foreground/5 rounded-lg border border-border/40 hover:border-border/60 transition-all"
|
||||||
title="Keyboard shortcuts (?)"
|
title="Keyboard shortcuts (?)"
|
||||||
>
|
>
|
||||||
<Command className="w-3.5 h-3.5" />
|
<Command className="w-3.5 h-3.5" />
|
||||||
<span>?</span>
|
<span>?</span>
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user