feat(settings): add session review to unified bot & spam tab
This commit is contained in:
@@ -1,17 +1,26 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Button, Toggle, toast, Spinner } from '@ciphera-net/ui'
|
||||
import { Button, Toggle, toast, Spinner, getDateRange } from '@ciphera-net/ui'
|
||||
import { ShieldCheck } from '@phosphor-icons/react'
|
||||
import { useSite, useBotFilterStats } from '@/lib/swr/dashboard'
|
||||
import { useSite, useBotFilterStats, useSessions } from '@/lib/swr/dashboard'
|
||||
import { updateSite } from '@/lib/api/sites'
|
||||
import { botFilterSessions, botUnfilterSessions } from '@/lib/api/bot-filter'
|
||||
|
||||
export default function SiteBotSpamTab({ siteId }: { siteId: string }) {
|
||||
const { data: site, mutate } = useSite(siteId)
|
||||
const { data: botStats } = useBotFilterStats(siteId)
|
||||
const { data: botStats, mutate: mutateBotStats } = useBotFilterStats(siteId)
|
||||
const [filterBots, setFilterBots] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const [botView, setBotView] = useState<'review' | 'blocked'>('review')
|
||||
const [suspiciousOnly, setSuspiciousOnly] = useState(true)
|
||||
const [selectedSessions, setSelectedSessions] = useState<Set<string>>(new Set())
|
||||
const [botDateRange] = useState(() => getDateRange(7))
|
||||
|
||||
const { data: sessionsData, mutate: mutateSessions } = useSessions(siteId, botDateRange.start, botDateRange.end, botView === 'review' ? suspiciousOnly : false)
|
||||
const sessions = sessionsData?.sessions
|
||||
|
||||
useEffect(() => {
|
||||
if (site) setFilterBots(site.filter_bots ?? false)
|
||||
}, [site])
|
||||
@@ -29,6 +38,30 @@ export default function SiteBotSpamTab({ siteId }: { siteId: string }) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleBotFilter = async (sessionIds: string[]) => {
|
||||
try {
|
||||
await botFilterSessions(siteId, sessionIds)
|
||||
toast.success(`${sessionIds.length} session(s) flagged as bot`)
|
||||
setSelectedSessions(new Set())
|
||||
mutateSessions()
|
||||
mutateBotStats()
|
||||
} catch {
|
||||
toast.error('Failed to flag sessions')
|
||||
}
|
||||
}
|
||||
|
||||
const handleBotUnfilter = async (sessionIds: string[]) => {
|
||||
try {
|
||||
await botUnfilterSessions(siteId, sessionIds)
|
||||
toast.success(`${sessionIds.length} session(s) unblocked`)
|
||||
setSelectedSessions(new Set())
|
||||
mutateSessions()
|
||||
mutateBotStats()
|
||||
} catch {
|
||||
toast.error('Failed to unblock sessions')
|
||||
}
|
||||
}
|
||||
|
||||
if (!site) return <div className="flex items-center justify-center py-12"><Spinner className="w-6 h-6 text-neutral-500" /></div>
|
||||
|
||||
return (
|
||||
@@ -68,12 +101,111 @@ export default function SiteBotSpamTab({ siteId }: { siteId: string }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-sm text-neutral-400">
|
||||
For detailed session review and manual blocking, use the full{' '}
|
||||
<a href={`/sites/${siteId}/settings?tab=bot-spam`} className="text-brand-orange hover:underline">
|
||||
site settings page
|
||||
</a>.
|
||||
{/* Session Review */}
|
||||
<div className="space-y-3 pt-6 border-t border-neutral-800">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium text-neutral-300">Session Review</h4>
|
||||
{/* Review/Blocked toggle */}
|
||||
<div className="flex items-center rounded-lg border border-neutral-700 overflow-hidden text-sm">
|
||||
<button
|
||||
onClick={() => { setBotView('review'); setSelectedSessions(new Set()) }}
|
||||
className={`px-3 py-1.5 text-xs font-medium transition-colors ${botView === 'review' ? 'bg-neutral-700 text-white' : 'text-neutral-400 hover:text-white'}`}
|
||||
>
|
||||
Review
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setBotView('blocked'); setSelectedSessions(new Set()) }}
|
||||
className={`px-3 py-1.5 text-xs font-medium transition-colors ${botView === 'blocked' ? 'bg-neutral-700 text-white' : 'text-neutral-400 hover:text-white'}`}
|
||||
>
|
||||
Blocked
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Suspicious only filter (review mode only) */}
|
||||
{botView === 'review' && (
|
||||
<label className="flex items-center gap-2 text-sm text-neutral-400">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={suspiciousOnly}
|
||||
onChange={e => setSuspiciousOnly(e.target.checked)}
|
||||
className="w-4 h-4 rounded"
|
||||
style={{ accentColor: '#FD5E0F' }}
|
||||
/>
|
||||
Suspicious only
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* Bulk actions bar */}
|
||||
{selectedSessions.size > 0 && (
|
||||
<div className="flex items-center gap-3 p-2 bg-brand-orange/10 border border-brand-orange/20 rounded-lg text-sm">
|
||||
<span className="text-neutral-300">{selectedSessions.size} selected</span>
|
||||
{botView === 'review' ? (
|
||||
<button onClick={() => handleBotFilter(Array.from(selectedSessions))} className="text-red-400 hover:text-red-300 font-medium">Flag as bot</button>
|
||||
) : (
|
||||
<button onClick={() => handleBotUnfilter(Array.from(selectedSessions))} className="text-green-400 hover:text-green-300 font-medium">Unblock</button>
|
||||
)}
|
||||
<button onClick={() => setSelectedSessions(new Set())} className="text-neutral-500 hover:text-neutral-300 ml-auto">Clear</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Session cards */}
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{(sessions || [])
|
||||
.filter(s => botView === 'blocked' ? s.bot_filtered : !s.bot_filtered)
|
||||
.map(session => (
|
||||
<div key={session.session_id} className="flex items-center gap-3 p-3 rounded-xl border border-neutral-800 hover:border-neutral-700 transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedSessions.has(session.session_id)}
|
||||
onChange={e => {
|
||||
const next = new Set(selectedSessions)
|
||||
e.target.checked ? next.add(session.session_id) : next.delete(session.session_id)
|
||||
setSelectedSessions(next)
|
||||
}}
|
||||
className="w-4 h-4 shrink-0 cursor-pointer"
|
||||
style={{ accentColor: '#FD5E0F' }}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-white truncate">{session.first_page || '/'}</span>
|
||||
{session.suspicion_score != null && (
|
||||
<span className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${
|
||||
session.suspicion_score >= 5 ? 'bg-red-900/30 text-red-400' :
|
||||
session.suspicion_score >= 3 ? 'bg-yellow-900/30 text-yellow-400' :
|
||||
'bg-neutral-800 text-neutral-400'
|
||||
}`}>
|
||||
{session.suspicion_score >= 5 ? 'High risk' : session.suspicion_score >= 3 ? 'Suspicious' : 'Low risk'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-xs text-neutral-500 mt-0.5">
|
||||
<span>{session.pageviews} page(s)</span>
|
||||
<span>{session.duration ? `${Math.round(session.duration)}s` : 'No duration'}</span>
|
||||
<span>{[session.city, session.country].filter(Boolean).join(', ') || 'Unknown location'}</span>
|
||||
<span>{session.browser || 'Unknown browser'}</span>
|
||||
<span>{session.referrer || 'Direct'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => botView === 'review' ? handleBotFilter([session.session_id]) : handleBotUnfilter([session.session_id])}
|
||||
className={`shrink-0 px-3 py-1.5 text-xs font-medium rounded-lg border transition-colors ${
|
||||
botView === 'review'
|
||||
? 'text-red-400 border-red-500/20 hover:bg-red-900/20'
|
||||
: 'text-green-400 border-green-500/20 hover:bg-green-900/20'
|
||||
}`}
|
||||
>
|
||||
{botView === 'review' ? 'Flag as bot' : 'Unblock'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{(!sessions || sessions.filter(s => botView === 'blocked' ? s.bot_filtered : !s.bot_filtered).length === 0) && (
|
||||
<p className="text-sm text-neutral-500 text-center py-4">
|
||||
{botView === 'blocked' ? 'No blocked sessions' : 'No suspicious sessions found'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button onClick={handleSave} variant="primary" disabled={saving}>
|
||||
|
||||
Reference in New Issue
Block a user