perf: migrate Settings, Funnels, and Uptime to SWR data fetching

Replace manual useState/useEffect fetch patterns with SWR hooks so
cached data renders instantly on tab revisit. Skeleton loading now
only appears on the initial cold load, not every navigation.

New hooks: useFunnels, useUptimeStatus, useGoals, useReportSchedules,
useSubscription — all with background revalidation.
This commit is contained in:
Usman Baig
2026-03-13 12:21:55 +01:00
parent b6a7c642f2
commit 6380f216aa
5 changed files with 143 additions and 190 deletions

View File

@@ -1,8 +1,8 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { listFunnels, deleteFunnel, type Funnel } from '@/lib/api/funnels'
import { deleteFunnel, type Funnel } from '@/lib/api/funnels'
import { useFunnels } from '@/lib/swr/dashboard'
import { toast, PlusIcon, ArrowRightIcon, ChevronLeftIcon, TrashIcon, Button } from '@ciphera-net/ui'
import { FunnelsListSkeleton, useMinimumLoading } from '@/components/skeletons'
import Link from 'next/link'
@@ -12,24 +12,7 @@ export default function FunnelsPage() {
const router = useRouter()
const siteId = params.id as string
const [funnels, setFunnels] = useState<Funnel[]>([])
const [loading, setLoading] = useState(true)
const loadFunnels = useCallback(async () => {
try {
setLoading(true)
const data = await listFunnels(siteId)
setFunnels(data)
} catch (error) {
toast.error('Failed to load your funnels')
} finally {
setLoading(false)
}
}, [siteId])
useEffect(() => {
loadFunnels()
}, [loadFunnels])
const { data: funnels = [], isLoading, mutate } = useFunnels(siteId)
const handleDelete = async (e: React.MouseEvent, funnelId: string) => {
e.preventDefault() // Prevent navigation
@@ -38,13 +21,13 @@ export default function FunnelsPage() {
try {
await deleteFunnel(siteId, funnelId)
toast.success('Funnel deleted')
loadFunnels()
mutate()
} catch (error) {
toast.error('Failed to delete funnel')
}
}
const showSkeleton = useMinimumLoading(loading)
const showSkeleton = useMinimumLoading(isLoading && !funnels.length)
if (showSkeleton) {
return <FunnelsListSkeleton />

View File

@@ -2,9 +2,9 @@
import { useEffect, useState, useRef } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { getSite, updateSite, resetSiteData, deleteSite, type Site, type GeoDataLevel } from '@/lib/api/sites'
import { listGoals, createGoal, updateGoal, deleteGoal, type Goal } from '@/lib/api/goals'
import { listReportSchedules, createReportSchedule, updateReportSchedule, deleteReportSchedule, testReportSchedule, type ReportSchedule, type CreateReportScheduleRequest, type EmailConfig, type WebhookConfig } from '@/lib/api/report-schedules'
import { updateSite, resetSiteData, deleteSite, 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 { toast } from '@ciphera-net/ui'
import { getAuthErrorMessage } from '@ciphera-net/ui'
import { SettingsFormSkeleton, GoalsListSkeleton, useMinimumLoading } from '@/components/skeletons'
@@ -15,7 +15,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 { getSubscription, type SubscriptionDetails } from '@/lib/api/billing'
import { useSite, useGoals, useReportSchedules, useSubscription } from '@/lib/swr/dashboard'
import { getRetentionOptionsForPlan, formatRetentionMonths } from '@/lib/plans'
import { motion, AnimatePresence } from 'framer-motion'
import { useAuth } from '@/lib/auth/context'
@@ -53,8 +53,7 @@ export default function SiteSettingsPage() {
const router = useRouter()
const siteId = params.id as string
const [site, setSite] = useState<Site | null>(null)
const [loading, setLoading] = useState(true)
const { data: site, isLoading: siteLoading, mutate: mutateSite } = useSite(siteId)
const [saving, setSaving] = useState(false)
const [activeTab, setActiveTab] = useState<'general' | 'visibility' | 'data' | 'goals' | 'reports'>('general')
@@ -79,23 +78,20 @@ export default function SiteSettingsPage() {
// Data retention (6 = free-tier max; safe default)
data_retention_months: 6
})
const [subscription, setSubscription] = useState<SubscriptionDetails | null>(null)
const [subscriptionLoadFailed, setSubscriptionLoadFailed] = useState(false)
const { data: subscription, error: subscriptionError, mutate: mutateSubscription } = useSubscription()
const [linkCopied, setLinkCopied] = useState(false)
const [snippetCopied, setSnippetCopied] = useState(false)
const [showVerificationModal, setShowVerificationModal] = useState(false)
const [isPasswordEnabled, setIsPasswordEnabled] = useState(false)
const [goals, setGoals] = useState<Goal[]>([])
const [goalsLoading, setGoalsLoading] = useState(false)
const { data: goals = [], isLoading: goalsLoading, mutate: mutateGoals } = useGoals(siteId)
const [goalModalOpen, setGoalModalOpen] = useState(false)
const [editingGoal, setEditingGoal] = useState<Goal | null>(null)
const [goalForm, setGoalForm] = useState({ name: '', event_name: '' })
const [goalSaving, setGoalSaving] = useState(false)
const initialFormRef = useRef<string>('')
// Report schedules state
const [reportSchedules, setReportSchedules] = useState<ReportSchedule[]>([])
const [reportLoading, setReportLoading] = useState(false)
// Report schedules
const { data: reportSchedules = [], isLoading: reportLoading, mutate: mutateReportSchedules } = useReportSchedules(siteId)
const [reportModalOpen, setReportModalOpen] = useState(false)
const [editingSchedule, setEditingSchedule] = useState<ReportSchedule | null>(null)
const [reportSaving, setReportSaving] = useState(false)
@@ -112,32 +108,40 @@ export default function SiteSettingsPage() {
})
useEffect(() => {
loadSite()
loadSubscription()
}, [siteId])
useEffect(() => {
if (activeTab === 'goals' && siteId) {
loadGoals()
}
}, [activeTab, siteId])
useEffect(() => {
if (activeTab === 'reports' && siteId) {
loadReportSchedules()
}
}, [activeTab, siteId])
const loadSubscription = async () => {
try {
setSubscriptionLoadFailed(false)
const sub = await getSubscription()
setSubscription(sub)
} catch (e) {
setSubscriptionLoadFailed(true)
toast.error(getAuthErrorMessage(e as Error) || 'Could not load plan limits. Showing default options.')
}
}
if (!site) return
setFormData({
name: site.name,
timezone: site.timezone || 'UTC',
is_public: site.is_public || false,
password: '',
excluded_paths: (site.excluded_paths || []).join('\n'),
collect_page_paths: site.collect_page_paths ?? true,
collect_referrers: site.collect_referrers ?? true,
collect_device_info: site.collect_device_info ?? true,
collect_geo_data: site.collect_geo_data || 'full',
collect_screen_resolution: site.collect_screen_resolution ?? true,
enable_performance_insights: site.enable_performance_insights ?? false,
filter_bots: site.filter_bots ?? true,
hide_unknown_locations: site.hide_unknown_locations ?? false,
data_retention_months: site.data_retention_months ?? 6
})
initialFormRef.current = JSON.stringify({
name: site.name,
timezone: site.timezone || 'UTC',
is_public: site.is_public || false,
excluded_paths: (site.excluded_paths || []).join('\n'),
collect_page_paths: site.collect_page_paths ?? true,
collect_referrers: site.collect_referrers ?? true,
collect_device_info: site.collect_device_info ?? true,
collect_geo_data: site.collect_geo_data || 'full',
collect_screen_resolution: site.collect_screen_resolution ?? true,
enable_performance_insights: site.enable_performance_insights ?? false,
filter_bots: site.filter_bots ?? true,
hide_unknown_locations: site.hide_unknown_locations ?? false,
data_retention_months: site.data_retention_months ?? 6
})
setIsPasswordEnabled(!!site.has_password)
}, [site])
// * Snap data_retention_months to nearest valid option when subscription loads
useEffect(() => {
@@ -152,83 +156,6 @@ export default function SiteSettingsPage() {
})
}, [subscription])
const loadSite = async () => {
try {
setLoading(true)
const data = await getSite(siteId)
setSite(data)
setFormData({
name: data.name,
timezone: data.timezone || 'UTC',
is_public: data.is_public || false,
password: '', // Don't show existing password
excluded_paths: (data.excluded_paths || []).join('\n'),
// Data collection settings (default to true/full for backwards compatibility)
collect_page_paths: data.collect_page_paths ?? true,
collect_referrers: data.collect_referrers ?? true,
collect_device_info: data.collect_device_info ?? true,
collect_geo_data: data.collect_geo_data || 'full',
collect_screen_resolution: data.collect_screen_resolution ?? true,
// Performance insights setting (default to false)
enable_performance_insights: data.enable_performance_insights ?? false,
// Bot and noise filtering (default to true)
filter_bots: data.filter_bots ?? true,
// Hide unknown locations (default to false)
hide_unknown_locations: data.hide_unknown_locations ?? false,
// Data retention (default 6 = free-tier max; avoids flash-then-clamp for existing sites)
data_retention_months: data.data_retention_months ?? 6
})
initialFormRef.current = JSON.stringify({
name: data.name,
timezone: data.timezone || 'UTC',
is_public: data.is_public || false,
excluded_paths: (data.excluded_paths || []).join('\n'),
collect_page_paths: data.collect_page_paths ?? true,
collect_referrers: data.collect_referrers ?? true,
collect_device_info: data.collect_device_info ?? true,
collect_geo_data: data.collect_geo_data || 'full',
collect_screen_resolution: data.collect_screen_resolution ?? true,
enable_performance_insights: data.enable_performance_insights ?? false,
filter_bots: data.filter_bots ?? true,
hide_unknown_locations: data.hide_unknown_locations ?? false,
data_retention_months: data.data_retention_months ?? 6
})
if (data.has_password) {
setIsPasswordEnabled(true)
} else {
setIsPasswordEnabled(false)
}
} catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || 'Failed to load site settings')
} finally {
setLoading(false)
}
}
const loadGoals = async () => {
try {
setGoalsLoading(true)
const data = await listGoals(siteId)
setGoals(data ?? [])
} catch (e) {
toast.error(getAuthErrorMessage(e as Error) || 'Failed to load goals')
} finally {
setGoalsLoading(false)
}
}
const loadReportSchedules = async () => {
try {
setReportLoading(true)
const data = await listReportSchedules(siteId)
setReportSchedules(data)
} catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || 'Failed to load report schedules')
} finally {
setReportLoading(false)
}
}
const resetReportForm = () => {
setReportForm({
channel: 'email',
@@ -297,7 +224,7 @@ export default function SiteSettingsPage() {
toast.success('Report schedule created')
}
setReportModalOpen(false)
loadReportSchedules()
mutateReportSchedules()
} catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || 'Failed to save report schedule')
} finally {
@@ -310,7 +237,7 @@ export default function SiteSettingsPage() {
try {
await deleteReportSchedule(siteId, schedule.id)
toast.success('Report schedule deleted')
loadReportSchedules()
mutateReportSchedules()
} catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || 'Failed to delete report schedule')
}
@@ -320,7 +247,7 @@ export default function SiteSettingsPage() {
try {
await updateReportSchedule(siteId, schedule.id, { enabled: !schedule.enabled })
toast.success(schedule.enabled ? 'Report paused' : 'Report enabled')
loadReportSchedules()
mutateReportSchedules()
} catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || 'Failed to update report schedule')
}
@@ -439,7 +366,7 @@ export default function SiteSettingsPage() {
toast.success('Goal created')
}
setGoalModalOpen(false)
loadGoals()
mutateGoals()
} catch (err) {
toast.error(getAuthErrorMessage(err as Error) || 'Failed to save goal')
} finally {
@@ -452,7 +379,7 @@ export default function SiteSettingsPage() {
try {
await deleteGoal(siteId, goal.id)
toast.success('Goal deleted')
loadGoals()
mutateGoals()
} catch (err) {
toast.error(getAuthErrorMessage(err as Error) || 'Failed to delete goal')
}
@@ -506,7 +433,7 @@ export default function SiteSettingsPage() {
hide_unknown_locations: formData.hide_unknown_locations,
data_retention_months: formData.data_retention_months
})
loadSite()
mutateSite()
} catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || 'Failed to save site settings')
} finally {
@@ -581,7 +508,7 @@ export default function SiteSettingsPage() {
if (site?.domain) document.title = `Settings · ${site.domain} | Pulse`
}, [site?.domain])
const showSkeleton = useMinimumLoading(loading)
const showSkeleton = useMinimumLoading(siteLoading && !site)
if (showSkeleton) {
return (
@@ -1157,14 +1084,14 @@ export default function SiteSettingsPage() {
{/* Data Retention */}
<div className="space-y-4 pt-6 border-t border-neutral-100 dark:border-neutral-800">
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">Data Retention</h3>
{subscriptionLoadFailed && (
{!!subscriptionError && (
<div className="p-3 rounded-xl border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 flex items-center justify-between gap-3">
<p className="text-sm text-amber-800 dark:text-amber-200">
Plan limits could not be loaded. Options shown may be limited.
</p>
<button
type="button"
onClick={loadSubscription}
onClick={() => mutateSubscription()}
className="shrink-0 text-sm font-medium text-amber-800 dark:text-amber-200 hover:underline"
>
Retry

View File

@@ -1,12 +1,11 @@
'use client'
import { useAuth } from '@/lib/auth/context'
import { useEffect, useState, useCallback, useRef } from 'react'
import { useEffect, useState, useRef } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { motion, AnimatePresence } from 'framer-motion'
import { getSite, type Site } from '@/lib/api/sites'
import { useSite, useUptimeStatus } from '@/lib/swr/dashboard'
import {
getUptimeStatus,
createUptimeMonitor,
updateUptimeMonitor,
deleteUptimeMonitor,
@@ -561,9 +560,8 @@ export default function UptimePage() {
const router = useRouter()
const siteId = params.id as string
const [site, setSite] = useState<Site | null>(null)
const [loading, setLoading] = useState(true)
const [uptimeData, setUptimeData] = useState<UptimeStatusResponse | null>(null)
const { data: site } = useSite(siteId)
const { data: uptimeData, isLoading, mutate: mutateUptime } = useUptimeStatus(siteId)
const [expandedMonitor, setExpandedMonitor] = useState<string | null>(null)
const [showAddModal, setShowAddModal] = useState(false)
const [showEditModal, setShowEditModal] = useState(false)
@@ -577,38 +575,6 @@ export default function UptimePage() {
})
const [saving, setSaving] = useState(false)
const loadData = useCallback(async () => {
try {
const [siteData, statusData] = await Promise.all([
getSite(siteId),
getUptimeStatus(siteId),
])
setSite(siteData)
setUptimeData(statusData)
} catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || 'Failed to load uptime monitors')
} finally {
setLoading(false)
}
}, [siteId])
useEffect(() => {
loadData()
}, [loadData])
// * Auto-refresh every 30 seconds; show toast on failure (e.g. network loss or auth expiry)
useEffect(() => {
const interval = setInterval(async () => {
try {
const statusData = await getUptimeStatus(siteId)
setUptimeData(statusData)
} catch {
toast.error('Could not refresh uptime data. Check your connection or sign in again.')
}
}, 30000)
return () => clearInterval(interval)
}, [siteId])
const handleAddMonitor = async () => {
if (!formData.name || !formData.url) {
toast.error('Name and URL are required')
@@ -620,7 +586,7 @@ export default function UptimePage() {
toast.success('Monitor created successfully')
setShowAddModal(false)
setFormData({ name: '', url: '', check_interval_seconds: 300, expected_status_code: 200, timeout_seconds: 30 })
await loadData()
mutateUptime()
} catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || 'Failed to create monitor')
} finally {
@@ -643,7 +609,7 @@ export default function UptimePage() {
toast.success('Monitor updated successfully')
setShowEditModal(false)
setEditingMonitor(null)
await loadData()
mutateUptime()
} catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || 'Failed to update monitor')
} finally {
@@ -656,7 +622,7 @@ export default function UptimePage() {
try {
await deleteUptimeMonitor(siteId, monitorId)
toast.success('Monitor deleted')
await loadData()
mutateUptime()
} catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || 'Failed to delete monitor')
}
@@ -678,7 +644,7 @@ export default function UptimePage() {
if (site?.domain) document.title = `Uptime · ${site.domain} | Pulse`
}, [site?.domain])
const showSkeleton = useMinimumLoading(loading)
const showSkeleton = useMinimumLoading(isLoading && !uptimeData)
if (showSkeleton) return <UptimeSkeleton />
if (!site) return <div className="p-8 text-neutral-500">Site not found</div>