From 6a1698b79493e8bced572c0e2f44d1f3f3db912b Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 22 Mar 2026 16:57:04 +0100 Subject: [PATCH] feat: add Notifications section to settings with Reports and Alerts - Adds purpose field to report schedule API client - Adds useAlertSchedules SWR hook - Reorganizes settings: Reports tab becomes Notifications tab - Groups existing Reports and new Alerts subsections - Alert channels reuse report delivery infrastructure (email, Slack, Discord, webhooks) --- app/sites/[id]/settings/page.tsx | 562 ++++++++++++++++++++++++------- lib/api/report-schedules.ts | 8 + lib/swr/dashboard.ts | 16 +- 3 files changed, 460 insertions(+), 126 deletions(-) diff --git a/app/sites/[id]/settings/page.tsx b/app/sites/[id]/settings/page.tsx index 9d26399..6e1520f 100644 --- a/app/sites/[id]/settings/page.tsx +++ b/app/sites/[id]/settings/page.tsx @@ -4,7 +4,7 @@ import { useEffect, useState, useRef } from 'react' 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 { createReportSchedule, updateReportSchedule, deleteReportSchedule, testReportSchedule, listAlertSchedules, 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' @@ -21,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, useSessions, useBotFilterStats } from '@/lib/swr/dashboard' +import { useSite, useGoals, useReportSchedules, useAlertSchedules, 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' @@ -32,7 +32,7 @@ import { AlertTriangleIcon, ZapIcon, } from '@ciphera-net/ui' -import { PaperPlaneTilt, Envelope, WebhooksLogo, SpinnerGap, Trash, PencilSimple, Play, Plugs, ShieldCheck, Bug } from '@phosphor-icons/react' +import { PaperPlaneTilt, Envelope, WebhooksLogo, SpinnerGap, Trash, PencilSimple, Play, Plugs, ShieldCheck, Bug, BellSimple } from '@phosphor-icons/react' import { SiDiscord } from '@icons-pack/react-simple-icons' function SlackIcon({ size = 16 }: { size?: number }) { @@ -88,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' | 'bot' | 'goals' | 'reports' | 'integrations'>('general') + const [activeTab, setActiveTab] = useState<'general' | 'visibility' | 'data' | 'bot' | 'goals' | 'notifications' | 'integrations'>('general') const searchParams = useSearchParams() const [formData, setFormData] = useState({ @@ -124,6 +124,8 @@ export default function SiteSettingsPage() { // Report schedules const { data: reportSchedules = [], isLoading: reportLoading, mutate: mutateReportSchedules } = useReportSchedules(siteId) + // Alert schedules (uptime alerts) + const { data: alertSchedules = [], isLoading: alertLoading, mutate: mutateAlertSchedules } = useAlertSchedules(siteId) const { data: gscStatus, mutate: mutateGSCStatus } = useGSCStatus(siteId) const [gscConnecting, setGscConnecting] = useState(false) const [gscDisconnecting, setGscDisconnecting] = useState(false) @@ -149,6 +151,17 @@ export default function SiteSettingsPage() { sendDay: 1, }) + // Alert channel state + const [alertModalOpen, setAlertModalOpen] = useState(false) + const [editingAlert, setEditingAlert] = useState(null) + const [alertSaving, setAlertSaving] = useState(false) + const [alertTesting, setAlertTesting] = useState(null) + const [alertForm, setAlertForm] = useState({ + channel: 'email' as string, + recipients: '', + webhookUrl: '', + }) + // Bot & Spam tab state const [botDateRange, setBotDateRange] = useState(() => getDateRange(7)) const [suspiciousOnly, setSuspiciousOnly] = useState(true) @@ -337,6 +350,103 @@ export default function SiteSettingsPage() { } } + // Alert channel handlers + const resetAlertForm = () => { + setAlertForm({ + channel: 'email', + recipients: '', + webhookUrl: '', + }) + } + + const openEditAlert = (schedule: ReportSchedule) => { + setEditingAlert(schedule) + const isEmail = schedule.channel === 'email' + setAlertForm({ + channel: schedule.channel, + recipients: isEmail ? (schedule.channel_config as EmailConfig).recipients.join(', ') : '', + webhookUrl: !isEmail ? (schedule.channel_config as WebhookConfig).url : '', + }) + setAlertModalOpen(true) + } + + const handleAlertSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + let channelConfig: EmailConfig | WebhookConfig + if (alertForm.channel === 'email') { + const recipients = alertForm.recipients.split(',').map(r => r.trim()).filter(r => r.length > 0) + if (recipients.length === 0) { + toast.error('At least one recipient email is required') + return + } + channelConfig = { recipients } + } else { + if (!alertForm.webhookUrl.trim()) { + toast.error('Webhook URL is required') + return + } + channelConfig = { url: alertForm.webhookUrl.trim() } + } + + const payload: CreateReportScheduleRequest = { + channel: alertForm.channel, + channel_config: channelConfig, + frequency: 'daily', + purpose: 'alert', + } + + setAlertSaving(true) + try { + if (editingAlert) { + await updateReportSchedule(siteId, editingAlert.id, { ...payload, purpose: 'alert' }) + toast.success('Alert channel updated') + } else { + await createReportSchedule(siteId, payload) + toast.success('Alert channel created') + } + setAlertModalOpen(false) + mutateAlertSchedules() + } catch (error: unknown) { + toast.error(getAuthErrorMessage(error) || 'Failed to save alert channel') + } finally { + setAlertSaving(false) + } + } + + const handleAlertDelete = async (schedule: ReportSchedule) => { + if (!confirm('Delete this alert channel?')) return + try { + await deleteReportSchedule(siteId, schedule.id) + toast.success('Alert channel deleted') + mutateAlertSchedules() + } catch (error: unknown) { + toast.error(getAuthErrorMessage(error) || 'Failed to delete alert channel') + } + } + + const handleAlertToggle = async (schedule: ReportSchedule) => { + try { + await updateReportSchedule(siteId, schedule.id, { enabled: !schedule.enabled }) + toast.success(schedule.enabled ? 'Alert paused' : 'Alert enabled') + mutateAlertSchedules() + } catch (error: unknown) { + toast.error(getAuthErrorMessage(error) || 'Failed to update alert channel') + } + } + + const handleAlertTest = async (schedule: ReportSchedule) => { + setAlertTesting(schedule.id) + try { + await testReportSchedule(siteId, schedule.id) + toast.success('Test alert sent successfully') + } catch (error: unknown) { + toast.error(getAuthErrorMessage(error) || 'Failed to send test alert') + } finally { + setAlertTesting(null) + } + } + const getChannelLabel = (channel: string) => { switch (channel) { case 'email': return 'Email' @@ -701,17 +811,17 @@ export default function SiteSettingsPage() { Goals & Events + )} - {canEdit && ( - + + {reportLoading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ ))} +
+ ) : reportSchedules.length === 0 ? ( +
+ No scheduled reports yet. Add a report to automatically receive analytics summaries. +
+ ) : ( +
+ {reportSchedules.map((schedule) => ( +
+
+
+
+ {CHANNEL_ICONS_LG[schedule.channel] || } +
+
+
+ + {getChannelLabel(schedule.channel)} + + + {getFrequencyLabel(schedule.frequency)} + + + {getReportTypeLabel(schedule.report_type)} + +
+

+ {schedule.channel === 'email' + ? (schedule.channel_config as EmailConfig).recipients.join(', ') + : (schedule.channel_config as WebhookConfig).url} +

+

+ {getScheduleDescription(schedule)} +

+
+ + Last sent: {schedule.last_sent_at + ? formatDateTime(new Date(schedule.last_sent_at)) + : 'Never'} + +
+ {schedule.last_error && ( +

+ Error: {schedule.last_error} +

+ )} +
+
+ + {canEdit && ( +
+ + + + +
+ )} +
+
+ ))} +
)}
- {reportLoading ? ( -
- {Array.from({ length: 3 }).map((_, i) => ( -
- ))} -
- ) : reportSchedules.length === 0 ? ( -
- No scheduled reports yet. Add a report to automatically receive analytics summaries. -
- ) : ( -
- {reportSchedules.map((schedule) => ( -
-
-
-
- {CHANNEL_ICONS_LG[schedule.channel] || } -
-
-
- - {getChannelLabel(schedule.channel)} - - - {getFrequencyLabel(schedule.frequency)} - - - {getReportTypeLabel(schedule.report_type)} - -
-

- {schedule.channel === 'email' - ? (schedule.channel_config as EmailConfig).recipients.join(', ') - : (schedule.channel_config as WebhookConfig).url} -

-

- {getScheduleDescription(schedule)} -

-
- - Last sent: {schedule.last_sent_at - ? formatDateTime(new Date(schedule.last_sent_at)) - : 'Never'} - -
- {schedule.last_error && ( -

- Error: {schedule.last_error} -

- )} -
-
+ {/* Divider */} +
- {canEdit && ( -
- - - - -
- )} -
-
- ))} + {/* Alerts subsection */} +
+
+
+

Alerts

+

Get notified when your site goes down or recovers.

+
+ {canEdit && ( + + )}
- )} + + {alertLoading ? ( +
+ {Array.from({ length: 2 }).map((_, i) => ( +
+ ))} +
+ ) : alertSchedules.length === 0 ? ( +
+ No alert channels configured. Add a channel to receive uptime alerts when your site goes down or recovers. +
+ ) : ( +
+ {alertSchedules.map((schedule) => ( +
+
+
+
+ {CHANNEL_ICONS_LG[schedule.channel] || } +
+
+
+ + {getChannelLabel(schedule.channel)} + + + Uptime Alert + +
+

+ {schedule.channel === 'email' + ? (schedule.channel_config as EmailConfig).recipients.join(', ') + : (schedule.channel_config as WebhookConfig).url} +

+
+ + Last sent: {schedule.last_sent_at + ? formatDateTime(new Date(schedule.last_sent_at)) + : 'Never'} + +
+ {schedule.last_error && ( +

+ Error: {schedule.last_error} +

+ )} +
+
+ + {canEdit && ( +
+ + + + +
+ )} +
+
+ ))} +
+ )} +
)} @@ -2265,6 +2504,79 @@ export default function SiteSettingsPage() { + setAlertModalOpen(false)} + title={editingAlert ? 'Edit alert channel' : 'Add alert channel'} + > +
+
+ +
+ {(['email', 'slack', 'discord', 'webhook'] as const).map((ch) => ( + + ))} +
+
+ + {alertForm.channel === 'email' ? ( +
+ + setAlertForm({ ...alertForm, recipients: e.target.value })} + placeholder="email1@example.com, email2@example.com" + className="w-full px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-lg bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white" + required + /> +

Comma-separated email addresses.

+
+ ) : ( +
+ + setAlertForm({ ...alertForm, webhookUrl: e.target.value })} + placeholder="https://hooks.example.com/..." + className="w-full px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-lg bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white" + required + /> +
+ )} + +
+

+ Alerts are sent automatically when your site goes down or recovers. No schedule configuration needed. +

+
+ +
+ + +
+
+
+ setShowVerificationModal(false)} diff --git a/lib/api/report-schedules.ts b/lib/api/report-schedules.ts index 685b06f..cc0cfdb 100644 --- a/lib/api/report-schedules.ts +++ b/lib/api/report-schedules.ts @@ -10,6 +10,7 @@ export interface ReportSchedule { timezone: string enabled: boolean report_type: 'summary' | 'pages' | 'sources' | 'goals' + purpose: 'report' | 'alert' send_hour: number send_day: number | null next_send_at: string | null @@ -33,6 +34,7 @@ export interface CreateReportScheduleRequest { frequency: string timezone?: string report_type?: string + purpose?: 'report' | 'alert' send_hour?: number send_day?: number } @@ -43,6 +45,7 @@ export interface UpdateReportScheduleRequest { frequency?: string timezone?: string report_type?: string + purpose?: 'report' | 'alert' enabled?: boolean send_hour?: number send_day?: number @@ -73,6 +76,11 @@ export async function deleteReportSchedule(siteId: string, scheduleId: string): }) } +export async function listAlertSchedules(siteId: string): Promise { + const res = await apiRequest<{ report_schedules: ReportSchedule[] }>(`/sites/${siteId}/report-schedules?purpose=alert`) + return res?.report_schedules ?? [] +} + export async function testReportSchedule(siteId: string, scheduleId: string): Promise { await apiRequest(`/sites/${siteId}/report-schedules/${scheduleId}/test`, { method: 'POST', diff --git a/lib/swr/dashboard.ts b/lib/swr/dashboard.ts index 60b0fdb..1962e67 100644 --- a/lib/swr/dashboard.ts +++ b/lib/swr/dashboard.ts @@ -32,7 +32,7 @@ import type { Site } from '@/lib/api/sites' 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 { listReportSchedules, listAlertSchedules, 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' @@ -81,6 +81,7 @@ const fetchers = { uptimeStatus: (siteId: string) => getUptimeStatus(siteId), goals: (siteId: string) => listGoals(siteId), reportSchedules: (siteId: string) => listReportSchedules(siteId), + alertSchedules: (siteId: string) => listAlertSchedules(siteId), gscStatus: (siteId: string) => getGSCStatus(siteId), gscOverview: (siteId: string, start: string, end: string) => getGSCOverview(siteId, start, end), gscTopQueries: (siteId: string, start: string, end: string, limit: number, offset: number) => getGSCTopQueries(siteId, start, end, limit, offset), @@ -411,6 +412,19 @@ export function useReportSchedules(siteId: string) { ) } +// * Hook for alert schedules (uptime alerts) +export function useAlertSchedules(siteId: string) { + return useSWR( + siteId ? ['alertSchedules', siteId] : null, + () => fetchers.alertSchedules(siteId), + { + ...dashboardSWRConfig, + refreshInterval: 60 * 1000, + dedupingInterval: 10 * 1000, + } + ) +} + // * Hook for GSC connection status export function useGSCStatus(siteId: string) { return useSWR(