diff --git a/CHANGELOG.md b/CHANGELOG.md index e8929e5..1e86d4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [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 - **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. diff --git a/app/notifications/page.tsx b/app/notifications/page.tsx index 2b4fe85..1e7ea60 100644 --- a/app/notifications/page.tsx +++ b/app/notifications/page.tsx @@ -16,7 +16,7 @@ import { import { getAuthErrorMessage } from '@ciphera-net/ui' import { formatTimeAgo, getTypeIcon } from '@/lib/utils/notifications' 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' const PAGE_SIZE = 50 @@ -31,6 +31,7 @@ export default function NotificationsPage() { const [hasMore, setHasMore] = useState(true) const [loadingMore, setLoadingMore] = useState(false) const showSkeleton = useMinimumLoading(loading) + const fadeClass = useSkeletonFade(showSkeleton) const fetchPage = async (pageOffset: number, append: boolean) => { if (append) setLoadingMore(true) @@ -104,7 +105,7 @@ export default function NotificationsPage() { } return ( -
+
@@ -274,7 +275,7 @@ export default function PublicDashboardPage() { const safeScreenResolutions = screen_resolutions || [] return ( -
+
{/* Header */}
diff --git a/app/sites/[id]/funnels/[funnelId]/page.tsx b/app/sites/[id]/funnels/[funnelId]/page.tsx index 923ade2..655abe3 100644 --- a/app/sites/[id]/funnels/[funnelId]/page.tsx +++ b/app/sites/[id]/funnels/[funnelId]/page.tsx @@ -5,7 +5,7 @@ import { useParams, useRouter } from 'next/navigation' import { ApiError } from '@/lib/api/client' 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 { FunnelDetailSkeleton, useMinimumLoading } from '@/components/skeletons' +import { FunnelDetailSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons' import Link from 'next/link' import { FunnelChart } from '@/components/ui/funnel-chart' import { getDateRange } from '@ciphera-net/ui' @@ -62,6 +62,7 @@ export default function FunnelReportPage() { } const showSkeleton = useMinimumLoading(loading && !funnel) + const fadeClass = useSkeletonFade(showSkeleton) if (showSkeleton) { return @@ -113,7 +114,7 @@ export default function FunnelReportPage() { })) return ( -
+
diff --git a/app/sites/[id]/funnels/page.tsx b/app/sites/[id]/funnels/page.tsx index f42137b..2b40d16 100644 --- a/app/sites/[id]/funnels/page.tsx +++ b/app/sites/[id]/funnels/page.tsx @@ -1,10 +1,10 @@ '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 { FunnelsListSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons' import Link from 'next/link' export default function FunnelsPage() { @@ -12,24 +12,7 @@ export default function FunnelsPage() { const router = useRouter() const siteId = params.id as string - const [funnels, setFunnels] = useState([]) - 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,20 +21,21 @@ 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) + const fadeClass = useSkeletonFade(showSkeleton) if (showSkeleton) { return } return ( -
+
diff --git a/app/sites/[id]/journeys/page.tsx b/app/sites/[id]/journeys/page.tsx index 388369b..ce95a71 100644 --- a/app/sites/[id]/journeys/page.tsx +++ b/app/sites/[id]/journeys/page.tsx @@ -6,7 +6,7 @@ import { getDateRange, formatDate } from '@ciphera-net/ui' import { Select, DatePicker } from '@ciphera-net/ui' import SankeyDiagram from '@/components/journeys/SankeyDiagram' import TopPathsTable from '@/components/journeys/TopPathsTable' -import { SkeletonCard } from '@/components/skeletons' +import { JourneysSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons' import { useDashboard, useJourneyTransitions, @@ -52,6 +52,9 @@ export default function JourneysPage() { document.title = domain ? `Journeys \u00b7 ${domain} | Pulse` : 'Journeys | Pulse' }, [dashboard?.site?.domain]) + const showSkeleton = useMinimumLoading(transitionsLoading && !transitionsData) + const fadeClass = useSkeletonFade(showSkeleton) + const entryPointOptions = [ { value: '', label: 'All entry points' }, ...(entryPoints ?? []).map((ep) => ({ @@ -60,8 +63,10 @@ export default function JourneysPage() { })), ] + if (showSkeleton) return + return ( -
+
{/* Header */}
@@ -146,18 +151,12 @@ export default function JourneysPage() { {/* Sankey Diagram */}
- {transitionsLoading ? ( -
- -
- ) : ( - setEntryPath(path)} - /> - )} + setEntryPath(path)} + />
{/* Top Paths */} diff --git a/app/sites/[id]/page.tsx b/app/sites/[id]/page.tsx index 50d3072..f7e4ff4 100644 --- a/app/sites/[id]/page.tsx +++ b/app/sites/[id]/page.tsx @@ -23,7 +23,7 @@ import { toast } from '@ciphera-net/ui' import { Button } from '@ciphera-net/ui' import { Select, DatePicker, DownloadIcon } from '@ciphera-net/ui' 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 AddFilterDropdown, { type FilterSuggestion, type FilterSuggestions } from '@/components/dashboard/AddFilterDropdown' 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 // (prevents the 300ms flash when navigating back to the dashboard) const showSkeleton = useMinimumLoading(dashboardLoading && !dashboard) + const fadeClass = useSkeletonFade(showSkeleton) if (showSkeleton) { return @@ -437,7 +438,7 @@ export default function SiteDashboardPage() { } return ( -
+
diff --git a/app/sites/[id]/settings/page.tsx b/app/sites/[id]/settings/page.tsx index 0a32b42..4a6a939 100644 --- a/app/sites/[id]/settings/page.tsx +++ b/app/sites/[id]/settings/page.tsx @@ -2,12 +2,12 @@ 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' +import { SettingsFormSkeleton, GoalsListSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons' import VerificationModal from '@/components/sites/VerificationModal' import ScriptSetupBlock from '@/components/sites/ScriptSetupBlock' 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 { 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(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(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([]) - const [goalsLoading, setGoalsLoading] = useState(false) + const { data: goals = [], isLoading: goalsLoading, mutate: mutateGoals } = useGoals(siteId) const [goalModalOpen, setGoalModalOpen] = useState(false) const [editingGoal, setEditingGoal] = useState(null) const [goalForm, setGoalForm] = useState({ name: '', event_name: '' }) const [goalSaving, setGoalSaving] = useState(false) const initialFormRef = useRef('') - // Report schedules state - const [reportSchedules, setReportSchedules] = useState([]) - 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(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,8 @@ export default function SiteSettingsPage() { if (site?.domain) document.title = `Settings · ${site.domain} | Pulse` }, [site?.domain]) - const showSkeleton = useMinimumLoading(loading) + const showSkeleton = useMinimumLoading(siteLoading && !site) + const fadeClass = useSkeletonFade(showSkeleton) if (showSkeleton) { return ( @@ -615,7 +543,7 @@ export default function SiteSettingsPage() { } return ( -
+
@@ -1157,14 +1085,14 @@ export default function SiteSettingsPage() { {/* Data Retention */}

Data Retention

- {subscriptionLoadFailed && ( + {!!subscriptionError && (

Plan limits could not be loaded. Options shown may be limited.