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:
@@ -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.
|
||||
|
||||
20
app/sites/[id]/SiteLayoutShell.tsx
Normal file
20
app/sites/[id]/SiteLayoutShell.tsx
Normal 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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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`}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user