feat: instant tab navigation by moving SiteNav to shared layout

SiteNav now lives in the [id] layout instead of each page, so it stays
mounted during route transitions. Switching between Dashboard, Uptime,
Funnels, and Settings no longer flashes a full-page skeleton.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Usman Baig
2026-03-09 23:35:06 +01:00
parent 92fae83772
commit 330cc134aa
11 changed files with 46 additions and 32 deletions

View File

@@ -17,6 +17,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
- **Beautiful funnel visualization.** Funnel reports now show a smooth, animated funnel shape instead of a plain bar chart. Each step flows into the next with curved segments, hover effects, and labels showing visitor counts and conversion percentages at a glance.
- **Tidier dashboard layout.** The tab navigation (Dashboard, Uptime, Funnels, Settings) now sits above your site name and controls, keeping the tabs front and center.
- **Instant tab switching.** Clicking between Dashboard, Uptime, Funnels, and Settings now feels instant — the tab bar stays in place while the page content loads below it, instead of the whole screen flashing with a loading skeleton.
- **Smoother theme switching.** Toggling between light and dark mode now plays a satisfying circular reveal animation that expands from the toggle button, instead of everything just flipping instantly.
- **Cleaner site navigation.** Dashboard, Uptime, Funnels, and Settings now use an underline tab bar instead of floating buttons. The active section is highlighted with an orange underline, making it easy to see where you are and switch between views.
- **Consistent icon style.** All dashboard icons now use a single, unified icon set for a cleaner look across Technology, Locations, Campaigns, and Referrers panels.

View File

@@ -0,0 +1,20 @@
'use client'
import SiteNav from '@/components/dashboard/SiteNav'
export default function SiteLayoutShell({
siteId,
children,
}: {
siteId: string
children: React.ReactNode
}) {
return (
<>
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pt-8">
<SiteNav siteId={siteId} />
</div>
{children}
</>
)
}

View File

@@ -69,7 +69,7 @@ export default function FunnelReportPage() {
if (loadError === 'not_found' || (!funnel && !stats && !loadError)) {
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 pb-8">
<p className="text-neutral-600 dark:text-neutral-400">Funnel not found</p>
</div>
)
@@ -77,7 +77,7 @@ export default function FunnelReportPage() {
if (loadError === 'forbidden') {
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 pb-8">
<p className="text-neutral-600 dark:text-neutral-400">Access denied</p>
<Link href={`/sites/${siteId}/funnels`}>
<Button variant="primary" className="mt-4">
@@ -90,7 +90,7 @@ export default function FunnelReportPage() {
if (loadError === 'error') {
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 pb-8">
<p className="text-neutral-600 dark:text-neutral-400 mb-4">Unable to load funnel</p>
<Button type="button" onClick={() => loadData()} variant="primary">
Try again
@@ -101,7 +101,7 @@ export default function FunnelReportPage() {
if (!funnel || !stats) {
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 pb-8">
<p className="text-neutral-600 dark:text-neutral-400">Funnel not found</p>
</div>
)
@@ -113,7 +113,7 @@ export default function FunnelReportPage() {
}))
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 pb-8">
<div className="mb-8">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">

View File

@@ -91,7 +91,7 @@ export default function CreateFunnelPage() {
}
return (
<div className="w-full max-w-3xl mx-auto px-4 sm:px-6 py-8">
<div className="w-full max-w-3xl mx-auto px-4 sm:px-6 pb-8">
<div className="mb-8">
<Link
href={`/sites/${siteId}/funnels`}

View File

@@ -6,7 +6,6 @@ import { listFunnels, deleteFunnel, type Funnel } from '@/lib/api/funnels'
import { toast, PlusIcon, ArrowRightIcon, ChevronLeftIcon, TrashIcon, Button } from '@ciphera-net/ui'
import { FunnelsListSkeleton, useMinimumLoading } from '@/components/skeletons'
import Link from 'next/link'
import SiteNav from '@/components/dashboard/SiteNav'
export default function FunnelsPage() {
const params = useParams()
@@ -52,9 +51,7 @@ export default function FunnelsPage() {
}
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
<SiteNav siteId={siteId} />
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
<div className="mb-8">
<div className="flex items-center justify-between mb-6">
<div>

View File

@@ -1,4 +1,5 @@
import type { Metadata } from 'next'
import SiteLayoutShell from './SiteLayoutShell'
export const metadata: Metadata = {
title: 'Dashboard | Pulse',
@@ -6,10 +7,13 @@ export const metadata: Metadata = {
robots: { index: false, follow: false },
}
export default function SiteLayout({
export default async function SiteLayout({
children,
params,
}: {
children: React.ReactNode
params: Promise<{ id: string }>
}) {
return children
const { id } = await params
return <SiteLayoutShell siteId={id}>{children}</SiteLayoutShell>
}

View File

@@ -34,7 +34,6 @@ import PerformanceStats from '@/components/dashboard/PerformanceStats'
import GoalStats from '@/components/dashboard/GoalStats'
import ScrollDepth from '@/components/dashboard/ScrollDepth'
import Campaigns from '@/components/dashboard/Campaigns'
import SiteNav from '@/components/dashboard/SiteNav'
import FilterBar from '@/components/dashboard/FilterBar'
import AddFilterDropdown, { type FilterSuggestion, type FilterSuggestions } from '@/components/dashboard/AddFilterDropdown'
import EventProperties from '@/components/dashboard/EventProperties'
@@ -426,7 +425,7 @@ export default function SiteDashboardPage() {
if (!site) {
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 pb-8">
<p className="text-neutral-600 dark:text-neutral-400">Site not found</p>
</div>
)
@@ -437,10 +436,8 @@ export default function SiteDashboardPage() {
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2 }}
className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8"
className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8"
>
<SiteNav siteId={siteId} />
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-4">

View File

@@ -101,7 +101,7 @@ export default function RealtimePage() {
if (!site) return <div className="p-8">Site not found</div>
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8 h-[calc(100vh-64px)] flex flex-col">
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8 h-[calc(100vh-64px)] flex flex-col">
<div className="mb-6 flex items-center justify-between">
<div>
<div className="flex items-center gap-2 mb-1">

View File

@@ -15,7 +15,6 @@ 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 SiteNav from '@/components/dashboard/SiteNav'
import { getRetentionOptionsForPlan, formatRetentionMonths } from '@/lib/plans'
import { motion, AnimatePresence } from 'framer-motion'
import { useAuth } from '@/lib/auth/context'
@@ -382,7 +381,7 @@ export default function SiteSettingsPage() {
if (showSkeleton) {
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 pb-8">
<div className="space-y-8">
<div>
<div className="h-8 w-40 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800 mb-2" />
@@ -405,15 +404,14 @@ export default function SiteSettingsPage() {
if (!site) {
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 pb-8">
<p className="text-neutral-600 dark:text-neutral-400">Site not found</p>
</div>
)
}
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
<SiteNav siteId={siteId} />
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
<div className="space-y-8">
<div>

View File

@@ -21,7 +21,6 @@ import { toast } from '@ciphera-net/ui'
import { useTheme } from '@ciphera-net/ui'
import { getAuthErrorMessage } from '@ciphera-net/ui'
import { Button, Modal } from '@ciphera-net/ui'
import SiteNav from '@/components/dashboard/SiteNav'
import { UptimeSkeleton, ChecksSkeleton, useMinimumLoading } from '@/components/skeletons'
import {
AreaChart,
@@ -693,10 +692,8 @@ export default function UptimePage() {
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2 }}
className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8"
className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8"
>
<SiteNav siteId={siteId} />
{/* Header */}
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>

View File

@@ -122,7 +122,7 @@ export function ChartSkeleton() {
export function DashboardSkeleton() {
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 pb-8">
{/* Header */}
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
@@ -170,7 +170,7 @@ export function DashboardSkeleton() {
export function RealtimeSkeleton() {
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8 h-[calc(100vh-64px)] flex flex-col">
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8 h-[calc(100vh-64px)] flex flex-col">
<div className="mb-6">
<SkeletonLine className="h-4 w-32 mb-2" />
<SkeletonLine className="h-8 w-64" />
@@ -242,7 +242,7 @@ export function SessionEventsSkeleton() {
export function UptimeSkeleton() {
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 pb-8">
<div className="mb-8">
<SkeletonLine className="h-4 w-32 mb-2" />
<SkeletonLine className="h-8 w-24 mb-1" />
@@ -295,7 +295,7 @@ export function ChecksSkeleton() {
export function FunnelsListSkeleton() {
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 pb-8">
<div className="mb-8">
<div className="flex items-center gap-4 mb-6">
<SkeletonLine className="h-10 w-10 rounded-xl" />
@@ -329,7 +329,7 @@ export function FunnelsListSkeleton() {
export function FunnelDetailSkeleton() {
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 pb-8">
<div className="mb-8">
<SkeletonLine className="h-4 w-32 mb-2" />
<SkeletonLine className="h-8 w-48 mb-1" />