From 42b7363cf96722fba7ad78b3b059a341d9151b49 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 22 Mar 2026 13:16:07 +0100 Subject: [PATCH] feat: add Bot & Spam settings tab with session review UI --- app/sites/[id]/settings/page.tsx | 270 ++++++++++++++++++++++++++++--- lib/api/bot-filter.ts | 56 +++++++ lib/swr/dashboard.ts | 19 +++ 3 files changed, 321 insertions(+), 24 deletions(-) create mode 100644 lib/api/bot-filter.ts diff --git a/app/sites/[id]/settings/page.tsx b/app/sites/[id]/settings/page.tsx index eee5b49..5fefba0 100644 --- a/app/sites/[id]/settings/page.tsx +++ b/app/sites/[id]/settings/page.tsx @@ -5,10 +5,11 @@ import { useParams, useRouter, useSearchParams } from 'next/navigation' import { updateSite, resetSiteData, type Site, type GeoDataLevel } from '@/lib/api/sites' import { createGoal, updateGoal, deleteGoal, type Goal } from '@/lib/api/goals' import { createReportSchedule, updateReportSchedule, deleteReportSchedule, testReportSchedule, type ReportSchedule, type CreateReportScheduleRequest, type EmailConfig, type WebhookConfig } from '@/lib/api/report-schedules' +import { botFilterSessions, botUnfilterSessions } from '@/lib/api/bot-filter' import { getGSCAuthURL, disconnectGSC } from '@/lib/api/gsc' import { getBunnyPullZones, connectBunny, disconnectBunny } from '@/lib/api/bunny' import type { BunnyPullZone } from '@/lib/api/bunny' -import { toast } from '@ciphera-net/ui' +import { toast, getDateRange } from '@ciphera-net/ui' import { getAuthErrorMessage } from '@ciphera-net/ui' import { formatDateTime } from '@/lib/utils/formatDate' import { SettingsFormSkeleton, GoalsListSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons' @@ -20,7 +21,7 @@ import { Select, Modal, Button } from '@ciphera-net/ui' import { APP_URL } from '@/lib/api/client' import { generatePrivacySnippet } from '@/lib/utils/privacySnippet' import { useUnsavedChanges } from '@/lib/hooks/useUnsavedChanges' -import { useSite, useGoals, useReportSchedules, useSubscription, useGSCStatus, useBunnyStatus } from '@/lib/swr/dashboard' +import { useSite, useGoals, useReportSchedules, useSubscription, useGSCStatus, useBunnyStatus, useSessions, useBotFilterStats } from '@/lib/swr/dashboard' import { getRetentionOptionsForPlan, formatRetentionMonths } from '@/lib/plans' import { motion, AnimatePresence } from 'framer-motion' import { useAuth } from '@/lib/auth/context' @@ -31,7 +32,7 @@ import { AlertTriangleIcon, ZapIcon, } from '@ciphera-net/ui' -import { PaperPlaneTilt, Envelope, WebhooksLogo, SpinnerGap, Trash, PencilSimple, Play, Plugs, ShieldCheck } from '@phosphor-icons/react' +import { PaperPlaneTilt, Envelope, WebhooksLogo, SpinnerGap, Trash, PencilSimple, Play, Plugs, ShieldCheck, Bug } from '@phosphor-icons/react' import { SiDiscord } from '@icons-pack/react-simple-icons' function SlackIcon({ size = 16 }: { size?: number }) { @@ -87,7 +88,7 @@ export default function SiteSettingsPage() { const { data: site, isLoading: siteLoading, mutate: mutateSite } = useSite(siteId) const [saving, setSaving] = useState(false) const [showDeleteModal, setShowDeleteModal] = useState(false) - const [activeTab, setActiveTab] = useState<'general' | 'visibility' | 'data' | 'goals' | 'reports' | 'integrations'>('general') + const [activeTab, setActiveTab] = useState<'general' | 'visibility' | 'data' | 'bot' | 'goals' | 'reports' | 'integrations'>('general') const searchParams = useSearchParams() const [formData, setFormData] = useState({ @@ -148,6 +149,38 @@ export default function SiteSettingsPage() { sendDay: 1, }) + // Bot & Spam tab state + const [botDateRange, setBotDateRange] = useState(() => getDateRange(7)) + const [suspiciousOnly, setSuspiciousOnly] = useState(true) + const [selectedSessions, setSelectedSessions] = useState>(new Set()) + const [botView, setBotView] = useState<'review' | 'blocked'>('review') + const { data: sessions, mutate: mutateSessions } = useSessions(siteId, botDateRange.start, botDateRange.end, botView === 'review' ? suspiciousOnly : false) + const { data: botStats, mutate: mutateBotStats } = useBotFilterStats(siteId) + + 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') + } + } + useEffect(() => { if (!site) return setFormData({ @@ -641,6 +674,19 @@ export default function SiteSettingsPage() { Data & Privacy + + + + {botView === 'review' && ( + + )} + + + + {/* Bulk actions */} + {selectedSessions.size > 0 && ( +
+ {selectedSessions.size} selected + {botView === 'review' ? ( + + ) : ( + + )} + +
+ )} + + {/* Sessions table */} +
+ + + + + + + + + + + + + + + + {(sessions?.sessions || []) + .filter(s => botView === 'blocked' ? s.bot_filtered : !s.bot_filtered) + .map((session) => ( + + + + + + + + + + + + ))} + {(sessions?.sessions || []).filter(s => botView === 'blocked' ? s.bot_filtered : !s.bot_filtered).length === 0 && ( + + + + )} + +
+ { + if (e.target.checked) { + const allIds = new Set((sessions?.sessions || []).map(s => s.session_id)) + setSelectedSessions(allIds) + } else { + setSelectedSessions(new Set()) + } + }} + checked={selectedSessions.size > 0 && selectedSessions.size === (sessions?.sessions || []).length} + className="rounded border-neutral-600 bg-neutral-800 text-brand-orange focus:ring-brand-orange" + /> + SessionPagesDurationLocationBrowserReferrerScoreAction
+ { + const next = new Set(selectedSessions) + if (e.target.checked) next.add(session.session_id) + else next.delete(session.session_id) + setSelectedSessions(next) + }} + className="rounded border-neutral-600 bg-neutral-800 text-brand-orange focus:ring-brand-orange" + /> + +
{session.session_id.slice(0, 12)}...
+
{session.first_page}
+
{session.pageviews} + {session.duration != null ? `${Math.round(session.duration)}s` : } + + {[session.city, session.country].filter(Boolean).join(', ') || '\u2014'} + {session.browser || '\u2014'}{session.referrer || Direct} + = 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} + + + {botView === 'review' ? ( + + ) : ( + + )} +
+ {botView === 'blocked' ? 'No blocked sessions' : 'No suspicious sessions found'} +
+
+ + + )} + {activeTab === 'goals' && (
diff --git a/lib/api/bot-filter.ts b/lib/api/bot-filter.ts new file mode 100644 index 0000000..3361fdb --- /dev/null +++ b/lib/api/bot-filter.ts @@ -0,0 +1,56 @@ +import apiRequest from './client' + +export interface SessionSummary { + session_id: string + pageviews: number + duration: number | null + first_page: string + referrer: string | null + country: string | null + city: string | null + region: string | null + browser: string | null + os: string | null + screen_resolution: string | null + first_seen: string + bot_filtered: boolean + suspicion_score: number +} + +export interface BotFilterStats { + filtered_sessions: number + filtered_events: number + auto_blocked_this_month: number +} + +function buildQuery(opts: { startDate?: string; endDate?: string; suspicious?: boolean; limit?: number }): string { + const params = new URLSearchParams() + if (opts.startDate) params.append('start_date', opts.startDate) + if (opts.endDate) params.append('end_date', opts.endDate) + if (opts.suspicious) params.append('suspicious', 'true') + if (opts.limit != null) params.append('limit', opts.limit.toString()) + const q = params.toString() + return q ? `?${q}` : '' +} + +export function listSessions(siteId: string, startDate: string, endDate: string, suspiciousOnly?: boolean, limit?: number): Promise<{ sessions: SessionSummary[] }> { + return apiRequest<{ sessions: SessionSummary[] }>(`/sites/${siteId}/sessions${buildQuery({ startDate, endDate, suspicious: suspiciousOnly, limit })}`) +} + +export function botFilterSessions(siteId: string, sessionIds: string[]): Promise<{ updated: number }> { + return apiRequest<{ updated: number }>(`/sites/${siteId}/bot-filter`, { + method: 'POST', + body: JSON.stringify({ session_ids: sessionIds }), + }) +} + +export function botUnfilterSessions(siteId: string, sessionIds: string[]): Promise<{ updated: number }> { + return apiRequest<{ updated: number }>(`/sites/${siteId}/bot-filter`, { + method: 'DELETE', + body: JSON.stringify({ session_ids: sessionIds }), + }) +} + +export function getBotFilterStats(siteId: string): Promise { + return apiRequest(`/sites/${siteId}/bot-filter/stats`) +} diff --git a/lib/swr/dashboard.ts b/lib/swr/dashboard.ts index 8b15a85..60b0fdb 100644 --- a/lib/swr/dashboard.ts +++ b/lib/swr/dashboard.ts @@ -33,6 +33,7 @@ import { listFunnels, type Funnel } from '@/lib/api/funnels' import { getUptimeStatus, type UptimeStatusResponse } from '@/lib/api/uptime' import { listGoals, type Goal } from '@/lib/api/goals' import { listReportSchedules, type ReportSchedule } from '@/lib/api/report-schedules' +import { listSessions, getBotFilterStats, type SessionSummary, type BotFilterStats } from '@/lib/api/bot-filter' import { getGSCStatus, getGSCOverview, getGSCTopQueries, getGSCTopPages, getGSCDailyTotals, getGSCNewQueries } from '@/lib/api/gsc' import type { GSCStatus, GSCOverview, GSCQueryResponse, GSCPageResponse, GSCDailyTotal, GSCNewQueries } from '@/lib/api/gsc' import { getBunnyStatus, getBunnyOverview, getBunnyDailyStats, getBunnyTopCountries } from '@/lib/api/bunny' @@ -517,5 +518,23 @@ export function useSubscription() { ) } +// * Hook for session list (bot review) +export function useSessions(siteId: string, startDate: string, endDate: string, suspiciousOnly: boolean) { + return useSWR<{ sessions: SessionSummary[] }>( + siteId && startDate && endDate ? ['sessions', siteId, startDate, endDate, suspiciousOnly] : null, + () => listSessions(siteId, startDate, endDate, suspiciousOnly), + { ...dashboardSWRConfig, refreshInterval: 0, dedupingInterval: 10 * 1000 } + ) +} + +// * Hook for bot filter stats +export function useBotFilterStats(siteId: string) { + return useSWR( + siteId ? ['botFilterStats', siteId] : null, + () => getBotFilterStats(siteId), + { ...dashboardSWRConfig, refreshInterval: 60 * 1000, dedupingInterval: 10 * 1000 } + ) +} + // * Re-export for convenience export { fetchers }