diff --git a/CHANGELOG.md b/CHANGELOG.md index aff8479..7ba9082 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,32 +6,44 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] -### Improved - -- **Visit duration now works for single-page sessions.** Previously, if a visitor viewed only one page and left, the visit duration showed as "0s" because there was no second pageview to measure against. Pulse now tracks how long you actually spent on the page and reports real durations — even for single-page visits. This makes the Visit Duration metric, Journeys, and Top Paths much more accurate. -- **Journeys page now shows data on low-traffic sites.** The Journeys page previously required at least 2–3 sessions following the same path before showing any data. It now shows all navigation flows immediately, so you can see how visitors move through your site from day one. -- **European date and time formatting.** All dates across Pulse now use day-first ordering (14 Mar 2025) and 24-hour time (14:30) instead of the US-style month-first format. This applies everywhere — dashboard charts, exports, billing dates, invoices, uptime checks, audit logs, and more. - ### Added +- **BunnyCDN integration.** Connect your BunnyCDN account in Settings > Integrations to monitor your CDN performance right alongside your analytics. A new "CDN" tab on your dashboard shows total bandwidth served, request volume, cache hit rate, origin response time, and error counts — each with percentage changes compared to the previous period. Charts show bandwidth trends (total vs cached), daily request volume, and error breakdowns over time. A geographic breakdown shows which countries consume the most bandwidth. When connecting, Pulse automatically filters your pull zones to only show ones matching your site's domain. Pulse only stores your API key encrypted and only reads statistics — it never modifies anything in your BunnyCDN account. You can disconnect and fully remove all CDN data at any time. +- **Google Search Console integration.** Connect your Google Search Console account in Settings > Integrations to see which search queries bring visitors to your site. A new "Search" tab on your dashboard shows total clicks, impressions, average CTR, and average ranking position — with percentage changes compared to the previous period. Browse your top search queries and top pages in sortable, paginated tables. Click any query to see which pages rank for it, or click any page to see which queries drive traffic to it. A trend chart shows how clicks and impressions change over time, and a green badge highlights new queries that appeared this period. Pulse only requests read-only access to your Search Console data, encrypts your Google credentials, and lets you disconnect and fully remove all search data at any time. - **Free plan now visible on the Pricing page.** The free tier is no longer hidden — it's displayed as the first option on the Pricing page so you can see exactly what you get before signing up: 1 site, 5,000 monthly pageviews, and 6 months of data retention, completely free. - **Free plan limited to 1 site.** Free accounts are now limited to a single site. If you need more, you can upgrade to Solo or above from the Pricing page. ### Improved -- **Sites now show their verification status.** Each site on your dashboard now displays either a green "Active" badge (if verified) or an amber "Unverified" badge. When you verify your tracking script installation, the status is saved permanently — no more showing "Active" for sites that haven't been set up yet. -- **Verification status visible in Settings too.** Once your tracking script is verified, the Settings page shows a green confirmation bar instead of the verify button — so you can tell at a glance that everything is working. A "Re-verify" link is still there if you ever need to check again. -- **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. +- **Smarter bot filtering.** Pulse now catches more types of automated traffic that were slipping through — like headless browsers with default screen sizes, bot farms that rotate through different locations, and bots that fire duplicate events within milliseconds. Bot detection checks have also been moved from the tracking script to the server, making the script smaller and faster for real visitors. +- **Actionable empty states.** When a dashboard section has no data yet, you now get a direct action — like "Install tracking script" or "Build a UTM URL" — instead of just passive text. Gets you set up faster. +- **Animated numbers across the dashboard.** Stats like visitors, pageviews, bounce rate, and visit duration now smoothly count up or down when you switch date ranges, apply filters, or when real-time visitor counts change — instead of just jumping to the new value. +- **Inline bar charts on dashboard lists.** Pages, referrers, locations, technology, and campaigns now show subtle proportional bars behind each row, making it easier to compare values at a glance without reading numbers. +- **Redesigned Journeys page.** The Journeys page has been rebuilt — the depth slider now matches the rest of the UI and goes up to 10 steps, controls are integrated into the chart card, and Top Paths uses a clean compact list with inline bars instead of bulky cards. +- **More reliable visit duration tracking.** Visit duration was silently dropping to 0s for visitors who only viewed one page — especially on mobile or when closing a tab quickly. The tracking script now captures time-on-page more reliably across all browsers, and sessions where duration couldn't be measured are excluded from the average instead of counting as 0s. This makes the Visit Duration metric, Journeys, and Top Paths much more accurate. +- **More accurate rage click detection.** Rage clicks no longer fire when you triple-click to select text on a page. Previously, selecting a paragraph (a normal 3-click action) was being counted as a rage click, which inflated frustration metrics. Only genuinely frustrated rapid clicking is tracked now. +- **Fresher CDN data.** BunnyCDN statistics now refresh every 3 hours instead of once a day, so your CDN tab shows much more current bandwidth, request, and cache data. +- **More accurate dead click detection.** Dead clicks were being reported on elements that actually worked — like close buttons on cart drawers, modal dismiss buttons, and page content areas. Three fixes make dead clicks much more reliable: + - Buttons that trigger changes elsewhere on the page (closing a drawer, opening a modal) are no longer flagged as dead. + - Page content areas that aren't actually clickable (like `
` containers) are no longer treated as interactive elements. + - Single-page app navigations are now properly detected, so links that use client-side routing aren't mistakenly reported as broken. +- **Journeys page now shows data on low-traffic sites.** The Journeys page previously required at least 2–3 sessions following the same path before showing any data. It now shows all navigation flows immediately, so you can see how visitors move through your site from day one. +- **European date and time formatting.** All dates across Pulse now use day-first ordering (14 Mar 2025) and 24-hour time (14:30) instead of the US-style month-first format. This applies everywhere — dashboard charts, exports, billing dates, invoices, uptime checks, audit logs, and more. +- **Sites now show their verification status.** Each site on your dashboard now displays either a green "Active" badge (if verified) or an amber "Unverified" badge. The Settings page also shows a green confirmation bar once verified. When you verify your tracking script installation, the status is saved permanently — no more showing "Active" for sites that haven't been set up yet. +- **Cleaner page paths in your reports.** Pages like `/products?_t=123456` or `/about?session=abc` now correctly show as `/products` and `/about`. Trailing slashes are also normalized — `/about/` and `/about` count as the same page. 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. - **Easier to hover country dots on the map.** The orange location markers on the world map are now much easier to interact with — you no longer need pixel-perfect aim to see the tooltip. - **Smoother chart curves and filled area.** The dashboard chart line now flows with natural curves instead of sharp flat tops at peaks. The area beneath the line is filled with a soft transparent orange gradient that fades toward the bottom, making trends easier to read at a glance. -- **Refreshed chart background.** The dashboard chart now has subtle horizontal lines instead of the old dotted background, giving the chart area a cleaner look. - **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, Behavior, 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. + +### Removed + +- **Performance insights removed.** The Performance tab, Core Web Vitals tracking (LCP, CLS, INP), and the "Enable performance insights" toggle in Settings have been removed. The tracking script no longer collects Web Vitals data. Visit duration tracking continues to work as before. ### Fixed +- **Your BunnyCDN API key is no longer visible in network URLs.** When loading pull zones, the API key was previously sent as a URL parameter. It's now sent securely in the request body, just like when connecting. +- **No more "Site not found" when switching back to Pulse.** If you left Pulse in the background and came back, you could see a wall of errors and a blank page. This happened because the browser fired several requests at once when the tab regained focus, and if any failed, they all retried repeatedly — flooding the connection and making it worse. Failed requests now back off gracefully instead of retrying in a loop. - **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. - **More accurate pageview counts.** Refreshing a page no longer inflates your pageview numbers. The tracking script now detects when the same page is loaded again within a few seconds and skips the duplicate, so metrics like total pageviews, pages per session, and visit duration reflect real navigation instead of reload habits. - **Self-referrals no longer pollute your traffic sources.** Internal navigation within your own site (e.g. clicking from your homepage to your about page) no longer shows your own domain as a referrer. Only external traffic sources appear in your Referrers panel now. @@ -39,11 +51,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - **Browser back/forward no longer double-counts pageviews.** Pressing the back or forward button could occasionally register two pageviews instead of one. The tracking script now correctly deduplicates these navigations. - **Preloaded pages no longer count as visits.** Modern browsers sometimes preload pages in the background before you actually visit them. These ghost visits no longer inflate your pageview counts — only pages the visitor actually sees are tracked. - **Marketing parameters no longer fragment your pages.** Pages like `/about?utm_source=google` and `/about?utm_campaign=spring` now correctly show as just `/about` in your Top Pages. UTM tags, Facebook click IDs, Google click IDs, and other tracking parameters are stripped from the page path so all visits to the same page are grouped together. -- **Trailing slashes no longer split pages.** `/about/` and `/about` now count as the same page instead of appearing as separate entries in your analytics. - **Traffic sources are no longer over-counted.** When a visitor arrived from Facebook (or any external source) and browsed multiple pages, every page was credited to Facebook instead of just the first. Now only the landing page shows the referrer, giving you accurate traffic source numbers. - **UTM attribution now works correctly.** Visitors arriving via campaign links (e.g. from Facebook Ads, Google Ads, or email campaigns) now have their traffic source, medium, and campaign properly recorded. Previously, this data was accidentally lost before it reached the server. -- **More ad tracking clutter removed from page paths.** Facebook ad parameters and click IDs from various platforms are cleaned from your page URLs so your Top Pages stay tidy. -- **Better bot detection.** Automated browsers (used by scrapers and testing tools) and bots with no screen dimensions are now filtered out before they can send events, keeping your visitor counts cleaner. - **Outbound links and file downloads now show the URL.** Previously you could only see how many outbound clicks or downloads happened. Now you can see exactly which external links visitors clicked and which files they downloaded. - **Dead click detection no longer triggers on form fields.** Clicking on a text input, dropdown, or text area to interact with it is normal — it no longer gets flagged as a dead click. diff --git a/app/page.tsx b/app/page.tsx index 04dd558..4501e98 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -400,7 +400,7 @@ export default function HomePage() { ) : null })() ?? ( - @@ -409,11 +409,11 @@ export default function HomePage() { {/* * Global Overview - min-h ensures no layout shift when Plan & usage loads */}
-
+

Total Sites

{sites.length}

-
+

Total Visitors (24h)

{sites.length === 0 || Object.keys(siteStats).length < sites.length diff --git a/app/share/[id]/page.tsx b/app/share/[id]/page.tsx index 53e6acb..125e030 100644 --- a/app/share/[id]/page.tsx +++ b/app/share/[id]/page.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useState } from 'react' import Image from 'next/image' import { useParams, useSearchParams, useRouter } from 'next/navigation' -import { getPublicDashboard, getPublicStats, getPublicDailyStats, getPublicRealtime, getPublicPerformanceByPage, type DashboardData, type Stats, type DailyStat, type PerformanceByPageStat } from '@/lib/api/stats' +import { getPublicDashboard, getPublicStats, getPublicDailyStats, getPublicRealtime, type DashboardData, type Stats, type DailyStat } from '@/lib/api/stats' import { toast } from '@ciphera-net/ui' import { getAuthErrorMessage } from '@ciphera-net/ui' import { ApiError } from '@/lib/api/client' @@ -13,7 +13,6 @@ import TopPages from '@/components/dashboard/ContentStats' import TopReferrers from '@/components/dashboard/TopReferrers' import Locations from '@/components/dashboard/Locations' import TechSpecs from '@/components/dashboard/TechSpecs' -import PerformanceStats from '@/components/dashboard/PerformanceStats' import { Select, DatePicker as DatePickerModal, Captcha, DownloadIcon, ZapIcon } from '@ciphera-net/ui' import { DashboardSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons' import ExportModal from '@/components/dashboard/ExportModal' @@ -257,7 +256,7 @@ export default function PublicDashboardPage() { if (!data) return null - const { site, stats, daily_stats, top_pages, entry_pages, exit_pages, top_referrers, countries, cities, regions, browsers, os, devices, screen_resolutions, performance, performance_by_page, realtime_visitors } = data + const { site, stats, daily_stats, top_pages, entry_pages, exit_pages, top_referrers, countries, cities, regions, browsers, os, devices, screen_resolutions, realtime_visitors } = data // Provide defaults for potentially undefined data const safeDailyStats = daily_stats || [] @@ -395,29 +394,6 @@ export default function PublicDashboardPage() { />

- {/* Performance Stats - Only show if enabled */} - {performance && data.site?.enable_performance_insights && ( -
- { - return getPublicPerformanceByPage(siteId, startDate, endDate, opts, { - password, - captcha: { - captcha_id: captchaId, - captcha_solution: captchaSolution, - captcha_token: captchaToken - } - }) - }} - /> -
- )} - {/* Details Grid */}
{/* Rage clicks + Dead clicks side by side */} -
+
+
import('@/components/dashboard/DottedMap'), { ssr: false }) +import { getDateRange, formatDate, Select } from '@ciphera-net/ui' +import { ArrowSquareOut, CloudArrowUp } from '@phosphor-icons/react' +import { + ResponsiveContainer, + AreaChart, + Area, + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, +} from 'recharts' +import { useDashboard, useBunnyStatus, useBunnyOverview, useBunnyDailyStats, useBunnyTopCountries } from '@/lib/swr/dashboard' +import { SkeletonLine, StatCardSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons' + +// ─── Helpers ──────────────────────────────────────────────────── + +// US state codes → map to "US" for the dotted map +const US_STATES = new Set([ + 'AL','AK','AZ','AR','CO','CT','DC','DE','FL','GA','HI','ID','IL','IN','IA', + 'KS','KY','LA','ME','MD','MA','MI','MN','MS','MO','MT','NE','NV','NH','NJ', + 'NM','NY','NC','ND','OH','OK','OR','PA','RI','SC','SD','TN','TX','UT','VT', + 'VA','WA','WV','WI','WY', +]) +// Canadian province codes → map to "CA" +const CA_PROVINCES = new Set(['AB','BC','MB','NB','NL','NS','NT','NU','ON','PE','QC','SK','YT']) + +/** + * Extract ISO country code from BunnyCDN datacenter string. + * e.g. "EU: Zurich, CH" → "CH", "NA: Chicago, IL" → "US", "NA: Toronto, CA" → "CA" + */ +function extractCountryCode(datacenter: string): string { + const parts = datacenter.split(', ') + const code = parts[parts.length - 1]?.trim().toUpperCase() + if (!code || code.length !== 2) return '' + if (US_STATES.has(code)) return 'US' + if (CA_PROVINCES.has(code)) return 'CA' + return code +} + +/** + * Extract the city name from a BunnyCDN datacenter string. + * e.g. "EU: Zurich, CH" → "Zurich" + */ +function extractCity(datacenter: string): string { + const afterColon = datacenter.split(': ')[1] || datacenter + return afterColon.split(',')[0]?.trim() || datacenter +} + +/** Get flag icon component for a country code */ +function getFlagIcon(code: string) { + if (!code) return null + const FlagComponent = (Flags as Record>)[code] + return FlagComponent ? : null +} + +/** + * Map each datacenter entry to its country's centroid for the dotted map. + * Each datacenter gets its own dot (sized by bandwidth) at the country's position. + */ +function mapToCountryCentroids(data: Array<{ country_code: string; bandwidth: number }>): Array<{ country: string; pageviews: number }> { + return data + .map((row) => ({ + country: extractCountryCode(row.country_code), + pageviews: row.bandwidth, + })) + .filter((d) => d.country !== '') +} + +function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B' + const units = ['B', 'KB', 'MB', 'GB', 'TB'] + const i = Math.floor(Math.log(bytes) / Math.log(1024)) + const value = bytes / Math.pow(1024, i) + return value.toFixed(i === 0 ? 0 : 1) + ' ' + units[i] +} + +function formatNumber(n: number): string { + if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M' + if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K' + return n.toLocaleString() +} + +function formatDateShort(date: string): string { + const d = new Date(date + 'T00:00:00') + return d.getDate() + ' ' + d.toLocaleString('en-US', { month: 'short' }) +} + +function changePercent( + current: number, + prev: number +): { value: number; positive: boolean } | null { + if (prev === 0) return null + const pct = ((current - prev) / prev) * 100 + return { value: pct, positive: pct >= 0 } +} + +// ─── Page ─────────────────────────────────────────────────────── + +export default function CDNPage() { + const params = useParams() + const siteId = params.id as string + + // Date range + const [period, setPeriod] = useState('7') + const [dateRange, setDateRange] = useState(() => getDateRange(7)) + + // Data fetching + const { data: bunnyStatus } = useBunnyStatus(siteId) + const { data: dashboard } = useDashboard(siteId, dateRange.start, dateRange.end) + const { data: overview } = useBunnyOverview(siteId, dateRange.start, dateRange.end) + const { data: dailyStats } = useBunnyDailyStats(siteId, dateRange.start, dateRange.end) + const { data: topCountries } = useBunnyTopCountries(siteId, dateRange.start, dateRange.end) + + const showSkeleton = useMinimumLoading(!bunnyStatus) + const fadeClass = useSkeletonFade(showSkeleton) + + // Document title + useEffect(() => { + const domain = dashboard?.site?.domain + document.title = domain ? `CDN \u00b7 ${domain} | Pulse` : 'CDN | Pulse' + }, [dashboard?.site?.domain]) + + // ─── Loading skeleton ───────────────────────────────────── + + if (showSkeleton) { + return ( +
+
+
+ + +
+ +
+
+ + + + + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ ) + } + + // ─── Not connected state ────────────────────────────────── + + if (bunnyStatus && !bunnyStatus.connected) { + return ( +
+
+
+ +
+

+ Connect BunnyCDN +

+

+ Monitor your CDN performance including bandwidth usage, cache hit rates, request volumes, and geographic distribution. +

+ + Connect in Settings + + +
+
+ ) + } + + // ─── Connected — main view ──────────────────────────────── + + const bandwidthChange = overview ? changePercent(overview.total_bandwidth, overview.prev_total_bandwidth) : null + const requestsChange = overview ? changePercent(overview.total_requests, overview.prev_total_requests) : null + const cacheHitChange = overview ? changePercent(overview.cache_hit_rate, overview.prev_cache_hit_rate) : null + const originChange = overview ? changePercent(overview.avg_origin_response, overview.prev_avg_origin_response) : null + const errorsChange = overview ? changePercent(overview.total_errors, overview.prev_total_errors) : null + + const daily = dailyStats?.daily_stats ?? [] + const countries = topCountries?.countries ?? [] + const totalBandwidth = countries.reduce((sum, row) => sum + row.bandwidth, 0) + + return ( +
+ {/* Header */} +
+
+

+ CDN Analytics +

+

+ BunnyCDN performance, bandwidth, and cache metrics +

+
+ setDepth(Number(e.target.value))} - className="w-32 accent-brand-orange" - /> - {depth} + {/* Single card: toolbar + chart */} +
+ {/* Toolbar */} +
+
+ {/* Depth slider */} +
+
+ 2 steps + + {depth} steps deep + + 6 steps +
+ setDepth(parseInt(e.target.value))} + aria-label="Journey depth" + aria-valuetext={`${depth} steps deep`} + className="w-full h-2 bg-neutral-200 rounded-lg appearance-none cursor-pointer dark:bg-neutral-700 accent-brand-orange focus:outline-none" + /> +
+ + {/* Entry point + Reset */} +
+ setEntryPath(value)} - options={entryPointOptions} - /> + {/* Journey Chart */} +
+ {viewMode === 'columns' ? ( + + ) : ( + + )} +
- {(depth !== 3 || entryPath) && ( - + {/* Footer */} + {totalSessions > 0 && ( +
+ {totalSessions.toLocaleString()} sessions tracked +
)}
- {/* Sankey Diagram */} -
- setEntryPath(path)} - /> -
- {/* Top Paths */} - +
+ +
{/* Date Picker Modal */} import('@/components/dashboard/PerformanceStats')) const GoalStats = dynamic(() => import('@/components/dashboard/GoalStats')) const Campaigns = dynamic(() => import('@/components/dashboard/Campaigns')) const PeakHours = dynamic(() => import('@/components/dashboard/PeakHours')) +const SearchPerformance = dynamic(() => import('@/components/dashboard/SearchPerformance')) const EventProperties = dynamic(() => import('@/components/dashboard/EventProperties')) const ExportModal = dynamic(() => import('@/components/dashboard/ExportModal')) import { type DimensionFilter, serializeFilters, parseFiltersFromURL } from '@/lib/filters' @@ -235,10 +234,10 @@ export default function SiteDashboardPage() { return { start: prevStart.toISOString().split('T')[0], end: prevEnd.toISOString().split('T')[0] } }, [dateRange]) - // Single dashboard request replaces 7 focused hooks (overview, pages, locations, - // devices, referrers, performance, goals). The backend runs all queries in parallel - // and caches the result in Redis, reducing requests from 12 to 6 per refresh cycle. - const { data: dashboard, isLoading: dashboardLoading, error: dashboardError } = useDashboard(siteId, dateRange.start, dateRange.end, interval, filtersParam || undefined) + // Single dashboard request replaces focused hooks (overview, pages, locations, + // devices, referrers, goals). The backend runs all queries in parallel + // and caches the result in Redis for efficient data loading. + const { data: dashboard, isLoading: dashboardLoading, isValidating: dashboardValidating, error: dashboardError } = useDashboard(siteId, dateRange.start, dateRange.end, interval, filtersParam || undefined) const { data: realtimeData } = useRealtime(siteId) const { data: prevStats } = useStats(siteId, prevRange.start, prevRange.end) const { data: prevDailyStats } = useDailyStats(siteId, prevRange.start, prevRange.end, interval) @@ -532,6 +531,13 @@ export default function SiteDashboardPage() {
+ {/* Refetch indicator — visible when SWR is revalidating with stale data on screen */} + {dashboardValidating && !dashboardLoading && ( +
+
+
+ )} + {/* Advanced Chart with Integrated Stats */}
- {/* Performance Stats - Only show if enabled */} - {site.enable_performance_insights && ( -
- -
- )} - -
+
-
+
-
+
- -
+
+ !/^scroll_\d+$/.test(g.event_name))} onSelectEvent={setSelectedEvent} diff --git a/app/sites/[id]/search/page.tsx b/app/sites/[id]/search/page.tsx new file mode 100644 index 0000000..050298c --- /dev/null +++ b/app/sites/[id]/search/page.tsx @@ -0,0 +1,680 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useParams } from 'next/navigation' +import Link from 'next/link' +import { getDateRange, formatDate, Select, DatePicker } from '@ciphera-net/ui' +import { CaretDown, CaretUp, MagnifyingGlass, ArrowSquareOut } from '@phosphor-icons/react' +import { useDashboard, useGSCStatus, useGSCOverview, useGSCTopQueries, useGSCTopPages, useGSCNewQueries } from '@/lib/swr/dashboard' +import { getGSCQueryPages, getGSCPageQueries } from '@/lib/api/gsc' +import type { GSCDataRow } from '@/lib/api/gsc' +import { SkeletonLine, StatCardSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons' +import ClicksImpressionsChart from '@/components/search/ClicksImpressionsChart' + +// ─── Helpers ──────────────────────────────────────────────────── + +function getThisWeekRange(): { start: string; end: string } { + const today = new Date() + const dayOfWeek = today.getDay() + const monday = new Date(today) + monday.setDate(today.getDate() - (dayOfWeek === 0 ? 6 : dayOfWeek - 1)) + return { start: formatDate(monday), end: formatDate(today) } +} + +function getThisMonthRange(): { start: string; end: string } { + const today = new Date() + const firstOfMonth = new Date(today.getFullYear(), today.getMonth(), 1) + return { start: formatDate(firstOfMonth), end: formatDate(today) } +} + +const formatPosition = (pos: number) => pos.toFixed(1) +const formatCTR = (ctr: number) => (ctr * 100).toFixed(1) + '%' + +function formatChange(current: number, previous: number) { + if (previous === 0) return null + const change = ((current - previous) / previous) * 100 + return { value: change, label: (change >= 0 ? '+' : '') + change.toFixed(1) + '%' } +} + +function formatNumber(n: number) { + if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M' + if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K' + return n.toLocaleString() +} + +// ─── Page ─────────────────────────────────────────────────────── + +const PAGE_SIZE = 50 + +export default function SearchConsolePage() { + const params = useParams() + const siteId = params.id as string + + // Date range + const [period, setPeriod] = useState('28') + const [dateRange, setDateRange] = useState(() => getDateRange(28)) + const [isDatePickerOpen, setIsDatePickerOpen] = useState(false) + + // View toggle + const [activeView, setActiveView] = useState<'queries' | 'pages'>('queries') + + // Pagination + const [queryPage, setQueryPage] = useState(0) + const [pagePage, setPagePage] = useState(0) + + // Drill-down expansion + const [expandedQuery, setExpandedQuery] = useState(null) + const [expandedPage, setExpandedPage] = useState(null) + const [expandedData, setExpandedData] = useState([]) + const [expandedLoading, setExpandedLoading] = useState(false) + + // Data fetching + const { data: gscStatus } = useGSCStatus(siteId) + const { data: dashboard } = useDashboard(siteId, dateRange.start, dateRange.end) + const { data: overview } = useGSCOverview(siteId, dateRange.start, dateRange.end) + const { data: topQueries, isLoading: queriesLoading } = useGSCTopQueries(siteId, dateRange.start, dateRange.end, PAGE_SIZE, queryPage * PAGE_SIZE) + const { data: topPages, isLoading: pagesLoading } = useGSCTopPages(siteId, dateRange.start, dateRange.end, PAGE_SIZE, pagePage * PAGE_SIZE) + const { data: newQueries } = useGSCNewQueries(siteId, dateRange.start, dateRange.end) + + const showSkeleton = useMinimumLoading(!gscStatus) + const fadeClass = useSkeletonFade(showSkeleton) + + // Document title + useEffect(() => { + const domain = dashboard?.site?.domain + document.title = domain ? `Search Console \u00b7 ${domain} | Pulse` : 'Search Console | Pulse' + }, [dashboard?.site?.domain]) + + // Reset pagination when date range changes + useEffect(() => { + setQueryPage(0) + setPagePage(0) + setExpandedQuery(null) + setExpandedPage(null) + setExpandedData([]) + }, [dateRange.start, dateRange.end]) + + // ─── Expand handlers ─────────────────────────────────────── + + async function handleExpandQuery(query: string) { + if (expandedQuery === query) { + setExpandedQuery(null) + setExpandedData([]) + return + } + setExpandedQuery(query) + setExpandedPage(null) + setExpandedLoading(true) + try { + const res = await getGSCQueryPages(siteId, query, dateRange.start, dateRange.end) + setExpandedData(res.pages) + } catch { + setExpandedData([]) + } finally { + setExpandedLoading(false) + } + } + + async function handleExpandPage(page: string) { + if (expandedPage === page) { + setExpandedPage(null) + setExpandedData([]) + return + } + setExpandedPage(page) + setExpandedQuery(null) + setExpandedLoading(true) + try { + const res = await getGSCPageQueries(siteId, page, dateRange.start, dateRange.end) + setExpandedData(res.queries) + } catch { + setExpandedData([]) + } finally { + setExpandedLoading(false) + } + } + + // ─── Loading skeleton ───────────────────────────────────── + + if (showSkeleton) { + return ( +
+
+
+ + +
+ +
+
+ + + + +
+
+ + {Array.from({ length: 10 }).map((_, i) => ( +
+ +
+ + + + +
+
+ ))} +
+
+ ) + } + + // ─── Not connected state ────────────────────────────────── + + if (gscStatus && !gscStatus.connected) { + return ( +
+
+
+ +
+

+ Connect Google Search Console +

+

+ See how your site performs in Google Search. View top queries, pages, click-through rates, and average position data. +

+ + Connect in Settings + + +
+
+ ) + } + + // ─── Connected — main view ──────────────────────────────── + + const clicksChange = overview ? formatChange(overview.total_clicks, overview.prev_clicks) : null + const impressionsChange = overview ? formatChange(overview.total_impressions, overview.prev_impressions) : null + const ctrChange = overview ? formatChange(overview.avg_ctr, overview.prev_avg_ctr) : null + // For position, lower is better — invert the direction + const positionChange = overview ? formatChange(overview.avg_position, overview.prev_avg_position) : null + + const queries = topQueries?.queries ?? [] + const queriesTotal = topQueries?.total ?? 0 + const pages = topPages?.pages ?? [] + const pagesTotal = topPages?.total ?? 0 + + return ( +
+ {/* Header */} +
+
+

+ Search Console +

+

+ Google Search performance, queries, and page rankings +

+
+ setFormData({ ...formData, enable_performance_insights: e.target.checked })} - className="sr-only peer" - /> -
- -
-
-
- {/* Data Retention */}

Data Retention

@@ -1403,6 +1421,405 @@ export default function SiteSettingsPage() { )}
)} + + {activeTab === 'integrations' && ( +
+
+

Integrations

+

Connect external services to enrich your analytics data.

+
+ + {/* Google Search Console */} +
+ {!gscStatus?.connected ? ( +
+
+
+ + + + + + +
+
+

Google Search Console

+

+ See which search queries bring visitors to your site, with impressions, clicks, CTR, and ranking position. +

+
+
+
+ + + +

+ Pulse only requests read-only access. Your tokens are encrypted at rest and all data can be fully removed at any time. +

+
+ {canEdit && ( + + )} +
+ ) : ( +
+
+
+
+ + + + + + +
+
+

Google Search Console

+
+ + + {gscStatus.status === 'active' ? 'Connected' : gscStatus.status === 'syncing' ? 'Syncing...' : 'Error'} + +
+
+
+
+ +
+ {gscStatus.google_email && ( +
+

Google Account

+

{gscStatus.google_email}

+
+ )} + {gscStatus.gsc_property && ( +
+

Property

+

{gscStatus.gsc_property}

+
+ )} + {gscStatus.last_synced_at && ( +
+

Last Synced

+

+ {new Date(gscStatus.last_synced_at).toLocaleString('en-GB')} +

+
+ )} + {gscStatus.created_at && ( +
+

Connected Since

+

+ {new Date(gscStatus.created_at).toLocaleString('en-GB')} +

+
+ )} +
+ + {gscStatus.status === 'error' && gscStatus.error_message && ( +
+

{gscStatus.error_message}

+
+ )} + + {canEdit && ( +
+ +
+ )} +
+ )} +
+ + {/* BunnyCDN */} +
+ {!bunnyStatus?.connected ? ( +
+
+
+ + + + + + + + + + + + + + + + + + + + +
+
+

BunnyCDN

+

+ Monitor CDN performance with bandwidth usage, cache hit rates, response times, and geographic distribution. +

+
+
+
+ + + +

+ Your API key is encrypted at rest and only used to fetch read-only statistics. You can disconnect at any time. +

+
+ {canEdit && ( +
+
+ { + setBunnyApiKey(e.target.value) + setBunnyPullZones([]) + setBunnySelectedZone(null) + }} + placeholder="BunnyCDN API key" + className="flex-1 px-4 py-2.5 border border-neutral-200 dark:border-neutral-800 rounded-xl bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white text-sm placeholder:text-neutral-400" + /> + +
+ + {bunnyPullZones.length > 0 && ( +
+
+ + +
+ +
+ )} +
+ )} +
+ ) : ( +
+
+
+
+ + + + + + + + + + + + + + + + + + + + +
+
+

BunnyCDN

+
+ + + {bunnyStatus.status === 'active' ? 'Connected' : bunnyStatus.status === 'syncing' ? 'Syncing...' : 'Error'} + +
+
+
+
+ +
+ {bunnyStatus.pull_zone_name && ( +
+

Pull Zone

+

{bunnyStatus.pull_zone_name}

+
+ )} + {bunnyStatus.last_synced_at && ( +
+

Last Synced

+

+ {new Date(bunnyStatus.last_synced_at).toLocaleString('en-GB')} +

+
+ )} + {bunnyStatus.created_at && ( +
+

Connected Since

+

+ {new Date(bunnyStatus.created_at).toLocaleString('en-GB')} +

+
+ )} +
+ + {bunnyStatus.status === 'error' && bunnyStatus.error_message && ( +
+

{bunnyStatus.error_message}

+
+ )} + + {canEdit && ( +
+ +
+ )} +
+ )} +
+
+ )}
diff --git a/components/IntegrationGuide.tsx b/components/IntegrationGuide.tsx index efca796..fb53861 100644 --- a/components/IntegrationGuide.tsx +++ b/components/IntegrationGuide.tsx @@ -67,6 +67,18 @@ export function IntegrationGuide({ integration, children }: IntegrationGuideProp
{children} + +
+

Optional: Frustration Tracking

+

+ Detect rage clicks and dead clicks by adding the frustration tracking + add-on after the core script: +

+
{``}
+

+ No extra configuration needed. Add data-no-rage or{' '} + data-no-dead to disable individual signals. +

{/* * --- Related Integrations --- */} diff --git a/components/behavior/FrustrationByPageTable.tsx b/components/behavior/FrustrationByPageTable.tsx index eb1e3ea..fa1169b 100644 --- a/components/behavior/FrustrationByPageTable.tsx +++ b/components/behavior/FrustrationByPageTable.tsx @@ -45,7 +45,7 @@ export default function FrustrationByPageTable({ pages, loading }: FrustrationBy {loading ? ( ) : hasData ? ( -
+
{/* Header */}
Page @@ -60,7 +60,7 @@ export default function FrustrationByPageTable({ pages, loading }: FrustrationBy {/* Rows */}
{pages.map((page) => { - const barWidth = (page.total / maxTotal) * 100 + const barWidth = (page.total / maxTotal) * 75 return (
{/* Background bar */}
{page.page_path} diff --git a/components/behavior/FrustrationTable.tsx b/components/behavior/FrustrationTable.tsx index d16ee4f..916b749 100644 --- a/components/behavior/FrustrationTable.tsx +++ b/components/behavior/FrustrationTable.tsx @@ -81,7 +81,7 @@ function Row({ return (
-
+

- {description}. Data will appear here once frustration signals are detected on your site. + Frustration tracking requires the add-on script. Add it after your core Pulse script:

+ + {''} +
)}
diff --git a/components/dashboard/Campaigns.tsx b/components/dashboard/Campaigns.tsx index 397f5b9..1c7ed31 100644 --- a/components/dashboard/Campaigns.tsx +++ b/components/dashboard/Campaigns.tsx @@ -127,6 +127,7 @@ export default function Campaigns({ siteId, dateRange, filters, onFilter }: Camp
+

Campaigns

@@ -154,13 +155,19 @@ export default function Campaigns({ siteId, dateRange, filters, onFilter }: Camp ) : hasData ? ( <> {displayedData.map((item) => { + const maxVis = displayedData[0]?.visitors ?? 0 + const barWidth = maxVis > 0 ? (item.visitors / maxVis) * 75 : 0 return (
onFilter?.({ dimension: 'utm_source', operator: 'is', values: [item.source] })} - className={`flex items-center justify-between py-1.5 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors${onFilter ? ' cursor-pointer' : ''}`} + className={`relative flex items-center justify-between py-1.5 group hover:bg-neutral-50/50 dark:hover:bg-neutral-800/50 rounded-lg px-2 -mx-2 transition-colors${onFilter ? ' cursor-pointer' : ''}`} > -
+
+
{renderSourceIcon(item.source)}
@@ -173,7 +180,7 @@ export default function Campaigns({ siteId, dateRange, filters, onFilter }: Camp
-
+
{totalVisitors > 0 ? `${Math.round((item.visitors / totalVisitors) * 100)}%` : ''} @@ -199,13 +206,13 @@ export default function Campaigns({ siteId, dateRange, filters, onFilter }: Camp

Add UTM parameters to your links to see campaign performance here.

- setIsBuilderOpen(true)} + className="inline-flex items-center gap-2 text-sm font-medium text-brand-orange hover:text-brand-orange/90 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange/20 rounded cursor-pointer" > - Learn more + Build a UTM URL - +
)}
diff --git a/components/dashboard/Chart.tsx b/components/dashboard/Chart.tsx index 85ab438..7fc7bc8 100644 --- a/components/dashboard/Chart.tsx +++ b/components/dashboard/Chart.tsx @@ -10,6 +10,7 @@ import { Select, DownloadIcon, PlusIcon, XIcon } from '@ciphera-net/ui' import { Checkbox } from '@ciphera-net/ui' import { ArrowUpRight, ArrowDownRight } from '@phosphor-icons/react' import { motion } from 'framer-motion' +import { AnimatedNumber } from '@/components/ui/animated-number' import { cn } from '@/lib/utils' import { formatTime, formatDateShort, formatDate } from '@/lib/utils/formatDate' @@ -350,7 +351,7 @@ export default function Chart({ >
{m.label}
- {m.format(m.value)} + {m.change !== null && ( {m.isPositive ? : } diff --git a/components/dashboard/ContentStats.tsx b/components/dashboard/ContentStats.tsx index 4ed61aa..f828889 100644 --- a/components/dashboard/ContentStats.tsx +++ b/components/dashboard/ContentStats.tsx @@ -6,8 +6,9 @@ import { logger } from '@/lib/utils/logger' import { formatNumber } from '@ciphera-net/ui' import { useTabListKeyboard } from '@/lib/hooks/useTabListKeyboard' import { TopPage, getTopPages, getEntryPages, getExitPages } from '@/lib/api/stats' -import { FrameCornersIcon } from '@phosphor-icons/react' -import { Modal, ArrowUpRightIcon, LayoutDashboardIcon } from '@ciphera-net/ui' +import Link from 'next/link' +import { Files, FrameCornersIcon } from '@phosphor-icons/react' +import { Modal, ArrowUpRightIcon, ArrowRightIcon, LayoutDashboardIcon } from '@ciphera-net/ui' import { ListSkeleton } from '@/components/skeletons' import VirtualList from './VirtualList' import { type DimensionFilter } from '@/lib/filters' @@ -101,6 +102,7 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain,
+

Pages

@@ -114,7 +116,7 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain, )}
-
+
{(['top_pages', 'entry_pages', 'exit_pages'] as Tab[]).map((tab) => (
diff --git a/components/dashboard/ExportModal.tsx b/components/dashboard/ExportModal.tsx index 39566d1..8993ab9 100644 --- a/components/dashboard/ExportModal.tsx +++ b/components/dashboard/ExportModal.tsx @@ -51,6 +51,8 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to const [filename, setFilename] = useState(`pulse_export_${formatDateISO(new Date())}`) const [includeHeader, setIncludeHeader] = useState(true) const [isExporting, setIsExporting] = useState(false) + const [exportDone, setExportDone] = useState(false) + const [exportProgress, setExportProgress] = useState({ step: 0, total: 1, label: '' }) const [selectedFields, setSelectedFields] = useState>({ date: true, pageviews: true, @@ -63,8 +65,24 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to setSelectedFields((prev) => ({ ...prev, [field]: checked })) } + const finishExport = useCallback(() => { + setExportDone(true) + setIsExporting(false) + setTimeout(() => { + setExportDone(false) + onClose() + }, 600) + }, [onClose]) + + // Yield to the UI thread so the browser can paint progress updates + const updateProgress = useCallback(async (step: number, total: number, label: string) => { + setExportProgress({ step, total, label }) + await new Promise(resolve => setTimeout(resolve, 0)) + }, []) + const handleExport = () => { setIsExporting(true) + setExportProgress({ step: 0, total: 1, label: 'Preparing...' }) // Let the browser paint the loading state before starting heavy work requestAnimationFrame(() => { setTimeout(async () => { @@ -100,6 +118,7 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to mimeType = 'text/csv;charset=utf-8;' extension = 'csv' } else if (format === 'xlsx') { + await updateProgress(1, 2, 'Building spreadsheet...') const ws = XLSX.utils.json_to_sheet(exportData) const wb = XLSX.utils.book_new() XLSX.utils.book_append_sheet(wb, ws, 'Data') @@ -125,12 +144,15 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to document.body.appendChild(link) link.click() document.body.removeChild(link) - onClose() + finishExport() return } else if (format === 'pdf') { + const totalSteps = 3 + (topPages?.length ? 1 : 0) + (topReferrers?.length ? 1 : 0) + (campaigns?.length ? 1 : 0) + let currentStep = 0 const doc = new jsPDF() // Header Section + await updateProgress(++currentStep, totalSteps, 'Building header...') try { // Logo const logoData = await loadImage('/pulse_icon_no_margins.png') @@ -195,6 +217,7 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to startY = 65 // Move table down } + await updateProgress(++currentStep, totalSteps, 'Generating data table...') // Check if data is hourly (same date for multiple rows) const isHourly = data.length > 1 && data[0].date.split('T')[0] === data[1].date.split('T')[0] @@ -258,6 +281,7 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to // Top Pages Table if (topPages && topPages.length > 0) { + await updateProgress(++currentStep, totalSteps, 'Adding top pages...') // Check if we need a new page if (finalY + 40 > doc.internal.pageSize.height) { doc.addPage() @@ -286,6 +310,7 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to // Top Referrers Table if (topReferrers && topReferrers.length > 0) { + await updateProgress(++currentStep, totalSteps, 'Adding top referrers...') // Check if we need a new page if (finalY + 40 > doc.internal.pageSize.height) { doc.addPage() @@ -315,6 +340,7 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to // Campaigns Table if (campaigns && campaigns.length > 0) { + await updateProgress(++currentStep, totalSteps, 'Adding campaigns...') if (finalY + 40 > doc.internal.pageSize.height) { doc.addPage() finalY = 20 @@ -341,8 +367,9 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to }) } + await updateProgress(totalSteps, totalSteps, 'Saving PDF...') doc.save(`${filename || 'export'}.pdf`) - onClose() + finishExport() return } else { content = JSON.stringify(exportData, null, 2) @@ -359,7 +386,7 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to link.click() document.body.removeChild(link) - onClose() + finishExport() } catch (e) { console.error('Export failed:', e) } finally { @@ -450,13 +477,29 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to
)} + {/* Progress Bar */} + {(isExporting || exportDone) && ( +
+
+ {exportDone ? 'Export complete' : exportProgress.label} + {exportDone ? '100%' : `${Math.round((exportProgress.step / exportProgress.total) * 100)}%`} +
+
+
+
+
+ )} + {/* Actions */}
-
diff --git a/components/dashboard/GoalStats.tsx b/components/dashboard/GoalStats.tsx index 8874081..2299642 100644 --- a/components/dashboard/GoalStats.tsx +++ b/components/dashboard/GoalStats.tsx @@ -2,6 +2,7 @@ import Link from 'next/link' import { formatNumber } from '@ciphera-net/ui' +import { Target } from '@phosphor-icons/react' import { BookOpenIcon, ArrowRightIcon } from '@ciphera-net/ui' import type { GoalCountStat } from '@/lib/api/stats' @@ -21,9 +22,12 @@ export default function GoalStats({ goalCounts, onSelectEvent }: GoalStatsProps) return (
-

- Goals & Events -

+
+ +

+ Goals & Events +

+
{hasData ? ( diff --git a/components/dashboard/Locations.tsx b/components/dashboard/Locations.tsx index b752447..3fb9374 100644 --- a/components/dashboard/Locations.tsx +++ b/components/dashboard/Locations.tsx @@ -11,10 +11,11 @@ import iso3166 from 'iso-3166-2' const DottedMap = dynamic(() => import('./DottedMap'), { ssr: false }) const Globe = dynamic(() => import('./Globe'), { ssr: false }) -import { Modal, GlobeIcon } from '@ciphera-net/ui' +import Link from 'next/link' +import { Modal, GlobeIcon, ArrowRightIcon } from '@ciphera-net/ui' import { ListSkeleton } from '@/components/skeletons' import VirtualList from './VirtualList' -import { ShieldCheck, Detective, Broadcast, FrameCornersIcon } from '@phosphor-icons/react' +import { ShieldCheck, Detective, Broadcast, MapPin, FrameCornersIcon } from '@phosphor-icons/react' import { getCountries, getCities, getRegions } from '@/lib/api/stats' import { type DimensionFilter } from '@/lib/filters' @@ -219,6 +220,7 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
+

Locations

@@ -232,7 +234,7 @@ export default function Locations({ countries, cities, regions, geoDataLevel = ' )}
-
+
{(['map', 'globe', 'countries', 'regions', 'cities'] as Tab[]).map((tab) => ( - - {/* * Expanded: full LCP/CLS/INP cards, footnote, and Worst pages (collapsible) */} - -
-
- - - -
-
- * Averages calculated from real user sessions. Lower is better. -
- - {/* * Worst pages by metric – collapsed by default */} -
-
- - {worstPagesOpen && canRefetch && ( - +
+ {storage === 'local' && ( +
+ +