Polish dashboard UX, loading states, and tracking accuracy #45
@@ -6,6 +6,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
|
||||||
|
- **Cleaner page paths in your reports.** Pages like `/products?_t=123456` or `/about?session=abc` now correctly show as `/products` and `/about`. Only marketing attribution parameters (like UTM tags) are preserved for traffic source tracking — all other junk parameters are automatically removed, so your Top Pages and Journeys stay clean without us having to chase down every new parameter format.
|
||||||
|
- **Refreshed chart background.** The dashboard chart now has subtle horizontal lines instead of the old dotted background, giving the chart area a cleaner look with soft faded edges.
|
||||||
|
- **Smoother loading transitions.** When your data finishes loading, the page now fades in smoothly instead of appearing all at once. This applies across Dashboard, Journeys, Funnels, Uptime, Settings, Notifications, and shared dashboards. If your data was already cached from a previous visit, it still loads instantly with no animation — the fade only kicks in when you're actually waiting for fresh data.
|
||||||
|
- **Faster tab switching across the board.** Switching between Settings, Funnels, Uptime, and other tabs now shows your data instantly instead of flashing a loading skeleton every time. Previously visited tabs remember their data and show it right away, while quietly refreshing in the background so you always see the latest numbers without the wait.
|
||||||
|
- **Smoother loading on the Journeys page.** The Journeys tab now shows a polished skeleton placeholder while data loads, matching the loading experience on other tabs.
|
||||||
|
- **Consistent chart colors.** All dashboard charts — Unique Visitors, Total Pageviews, Bounce Rate, and Visit Duration — now use the same brand orange color for a cleaner, more cohesive look.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **No more random errors when switching tabs.** Navigating between Dashboard, Funnels, Uptime, and Settings no longer shows "Invalid credentials", "Something went wrong", or "Site not found" errors. This was caused by a timing issue when your login session refreshed in the background while multiple pages were loading at the same time — all those requests now wait for the refresh to finish and retry cleanly.
|
- **No more random errors when switching tabs.** Navigating between Dashboard, Funnels, Uptime, and Settings no longer shows "Invalid credentials", "Something went wrong", or "Site not found" errors. This was caused by a timing issue when your login session refreshed in the background while multiple pages were loading at the same time — all those requests now wait for the refresh to finish and retry cleanly.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
import { getAuthErrorMessage } from '@ciphera-net/ui'
|
import { getAuthErrorMessage } from '@ciphera-net/ui'
|
||||||
import { formatTimeAgo, getTypeIcon } from '@/lib/utils/notifications'
|
import { formatTimeAgo, getTypeIcon } from '@/lib/utils/notifications'
|
||||||
import { Button, ArrowLeftIcon } from '@ciphera-net/ui'
|
import { Button, ArrowLeftIcon } from '@ciphera-net/ui'
|
||||||
import { NotificationsListSkeleton, useMinimumLoading } from '@/components/skeletons'
|
import { NotificationsListSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
|
||||||
import { toast } from '@ciphera-net/ui'
|
import { toast } from '@ciphera-net/ui'
|
||||||
|
|
||||||
const PAGE_SIZE = 50
|
const PAGE_SIZE = 50
|
||||||
@@ -31,6 +31,7 @@ export default function NotificationsPage() {
|
|||||||
const [hasMore, setHasMore] = useState(true)
|
const [hasMore, setHasMore] = useState(true)
|
||||||
const [loadingMore, setLoadingMore] = useState(false)
|
const [loadingMore, setLoadingMore] = useState(false)
|
||||||
const showSkeleton = useMinimumLoading(loading)
|
const showSkeleton = useMinimumLoading(loading)
|
||||||
|
const fadeClass = useSkeletonFade(showSkeleton)
|
||||||
|
|
||||||
const fetchPage = async (pageOffset: number, append: boolean) => {
|
const fetchPage = async (pageOffset: number, append: boolean) => {
|
||||||
if (append) setLoadingMore(true)
|
if (append) setLoadingMore(true)
|
||||||
@@ -104,7 +105,7 @@ export default function NotificationsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
|
<div className={`w-full max-w-6xl mx-auto px-4 sm:px-6 py-8 ${fadeClass}`}>
|
||||||
<div className="max-w-2xl mx-auto">
|
<div className="max-w-2xl mx-auto">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import Locations from '@/components/dashboard/Locations'
|
|||||||
import TechSpecs from '@/components/dashboard/TechSpecs'
|
import TechSpecs from '@/components/dashboard/TechSpecs'
|
||||||
import PerformanceStats from '@/components/dashboard/PerformanceStats'
|
import PerformanceStats from '@/components/dashboard/PerformanceStats'
|
||||||
import { Select, DatePicker as DatePickerModal, Captcha, DownloadIcon, ZapIcon } from '@ciphera-net/ui'
|
import { Select, DatePicker as DatePickerModal, Captcha, DownloadIcon, ZapIcon } from '@ciphera-net/ui'
|
||||||
import { DashboardSkeleton, useMinimumLoading } from '@/components/skeletons'
|
import { DashboardSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
|
||||||
import ExportModal from '@/components/dashboard/ExportModal'
|
import ExportModal from '@/components/dashboard/ExportModal'
|
||||||
import { FAVICON_SERVICE_URL } from '@/lib/utils/icons'
|
import { FAVICON_SERVICE_URL } from '@/lib/utils/icons'
|
||||||
|
|
||||||
@@ -198,6 +198,7 @@ export default function PublicDashboardPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const showSkeleton = useMinimumLoading(loading && !data && !isPasswordProtected)
|
const showSkeleton = useMinimumLoading(loading && !data && !isPasswordProtected)
|
||||||
|
const fadeClass = useSkeletonFade(showSkeleton)
|
||||||
|
|
||||||
if (showSkeleton) {
|
if (showSkeleton) {
|
||||||
return <DashboardSkeleton />
|
return <DashboardSkeleton />
|
||||||
@@ -274,7 +275,7 @@ export default function PublicDashboardPage() {
|
|||||||
const safeScreenResolutions = screen_resolutions || []
|
const safeScreenResolutions = screen_resolutions || []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className={`min-h-screen ${fadeClass}`}>
|
||||||
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
|
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useParams, useRouter } from 'next/navigation'
|
|||||||
import { ApiError } from '@/lib/api/client'
|
import { ApiError } from '@/lib/api/client'
|
||||||
import { getFunnel, getFunnelStats, deleteFunnel, type Funnel, type FunnelStats } from '@/lib/api/funnels'
|
import { getFunnel, getFunnelStats, deleteFunnel, type Funnel, type FunnelStats } from '@/lib/api/funnels'
|
||||||
import { toast, Select, DatePicker, ChevronLeftIcon, ArrowRightIcon, TrashIcon, Button } from '@ciphera-net/ui'
|
import { toast, Select, DatePicker, ChevronLeftIcon, ArrowRightIcon, TrashIcon, Button } from '@ciphera-net/ui'
|
||||||
import { FunnelDetailSkeleton, useMinimumLoading } from '@/components/skeletons'
|
import { FunnelDetailSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { FunnelChart } from '@/components/ui/funnel-chart'
|
import { FunnelChart } from '@/components/ui/funnel-chart'
|
||||||
import { getDateRange } from '@ciphera-net/ui'
|
import { getDateRange } from '@ciphera-net/ui'
|
||||||
@@ -62,6 +62,7 @@ export default function FunnelReportPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const showSkeleton = useMinimumLoading(loading && !funnel)
|
const showSkeleton = useMinimumLoading(loading && !funnel)
|
||||||
|
const fadeClass = useSkeletonFade(showSkeleton)
|
||||||
|
|
||||||
if (showSkeleton) {
|
if (showSkeleton) {
|
||||||
return <FunnelDetailSkeleton />
|
return <FunnelDetailSkeleton />
|
||||||
@@ -113,7 +114,7 @@ export default function FunnelReportPage() {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
|
<div className={`w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
|
||||||
import { useParams, useRouter } from 'next/navigation'
|
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 { toast, PlusIcon, ArrowRightIcon, ChevronLeftIcon, TrashIcon, Button } from '@ciphera-net/ui'
|
||||||
import { FunnelsListSkeleton, useMinimumLoading } from '@/components/skeletons'
|
import { FunnelsListSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
|
||||||
export default function FunnelsPage() {
|
export default function FunnelsPage() {
|
||||||
@@ -12,24 +12,7 @@ export default function FunnelsPage() {
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const siteId = params.id as string
|
const siteId = params.id as string
|
||||||
|
|
||||||
const [funnels, setFunnels] = useState<Funnel[]>([])
|
const { data: funnels = [], isLoading, mutate } = useFunnels(siteId)
|
||||||
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 handleDelete = async (e: React.MouseEvent, funnelId: string) => {
|
const handleDelete = async (e: React.MouseEvent, funnelId: string) => {
|
||||||
e.preventDefault() // Prevent navigation
|
e.preventDefault() // Prevent navigation
|
||||||
@@ -38,20 +21,21 @@ export default function FunnelsPage() {
|
|||||||
try {
|
try {
|
||||||
await deleteFunnel(siteId, funnelId)
|
await deleteFunnel(siteId, funnelId)
|
||||||
toast.success('Funnel deleted')
|
toast.success('Funnel deleted')
|
||||||
loadFunnels()
|
mutate()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error('Failed to delete funnel')
|
toast.error('Failed to delete funnel')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const showSkeleton = useMinimumLoading(loading)
|
const showSkeleton = useMinimumLoading(isLoading && !funnels.length)
|
||||||
|
const fadeClass = useSkeletonFade(showSkeleton)
|
||||||
|
|
||||||
if (showSkeleton) {
|
if (showSkeleton) {
|
||||||
return <FunnelsListSkeleton />
|
return <FunnelsListSkeleton />
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
|
<div className={`w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { getDateRange, formatDate } from '@ciphera-net/ui'
|
|||||||
import { Select, DatePicker } from '@ciphera-net/ui'
|
import { Select, DatePicker } from '@ciphera-net/ui'
|
||||||
import SankeyDiagram from '@/components/journeys/SankeyDiagram'
|
import SankeyDiagram from '@/components/journeys/SankeyDiagram'
|
||||||
import TopPathsTable from '@/components/journeys/TopPathsTable'
|
import TopPathsTable from '@/components/journeys/TopPathsTable'
|
||||||
import { SkeletonCard } from '@/components/skeletons'
|
import { JourneysSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
|
||||||
import {
|
import {
|
||||||
useDashboard,
|
useDashboard,
|
||||||
useJourneyTransitions,
|
useJourneyTransitions,
|
||||||
@@ -52,6 +52,9 @@ export default function JourneysPage() {
|
|||||||
document.title = domain ? `Journeys \u00b7 ${domain} | Pulse` : 'Journeys | Pulse'
|
document.title = domain ? `Journeys \u00b7 ${domain} | Pulse` : 'Journeys | Pulse'
|
||||||
}, [dashboard?.site?.domain])
|
}, [dashboard?.site?.domain])
|
||||||
|
|
||||||
|
const showSkeleton = useMinimumLoading(transitionsLoading && !transitionsData)
|
||||||
|
const fadeClass = useSkeletonFade(showSkeleton)
|
||||||
|
|
||||||
const entryPointOptions = [
|
const entryPointOptions = [
|
||||||
{ value: '', label: 'All entry points' },
|
{ value: '', label: 'All entry points' },
|
||||||
...(entryPoints ?? []).map((ep) => ({
|
...(entryPoints ?? []).map((ep) => ({
|
||||||
@@ -60,8 +63,10 @@ export default function JourneysPage() {
|
|||||||
})),
|
})),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if (showSkeleton) return <JourneysSkeleton />
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
|
<div className={`w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -146,18 +151,12 @@ export default function JourneysPage() {
|
|||||||
|
|
||||||
{/* Sankey Diagram */}
|
{/* Sankey Diagram */}
|
||||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 mb-6">
|
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 mb-6">
|
||||||
{transitionsLoading ? (
|
|
||||||
<div className="h-[400px] flex items-center justify-center">
|
|
||||||
<SkeletonCard className="w-full h-full" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<SankeyDiagram
|
<SankeyDiagram
|
||||||
transitions={transitionsData?.transitions ?? []}
|
transitions={transitionsData?.transitions ?? []}
|
||||||
totalSessions={transitionsData?.total_sessions ?? 0}
|
totalSessions={transitionsData?.total_sessions ?? 0}
|
||||||
depth={depth}
|
depth={depth}
|
||||||
onNodeClick={(path) => setEntryPath(path)}
|
onNodeClick={(path) => setEntryPath(path)}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Top Paths */}
|
{/* Top Paths */}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import { toast } from '@ciphera-net/ui'
|
|||||||
import { Button } from '@ciphera-net/ui'
|
import { Button } from '@ciphera-net/ui'
|
||||||
import { Select, DatePicker, DownloadIcon } from '@ciphera-net/ui'
|
import { Select, DatePicker, DownloadIcon } from '@ciphera-net/ui'
|
||||||
import dynamic from 'next/dynamic'
|
import dynamic from 'next/dynamic'
|
||||||
import { DashboardSkeleton, useMinimumLoading } from '@/components/skeletons'
|
import { DashboardSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
|
||||||
import FilterBar from '@/components/dashboard/FilterBar'
|
import FilterBar from '@/components/dashboard/FilterBar'
|
||||||
import AddFilterDropdown, { type FilterSuggestion, type FilterSuggestions } from '@/components/dashboard/AddFilterDropdown'
|
import AddFilterDropdown, { type FilterSuggestion, type FilterSuggestions } from '@/components/dashboard/AddFilterDropdown'
|
||||||
import Chart from '@/components/dashboard/Chart'
|
import Chart from '@/components/dashboard/Chart'
|
||||||
@@ -423,6 +423,7 @@ export default function SiteDashboardPage() {
|
|||||||
// Skip the minimum-loading skeleton when SWR already has cached data
|
// Skip the minimum-loading skeleton when SWR already has cached data
|
||||||
// (prevents the 300ms flash when navigating back to the dashboard)
|
// (prevents the 300ms flash when navigating back to the dashboard)
|
||||||
const showSkeleton = useMinimumLoading(dashboardLoading && !dashboard)
|
const showSkeleton = useMinimumLoading(dashboardLoading && !dashboard)
|
||||||
|
const fadeClass = useSkeletonFade(showSkeleton)
|
||||||
|
|
||||||
if (showSkeleton) {
|
if (showSkeleton) {
|
||||||
return <DashboardSkeleton />
|
return <DashboardSkeleton />
|
||||||
@@ -437,7 +438,7 @@ export default function SiteDashboardPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
|
<div className={`w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
import { useEffect, useState, useRef } from 'react'
|
import { useEffect, useState, useRef } from 'react'
|
||||||
import { useParams, useRouter } from 'next/navigation'
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
import { getSite, updateSite, resetSiteData, deleteSite, type Site, type GeoDataLevel } from '@/lib/api/sites'
|
import { updateSite, resetSiteData, deleteSite, type Site, type GeoDataLevel } from '@/lib/api/sites'
|
||||||
import { listGoals, createGoal, updateGoal, deleteGoal, type Goal } from '@/lib/api/goals'
|
import { 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 { createReportSchedule, updateReportSchedule, deleteReportSchedule, testReportSchedule, type ReportSchedule, type CreateReportScheduleRequest, type EmailConfig, type WebhookConfig } from '@/lib/api/report-schedules'
|
||||||
import { toast } from '@ciphera-net/ui'
|
import { toast } from '@ciphera-net/ui'
|
||||||
import { getAuthErrorMessage } from '@ciphera-net/ui'
|
import { getAuthErrorMessage } from '@ciphera-net/ui'
|
||||||
import { SettingsFormSkeleton, GoalsListSkeleton, useMinimumLoading } from '@/components/skeletons'
|
import { SettingsFormSkeleton, GoalsListSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
|
||||||
import VerificationModal from '@/components/sites/VerificationModal'
|
import VerificationModal from '@/components/sites/VerificationModal'
|
||||||
import ScriptSetupBlock from '@/components/sites/ScriptSetupBlock'
|
import ScriptSetupBlock from '@/components/sites/ScriptSetupBlock'
|
||||||
import { PasswordInput } from '@ciphera-net/ui'
|
import { PasswordInput } from '@ciphera-net/ui'
|
||||||
@@ -15,7 +15,7 @@ import { Select, Modal, Button } from '@ciphera-net/ui'
|
|||||||
import { APP_URL } from '@/lib/api/client'
|
import { APP_URL } from '@/lib/api/client'
|
||||||
import { generatePrivacySnippet } from '@/lib/utils/privacySnippet'
|
import { generatePrivacySnippet } from '@/lib/utils/privacySnippet'
|
||||||
import { useUnsavedChanges } from '@/lib/hooks/useUnsavedChanges'
|
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 { getRetentionOptionsForPlan, formatRetentionMonths } from '@/lib/plans'
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
import { useAuth } from '@/lib/auth/context'
|
import { useAuth } from '@/lib/auth/context'
|
||||||
@@ -53,8 +53,7 @@ export default function SiteSettingsPage() {
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const siteId = params.id as string
|
const siteId = params.id as string
|
||||||
|
|
||||||
const [site, setSite] = useState<Site | null>(null)
|
const { data: site, isLoading: siteLoading, mutate: mutateSite } = useSite(siteId)
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [activeTab, setActiveTab] = useState<'general' | 'visibility' | 'data' | 'goals' | 'reports'>('general')
|
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 (6 = free-tier max; safe default)
|
||||||
data_retention_months: 6
|
data_retention_months: 6
|
||||||
})
|
})
|
||||||
const [subscription, setSubscription] = useState<SubscriptionDetails | null>(null)
|
const { data: subscription, error: subscriptionError, mutate: mutateSubscription } = useSubscription()
|
||||||
const [subscriptionLoadFailed, setSubscriptionLoadFailed] = useState(false)
|
|
||||||
const [linkCopied, setLinkCopied] = useState(false)
|
const [linkCopied, setLinkCopied] = useState(false)
|
||||||
const [snippetCopied, setSnippetCopied] = useState(false)
|
const [snippetCopied, setSnippetCopied] = useState(false)
|
||||||
const [showVerificationModal, setShowVerificationModal] = useState(false)
|
const [showVerificationModal, setShowVerificationModal] = useState(false)
|
||||||
const [isPasswordEnabled, setIsPasswordEnabled] = useState(false)
|
const [isPasswordEnabled, setIsPasswordEnabled] = useState(false)
|
||||||
const [goals, setGoals] = useState<Goal[]>([])
|
const { data: goals = [], isLoading: goalsLoading, mutate: mutateGoals } = useGoals(siteId)
|
||||||
const [goalsLoading, setGoalsLoading] = useState(false)
|
|
||||||
const [goalModalOpen, setGoalModalOpen] = useState(false)
|
const [goalModalOpen, setGoalModalOpen] = useState(false)
|
||||||
const [editingGoal, setEditingGoal] = useState<Goal | null>(null)
|
const [editingGoal, setEditingGoal] = useState<Goal | null>(null)
|
||||||
const [goalForm, setGoalForm] = useState({ name: '', event_name: '' })
|
const [goalForm, setGoalForm] = useState({ name: '', event_name: '' })
|
||||||
const [goalSaving, setGoalSaving] = useState(false)
|
const [goalSaving, setGoalSaving] = useState(false)
|
||||||
const initialFormRef = useRef<string>('')
|
const initialFormRef = useRef<string>('')
|
||||||
|
|
||||||
// Report schedules state
|
// Report schedules
|
||||||
const [reportSchedules, setReportSchedules] = useState<ReportSchedule[]>([])
|
const { data: reportSchedules = [], isLoading: reportLoading, mutate: mutateReportSchedules } = useReportSchedules(siteId)
|
||||||
const [reportLoading, setReportLoading] = useState(false)
|
|
||||||
const [reportModalOpen, setReportModalOpen] = useState(false)
|
const [reportModalOpen, setReportModalOpen] = useState(false)
|
||||||
const [editingSchedule, setEditingSchedule] = useState<ReportSchedule | null>(null)
|
const [editingSchedule, setEditingSchedule] = useState<ReportSchedule | null>(null)
|
||||||
const [reportSaving, setReportSaving] = useState(false)
|
const [reportSaving, setReportSaving] = useState(false)
|
||||||
@@ -112,32 +108,40 @@ export default function SiteSettingsPage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadSite()
|
if (!site) return
|
||||||
loadSubscription()
|
setFormData({
|
||||||
}, [siteId])
|
name: site.name,
|
||||||
|
timezone: site.timezone || 'UTC',
|
||||||
useEffect(() => {
|
is_public: site.is_public || false,
|
||||||
if (activeTab === 'goals' && siteId) {
|
password: '',
|
||||||
loadGoals()
|
excluded_paths: (site.excluded_paths || []).join('\n'),
|
||||||
}
|
collect_page_paths: site.collect_page_paths ?? true,
|
||||||
}, [activeTab, siteId])
|
collect_referrers: site.collect_referrers ?? true,
|
||||||
|
collect_device_info: site.collect_device_info ?? true,
|
||||||
useEffect(() => {
|
collect_geo_data: site.collect_geo_data || 'full',
|
||||||
if (activeTab === 'reports' && siteId) {
|
collect_screen_resolution: site.collect_screen_resolution ?? true,
|
||||||
loadReportSchedules()
|
enable_performance_insights: site.enable_performance_insights ?? false,
|
||||||
}
|
filter_bots: site.filter_bots ?? true,
|
||||||
}, [activeTab, siteId])
|
hide_unknown_locations: site.hide_unknown_locations ?? false,
|
||||||
|
data_retention_months: site.data_retention_months ?? 6
|
||||||
const loadSubscription = async () => {
|
})
|
||||||
try {
|
initialFormRef.current = JSON.stringify({
|
||||||
setSubscriptionLoadFailed(false)
|
name: site.name,
|
||||||
const sub = await getSubscription()
|
timezone: site.timezone || 'UTC',
|
||||||
setSubscription(sub)
|
is_public: site.is_public || false,
|
||||||
} catch (e) {
|
excluded_paths: (site.excluded_paths || []).join('\n'),
|
||||||
setSubscriptionLoadFailed(true)
|
collect_page_paths: site.collect_page_paths ?? true,
|
||||||
toast.error(getAuthErrorMessage(e as Error) || 'Could not load plan limits. Showing default options.')
|
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
|
// * Snap data_retention_months to nearest valid option when subscription loads
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -152,83 +156,6 @@ export default function SiteSettingsPage() {
|
|||||||
})
|
})
|
||||||
}, [subscription])
|
}, [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 = () => {
|
const resetReportForm = () => {
|
||||||
setReportForm({
|
setReportForm({
|
||||||
channel: 'email',
|
channel: 'email',
|
||||||
@@ -297,7 +224,7 @@ export default function SiteSettingsPage() {
|
|||||||
toast.success('Report schedule created')
|
toast.success('Report schedule created')
|
||||||
}
|
}
|
||||||
setReportModalOpen(false)
|
setReportModalOpen(false)
|
||||||
loadReportSchedules()
|
mutateReportSchedules()
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
toast.error(getAuthErrorMessage(error) || 'Failed to save report schedule')
|
toast.error(getAuthErrorMessage(error) || 'Failed to save report schedule')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -310,7 +237,7 @@ export default function SiteSettingsPage() {
|
|||||||
try {
|
try {
|
||||||
await deleteReportSchedule(siteId, schedule.id)
|
await deleteReportSchedule(siteId, schedule.id)
|
||||||
toast.success('Report schedule deleted')
|
toast.success('Report schedule deleted')
|
||||||
loadReportSchedules()
|
mutateReportSchedules()
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
toast.error(getAuthErrorMessage(error) || 'Failed to delete report schedule')
|
toast.error(getAuthErrorMessage(error) || 'Failed to delete report schedule')
|
||||||
}
|
}
|
||||||
@@ -320,7 +247,7 @@ export default function SiteSettingsPage() {
|
|||||||
try {
|
try {
|
||||||
await updateReportSchedule(siteId, schedule.id, { enabled: !schedule.enabled })
|
await updateReportSchedule(siteId, schedule.id, { enabled: !schedule.enabled })
|
||||||
toast.success(schedule.enabled ? 'Report paused' : 'Report enabled')
|
toast.success(schedule.enabled ? 'Report paused' : 'Report enabled')
|
||||||
loadReportSchedules()
|
mutateReportSchedules()
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
toast.error(getAuthErrorMessage(error) || 'Failed to update report schedule')
|
toast.error(getAuthErrorMessage(error) || 'Failed to update report schedule')
|
||||||
}
|
}
|
||||||
@@ -439,7 +366,7 @@ export default function SiteSettingsPage() {
|
|||||||
toast.success('Goal created')
|
toast.success('Goal created')
|
||||||
}
|
}
|
||||||
setGoalModalOpen(false)
|
setGoalModalOpen(false)
|
||||||
loadGoals()
|
mutateGoals()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(getAuthErrorMessage(err as Error) || 'Failed to save goal')
|
toast.error(getAuthErrorMessage(err as Error) || 'Failed to save goal')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -452,7 +379,7 @@ export default function SiteSettingsPage() {
|
|||||||
try {
|
try {
|
||||||
await deleteGoal(siteId, goal.id)
|
await deleteGoal(siteId, goal.id)
|
||||||
toast.success('Goal deleted')
|
toast.success('Goal deleted')
|
||||||
loadGoals()
|
mutateGoals()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(getAuthErrorMessage(err as Error) || 'Failed to delete goal')
|
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,
|
hide_unknown_locations: formData.hide_unknown_locations,
|
||||||
data_retention_months: formData.data_retention_months
|
data_retention_months: formData.data_retention_months
|
||||||
})
|
})
|
||||||
loadSite()
|
mutateSite()
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
toast.error(getAuthErrorMessage(error) || 'Failed to save site settings')
|
toast.error(getAuthErrorMessage(error) || 'Failed to save site settings')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -581,7 +508,8 @@ export default function SiteSettingsPage() {
|
|||||||
if (site?.domain) document.title = `Settings · ${site.domain} | Pulse`
|
if (site?.domain) document.title = `Settings · ${site.domain} | Pulse`
|
||||||
}, [site?.domain])
|
}, [site?.domain])
|
||||||
|
|
||||||
const showSkeleton = useMinimumLoading(loading)
|
const showSkeleton = useMinimumLoading(siteLoading && !site)
|
||||||
|
const fadeClass = useSkeletonFade(showSkeleton)
|
||||||
|
|
||||||
if (showSkeleton) {
|
if (showSkeleton) {
|
||||||
return (
|
return (
|
||||||
@@ -615,7 +543,7 @@ export default function SiteSettingsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
|
<div className={`w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
|
||||||
|
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div>
|
<div>
|
||||||
@@ -1157,14 +1085,14 @@ export default function SiteSettingsPage() {
|
|||||||
{/* Data Retention */}
|
{/* Data Retention */}
|
||||||
<div className="space-y-4 pt-6 border-t border-neutral-100 dark:border-neutral-800">
|
<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>
|
<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">
|
<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">
|
<p className="text-sm text-amber-800 dark:text-amber-200">
|
||||||
Plan limits could not be loaded. Options shown may be limited.
|
Plan limits could not be loaded. Options shown may be limited.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={loadSubscription}
|
onClick={() => mutateSubscription()}
|
||||||
className="shrink-0 text-sm font-medium text-amber-800 dark:text-amber-200 hover:underline"
|
className="shrink-0 text-sm font-medium text-amber-800 dark:text-amber-200 hover:underline"
|
||||||
>
|
>
|
||||||
Retry
|
Retry
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useAuth } from '@/lib/auth/context'
|
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 { useParams, useRouter } from 'next/navigation'
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
import { getSite, type Site } from '@/lib/api/sites'
|
import { useSite, useUptimeStatus } from '@/lib/swr/dashboard'
|
||||||
import {
|
import {
|
||||||
getUptimeStatus,
|
|
||||||
createUptimeMonitor,
|
createUptimeMonitor,
|
||||||
updateUptimeMonitor,
|
updateUptimeMonitor,
|
||||||
deleteUptimeMonitor,
|
deleteUptimeMonitor,
|
||||||
@@ -21,7 +20,7 @@ import { toast } from '@ciphera-net/ui'
|
|||||||
import { useTheme } from '@ciphera-net/ui'
|
import { useTheme } from '@ciphera-net/ui'
|
||||||
import { getAuthErrorMessage } from '@ciphera-net/ui'
|
import { getAuthErrorMessage } from '@ciphera-net/ui'
|
||||||
import { Button, Modal } from '@ciphera-net/ui'
|
import { Button, Modal } from '@ciphera-net/ui'
|
||||||
import { UptimeSkeleton, ChecksSkeleton, useMinimumLoading } from '@/components/skeletons'
|
import { UptimeSkeleton, ChecksSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
|
||||||
import {
|
import {
|
||||||
AreaChart,
|
AreaChart,
|
||||||
Area,
|
Area,
|
||||||
@@ -561,9 +560,8 @@ export default function UptimePage() {
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const siteId = params.id as string
|
const siteId = params.id as string
|
||||||
|
|
||||||
const [site, setSite] = useState<Site | null>(null)
|
const { data: site } = useSite(siteId)
|
||||||
const [loading, setLoading] = useState(true)
|
const { data: uptimeData, isLoading, mutate: mutateUptime } = useUptimeStatus(siteId)
|
||||||
const [uptimeData, setUptimeData] = useState<UptimeStatusResponse | null>(null)
|
|
||||||
const [expandedMonitor, setExpandedMonitor] = useState<string | null>(null)
|
const [expandedMonitor, setExpandedMonitor] = useState<string | null>(null)
|
||||||
const [showAddModal, setShowAddModal] = useState(false)
|
const [showAddModal, setShowAddModal] = useState(false)
|
||||||
const [showEditModal, setShowEditModal] = useState(false)
|
const [showEditModal, setShowEditModal] = useState(false)
|
||||||
@@ -577,38 +575,6 @@ export default function UptimePage() {
|
|||||||
})
|
})
|
||||||
const [saving, setSaving] = useState(false)
|
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 () => {
|
const handleAddMonitor = async () => {
|
||||||
if (!formData.name || !formData.url) {
|
if (!formData.name || !formData.url) {
|
||||||
toast.error('Name and URL are required')
|
toast.error('Name and URL are required')
|
||||||
@@ -620,7 +586,7 @@ export default function UptimePage() {
|
|||||||
toast.success('Monitor created successfully')
|
toast.success('Monitor created successfully')
|
||||||
setShowAddModal(false)
|
setShowAddModal(false)
|
||||||
setFormData({ name: '', url: '', check_interval_seconds: 300, expected_status_code: 200, timeout_seconds: 30 })
|
setFormData({ name: '', url: '', check_interval_seconds: 300, expected_status_code: 200, timeout_seconds: 30 })
|
||||||
await loadData()
|
mutateUptime()
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
toast.error(getAuthErrorMessage(error) || 'Failed to create monitor')
|
toast.error(getAuthErrorMessage(error) || 'Failed to create monitor')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -643,7 +609,7 @@ export default function UptimePage() {
|
|||||||
toast.success('Monitor updated successfully')
|
toast.success('Monitor updated successfully')
|
||||||
setShowEditModal(false)
|
setShowEditModal(false)
|
||||||
setEditingMonitor(null)
|
setEditingMonitor(null)
|
||||||
await loadData()
|
mutateUptime()
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
toast.error(getAuthErrorMessage(error) || 'Failed to update monitor')
|
toast.error(getAuthErrorMessage(error) || 'Failed to update monitor')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -656,7 +622,7 @@ export default function UptimePage() {
|
|||||||
try {
|
try {
|
||||||
await deleteUptimeMonitor(siteId, monitorId)
|
await deleteUptimeMonitor(siteId, monitorId)
|
||||||
toast.success('Monitor deleted')
|
toast.success('Monitor deleted')
|
||||||
await loadData()
|
mutateUptime()
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
toast.error(getAuthErrorMessage(error) || 'Failed to delete monitor')
|
toast.error(getAuthErrorMessage(error) || 'Failed to delete monitor')
|
||||||
}
|
}
|
||||||
@@ -678,7 +644,8 @@ export default function UptimePage() {
|
|||||||
if (site?.domain) document.title = `Uptime · ${site.domain} | Pulse`
|
if (site?.domain) document.title = `Uptime · ${site.domain} | Pulse`
|
||||||
}, [site?.domain])
|
}, [site?.domain])
|
||||||
|
|
||||||
const showSkeleton = useMinimumLoading(loading)
|
const showSkeleton = useMinimumLoading(isLoading && !uptimeData)
|
||||||
|
const fadeClass = useSkeletonFade(showSkeleton)
|
||||||
|
|
||||||
if (showSkeleton) return <UptimeSkeleton />
|
if (showSkeleton) return <UptimeSkeleton />
|
||||||
if (!site) return <div className="p-8 text-neutral-500">Site not found</div>
|
if (!site) return <div className="p-8 text-neutral-500">Site not found</div>
|
||||||
@@ -688,7 +655,7 @@ export default function UptimePage() {
|
|||||||
const overallStatus = uptimeData?.status ?? 'operational'
|
const overallStatus = uptimeData?.status ?? 'operational'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
|
<div className={`w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useMemo, useRef, useCallback, useEffect } from 'react'
|
import { useState, useMemo, useRef, useCallback, useEffect } from 'react'
|
||||||
import { useTheme } from '@ciphera-net/ui'
|
import { useTheme } from '@ciphera-net/ui'
|
||||||
import { Line, LineChart, XAxis, YAxis, ReferenceLine } from 'recharts'
|
import { CartesianGrid, Line, LineChart, XAxis, YAxis, ReferenceLine } from 'recharts'
|
||||||
import { ChartContainer, ChartTooltip, type ChartConfig } from '@/components/ui/line-charts-6'
|
import { ChartContainer, ChartTooltip, type ChartConfig } from '@/components/ui/line-charts-6'
|
||||||
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
||||||
import { formatNumber, formatDuration, formatUpdatedAgo, DatePicker } from '@ciphera-net/ui'
|
import { formatNumber, formatDuration, formatUpdatedAgo, DatePicker } from '@ciphera-net/ui'
|
||||||
@@ -104,9 +104,9 @@ const METRIC_CONFIGS: {
|
|||||||
|
|
||||||
const chartConfig = {
|
const chartConfig = {
|
||||||
visitors: { label: 'Unique Visitors', color: '#FD5E0F' },
|
visitors: { label: 'Unique Visitors', color: '#FD5E0F' },
|
||||||
pageviews: { label: 'Total Pageviews', color: '#3b82f6' },
|
pageviews: { label: 'Total Pageviews', color: '#FD5E0F' },
|
||||||
bounce_rate: { label: 'Bounce Rate', color: '#a855f7' },
|
bounce_rate: { label: 'Bounce Rate', color: '#FD5E0F' },
|
||||||
avg_duration: { label: 'Visit Duration', color: '#10b981' },
|
avg_duration: { label: 'Visit Duration', color: '#FD5E0F' },
|
||||||
} satisfies ChartConfig
|
} satisfies ChartConfig
|
||||||
|
|
||||||
// ─── Custom Tooltip ─────────────────────────────────────────────────
|
// ─── Custom Tooltip ─────────────────────────────────────────────────
|
||||||
@@ -351,7 +351,7 @@ export default function Chart({
|
|||||||
metric === m.key && 'bg-neutral-50 dark:bg-neutral-800/40',
|
metric === m.key && 'bg-neutral-50 dark:bg-neutral-800/40',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className={cn('text-[10px] font-semibold uppercase tracking-widest mb-2', metric === m.key ? 'text-white' : 'text-neutral-400 dark:text-neutral-500')}>{m.label}</div>
|
<div className={cn('text-[10px] font-semibold uppercase tracking-widest mb-2', metric === m.key ? 'text-brand-orange' : 'text-neutral-400 dark:text-neutral-500')}>{m.label}</div>
|
||||||
<div className="flex items-baseline gap-2">
|
<div className="flex items-baseline gap-2">
|
||||||
<span className="text-2xl font-bold text-neutral-900 dark:text-white">{m.format(m.value)}</span>
|
<span className="text-2xl font-bold text-neutral-900 dark:text-white">{m.format(m.value)}</span>
|
||||||
{m.change !== null && (
|
{m.change !== null && (
|
||||||
@@ -462,9 +462,6 @@ export default function Chart({
|
|||||||
style={{ overflow: 'visible' }}
|
style={{ overflow: 'visible' }}
|
||||||
>
|
>
|
||||||
<defs>
|
<defs>
|
||||||
<pattern id="dotGrid" x="0" y="0" width="20" height="20" patternUnits="userSpaceOnUse">
|
|
||||||
<circle cx="10" cy="10" r="1" fill="var(--chart-grid)" fillOpacity="1" />
|
|
||||||
</pattern>
|
|
||||||
<filter id="lineShadow" x="-100%" y="-100%" width="300%" height="300%">
|
<filter id="lineShadow" x="-100%" y="-100%" width="300%" height="300%">
|
||||||
<feDropShadow
|
<feDropShadow
|
||||||
dx="4"
|
dx="4"
|
||||||
@@ -478,6 +475,13 @@ export default function Chart({
|
|||||||
</filter>
|
</filter>
|
||||||
</defs>
|
</defs>
|
||||||
|
|
||||||
|
<CartesianGrid
|
||||||
|
horizontal={true}
|
||||||
|
vertical={false}
|
||||||
|
stroke="var(--chart-grid)"
|
||||||
|
strokeOpacity={0.7}
|
||||||
|
/>
|
||||||
|
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="date"
|
dataKey="date"
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
@@ -501,15 +505,6 @@ export default function Chart({
|
|||||||
|
|
||||||
<ChartTooltip content={<CustomTooltip metric={metric} />} cursor={{ strokeDasharray: '3 3', stroke: '#9ca3af' }} />
|
<ChartTooltip content={<CustomTooltip metric={metric} />} cursor={{ strokeDasharray: '3 3', stroke: '#9ca3af' }} />
|
||||||
|
|
||||||
{/* Background dot grid pattern */}
|
|
||||||
<rect
|
|
||||||
x="60px"
|
|
||||||
y="-20px"
|
|
||||||
width="calc(100% - 75px)"
|
|
||||||
height="calc(100% - 10px)"
|
|
||||||
fill="url(#dotGrid)"
|
|
||||||
style={{ pointerEvents: 'none' }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Annotation reference lines */}
|
{/* Annotation reference lines */}
|
||||||
{visibleAnnotationMarkers.map((marker) => {
|
{visibleAnnotationMarkers.map((marker) => {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
const SK = 'animate-pulse bg-neutral-100 dark:bg-neutral-800'
|
const SK = 'animate-pulse bg-neutral-100 dark:bg-neutral-800'
|
||||||
|
|
||||||
export { useMinimumLoading } from './useMinimumLoading'
|
export { useMinimumLoading, useSkeletonFade } from './useMinimumLoading'
|
||||||
|
|
||||||
// ─── Primitives ──────────────────────────────────────────────
|
// ─── Primitives ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -32,3 +32,19 @@ export function useMinimumLoading(loading: boolean, minMs = 300): boolean {
|
|||||||
|
|
||||||
return show
|
return show
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns 'animate-fade-in' when transitioning from skeleton to content,
|
||||||
|
* empty string otherwise. Prevents the jarring visual "pop" when skeletons
|
||||||
|
* are replaced by real content, without adding unnecessary animation when
|
||||||
|
* data loads from cache (no skeleton shown).
|
||||||
|
*/
|
||||||
|
export function useSkeletonFade(showSkeleton: boolean): string {
|
||||||
|
const wasEverLoading = useRef(false)
|
||||||
|
|
||||||
|
if (showSkeleton) {
|
||||||
|
wasEverLoading.current = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return !showSkeleton && wasEverLoading.current ? 'animate-fade-in' : ''
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ import { listAnnotations } from '@/lib/api/annotations'
|
|||||||
import type { Annotation } from '@/lib/api/annotations'
|
import type { Annotation } from '@/lib/api/annotations'
|
||||||
import { getSite } from '@/lib/api/sites'
|
import { getSite } from '@/lib/api/sites'
|
||||||
import type { Site } from '@/lib/api/sites'
|
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 { getSubscription, type SubscriptionDetails } from '@/lib/api/billing'
|
||||||
import type {
|
import type {
|
||||||
Stats,
|
Stats,
|
||||||
DailyStat,
|
DailyStat,
|
||||||
@@ -69,6 +74,11 @@ const fetchers = {
|
|||||||
getJourneyTopPaths(siteId, start, end, { limit, minSessions, entryPath }),
|
getJourneyTopPaths(siteId, start, end, { limit, minSessions, entryPath }),
|
||||||
journeyEntryPoints: (siteId: string, start: string, end: string) =>
|
journeyEntryPoints: (siteId: string, start: string, end: string) =>
|
||||||
getJourneyEntryPoints(siteId, start, end),
|
getJourneyEntryPoints(siteId, start, end),
|
||||||
|
funnels: (siteId: string) => listFunnels(siteId),
|
||||||
|
uptimeStatus: (siteId: string) => getUptimeStatus(siteId),
|
||||||
|
goals: (siteId: string) => listGoals(siteId),
|
||||||
|
reportSchedules: (siteId: string) => listReportSchedules(siteId),
|
||||||
|
subscription: () => getSubscription(),
|
||||||
}
|
}
|
||||||
|
|
||||||
// * Standard SWR config for dashboard data
|
// * Standard SWR config for dashboard data
|
||||||
@@ -334,5 +344,71 @@ export function useJourneyEntryPoints(siteId: string, start: string, end: string
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// * Hook for funnels list
|
||||||
|
export function useFunnels(siteId: string) {
|
||||||
|
return useSWR<Funnel[]>(
|
||||||
|
siteId ? ['funnels', siteId] : null,
|
||||||
|
() => fetchers.funnels(siteId),
|
||||||
|
{
|
||||||
|
...dashboardSWRConfig,
|
||||||
|
refreshInterval: 60 * 1000,
|
||||||
|
dedupingInterval: 10 * 1000,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// * Hook for uptime status (refreshes every 30s to match original polling)
|
||||||
|
export function useUptimeStatus(siteId: string) {
|
||||||
|
return useSWR<UptimeStatusResponse>(
|
||||||
|
siteId ? ['uptimeStatus', siteId] : null,
|
||||||
|
() => fetchers.uptimeStatus(siteId),
|
||||||
|
{
|
||||||
|
...dashboardSWRConfig,
|
||||||
|
refreshInterval: 30 * 1000,
|
||||||
|
dedupingInterval: 10 * 1000,
|
||||||
|
keepPreviousData: true,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// * Hook for goals list
|
||||||
|
export function useGoals(siteId: string) {
|
||||||
|
return useSWR<Goal[]>(
|
||||||
|
siteId ? ['goals', siteId] : null,
|
||||||
|
() => fetchers.goals(siteId),
|
||||||
|
{
|
||||||
|
...dashboardSWRConfig,
|
||||||
|
refreshInterval: 60 * 1000,
|
||||||
|
dedupingInterval: 10 * 1000,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// * Hook for report schedules
|
||||||
|
export function useReportSchedules(siteId: string) {
|
||||||
|
return useSWR<ReportSchedule[]>(
|
||||||
|
siteId ? ['reportSchedules', siteId] : null,
|
||||||
|
() => fetchers.reportSchedules(siteId),
|
||||||
|
{
|
||||||
|
...dashboardSWRConfig,
|
||||||
|
refreshInterval: 60 * 1000,
|
||||||
|
dedupingInterval: 10 * 1000,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// * Hook for subscription details (changes rarely)
|
||||||
|
export function useSubscription() {
|
||||||
|
return useSWR<SubscriptionDetails>(
|
||||||
|
'subscription',
|
||||||
|
() => fetchers.subscription(),
|
||||||
|
{
|
||||||
|
...dashboardSWRConfig,
|
||||||
|
refreshInterval: 5 * 60 * 1000,
|
||||||
|
dedupingInterval: 30 * 1000,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// * Re-export for convenience
|
// * Re-export for convenience
|
||||||
export { fetchers }
|
export { fetchers }
|
||||||
|
|||||||
@@ -230,25 +230,29 @@
|
|||||||
return cachedSessionId;
|
return cachedSessionId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// * Normalize path: strip trailing slash and ad-platform click/tracking IDs.
|
// * Normalize path: strip trailing slash and all query params except UTM/attribution.
|
||||||
// * UTM params (utm_source, utm_medium, etc.) are intentionally kept in the path
|
// * Allowlist approach — only UTM params pass through because the backend extracts
|
||||||
// * because the backend extracts them for attribution before cleaning the path.
|
// * them for attribution before cleaning the stored path. Everything else (cache-busters,
|
||||||
var STRIP_PARAMS = ['fbclid', 'gclid', 'gad_source', 'msclkid', 'twclid', 'dclid', 'mc_cid', 'mc_eid', 'ad_id', 'adset_id', 'campaign_id', 'ad_name', 'adset_name', 'campaign_name', 'placement', 'site_source_name', 'utm_id'];
|
// * ad click IDs, filter params, etc.) is stripped to prevent path fragmentation.
|
||||||
|
var KEEP_PARAMS = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'source', 'ref'];
|
||||||
function cleanPath() {
|
function cleanPath() {
|
||||||
var pathname = window.location.pathname;
|
var pathname = window.location.pathname;
|
||||||
// * Strip trailing slash (but keep root /)
|
// * Strip trailing slash (but keep root /)
|
||||||
if (pathname.length > 1 && pathname.charAt(pathname.length - 1) === '/') {
|
if (pathname.length > 1 && pathname.charAt(pathname.length - 1) === '/') {
|
||||||
pathname = pathname.slice(0, -1);
|
pathname = pathname.slice(0, -1);
|
||||||
}
|
}
|
||||||
// * Strip UTM/marketing params, keep other query params
|
// * Only keep allowlisted params, strip everything else
|
||||||
var search = window.location.search;
|
var search = window.location.search;
|
||||||
if (search) {
|
if (search) {
|
||||||
try {
|
try {
|
||||||
var params = new URLSearchParams(search);
|
var params = new URLSearchParams(search);
|
||||||
for (var i = 0; i < STRIP_PARAMS.length; i++) {
|
var kept = new URLSearchParams();
|
||||||
params.delete(STRIP_PARAMS[i]);
|
for (var i = 0; i < KEEP_PARAMS.length; i++) {
|
||||||
|
if (params.has(KEEP_PARAMS[i])) {
|
||||||
|
kept.set(KEEP_PARAMS[i], params.get(KEEP_PARAMS[i]));
|
||||||
}
|
}
|
||||||
var remaining = params.toString();
|
}
|
||||||
|
var remaining = kept.toString();
|
||||||
if (remaining) pathname += '?' + remaining;
|
if (remaining) pathname += '?' + remaining;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// * URLSearchParams not supported — send path without query
|
// * URLSearchParams not supported — send path without query
|
||||||
|
|||||||
@@ -23,10 +23,15 @@ const config: Config = {
|
|||||||
'50%': { backgroundColor: 'var(--highlight)' },
|
'50%': { backgroundColor: 'var(--highlight)' },
|
||||||
'100%': { backgroundColor: 'transparent' },
|
'100%': { backgroundColor: 'transparent' },
|
||||||
},
|
},
|
||||||
|
'fade-in': {
|
||||||
|
'0%': { opacity: '0' },
|
||||||
|
'100%': { opacity: '1' },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
animation: {
|
animation: {
|
||||||
'cell-highlight': 'cell-highlight 0.5s ease forwards',
|
'cell-highlight': 'cell-highlight 0.5s ease forwards',
|
||||||
'cell-flash': 'cell-flash 0.6s ease forwards',
|
'cell-flash': 'cell-flash 0.6s ease forwards',
|
||||||
|
'fade-in': 'fade-in 150ms ease-out',
|
||||||
},
|
},
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
sans: ['var(--font-plus-jakarta-sans)', 'system-ui', 'sans-serif'],
|
sans: ['var(--font-plus-jakarta-sans)', 'system-ui', 'sans-serif'],
|
||||||
|
|||||||
Reference in New Issue
Block a user