yves.gugger 2f2a5218df fix: Remove serif font (font-display) from Command Center
CHANGED font-display → font-semibold:

Command Center Pages:
- alerts/page.tsx: Matches count, notifications, modal title
- marketplace/page.tsx: Listing price
- portfolio/page.tsx: Valuation price
- listings/page.tsx: Price display, modal titles (2)
- seo/page.tsx: Feature title, SEO score, backlinks count

Components (used in Command Center):
- CommandCenterLayout.tsx: Page title
- AdminLayout.tsx: Page title
- PremiumTable.tsx: StatCard value

KEPT serif font (as requested):
- Sidebar.tsx: POUNCE logo only
- Header.tsx: POUNCE logo (public pages)
- Footer.tsx: POUNCE logo

Now the Command Center uses only sans-serif fonts,
with the exception of the POUNCE logo in the navigation.
2025-12-10 16:10:37 +01:00

599 lines
25 KiB
TypeScript

'use client'
import { useEffect, useState } from 'react'
import { useStore } from '@/lib/store'
import { api } from '@/lib/api'
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
import { PageContainer, StatCard, Badge } from '@/components/PremiumTable'
import {
Plus,
Bell,
Target,
Zap,
Loader2,
Trash2,
Edit2,
CheckCircle,
AlertCircle,
X,
Play,
Pause,
Mail,
Smartphone,
Settings,
TestTube,
ChevronDown,
ChevronUp,
} from 'lucide-react'
import clsx from 'clsx'
interface SniperAlert {
id: number
name: string
description: string | null
tlds: string | null
keywords: string | null
exclude_keywords: string | null
max_length: number | null
min_length: number | null
max_price: number | null
min_price: number | null
max_bids: number | null
ending_within_hours: number | null
platforms: string | null
no_numbers: boolean
no_hyphens: boolean
exclude_chars: string | null
notify_email: boolean
notify_sms: boolean
is_active: boolean
matches_count: number
notifications_sent: number
last_matched_at: string | null
created_at: string
}
interface TestResult {
alert_name: string
auctions_checked: number
matches_found: number
matches: Array<{
domain: string
platform: string
current_bid: number
num_bids: number
end_time: string
}>
message: string
}
export default function SniperAlertsPage() {
const { subscription } = useStore()
const [alerts, setAlerts] = useState<SniperAlert[]>([])
const [loading, setLoading] = useState(true)
const [showCreateModal, setShowCreateModal] = useState(false)
const [creating, setCreating] = useState(false)
const [testing, setTesting] = useState<number | null>(null)
const [testResult, setTestResult] = useState<TestResult | null>(null)
const [expandedAlert, setExpandedAlert] = useState<number | null>(null)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
// Create form
const [newAlert, setNewAlert] = useState({
name: '',
description: '',
tlds: '',
keywords: '',
exclude_keywords: '',
max_length: '',
min_length: '',
max_price: '',
min_price: '',
max_bids: '',
no_numbers: false,
no_hyphens: false,
exclude_chars: '',
notify_email: true,
})
useEffect(() => {
loadAlerts()
}, [])
const loadAlerts = async () => {
setLoading(true)
try {
const data = await api.request<SniperAlert[]>('/sniper-alerts')
setAlerts(data)
} catch (err: any) {
setError(err.message)
} finally {
setLoading(false)
}
}
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault()
setCreating(true)
setError(null)
try {
await api.request('/sniper-alerts', {
method: 'POST',
body: JSON.stringify({
name: newAlert.name,
description: newAlert.description || null,
tlds: newAlert.tlds || null,
keywords: newAlert.keywords || null,
exclude_keywords: newAlert.exclude_keywords || null,
max_length: newAlert.max_length ? parseInt(newAlert.max_length) : null,
min_length: newAlert.min_length ? parseInt(newAlert.min_length) : null,
max_price: newAlert.max_price ? parseFloat(newAlert.max_price) : null,
min_price: newAlert.min_price ? parseFloat(newAlert.min_price) : null,
max_bids: newAlert.max_bids ? parseInt(newAlert.max_bids) : null,
no_numbers: newAlert.no_numbers,
no_hyphens: newAlert.no_hyphens,
exclude_chars: newAlert.exclude_chars || null,
notify_email: newAlert.notify_email,
}),
})
setSuccess('Sniper Alert created!')
setShowCreateModal(false)
setNewAlert({
name: '', description: '', tlds: '', keywords: '', exclude_keywords: '',
max_length: '', min_length: '', max_price: '', min_price: '', max_bids: '',
no_numbers: false, no_hyphens: false, exclude_chars: '', notify_email: true,
})
loadAlerts()
} catch (err: any) {
setError(err.message)
} finally {
setCreating(false)
}
}
const handleToggle = async (alert: SniperAlert) => {
try {
await api.request(`/sniper-alerts/${alert.id}`, {
method: 'PUT',
body: JSON.stringify({ is_active: !alert.is_active }),
})
loadAlerts()
} catch (err: any) {
setError(err.message)
}
}
const handleDelete = async (alert: SniperAlert) => {
if (!confirm(`Delete alert "${alert.name}"?`)) return
try {
await api.request(`/sniper-alerts/${alert.id}`, { method: 'DELETE' })
setSuccess('Alert deleted')
loadAlerts()
} catch (err: any) {
setError(err.message)
}
}
const handleTest = async (alert: SniperAlert) => {
setTesting(alert.id)
setTestResult(null)
try {
const result = await api.request<TestResult>(`/sniper-alerts/${alert.id}/test`, {
method: 'POST',
})
setTestResult(result)
setExpandedAlert(alert.id)
} catch (err: any) {
setError(err.message)
} finally {
setTesting(null)
}
}
const tier = subscription?.tier || 'scout'
const limits = { scout: 2, trader: 10, tycoon: 50 }
const maxAlerts = limits[tier as keyof typeof limits] || 2
return (
<CommandCenterLayout
title="Sniper Alerts"
subtitle={`Hyper-personalized auction notifications (${alerts.length}/${maxAlerts})`}
actions={
<button
onClick={() => setShowCreateModal(true)}
disabled={alerts.length >= maxAlerts}
className="flex items-center gap-2 px-4 py-2 bg-accent text-background text-sm font-medium rounded-lg
hover:bg-accent-hover transition-all disabled:opacity-50"
>
<Plus className="w-4 h-4" />
New Alert
</button>
}
>
<PageContainer>
{/* Messages */}
{error && (
<div className="p-4 bg-red-500/10 border border-red-500/20 rounded-xl flex items-center gap-3">
<AlertCircle className="w-5 h-5 text-red-400" />
<p className="text-sm text-red-400 flex-1">{error}</p>
<button onClick={() => setError(null)}><X className="w-4 h-4 text-red-400" /></button>
</div>
)}
{success && (
<div className="p-4 bg-accent/10 border border-accent/20 rounded-xl flex items-center gap-3">
<CheckCircle className="w-5 h-5 text-accent" />
<p className="text-sm text-accent flex-1">{success}</p>
<button onClick={() => setSuccess(null)}><X className="w-4 h-4 text-accent" /></button>
</div>
)}
{/* Stats */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard title="Active Alerts" value={alerts.filter(a => a.is_active).length} icon={Bell} />
<StatCard title="Total Matches" value={alerts.reduce((sum, a) => sum + a.matches_count, 0)} icon={Target} />
<StatCard title="Notifications Sent" value={alerts.reduce((sum, a) => sum + a.notifications_sent, 0)} icon={Zap} />
<StatCard title="Alert Slots" value={`${alerts.length}/${maxAlerts}`} icon={Settings} />
</div>
{/* Alerts List */}
{loading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-6 h-6 text-accent animate-spin" />
</div>
) : alerts.length === 0 ? (
<div className="text-center py-16 bg-background-secondary/30 border border-border rounded-2xl">
<Target className="w-16 h-16 text-foreground-subtle mx-auto mb-6" />
<h2 className="text-xl font-medium text-foreground mb-2">No Sniper Alerts</h2>
<p className="text-foreground-muted mb-6 max-w-md mx-auto">
Create alerts to get notified when domains matching your criteria appear in auctions.
</p>
<button
onClick={() => setShowCreateModal(true)}
className="inline-flex items-center gap-2 px-6 py-3 bg-accent text-background font-medium rounded-xl hover:bg-accent-hover transition-all"
>
<Plus className="w-5 h-5" />
Create Alert
</button>
</div>
) : (
<div className="space-y-4">
{alerts.map((alert) => (
<div
key={alert.id}
className="bg-background-secondary/30 border border-border rounded-2xl overflow-hidden transition-all hover:border-border-hover"
>
{/* Header */}
<div className="p-5">
<div className="flex flex-wrap items-start gap-4">
<div className="flex-1 min-w-[200px]">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-lg font-medium text-foreground">{alert.name}</h3>
<Badge variant={alert.is_active ? 'success' : 'default'}>
{alert.is_active ? 'Active' : 'Paused'}
</Badge>
</div>
{alert.description && (
<p className="text-sm text-foreground-muted">{alert.description}</p>
)}
</div>
{/* Stats */}
<div className="flex items-center gap-6 text-sm">
<div className="text-center">
<p className="text-lg font-semibold text-foreground">{alert.matches_count}</p>
<p className="text-xs text-foreground-muted">Matches</p>
</div>
<div className="text-center">
<p className="text-lg font-semibold text-foreground">{alert.notifications_sent}</p>
<p className="text-xs text-foreground-muted">Notified</p>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2">
<button
onClick={() => handleTest(alert)}
disabled={testing === alert.id}
className="flex items-center gap-1.5 px-3 py-2 bg-foreground/5 text-foreground-muted text-sm font-medium rounded-lg hover:bg-foreground/10 transition-all"
>
{testing === alert.id ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<TestTube className="w-4 h-4" />
)}
Test
</button>
<button
onClick={() => handleToggle(alert)}
className={clsx(
"flex items-center gap-1.5 px-3 py-2 text-sm font-medium rounded-lg transition-all",
alert.is_active
? "bg-amber-500/10 text-amber-400 hover:bg-amber-500/20"
: "bg-accent/10 text-accent hover:bg-accent/20"
)}
>
{alert.is_active ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
{alert.is_active ? 'Pause' : 'Activate'}
</button>
<button
onClick={() => handleDelete(alert)}
className="p-2 text-foreground-subtle hover:text-red-400 transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
<button
onClick={() => setExpandedAlert(expandedAlert === alert.id ? null : alert.id)}
className="p-2 text-foreground-subtle hover:text-foreground transition-colors"
>
{expandedAlert === alert.id ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
</button>
</div>
</div>
{/* Filter Summary */}
<div className="mt-4 flex flex-wrap gap-2">
{alert.tlds && (
<span className="px-2 py-1 bg-foreground/5 text-foreground-muted text-xs rounded">
TLDs: {alert.tlds}
</span>
)}
{alert.max_length && (
<span className="px-2 py-1 bg-foreground/5 text-foreground-muted text-xs rounded">
Max {alert.max_length} chars
</span>
)}
{alert.max_price && (
<span className="px-2 py-1 bg-foreground/5 text-foreground-muted text-xs rounded">
Max ${alert.max_price}
</span>
)}
{alert.no_numbers && (
<span className="px-2 py-1 bg-foreground/5 text-foreground-muted text-xs rounded">
No numbers
</span>
)}
{alert.no_hyphens && (
<span className="px-2 py-1 bg-foreground/5 text-foreground-muted text-xs rounded">
No hyphens
</span>
)}
{alert.notify_email && (
<span className="px-2 py-1 bg-accent/10 text-accent text-xs rounded flex items-center gap-1">
<Mail className="w-3 h-3" /> Email
</span>
)}
</div>
</div>
{/* Test Results */}
{expandedAlert === alert.id && testResult && testResult.alert_name === alert.name && (
<div className="px-5 pb-5">
<div className="p-4 bg-background rounded-xl border border-border">
<div className="flex items-center justify-between mb-3">
<p className="text-sm font-medium text-foreground">Test Results</p>
<p className="text-xs text-foreground-muted">
Checked {testResult.auctions_checked} auctions
</p>
</div>
{testResult.matches_found === 0 ? (
<p className="text-sm text-foreground-muted">{testResult.message}</p>
) : (
<div className="space-y-2">
<p className="text-sm text-accent">
Found {testResult.matches_found} matching domains!
</p>
<div className="max-h-48 overflow-y-auto space-y-1">
{testResult.matches.map((match, idx) => (
<div key={idx} className="flex items-center justify-between text-sm py-1">
<span className="font-mono text-foreground">{match.domain}</span>
<span className="text-foreground-muted">${match.current_bid}</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
)}
</div>
))}
</div>
)}
</PageContainer>
{/* Create Modal */}
{showCreateModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-background/80 backdrop-blur-sm overflow-y-auto">
<div className="w-full max-w-lg bg-background-secondary border border-border rounded-2xl p-6 my-8">
<h2 className="text-xl font-semibold text-foreground mb-2">Create Sniper Alert</h2>
<p className="text-sm text-foreground-muted mb-6">
Get notified when domains matching your criteria appear in auctions.
</p>
<form onSubmit={handleCreate} className="space-y-4 max-h-[60vh] overflow-y-auto pr-2">
<div>
<label className="block text-sm text-foreground-muted mb-1">Alert Name *</label>
<input
type="text"
required
value={newAlert.name}
onChange={(e) => setNewAlert({ ...newAlert, name: e.target.value })}
placeholder="4-letter .com without numbers"
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
/>
</div>
<div>
<label className="block text-sm text-foreground-muted mb-1">Description</label>
<input
type="text"
value={newAlert.description}
onChange={(e) => setNewAlert({ ...newAlert, description: e.target.value })}
placeholder="Optional description"
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-foreground-muted mb-1">TLDs (comma-separated)</label>
<input
type="text"
value={newAlert.tlds}
onChange={(e) => setNewAlert({ ...newAlert, tlds: e.target.value })}
placeholder="com,io,ai"
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
/>
</div>
<div>
<label className="block text-sm text-foreground-muted mb-1">Keywords (must contain)</label>
<input
type="text"
value={newAlert.keywords}
onChange={(e) => setNewAlert({ ...newAlert, keywords: e.target.value })}
placeholder="ai,tech,crypto"
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-foreground-muted mb-1">Min Length</label>
<input
type="number"
min="1"
max="63"
value={newAlert.min_length}
onChange={(e) => setNewAlert({ ...newAlert, min_length: e.target.value })}
placeholder="3"
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
/>
</div>
<div>
<label className="block text-sm text-foreground-muted mb-1">Max Length</label>
<input
type="number"
min="1"
max="63"
value={newAlert.max_length}
onChange={(e) => setNewAlert({ ...newAlert, max_length: e.target.value })}
placeholder="6"
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-foreground-muted mb-1">Max Price ($)</label>
<input
type="number"
min="0"
value={newAlert.max_price}
onChange={(e) => setNewAlert({ ...newAlert, max_price: e.target.value })}
placeholder="500"
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
/>
</div>
<div>
<label className="block text-sm text-foreground-muted mb-1">Max Bids (low competition)</label>
<input
type="number"
min="0"
value={newAlert.max_bids}
onChange={(e) => setNewAlert({ ...newAlert, max_bids: e.target.value })}
placeholder="5"
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
/>
</div>
</div>
<div>
<label className="block text-sm text-foreground-muted mb-1">Exclude Characters</label>
<input
type="text"
value={newAlert.exclude_chars}
onChange={(e) => setNewAlert({ ...newAlert, exclude_chars: e.target.value })}
placeholder="q,x,z"
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
/>
</div>
<div className="flex flex-wrap gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={newAlert.no_numbers}
onChange={(e) => setNewAlert({ ...newAlert, no_numbers: e.target.checked })}
className="w-5 h-5 rounded border-border text-accent focus:ring-accent"
/>
<span className="text-sm text-foreground">No numbers</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={newAlert.no_hyphens}
onChange={(e) => setNewAlert({ ...newAlert, no_hyphens: e.target.checked })}
className="w-5 h-5 rounded border-border text-accent focus:ring-accent"
/>
<span className="text-sm text-foreground">No hyphens</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={newAlert.notify_email}
onChange={(e) => setNewAlert({ ...newAlert, notify_email: e.target.checked })}
className="w-5 h-5 rounded border-border text-accent focus:ring-accent"
/>
<span className="text-sm text-foreground flex items-center gap-1">
<Mail className="w-4 h-4" /> Email alerts
</span>
</label>
</div>
</form>
<div className="flex gap-3 mt-6">
<button
type="button"
onClick={() => setShowCreateModal(false)}
className="flex-1 px-4 py-3 border border-border text-foreground-muted rounded-xl hover:bg-foreground/5 transition-all"
>
Cancel
</button>
<button
onClick={handleCreate}
disabled={creating || !newAlert.name}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-accent text-background font-medium rounded-xl hover:bg-accent-hover transition-all disabled:opacity-50"
>
{creating ? <Loader2 className="w-5 h-5 animate-spin" /> : <Target className="w-5 h-5" />}
{creating ? 'Creating...' : 'Create Alert'}
</button>
</div>
</div>
</div>
)}
</CommandCenterLayout>
)
}