diff --git a/components/dashboard/Chart.tsx b/components/dashboard/Chart.tsx
index d4db745..50062ab 100644
--- a/components/dashboard/Chart.tsx
+++ b/components/dashboard/Chart.tsx
@@ -2,7 +2,7 @@
import { useState, useMemo, useRef, useCallback, useEffect } from 'react'
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 { Card, CardContent, CardHeader } from '@/components/ui/card'
import { formatNumber, formatDuration, formatUpdatedAgo, DatePicker } from '@ciphera-net/ui'
@@ -104,9 +104,9 @@ const METRIC_CONFIGS: {
const chartConfig = {
visitors: { label: 'Unique Visitors', color: '#FD5E0F' },
- pageviews: { label: 'Total Pageviews', color: '#3b82f6' },
- bounce_rate: { label: 'Bounce Rate', color: '#a855f7' },
- avg_duration: { label: 'Visit Duration', color: '#10b981' },
+ pageviews: { label: 'Total Pageviews', color: '#FD5E0F' },
+ bounce_rate: { label: 'Bounce Rate', color: '#FD5E0F' },
+ avg_duration: { label: 'Visit Duration', color: '#FD5E0F' },
} satisfies ChartConfig
// ─── Custom Tooltip ─────────────────────────────────────────────────
@@ -351,7 +351,7 @@ export default function Chart({
metric === m.key && 'bg-neutral-50 dark:bg-neutral-800/40',
)}
>
-
{m.label}
+
{m.label}
{m.format(m.value)}
{m.change !== null && (
@@ -462,9 +462,6 @@ export default function Chart({
style={{ overflow: 'visible' }}
>
-
-
-
+
+
} cursor={{ strokeDasharray: '3 3', stroke: '#9ca3af' }} />
- {/* Background dot grid pattern */}
-
{/* Annotation reference lines */}
{visibleAnnotationMarkers.map((marker) => {
diff --git a/components/skeletons.tsx b/components/skeletons.tsx
index b21ac54..8e9483a 100644
--- a/components/skeletons.tsx
+++ b/components/skeletons.tsx
@@ -6,7 +6,7 @@
const SK = 'animate-pulse bg-neutral-100 dark:bg-neutral-800'
-export { useMinimumLoading } from './useMinimumLoading'
+export { useMinimumLoading, useSkeletonFade } from './useMinimumLoading'
// ─── Primitives ──────────────────────────────────────────────
diff --git a/components/useMinimumLoading.ts b/components/useMinimumLoading.ts
index a5a86c3..9b64a48 100644
--- a/components/useMinimumLoading.ts
+++ b/components/useMinimumLoading.ts
@@ -32,3 +32,19 @@ export function useMinimumLoading(loading: boolean, minMs = 300): boolean {
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' : ''
+}
diff --git a/lib/swr/dashboard.ts b/lib/swr/dashboard.ts
index c43faa2..2995c7c 100644
--- a/lib/swr/dashboard.ts
+++ b/lib/swr/dashboard.ts
@@ -29,6 +29,11 @@ import { listAnnotations } from '@/lib/api/annotations'
import type { Annotation } from '@/lib/api/annotations'
import { getSite } 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 {
Stats,
DailyStat,
@@ -69,6 +74,11 @@ const fetchers = {
getJourneyTopPaths(siteId, start, end, { limit, minSessions, entryPath }),
journeyEntryPoints: (siteId: string, start: string, end: string) =>
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
@@ -334,5 +344,71 @@ export function useJourneyEntryPoints(siteId: string, start: string, end: string
)
}
+// * Hook for funnels list
+export function useFunnels(siteId: string) {
+ return useSWR
(
+ 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(
+ 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(
+ 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(
+ 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(
+ 'subscription',
+ () => fetchers.subscription(),
+ {
+ ...dashboardSWRConfig,
+ refreshInterval: 5 * 60 * 1000,
+ dedupingInterval: 30 * 1000,
+ }
+ )
+}
+
// * Re-export for convenience
export { fetchers }
diff --git a/public/script.js b/public/script.js
index 64afa2c..00ed811 100644
--- a/public/script.js
+++ b/public/script.js
@@ -230,25 +230,29 @@
return cachedSessionId;
}
- // * Normalize path: strip trailing slash and ad-platform click/tracking IDs.
- // * UTM params (utm_source, utm_medium, etc.) are intentionally kept in the path
- // * because the backend extracts them for attribution before cleaning the path.
- 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'];
+ // * Normalize path: strip trailing slash and all query params except UTM/attribution.
+ // * Allowlist approach — only UTM params pass through because the backend extracts
+ // * them for attribution before cleaning the stored path. Everything else (cache-busters,
+ // * 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() {
var pathname = window.location.pathname;
// * Strip trailing slash (but keep root /)
if (pathname.length > 1 && pathname.charAt(pathname.length - 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;
if (search) {
try {
var params = new URLSearchParams(search);
- for (var i = 0; i < STRIP_PARAMS.length; i++) {
- params.delete(STRIP_PARAMS[i]);
+ var kept = new URLSearchParams();
+ 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;
} catch (e) {
// * URLSearchParams not supported — send path without query
diff --git a/tailwind.config.ts b/tailwind.config.ts
index 650517e..e4b09f6 100644
--- a/tailwind.config.ts
+++ b/tailwind.config.ts
@@ -23,10 +23,15 @@ const config: Config = {
'50%': { backgroundColor: 'var(--highlight)' },
'100%': { backgroundColor: 'transparent' },
},
+ 'fade-in': {
+ '0%': { opacity: '0' },
+ '100%': { opacity: '1' },
+ },
},
animation: {
'cell-highlight': 'cell-highlight 0.5s ease forwards',
'cell-flash': 'cell-flash 0.6s ease forwards',
+ 'fade-in': 'fade-in 150ms ease-out',
},
fontFamily: {
sans: ['var(--font-plus-jakarta-sans)', 'system-ui', 'sans-serif'],