BunnyCDN, Search tab, journeys redesign, and dashboard polish #52

Merged
uz1mani merged 86 commits from staging into main 2026-03-17 10:08:26 +00:00
50 changed files with 5100 additions and 1647 deletions

View File

@@ -6,32 +6,44 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
## [Unreleased] ## [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 23 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 ### 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 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. - **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 ### 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. - **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.
- **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. - **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.
- **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. - **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 `<main>` 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 23 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. - **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. - **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. - **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. - **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 ### 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. - **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. - **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. - **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. - **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. - **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. - **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. - **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. - **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. - **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. - **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.

View File

@@ -400,7 +400,7 @@ export default function HomePage() {
) : null ) : null
})() ?? ( })() ?? (
<Link href="/sites/new"> <Link href="/sites/new">
<Button variant="primary" className="text-sm"> <Button variant="primary" className="text-sm whitespace-nowrap">
Add New Site Add New Site
</Button> </Button>
</Link> </Link>
@@ -409,11 +409,11 @@ export default function HomePage() {
{/* * Global Overview - min-h ensures no layout shift when Plan & usage loads */} {/* * Global Overview - min-h ensures no layout shift when Plan & usage loads */}
<div className="mb-8 grid grid-cols-1 gap-4 sm:grid-cols-3"> <div className="mb-8 grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="flex min-h-[160px] flex-col rounded-2xl border border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900"> <div className="flex min-h-[100px] sm:min-h-[160px] flex-col rounded-2xl border border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900">
<p className="text-sm text-neutral-500 dark:text-neutral-400">Total Sites</p> <p className="text-sm text-neutral-500 dark:text-neutral-400">Total Sites</p>
<p className="text-2xl font-bold text-neutral-900 dark:text-white">{sites.length}</p> <p className="text-2xl font-bold text-neutral-900 dark:text-white">{sites.length}</p>
</div> </div>
<div className="flex min-h-[160px] flex-col rounded-2xl border border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900"> <div className="flex min-h-[100px] sm:min-h-[160px] flex-col rounded-2xl border border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900">
<p className="text-sm text-neutral-500 dark:text-neutral-400">Total Visitors (24h)</p> <p className="text-sm text-neutral-500 dark:text-neutral-400">Total Visitors (24h)</p>
<p className="text-2xl font-bold text-neutral-900 dark:text-white"> <p className="text-2xl font-bold text-neutral-900 dark:text-white">
{sites.length === 0 || Object.keys(siteStats).length < sites.length {sites.length === 0 || Object.keys(siteStats).length < sites.length

View File

@@ -3,7 +3,7 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import Image from 'next/image' import Image from 'next/image'
import { useParams, useSearchParams, useRouter } from 'next/navigation' 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 { toast } from '@ciphera-net/ui'
import { getAuthErrorMessage } from '@ciphera-net/ui' import { getAuthErrorMessage } from '@ciphera-net/ui'
import { ApiError } from '@/lib/api/client' import { ApiError } from '@/lib/api/client'
@@ -13,7 +13,6 @@ import TopPages from '@/components/dashboard/ContentStats'
import TopReferrers from '@/components/dashboard/TopReferrers' import TopReferrers from '@/components/dashboard/TopReferrers'
import Locations from '@/components/dashboard/Locations' import Locations from '@/components/dashboard/Locations'
import TechSpecs from '@/components/dashboard/TechSpecs' 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 { Select, DatePicker as DatePickerModal, Captcha, DownloadIcon, ZapIcon } from '@ciphera-net/ui'
import { DashboardSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons' import { DashboardSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
import ExportModal from '@/components/dashboard/ExportModal' import ExportModal from '@/components/dashboard/ExportModal'
@@ -257,7 +256,7 @@ export default function PublicDashboardPage() {
if (!data) return null 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 // Provide defaults for potentially undefined data
const safeDailyStats = daily_stats || [] const safeDailyStats = daily_stats || []
@@ -395,29 +394,6 @@ export default function PublicDashboardPage() {
/> />
</div> </div>
{/* Performance Stats - Only show if enabled */}
{performance && data.site?.enable_performance_insights && (
<div className="mb-8">
<PerformanceStats
stats={performance}
performanceByPage={performance_by_page}
siteId={siteId}
startDate={dateRange.start}
endDate={dateRange.end}
getPerformanceByPage={(siteId, startDate, endDate, opts) => {
return getPublicPerformanceByPage(siteId, startDate, endDate, opts, {
password,
captcha: {
captcha_id: captchaId,
captcha_solution: captchaSolution,
captcha_token: captchaToken
}
})
}}
/>
</div>
)}
{/* Details Grid */} {/* Details Grid */}
<div className="grid gap-6 lg:grid-cols-2 mb-8"> <div className="grid gap-6 lg:grid-cols-2 mb-8">
<TopPages <TopPages

View File

@@ -123,7 +123,7 @@ export default function BehaviorPage() {
<FrustrationSummaryCards data={summary} loading={loading} /> <FrustrationSummaryCards data={summary} loading={loading} />
{/* Rage clicks + Dead clicks side by side */} {/* Rage clicks + Dead clicks side by side */}
<div className="grid gap-6 lg:grid-cols-2 mb-8"> <div className="grid gap-6 lg:grid-cols-2 mb-8 [&>*]:min-w-0">
<FrustrationTable <FrustrationTable
title="Rage Clicks" title="Rage Clicks"
description="Elements users clicked repeatedly in frustration" description="Elements users clicked repeatedly in frustration"
@@ -150,7 +150,7 @@ export default function BehaviorPage() {
{/* Scroll depth + Frustration trend — hide when data failed to load */} {/* Scroll depth + Frustration trend — hide when data failed to load */}
{!behaviorError && ( {!behaviorError && (
<div className="grid gap-6 lg:grid-cols-2 mb-8"> <div className="grid gap-6 lg:grid-cols-2 mb-8 [&>*]:min-w-0">
<ScrollDepth <ScrollDepth
goalCounts={dashboard?.goal_counts ?? []} goalCounts={dashboard?.goal_counts ?? []}
totalPageviews={dashboard?.stats?.pageviews ?? 0} totalPageviews={dashboard?.stats?.pageviews ?? 0}

546
app/sites/[id]/cdn/page.tsx Normal file
View File

@@ -0,0 +1,546 @@
'use client'
import { useEffect, useState } from 'react'
import dynamic from 'next/dynamic'
import { useParams } from 'next/navigation'
import Link from 'next/link'
import * as Flags from 'country-flag-icons/react/3x2'
const DottedMap = dynamic(() => 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<string, React.ComponentType<{ className?: string }>>)[code]
return FlagComponent ? <FlagComponent className="w-5 h-3.5 rounded-sm shadow-sm shrink-0" /> : 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 (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<SkeletonLine className="h-8 w-48 mb-2" />
<SkeletonLine className="h-4 w-64" />
</div>
<SkeletonLine className="h-9 w-36 rounded-lg" />
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4 mb-8">
<StatCardSkeleton />
<StatCardSkeleton />
<StatCardSkeleton />
<StatCardSkeleton />
<StatCardSkeleton />
</div>
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-xl p-6 mb-6">
<SkeletonLine className="h-6 w-40 mb-4" />
<SkeletonLine className="h-64 w-full rounded-lg" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-xl p-6">
<SkeletonLine className="h-6 w-32 mb-4" />
<SkeletonLine className="h-48 w-full rounded-lg" />
</div>
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-xl p-6">
<SkeletonLine className="h-6 w-32 mb-4" />
<SkeletonLine className="h-48 w-full rounded-lg" />
</div>
</div>
</div>
)
}
// ─── Not connected state ──────────────────────────────────
if (bunnyStatus && !bunnyStatus.connected) {
return (
<div className={`w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
<div className="flex flex-col items-center justify-center py-24 text-center">
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-5 mb-6">
<CloudArrowUp size={40} className="text-neutral-400 dark:text-neutral-500" />
</div>
<h2 className="text-xl font-semibold text-neutral-900 dark:text-white mb-2">
Connect BunnyCDN
</h2>
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-md mb-6">
Monitor your CDN performance including bandwidth usage, cache hit rates, request volumes, and geographic distribution.
</p>
<Link
href={`/sites/${siteId}/settings?tab=integrations`}
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-brand-orange hover:bg-brand-orange/90 text-white text-sm font-medium transition-colors"
>
Connect in Settings
<ArrowSquareOut size={16} weight="bold" />
</Link>
</div>
</div>
)
}
// ─── 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 (
<div className={`w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
{/* Header */}
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-1">
CDN Analytics
</h1>
<p className="text-sm text-neutral-500 dark:text-neutral-400">
BunnyCDN performance, bandwidth, and cache metrics
</p>
</div>
<Select
variant="input"
className="min-w-[140px]"
value={period}
onChange={(value) => {
if (value === 'today') {
const today = formatDate(new Date())
setDateRange({ start: today, end: today })
setPeriod('today')
} else if (value === '7') {
setDateRange(getDateRange(7))
setPeriod('7')
} else if (value === '28') {
setDateRange(getDateRange(28))
setPeriod('28')
} else if (value === '30') {
setDateRange(getDateRange(30))
setPeriod('30')
}
}}
options={[
{ value: 'today', label: 'Today' },
{ value: '7', label: 'Last 7 days' },
{ value: '28', label: 'Last 28 days' },
{ value: '30', label: 'Last 30 days' },
]}
/>
</div>
{/* Overview cards */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4 mb-8">
<OverviewCard
label="Bandwidth"
value={overview ? formatBytes(overview.total_bandwidth) : '-'}
change={bandwidthChange}
/>
<OverviewCard
label="Requests"
value={overview ? formatNumber(overview.total_requests) : '-'}
change={requestsChange}
/>
<OverviewCard
label="Cache Hit Rate"
value={overview ? overview.cache_hit_rate.toFixed(1) + '%' : '-'}
change={cacheHitChange}
/>
<OverviewCard
label="Origin Response"
value={overview ? overview.avg_origin_response.toFixed(0) + 'ms' : '-'}
change={originChange}
invertColor
/>
<OverviewCard
label="Errors"
value={overview ? formatNumber(overview.total_errors) : '-'}
change={errorsChange}
invertColor
/>
</div>
{/* Bandwidth chart */}
<div className="rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-6 mb-6">
<h2 className="text-sm font-semibold text-neutral-900 dark:text-white mb-4">Bandwidth</h2>
{daily.length > 0 ? (
<ResponsiveContainer width="100%" height={280}>
<AreaChart data={daily} margin={{ top: 4, right: 4, bottom: 0, left: 0 }}>
<defs>
<linearGradient id="bandwidthGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#FD5E0F" stopOpacity={0.2} />
<stop offset="100%" stopColor="#FD5E0F" stopOpacity={0} />
</linearGradient>
<linearGradient id="cachedGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#22C55E" stopOpacity={0.15} />
<stop offset="100%" stopColor="#22C55E" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-neutral-200 dark:text-neutral-800" />
<XAxis
dataKey="date"
tickFormatter={formatDateShort}
tick={{ fontSize: 12, fill: 'currentColor' }}
className="text-neutral-400 dark:text-neutral-500"
axisLine={false}
tickLine={false}
/>
<YAxis
tickFormatter={(v) => formatBytes(v)}
tick={{ fontSize: 12, fill: 'currentColor' }}
className="text-neutral-400 dark:text-neutral-500"
axisLine={false}
tickLine={false}
width={60}
/>
<Tooltip
content={({ active, payload, label }) => {
if (!active || !payload?.length) return null
return (
<div className="rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 shadow-lg text-sm">
<p className="text-neutral-500 dark:text-neutral-400 mb-1">{formatDateShort(label)}</p>
<p className="text-neutral-900 dark:text-white font-medium">
Total: {formatBytes(payload[0]?.value as number)}
</p>
{payload[1] && (
<p className="text-green-600 dark:text-green-400">
Cached: {formatBytes(payload[1]?.value as number)}
</p>
)}
</div>
)
}}
/>
<Area
type="monotone"
dataKey="bandwidth_used"
stroke="#FD5E0F"
strokeWidth={2}
fill="url(#bandwidthGrad)"
name="Total"
/>
<Area
type="monotone"
dataKey="bandwidth_cached"
stroke="#22C55E"
strokeWidth={2}
fill="url(#cachedGrad)"
name="Cached"
/>
</AreaChart>
</ResponsiveContainer>
) : (
<div className="h-[280px] flex items-center justify-center text-neutral-400 dark:text-neutral-500 text-sm">
No bandwidth data for this period.
</div>
)}
</div>
{/* Requests + Errors charts side by side */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
{/* Requests chart */}
<div className="rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-6">
<h2 className="text-sm font-semibold text-neutral-900 dark:text-white mb-4">Requests</h2>
{daily.length > 0 ? (
<ResponsiveContainer width="100%" height={220}>
<BarChart data={daily} margin={{ top: 4, right: 4, bottom: 0, left: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-neutral-200 dark:text-neutral-800" />
<XAxis
dataKey="date"
tickFormatter={formatDateShort}
tick={{ fontSize: 11, fill: 'currentColor' }}
className="text-neutral-400 dark:text-neutral-500"
axisLine={false}
tickLine={false}
/>
<YAxis
tickFormatter={(v) => formatNumber(v)}
tick={{ fontSize: 11, fill: 'currentColor' }}
className="text-neutral-400 dark:text-neutral-500"
axisLine={false}
tickLine={false}
width={50}
/>
<Tooltip
content={({ active, payload, label }) => {
if (!active || !payload?.length) return null
return (
<div className="rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 shadow-lg text-sm">
<p className="text-neutral-500 dark:text-neutral-400 mb-1">{formatDateShort(label)}</p>
<p className="text-neutral-900 dark:text-white font-medium">
{formatNumber(payload[0]?.value as number)} requests
</p>
</div>
)
}}
/>
<Bar dataKey="requests_served" fill="#FD5E0F" radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
) : (
<div className="h-[220px] flex items-center justify-center text-neutral-400 dark:text-neutral-500 text-sm">
No request data for this period.
</div>
)}
</div>
{/* Errors chart */}
<div className="rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-6">
<h2 className="text-sm font-semibold text-neutral-900 dark:text-white mb-4">Errors</h2>
{daily.length > 0 ? (
<ResponsiveContainer width="100%" height={220}>
<BarChart
data={daily.map((d) => ({
date: d.date,
'3xx': d.error_3xx,
'4xx': d.error_4xx,
'5xx': d.error_5xx,
}))}
margin={{ top: 4, right: 4, bottom: 0, left: 0 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-neutral-200 dark:text-neutral-800" />
<XAxis
dataKey="date"
tickFormatter={formatDateShort}
tick={{ fontSize: 11, fill: 'currentColor' }}
className="text-neutral-400 dark:text-neutral-500"
axisLine={false}
tickLine={false}
/>
<YAxis
tickFormatter={(v) => formatNumber(v)}
tick={{ fontSize: 11, fill: 'currentColor' }}
className="text-neutral-400 dark:text-neutral-500"
axisLine={false}
tickLine={false}
width={50}
/>
<Tooltip
content={({ active, payload, label }) => {
if (!active || !payload?.length) return null
return (
<div className="rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 shadow-lg text-sm">
<p className="text-neutral-500 dark:text-neutral-400 mb-1">{formatDateShort(label)}</p>
{payload.map((entry) => (
<p key={entry.name} style={{ color: entry.color }} className="font-medium">
{entry.name}: {formatNumber(entry.value as number)}
</p>
))}
</div>
)
}}
/>
<Bar dataKey="3xx" stackId="errors" fill="#FACC15" radius={[0, 0, 0, 0]} />
<Bar dataKey="4xx" stackId="errors" fill="#F97316" radius={[0, 0, 0, 0]} />
<Bar dataKey="5xx" stackId="errors" fill="#EF4444" radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
) : (
<div className="h-[220px] flex items-center justify-center text-neutral-400 dark:text-neutral-500 text-sm">
No error data for this period.
</div>
)}
</div>
</div>
{/* Traffic Distribution */}
<div className="rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-6">
<h2 className="text-sm font-semibold text-neutral-900 dark:text-white mb-4">Traffic Distribution</h2>
{countries.length > 0 ? (
<>
<div className="h-[360px] mb-8">
<DottedMap data={mapToCountryCentroids(countries)} formatValue={formatBytes} />
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-x-6 gap-y-5">
{countries.map((row) => {
const pct = totalBandwidth > 0 ? (row.bandwidth / totalBandwidth) * 100 : 0
const cc = extractCountryCode(row.country_code)
const city = extractCity(row.country_code)
return (
<div key={row.country_code} className="group relative">
<div className="flex items-center gap-2.5 mb-2">
{cc && getFlagIcon(cc)}
<div className="flex-1 min-w-0">
<span className="text-sm font-medium text-neutral-900 dark:text-white truncate block">{city}</span>
</div>
<span className="text-sm tabular-nums text-neutral-500 dark:text-neutral-400 shrink-0">
{formatBytes(row.bandwidth)}
</span>
</div>
<div className="relative h-1.5 bg-neutral-100 dark:bg-neutral-800 rounded-full overflow-hidden">
<div
className="absolute inset-y-0 left-0 rounded-full bg-brand-orange transition-all"
style={{ width: `${Math.max(pct, 1)}%` }}
/>
</div>
<div className="absolute -top-8 left-1/2 -translate-x-1/2 px-2 py-1 bg-neutral-900 dark:bg-neutral-700 text-white text-xs rounded shadow-lg opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap z-10">
{pct.toFixed(1)}% of total traffic
</div>
</div>
)
})}
</div>
</>
) : (
<div className="h-[360px] flex items-center justify-center text-neutral-400 dark:text-neutral-500 text-sm">
No geographic data for this period.
</div>
)}
</div>
</div>
)
}
// ─── Sub-components ─────────────────────────────────────────────
function OverviewCard({
label,
value,
change,
invertColor = false,
}: {
label: string
value: string
change: { value: number; positive: boolean } | null
invertColor?: boolean
}) {
// For Origin Response and Errors, a decrease is good (green), an increase is bad (red)
const isGood = change ? (invertColor ? !change.positive : change.positive) : false
const isBad = change ? (invertColor ? change.positive : !change.positive) : false
const changeLabel = change ? (change.positive ? '+' : '') + change.value.toFixed(1) + '%' : null
return (
<div className="p-4 rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900">
<p className="text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-1">{label}</p>
<p className="text-2xl font-bold text-neutral-900 dark:text-white">{value}</p>
{changeLabel && (
<p className={`text-xs mt-1 font-medium ${
isGood ? 'text-green-600 dark:text-green-400' :
isBad ? 'text-red-600 dark:text-red-400' :
'text-neutral-500 dark:text-neutral-400'
}`}>
{changeLabel} vs previous period
</p>
)}
</div>
)
}

View File

@@ -2,6 +2,7 @@
import { useState } from 'react' import { useState } from 'react'
import { useParams, useRouter } from 'next/navigation' import { useParams, useRouter } from 'next/navigation'
import { useSWRConfig } from 'swr'
import { createFunnel, type CreateFunnelRequest, type FunnelStep } from '@/lib/api/funnels' import { createFunnel, type CreateFunnelRequest, type FunnelStep } from '@/lib/api/funnels'
import { toast, Input, Button, ChevronLeftIcon, PlusIcon, TrashIcon } from '@ciphera-net/ui' import { toast, Input, Button, ChevronLeftIcon, PlusIcon, TrashIcon } from '@ciphera-net/ui'
import Link from 'next/link' import Link from 'next/link'
@@ -18,6 +19,7 @@ function isValidRegex(pattern: string): boolean {
export default function CreateFunnelPage() { export default function CreateFunnelPage() {
const params = useParams() const params = useParams()
const router = useRouter() const router = useRouter()
const { mutate } = useSWRConfig()
const siteId = params.id as string const siteId = params.id as string
const [name, setName] = useState('') const [name, setName] = useState('')
@@ -72,7 +74,7 @@ export default function CreateFunnelPage() {
setSaving(true) setSaving(true)
const funnelSteps = steps.map((s, i) => ({ const funnelSteps = steps.map((s, i) => ({
...s, ...s,
order: i + 1 order: i
})) }))
await createFunnel(siteId, { await createFunnel(siteId, {
@@ -81,6 +83,7 @@ export default function CreateFunnelPage() {
steps: funnelSteps steps: funnelSteps
}) })
await mutate(['funnels', siteId])
toast.success('Funnel created') toast.success('Funnel created')
router.push(`/sites/${siteId}/funnels`) router.push(`/sites/${siteId}/funnels`)
} catch (error) { } catch (error) {

View File

@@ -2,9 +2,11 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useParams } from 'next/navigation' import { useParams } from 'next/navigation'
import { motion } from 'framer-motion'
import { getDateRange, formatDate } from '@ciphera-net/ui' import { getDateRange, formatDate } from '@ciphera-net/ui'
import { Select, DatePicker } from '@ciphera-net/ui' import { Select, DatePicker } from '@ciphera-net/ui'
import SankeyDiagram from '@/components/journeys/SankeyDiagram' import ColumnJourney from '@/components/journeys/ColumnJourney'
import SankeyJourney from '@/components/journeys/SankeyJourney'
import TopPathsTable from '@/components/journeys/TopPathsTable' import TopPathsTable from '@/components/journeys/TopPathsTable'
import { JourneysSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons' import { JourneysSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
import { import {
@@ -14,6 +16,8 @@ import {
useJourneyEntryPoints, useJourneyEntryPoints,
} from '@/lib/swr/dashboard' } from '@/lib/swr/dashboard'
const DEFAULT_DEPTH = 4
function getThisWeekRange(): { start: string; end: string } { function getThisWeekRange(): { start: string; end: string } {
const today = new Date() const today = new Date()
const dayOfWeek = today.getDay() const dayOfWeek = today.getDay()
@@ -35,11 +39,26 @@ export default function JourneysPage() {
const [period, setPeriod] = useState('30') const [period, setPeriod] = useState('30')
const [dateRange, setDateRange] = useState(() => getDateRange(30)) const [dateRange, setDateRange] = useState(() => getDateRange(30))
const [isDatePickerOpen, setIsDatePickerOpen] = useState(false) const [isDatePickerOpen, setIsDatePickerOpen] = useState(false)
const [depth, setDepth] = useState(3) const [depth, setDepth] = useState(DEFAULT_DEPTH)
const [committedDepth, setCommittedDepth] = useState(DEFAULT_DEPTH)
const [entryPath, setEntryPath] = useState('') const [entryPath, setEntryPath] = useState('')
const [viewMode, setViewMode] = useState<'columns' | 'flow'>('columns')
useEffect(() => {
const t = setTimeout(() => setCommittedDepth(depth), 300)
return () => clearTimeout(t)
}, [depth])
const isDefault = depth === DEFAULT_DEPTH && !entryPath
function resetFilters() {
setDepth(DEFAULT_DEPTH)
setCommittedDepth(DEFAULT_DEPTH)
setEntryPath('')
}
const { data: transitionsData, isLoading: transitionsLoading } = useJourneyTransitions( const { data: transitionsData, isLoading: transitionsLoading } = useJourneyTransitions(
siteId, dateRange.start, dateRange.end, depth, 1, entryPath || undefined siteId, dateRange.start, dateRange.end, committedDepth, 1, entryPath || undefined
) )
const { data: topPaths, isLoading: topPathsLoading } = useJourneyTopPaths( const { data: topPaths, isLoading: topPathsLoading } = useJourneyTopPaths(
siteId, dateRange.start, dateRange.end, 20, 1, entryPath || undefined siteId, dateRange.start, dateRange.end, 20, 1, entryPath || undefined
@@ -65,6 +84,8 @@ export default function JourneysPage() {
if (showSkeleton) return <JourneysSkeleton /> if (showSkeleton) return <JourneysSkeleton />
const totalSessions = transitionsData?.total_sessions ?? 0
return ( return (
<div className={`w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}> <div className={`w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
{/* Header */} {/* Header */}
@@ -115,52 +136,112 @@ export default function JourneysPage() {
/> />
</div> </div>
{/* Controls */} {/* Single card: toolbar + chart */}
<div className="flex flex-wrap items-center gap-4 mb-6"> <div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden">
<div className="flex items-center gap-3"> {/* Toolbar */}
<label className="text-sm text-neutral-500 dark:text-neutral-400">Depth</label> <div className="p-6 border-b border-neutral-200 dark:border-neutral-800 bg-neutral-50/50 dark:bg-neutral-900/50">
<input <div className="flex flex-col sm:flex-row sm:items-center gap-6">
type="range" {/* Depth slider */}
min={2} <div className="flex-1">
max={5} <div className="flex justify-between text-sm font-medium text-neutral-500 dark:text-neutral-400 mb-3">
step={1} <span>2 steps</span>
value={depth} <span className="text-brand-orange font-bold">
onChange={(e) => setDepth(Number(e.target.value))} {depth} steps deep
className="w-32 accent-brand-orange" </span>
/> <span>6 steps</span>
<span className="text-sm font-medium text-neutral-900 dark:text-white w-4">{depth}</span> </div>
<input
type="range"
min={2}
max={6}
step={1}
value={depth}
onChange={(e) => 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"
/>
</div>
{/* Entry point + Reset */}
<div className="flex items-center gap-3 shrink-0">
<Select
variant="input"
className="min-w-[180px]"
value={entryPath}
onChange={(value) => setEntryPath(value)}
options={entryPointOptions}
/>
<button
onClick={resetFilters}
disabled={isDefault}
className={`text-sm whitespace-nowrap transition-all duration-150 ${
isDefault
? 'opacity-0 pointer-events-none'
: 'opacity-100 text-neutral-500 hover:text-neutral-900 dark:hover:text-white'
}`}
>
Reset
</button>
</div>
</div>
{/* View toggle */}
<div className="flex gap-1 mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-800" role="tablist" aria-label="Journey view tabs">
{(['columns', 'flow'] as const).map((mode) => (
<button
key={mode}
onClick={() => setViewMode(mode)}
role="tab"
aria-selected={viewMode === mode}
className={`relative px-3 py-1 text-xs font-medium transition-colors capitalize focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange rounded cursor-pointer ${
viewMode === mode
? 'text-neutral-900 dark:text-white'
: 'text-neutral-400 dark:text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300'
}`}
>
{mode === 'columns' ? 'Columns' : 'Flow'}
{viewMode === mode && (
<motion.div
layoutId="journeyViewTab"
className="absolute inset-x-0 -bottom-px h-0.5 bg-brand-orange"
transition={{ type: 'spring', stiffness: 500, damping: 35 }}
/>
)}
</button>
))}
</div>
</div> </div>
<Select {/* Journey Chart */}
variant="input" <div className="p-6">
className="min-w-[180px]" {viewMode === 'columns' ? (
value={entryPath} <ColumnJourney
onChange={(value) => setEntryPath(value)} transitions={transitionsData?.transitions ?? []}
options={entryPointOptions} totalSessions={totalSessions}
/> depth={committedDepth}
/>
) : (
<SankeyJourney
transitions={transitionsData?.transitions ?? []}
totalSessions={totalSessions}
depth={committedDepth}
/>
)}
</div>
{(depth !== 3 || entryPath) && ( {/* Footer */}
<button {totalSessions > 0 && (
onClick={() => { setDepth(3); setEntryPath('') }} <div className="px-6 pb-5 text-sm text-neutral-500 dark:text-neutral-400">
className="text-sm text-neutral-500 hover:text-neutral-900 dark:hover:text-white transition-colors" {totalSessions.toLocaleString()} sessions tracked
> </div>
Reset
</button>
)} )}
</div> </div>
{/* Sankey Diagram */}
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 mb-6">
<SankeyDiagram
transitions={transitionsData?.transitions ?? []}
totalSessions={transitionsData?.total_sessions ?? 0}
depth={depth}
onNodeClick={(path) => setEntryPath(path)}
/>
</div>
{/* Top Paths */} {/* Top Paths */}
<TopPathsTable paths={topPaths ?? []} loading={topPathsLoading} /> <div className="mt-6">
<TopPathsTable paths={topPaths ?? []} loading={topPathsLoading} />
</div>
{/* Date Picker Modal */} {/* Date Picker Modal */}
<DatePicker <DatePicker

View File

@@ -5,7 +5,6 @@ import { logger } from '@/lib/utils/logger'
import { useCallback, useEffect, useRef, useState, useMemo } from 'react' import { useCallback, useEffect, useRef, useState, useMemo } from 'react'
import { useParams, useRouter, useSearchParams } from 'next/navigation' import { useParams, useRouter, useSearchParams } from 'next/navigation'
import { import {
getPerformanceByPage,
getTopPages, getTopPages,
getTopReferrers, getTopReferrers,
getCountries, getCountries,
@@ -32,10 +31,10 @@ import TopReferrers from '@/components/dashboard/TopReferrers'
import Locations from '@/components/dashboard/Locations' import Locations from '@/components/dashboard/Locations'
import TechSpecs from '@/components/dashboard/TechSpecs' import TechSpecs from '@/components/dashboard/TechSpecs'
const PerformanceStats = dynamic(() => import('@/components/dashboard/PerformanceStats'))
const GoalStats = dynamic(() => import('@/components/dashboard/GoalStats')) const GoalStats = dynamic(() => import('@/components/dashboard/GoalStats'))
const Campaigns = dynamic(() => import('@/components/dashboard/Campaigns')) const Campaigns = dynamic(() => import('@/components/dashboard/Campaigns'))
const PeakHours = dynamic(() => import('@/components/dashboard/PeakHours')) const PeakHours = dynamic(() => import('@/components/dashboard/PeakHours'))
const SearchPerformance = dynamic(() => import('@/components/dashboard/SearchPerformance'))
const EventProperties = dynamic(() => import('@/components/dashboard/EventProperties')) const EventProperties = dynamic(() => import('@/components/dashboard/EventProperties'))
const ExportModal = dynamic(() => import('@/components/dashboard/ExportModal')) const ExportModal = dynamic(() => import('@/components/dashboard/ExportModal'))
import { type DimensionFilter, serializeFilters, parseFiltersFromURL } from '@/lib/filters' 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] } return { start: prevStart.toISOString().split('T')[0], end: prevEnd.toISOString().split('T')[0] }
}, [dateRange]) }, [dateRange])
// Single dashboard request replaces 7 focused hooks (overview, pages, locations, // Single dashboard request replaces focused hooks (overview, pages, locations,
// devices, referrers, performance, goals). The backend runs all queries in parallel // devices, referrers, goals). The backend runs all queries in parallel
// and caches the result in Redis, reducing requests from 12 to 6 per refresh cycle. // and caches the result in Redis for efficient data loading.
const { data: dashboard, isLoading: dashboardLoading, error: dashboardError } = useDashboard(siteId, dateRange.start, dateRange.end, interval, filtersParam || undefined) 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: realtimeData } = useRealtime(siteId)
const { data: prevStats } = useStats(siteId, prevRange.start, prevRange.end) const { data: prevStats } = useStats(siteId, prevRange.start, prevRange.end)
const { data: prevDailyStats } = useDailyStats(siteId, prevRange.start, prevRange.end, interval) const { data: prevDailyStats } = useDailyStats(siteId, prevRange.start, prevRange.end, interval)
@@ -532,6 +531,13 @@ export default function SiteDashboardPage() {
<FilterBar filters={filters} onRemove={handleRemoveFilter} onClear={handleClearFilters} /> <FilterBar filters={filters} onRemove={handleRemoveFilter} onClear={handleClearFilters} />
</div> </div>
{/* Refetch indicator — visible when SWR is revalidating with stale data on screen */}
{dashboardValidating && !dashboardLoading && (
<div className="h-0.5 w-full rounded-full bg-neutral-100 dark:bg-neutral-800 overflow-hidden mb-2">
<div className="h-full w-1/3 rounded-full bg-brand-orange animate-[shimmer_1.2s_ease-in-out_infinite]" />
</div>
)}
{/* Advanced Chart with Integrated Stats */} {/* Advanced Chart with Integrated Stats */}
<div className="mb-8"> <div className="mb-8">
<Chart <Chart
@@ -555,21 +561,7 @@ export default function SiteDashboardPage() {
/> />
</div> </div>
{/* Performance Stats - Only show if enabled */} <div className="grid gap-6 lg:grid-cols-2 mb-8 [&>*]:min-w-0">
{site.enable_performance_insights && (
<div className="mb-8">
<PerformanceStats
stats={dashboard?.performance ?? { lcp: 0, cls: 0, inp: 0 }}
performanceByPage={dashboard?.performance_by_page ?? null}
siteId={siteId}
startDate={dateRange.start}
endDate={dateRange.end}
getPerformanceByPage={getPerformanceByPage}
/>
</div>
)}
<div className="grid gap-6 lg:grid-cols-2 mb-8">
<ContentStats <ContentStats
topPages={dashboard?.top_pages ?? []} topPages={dashboard?.top_pages ?? []}
entryPages={dashboard?.entry_pages ?? []} entryPages={dashboard?.entry_pages ?? []}
@@ -589,7 +581,7 @@ export default function SiteDashboardPage() {
/> />
</div> </div>
<div className="grid gap-6 lg:grid-cols-2 mb-8"> <div className="grid gap-6 lg:grid-cols-2 mb-8 [&>*]:min-w-0">
<Locations <Locations
countries={dashboard?.countries ?? []} countries={dashboard?.countries ?? []}
cities={dashboard?.cities ?? []} cities={dashboard?.cities ?? []}
@@ -612,12 +604,12 @@ export default function SiteDashboardPage() {
/> />
</div> </div>
<div className="grid gap-6 lg:grid-cols-2 mb-8"> <div className="grid gap-6 lg:grid-cols-2 mb-8 [&>*]:min-w-0">
<Campaigns siteId={siteId} dateRange={dateRange} filters={filtersParam || undefined} onFilter={handleAddFilter} /> <Campaigns siteId={siteId} dateRange={dateRange} filters={filtersParam || undefined} onFilter={handleAddFilter} />
<PeakHours siteId={siteId} dateRange={dateRange} /> <PeakHours siteId={siteId} dateRange={dateRange} />
</div> </div>
<div className="grid gap-6 lg:grid-cols-2 mb-8 [&>*]:min-w-0">
<div className="mb-8"> <SearchPerformance siteId={siteId} dateRange={dateRange} />
<GoalStats <GoalStats
goalCounts={(dashboard?.goal_counts ?? []).filter(g => !/^scroll_\d+$/.test(g.event_name))} goalCounts={(dashboard?.goal_counts ?? []).filter(g => !/^scroll_\d+$/.test(g.event_name))}
onSelectEvent={setSelectedEvent} onSelectEvent={setSelectedEvent}

View File

@@ -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<string | null>(null)
const [expandedPage, setExpandedPage] = useState<string | null>(null)
const [expandedData, setExpandedData] = useState<GSCDataRow[]>([])
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 (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<SkeletonLine className="h-8 w-48 mb-2" />
<SkeletonLine className="h-4 w-64" />
</div>
<SkeletonLine className="h-9 w-36 rounded-lg" />
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-8">
<StatCardSkeleton />
<StatCardSkeleton />
<StatCardSkeleton />
<StatCardSkeleton />
</div>
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-xl p-6">
<SkeletonLine className="h-9 w-48 rounded-lg mb-6" />
{Array.from({ length: 10 }).map((_, i) => (
<div key={i} className="flex items-center justify-between py-3">
<SkeletonLine className="h-4 w-1/3" />
<div className="flex gap-8">
<SkeletonLine className="h-4 w-16" />
<SkeletonLine className="h-4 w-16" />
<SkeletonLine className="h-4 w-12" />
<SkeletonLine className="h-4 w-12" />
</div>
</div>
))}
</div>
</div>
)
}
// ─── Not connected state ──────────────────────────────────
if (gscStatus && !gscStatus.connected) {
return (
<div className={`w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
<div className="flex flex-col items-center justify-center py-24 text-center">
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-5 mb-6">
<MagnifyingGlass size={40} className="text-neutral-400 dark:text-neutral-500" />
</div>
<h2 className="text-xl font-semibold text-neutral-900 dark:text-white mb-2">
Connect Google Search Console
</h2>
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-md mb-6">
See how your site performs in Google Search. View top queries, pages, click-through rates, and average position data.
</p>
<Link
href={`/sites/${siteId}/settings?tab=integrations`}
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-brand-orange hover:bg-brand-orange/90 text-white text-sm font-medium transition-colors"
>
Connect in Settings
<ArrowSquareOut size={16} weight="bold" />
</Link>
</div>
</div>
)
}
// ─── 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 (
<div className={`w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
{/* Header */}
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-1">
Search Console
</h1>
<p className="text-sm text-neutral-500 dark:text-neutral-400">
Google Search performance, queries, and page rankings
</p>
</div>
<Select
variant="input"
className="min-w-[140px]"
value={period}
onChange={(value) => {
if (value === 'today') {
const today = formatDate(new Date())
setDateRange({ start: today, end: today })
setPeriod('today')
} else if (value === '7') {
setDateRange(getDateRange(7))
setPeriod('7')
} else if (value === 'week') {
setDateRange(getThisWeekRange())
setPeriod('week')
} else if (value === '28') {
setDateRange(getDateRange(28))
setPeriod('28')
} else if (value === '30') {
setDateRange(getDateRange(30))
setPeriod('30')
} else if (value === 'month') {
setDateRange(getThisMonthRange())
setPeriod('month')
} else if (value === 'custom') {
setIsDatePickerOpen(true)
}
}}
options={[
{ value: 'today', label: 'Today' },
{ value: '7', label: 'Last 7 days' },
{ value: '28', label: 'Last 28 days' },
{ value: '30', label: 'Last 30 days' },
{ value: 'divider-1', label: '', divider: true },
{ value: 'week', label: 'This week' },
{ value: 'month', label: 'This month' },
{ value: 'divider-2', label: '', divider: true },
{ value: 'custom', label: 'Custom' },
]}
/>
</div>
{/* Overview cards */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-8">
<OverviewCard
label="Total Clicks"
value={overview ? formatNumber(overview.total_clicks) : '-'}
change={clicksChange}
/>
<OverviewCard
label="Total Impressions"
value={overview ? formatNumber(overview.total_impressions) : '-'}
change={impressionsChange}
/>
<OverviewCard
label="Average CTR"
value={overview ? formatCTR(overview.avg_ctr) : '-'}
change={ctrChange}
/>
<OverviewCard
label="Average Position"
value={overview ? formatPosition(overview.avg_position) : '-'}
change={positionChange}
invertChange
/>
</div>
<ClicksImpressionsChart siteId={siteId} startDate={dateRange.start} endDate={dateRange.end} />
{/* Position tracker */}
{topQueries?.queries && topQueries.queries.length > 0 && (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-3 mb-6">
{topQueries.queries.slice(0, 5).map((q) => (
<div key={q.query} className="rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-3">
<p className="text-xs text-neutral-500 dark:text-neutral-400 truncate mb-1">{q.query}</p>
<div className="flex items-baseline gap-1.5">
<p className="text-lg font-semibold text-neutral-900 dark:text-white">{q.position.toFixed(1)}</p>
<p className="text-xs text-neutral-400">pos</p>
</div>
<p className="text-xs text-neutral-500 mt-0.5">{q.clicks} {q.clicks === 1 ? 'click' : 'clicks'}</p>
</div>
))}
</div>
)}
{/* New queries badge */}
{newQueries && newQueries.count > 0 && (
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300 text-sm mb-4">
<span className="font-medium">{newQueries.count} new {newQueries.count === 1 ? 'query' : 'queries'}</span>
<span className="text-green-600 dark:text-green-400">appeared this period</span>
</div>
)}
{/* View toggle */}
<div className="mb-6">
<div className="inline-flex bg-neutral-100 dark:bg-neutral-800 rounded-lg p-1">
<button
onClick={() => { setActiveView('queries'); setExpandedQuery(null); setExpandedData([]) }}
className={`px-4 py-1.5 text-sm font-medium rounded-md transition-all cursor-pointer ${
activeView === 'queries'
? 'bg-white dark:bg-neutral-700 text-neutral-900 dark:text-white shadow-sm'
: 'text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300'
}`}
>
Top Queries
</button>
<button
onClick={() => { setActiveView('pages'); setExpandedPage(null); setExpandedData([]) }}
className={`px-4 py-1.5 text-sm font-medium rounded-md transition-all cursor-pointer ${
activeView === 'pages'
? 'bg-white dark:bg-neutral-700 text-neutral-900 dark:text-white shadow-sm'
: 'text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300'
}`}
>
Top Pages
</button>
</div>
</div>
{/* Queries table */}
{activeView === 'queries' && (
<div className="rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-neutral-200 dark:border-neutral-800">
<th className="text-left px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400 w-8" />
<th className="text-left px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">Query</th>
<th className="text-right px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">Clicks</th>
<th className="text-right px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">Impressions</th>
<th className="text-right px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">CTR</th>
<th className="text-right px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">Position</th>
</tr>
</thead>
<tbody>
{queriesLoading && queries.length === 0 ? (
Array.from({ length: 10 }).map((_, i) => (
<tr key={i} className="border-b border-neutral-100 dark:border-neutral-800/50">
<td className="px-4 py-3" />
<td className="px-4 py-3"><SkeletonLine className="h-4 w-3/4" /></td>
<td className="px-4 py-3"><SkeletonLine className="h-4 w-12 ml-auto" /></td>
<td className="px-4 py-3"><SkeletonLine className="h-4 w-16 ml-auto" /></td>
<td className="px-4 py-3"><SkeletonLine className="h-4 w-12 ml-auto" /></td>
<td className="px-4 py-3"><SkeletonLine className="h-4 w-10 ml-auto" /></td>
</tr>
))
) : queries.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-12 text-center text-neutral-500 dark:text-neutral-400">
No query data available for this period.
</td>
</tr>
) : (
queries.map((row) => (
<QueryRow
key={row.query}
row={row}
isExpanded={expandedQuery === row.query}
expandedData={expandedQuery === row.query ? expandedData : []}
expandedLoading={expandedQuery === row.query && expandedLoading}
onToggle={() => handleExpandQuery(row.query)}
/>
))
)}
</tbody>
</table>
{/* Pagination */}
{queriesTotal > PAGE_SIZE && (
<div className="flex items-center justify-between px-4 py-3 border-t border-neutral-200 dark:border-neutral-800">
<p className="text-sm text-neutral-500 dark:text-neutral-400">
Showing {queryPage * PAGE_SIZE + 1}-{Math.min((queryPage + 1) * PAGE_SIZE, queriesTotal)} of {queriesTotal.toLocaleString()}
</p>
<div className="flex gap-2">
<button
disabled={queryPage === 0}
onClick={() => { setQueryPage((p) => p - 1); setExpandedQuery(null); setExpandedData([]) }}
className="px-3 py-1.5 text-sm rounded-lg border border-neutral-200 dark:border-neutral-700 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-800 disabled:opacity-40 disabled:cursor-not-allowed transition-colors cursor-pointer"
>
Previous
</button>
<button
disabled={(queryPage + 1) * PAGE_SIZE >= queriesTotal}
onClick={() => { setQueryPage((p) => p + 1); setExpandedQuery(null); setExpandedData([]) }}
className="px-3 py-1.5 text-sm rounded-lg border border-neutral-200 dark:border-neutral-700 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-800 disabled:opacity-40 disabled:cursor-not-allowed transition-colors cursor-pointer"
>
Next
</button>
</div>
</div>
)}
</div>
)}
{/* Pages table */}
{activeView === 'pages' && (
<div className="rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-neutral-200 dark:border-neutral-800">
<th className="text-left px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400 w-8" />
<th className="text-left px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">Page</th>
<th className="text-right px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">Clicks</th>
<th className="text-right px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">Impressions</th>
<th className="text-right px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">CTR</th>
<th className="text-right px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">Position</th>
</tr>
</thead>
<tbody>
{pagesLoading && pages.length === 0 ? (
Array.from({ length: 10 }).map((_, i) => (
<tr key={i} className="border-b border-neutral-100 dark:border-neutral-800/50">
<td className="px-4 py-3" />
<td className="px-4 py-3"><SkeletonLine className="h-4 w-3/4" /></td>
<td className="px-4 py-3"><SkeletonLine className="h-4 w-12 ml-auto" /></td>
<td className="px-4 py-3"><SkeletonLine className="h-4 w-16 ml-auto" /></td>
<td className="px-4 py-3"><SkeletonLine className="h-4 w-12 ml-auto" /></td>
<td className="px-4 py-3"><SkeletonLine className="h-4 w-10 ml-auto" /></td>
</tr>
))
) : pages.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-12 text-center text-neutral-500 dark:text-neutral-400">
No page data available for this period.
</td>
</tr>
) : (
pages.map((row) => (
<PageRow
key={row.page}
row={row}
isExpanded={expandedPage === row.page}
expandedData={expandedPage === row.page ? expandedData : []}
expandedLoading={expandedPage === row.page && expandedLoading}
onToggle={() => handleExpandPage(row.page)}
/>
))
)}
</tbody>
</table>
{/* Pagination */}
{pagesTotal > PAGE_SIZE && (
<div className="flex items-center justify-between px-4 py-3 border-t border-neutral-200 dark:border-neutral-800">
<p className="text-sm text-neutral-500 dark:text-neutral-400">
Showing {pagePage * PAGE_SIZE + 1}-{Math.min((pagePage + 1) * PAGE_SIZE, pagesTotal)} of {pagesTotal.toLocaleString()}
</p>
<div className="flex gap-2">
<button
disabled={pagePage === 0}
onClick={() => { setPagePage((p) => p - 1); setExpandedPage(null); setExpandedData([]) }}
className="px-3 py-1.5 text-sm rounded-lg border border-neutral-200 dark:border-neutral-700 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-800 disabled:opacity-40 disabled:cursor-not-allowed transition-colors cursor-pointer"
>
Previous
</button>
<button
disabled={(pagePage + 1) * PAGE_SIZE >= pagesTotal}
onClick={() => { setPagePage((p) => p + 1); setExpandedPage(null); setExpandedData([]) }}
className="px-3 py-1.5 text-sm rounded-lg border border-neutral-200 dark:border-neutral-700 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-800 disabled:opacity-40 disabled:cursor-not-allowed transition-colors cursor-pointer"
>
Next
</button>
</div>
</div>
)}
</div>
)}
<DatePicker
isOpen={isDatePickerOpen}
onClose={() => setIsDatePickerOpen(false)}
onApply={(range) => {
setDateRange(range)
setPeriod('custom')
setIsDatePickerOpen(false)
}}
initialRange={dateRange}
/>
</div>
)
}
// ─── Sub-components ─────────────────────────────────────────────
function OverviewCard({
label,
value,
change,
invertChange = false,
}: {
label: string
value: string
change: { value: number; label: string } | null
invertChange?: boolean
}) {
// For position, lower is better so a negative change is good
const isPositive = change ? (invertChange ? change.value < 0 : change.value > 0) : false
const isNegative = change ? (invertChange ? change.value > 0 : change.value < 0) : false
return (
<div className="p-4 rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900">
<p className="text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-1">{label}</p>
<p className="text-2xl font-bold text-neutral-900 dark:text-white">{value}</p>
{change && (
<p className={`text-xs mt-1 font-medium ${
isPositive ? 'text-green-600 dark:text-green-400' :
isNegative ? 'text-red-600 dark:text-red-400' :
'text-neutral-500 dark:text-neutral-400'
}`}>
{change.label} vs previous period
</p>
)}
</div>
)
}
function QueryRow({
row,
isExpanded,
expandedData,
expandedLoading,
onToggle,
}: {
row: GSCDataRow
isExpanded: boolean
expandedData: GSCDataRow[]
expandedLoading: boolean
onToggle: () => void
}) {
const Caret = isExpanded ? CaretUp : CaretDown
return (
<>
<tr
onClick={onToggle}
className="border-b border-neutral-100 dark:border-neutral-800/50 hover:bg-neutral-50 dark:hover:bg-neutral-800/50 cursor-pointer transition-colors"
>
<td className="px-4 py-3 text-neutral-400 dark:text-neutral-500">
<Caret size={14} />
</td>
<td className="px-4 py-3 text-neutral-900 dark:text-white font-medium">{row.query}</td>
<td className="px-4 py-3 text-right text-neutral-700 dark:text-neutral-300 tabular-nums">{row.clicks.toLocaleString()}</td>
<td className="px-4 py-3 text-right text-neutral-700 dark:text-neutral-300 tabular-nums">{row.impressions.toLocaleString()}</td>
<td className="px-4 py-3 text-right text-neutral-700 dark:text-neutral-300 tabular-nums">{formatCTR(row.ctr)}</td>
<td className="px-4 py-3 text-right text-neutral-700 dark:text-neutral-300 tabular-nums">{formatPosition(row.position)}</td>
</tr>
{isExpanded && (
<tr className="bg-neutral-50 dark:bg-neutral-800/30">
<td colSpan={6} className="px-4 py-3">
{expandedLoading ? (
<div className="space-y-2 py-1">
{Array.from({ length: 3 }).map((_, i) => (
<SkeletonLine key={i} className="h-4 w-full" />
))}
</div>
) : expandedData.length === 0 ? (
<p className="text-sm text-neutral-500 dark:text-neutral-400 py-1">No pages found for this query.</p>
) : (
<table className="w-full text-sm">
<thead>
<tr>
<th className="text-left px-2 py-1.5 text-xs font-medium text-neutral-400 dark:text-neutral-500">Page</th>
<th className="text-right px-2 py-1.5 text-xs font-medium text-neutral-400 dark:text-neutral-500">Clicks</th>
<th className="text-right px-2 py-1.5 text-xs font-medium text-neutral-400 dark:text-neutral-500">Impressions</th>
<th className="text-right px-2 py-1.5 text-xs font-medium text-neutral-400 dark:text-neutral-500">CTR</th>
<th className="text-right px-2 py-1.5 text-xs font-medium text-neutral-400 dark:text-neutral-500">Position</th>
</tr>
</thead>
<tbody>
{expandedData.map((sub) => (
<tr key={sub.page} className="border-t border-neutral-200/50 dark:border-neutral-700/50">
<td className="px-2 py-1.5 text-neutral-700 dark:text-neutral-300 max-w-md truncate" title={sub.page}>{sub.page}</td>
<td className="px-2 py-1.5 text-right text-neutral-600 dark:text-neutral-400 tabular-nums">{sub.clicks.toLocaleString()}</td>
<td className="px-2 py-1.5 text-right text-neutral-600 dark:text-neutral-400 tabular-nums">{sub.impressions.toLocaleString()}</td>
<td className="px-2 py-1.5 text-right text-neutral-600 dark:text-neutral-400 tabular-nums">{formatCTR(sub.ctr)}</td>
<td className="px-2 py-1.5 text-right text-neutral-600 dark:text-neutral-400 tabular-nums">{formatPosition(sub.position)}</td>
</tr>
))}
</tbody>
</table>
)}
</td>
</tr>
)}
</>
)
}
function PageRow({
row,
isExpanded,
expandedData,
expandedLoading,
onToggle,
}: {
row: GSCDataRow
isExpanded: boolean
expandedData: GSCDataRow[]
expandedLoading: boolean
onToggle: () => void
}) {
const Caret = isExpanded ? CaretUp : CaretDown
return (
<>
<tr
onClick={onToggle}
className="border-b border-neutral-100 dark:border-neutral-800/50 hover:bg-neutral-50 dark:hover:bg-neutral-800/50 cursor-pointer transition-colors"
>
<td className="px-4 py-3 text-neutral-400 dark:text-neutral-500">
<Caret size={14} />
</td>
<td className="px-4 py-3 text-neutral-900 dark:text-white font-medium max-w-md truncate" title={row.page}>{row.page}</td>
<td className="px-4 py-3 text-right text-neutral-700 dark:text-neutral-300 tabular-nums">{row.clicks.toLocaleString()}</td>
<td className="px-4 py-3 text-right text-neutral-700 dark:text-neutral-300 tabular-nums">{row.impressions.toLocaleString()}</td>
<td className="px-4 py-3 text-right text-neutral-700 dark:text-neutral-300 tabular-nums">{formatCTR(row.ctr)}</td>
<td className="px-4 py-3 text-right text-neutral-700 dark:text-neutral-300 tabular-nums">{formatPosition(row.position)}</td>
</tr>
{isExpanded && (
<tr className="bg-neutral-50 dark:bg-neutral-800/30">
<td colSpan={6} className="px-4 py-3">
{expandedLoading ? (
<div className="space-y-2 py-1">
{Array.from({ length: 3 }).map((_, i) => (
<SkeletonLine key={i} className="h-4 w-full" />
))}
</div>
) : expandedData.length === 0 ? (
<p className="text-sm text-neutral-500 dark:text-neutral-400 py-1">No queries found for this page.</p>
) : (
<table className="w-full text-sm">
<thead>
<tr>
<th className="text-left px-2 py-1.5 text-xs font-medium text-neutral-400 dark:text-neutral-500">Query</th>
<th className="text-right px-2 py-1.5 text-xs font-medium text-neutral-400 dark:text-neutral-500">Clicks</th>
<th className="text-right px-2 py-1.5 text-xs font-medium text-neutral-400 dark:text-neutral-500">Impressions</th>
<th className="text-right px-2 py-1.5 text-xs font-medium text-neutral-400 dark:text-neutral-500">CTR</th>
<th className="text-right px-2 py-1.5 text-xs font-medium text-neutral-400 dark:text-neutral-500">Position</th>
</tr>
</thead>
<tbody>
{expandedData.map((sub) => (
<tr key={sub.query} className="border-t border-neutral-200/50 dark:border-neutral-700/50">
<td className="px-2 py-1.5 text-neutral-700 dark:text-neutral-300">{sub.query}</td>
<td className="px-2 py-1.5 text-right text-neutral-600 dark:text-neutral-400 tabular-nums">{sub.clicks.toLocaleString()}</td>
<td className="px-2 py-1.5 text-right text-neutral-600 dark:text-neutral-400 tabular-nums">{sub.impressions.toLocaleString()}</td>
<td className="px-2 py-1.5 text-right text-neutral-600 dark:text-neutral-400 tabular-nums">{formatCTR(sub.ctr)}</td>
<td className="px-2 py-1.5 text-right text-neutral-600 dark:text-neutral-400 tabular-nums">{formatPosition(sub.position)}</td>
</tr>
))}
</tbody>
</table>
)}
</td>
</tr>
)}
</>
)
}

View File

@@ -1,10 +1,13 @@
'use client' 'use client'
import { useEffect, useState, useRef } from 'react' import { useEffect, useState, useRef } from 'react'
import { useParams, useRouter } from 'next/navigation' import { useParams, useRouter, useSearchParams } from 'next/navigation'
import { updateSite, resetSiteData, deleteSite, type Site, type GeoDataLevel } from '@/lib/api/sites' import { updateSite, resetSiteData, deleteSite, type Site, type GeoDataLevel } from '@/lib/api/sites'
import { createGoal, updateGoal, deleteGoal, type Goal } from '@/lib/api/goals' 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 { createReportSchedule, updateReportSchedule, deleteReportSchedule, testReportSchedule, type ReportSchedule, type CreateReportScheduleRequest, type EmailConfig, type WebhookConfig } from '@/lib/api/report-schedules'
import { getGSCAuthURL, disconnectGSC } from '@/lib/api/gsc'
import { getBunnyPullZones, connectBunny, disconnectBunny } from '@/lib/api/bunny'
import type { BunnyPullZone } from '@/lib/api/bunny'
import { toast } from '@ciphera-net/ui' import { toast } from '@ciphera-net/ui'
import { getAuthErrorMessage } from '@ciphera-net/ui' import { getAuthErrorMessage } from '@ciphera-net/ui'
import { formatDateTime } from '@/lib/utils/formatDate' import { formatDateTime } from '@/lib/utils/formatDate'
@@ -16,7 +19,7 @@ import { Select, Modal, Button } from '@ciphera-net/ui'
import { APP_URL } from '@/lib/api/client' import { APP_URL } from '@/lib/api/client'
import { generatePrivacySnippet } from '@/lib/utils/privacySnippet' import { generatePrivacySnippet } from '@/lib/utils/privacySnippet'
import { useUnsavedChanges } from '@/lib/hooks/useUnsavedChanges' import { useUnsavedChanges } from '@/lib/hooks/useUnsavedChanges'
import { useSite, useGoals, useReportSchedules, useSubscription } from '@/lib/swr/dashboard' import { useSite, useGoals, useReportSchedules, useSubscription, useGSCStatus, useBunnyStatus } from '@/lib/swr/dashboard'
import { getRetentionOptionsForPlan, formatRetentionMonths } from '@/lib/plans' import { getRetentionOptionsForPlan, formatRetentionMonths } from '@/lib/plans'
import { motion, AnimatePresence } from 'framer-motion' import { motion, AnimatePresence } from 'framer-motion'
import { useAuth } from '@/lib/auth/context' import { useAuth } from '@/lib/auth/context'
@@ -27,7 +30,7 @@ import {
AlertTriangleIcon, AlertTriangleIcon,
ZapIcon, ZapIcon,
} from '@ciphera-net/ui' } from '@ciphera-net/ui'
import { PaperPlaneTilt, Envelope, WebhooksLogo, SpinnerGap, Trash, PencilSimple, Play } from '@phosphor-icons/react' import { PaperPlaneTilt, Envelope, WebhooksLogo, SpinnerGap, Trash, PencilSimple, Play, Plugs, ShieldCheck } from '@phosphor-icons/react'
const TIMEZONES = [ const TIMEZONES = [
'UTC', 'UTC',
@@ -56,7 +59,8 @@ export default function SiteSettingsPage() {
const { data: site, isLoading: siteLoading, mutate: mutateSite } = useSite(siteId) const { data: site, isLoading: siteLoading, mutate: mutateSite } = useSite(siteId)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [activeTab, setActiveTab] = useState<'general' | 'visibility' | 'data' | 'goals' | 'reports'>('general') const [activeTab, setActiveTab] = useState<'general' | 'visibility' | 'data' | 'goals' | 'reports' | 'integrations'>('general')
const searchParams = useSearchParams()
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: '', name: '',
@@ -70,8 +74,6 @@ export default function SiteSettingsPage() {
collect_device_info: true, collect_device_info: true,
collect_geo_data: 'full' as GeoDataLevel, collect_geo_data: 'full' as GeoDataLevel,
collect_screen_resolution: true, collect_screen_resolution: true,
// Performance insights setting
enable_performance_insights: false,
// Bot and noise filtering // Bot and noise filtering
filter_bots: true, filter_bots: true,
// Hide unknown locations // Hide unknown locations
@@ -93,6 +95,16 @@ export default function SiteSettingsPage() {
// Report schedules // Report schedules
const { data: reportSchedules = [], isLoading: reportLoading, mutate: mutateReportSchedules } = useReportSchedules(siteId) const { data: reportSchedules = [], isLoading: reportLoading, mutate: mutateReportSchedules } = useReportSchedules(siteId)
const { data: gscStatus, mutate: mutateGSCStatus } = useGSCStatus(siteId)
const [gscConnecting, setGscConnecting] = useState(false)
const [gscDisconnecting, setGscDisconnecting] = useState(false)
const { data: bunnyStatus, mutate: mutateBunnyStatus } = useBunnyStatus(siteId)
const [bunnyApiKey, setBunnyApiKey] = useState('')
const [bunnyPullZones, setBunnyPullZones] = useState<BunnyPullZone[]>([])
const [bunnySelectedZone, setBunnySelectedZone] = useState<BunnyPullZone | null>(null)
const [bunnyLoadingZones, setBunnyLoadingZones] = useState(false)
const [bunnyConnecting, setBunnyConnecting] = useState(false)
const [bunnyDisconnecting, setBunnyDisconnecting] = useState(false)
const [reportModalOpen, setReportModalOpen] = useState(false) const [reportModalOpen, setReportModalOpen] = useState(false)
const [editingSchedule, setEditingSchedule] = useState<ReportSchedule | null>(null) const [editingSchedule, setEditingSchedule] = useState<ReportSchedule | null>(null)
const [reportSaving, setReportSaving] = useState(false) const [reportSaving, setReportSaving] = useState(false)
@@ -121,7 +133,6 @@ export default function SiteSettingsPage() {
collect_device_info: site.collect_device_info ?? true, collect_device_info: site.collect_device_info ?? true,
collect_geo_data: site.collect_geo_data || 'full', collect_geo_data: site.collect_geo_data || 'full',
collect_screen_resolution: site.collect_screen_resolution ?? true, collect_screen_resolution: site.collect_screen_resolution ?? true,
enable_performance_insights: site.enable_performance_insights ?? false,
filter_bots: site.filter_bots ?? true, filter_bots: site.filter_bots ?? true,
hide_unknown_locations: site.hide_unknown_locations ?? false, hide_unknown_locations: site.hide_unknown_locations ?? false,
data_retention_months: site.data_retention_months ?? 6 data_retention_months: site.data_retention_months ?? 6
@@ -136,7 +147,6 @@ export default function SiteSettingsPage() {
collect_device_info: site.collect_device_info ?? true, collect_device_info: site.collect_device_info ?? true,
collect_geo_data: site.collect_geo_data || 'full', collect_geo_data: site.collect_geo_data || 'full',
collect_screen_resolution: site.collect_screen_resolution ?? true, collect_screen_resolution: site.collect_screen_resolution ?? true,
enable_performance_insights: site.enable_performance_insights ?? false,
filter_bots: site.filter_bots ?? true, filter_bots: site.filter_bots ?? true,
hide_unknown_locations: site.hide_unknown_locations ?? false, hide_unknown_locations: site.hide_unknown_locations ?? false,
data_retention_months: site.data_retention_months ?? 6 data_retention_months: site.data_retention_months ?? 6
@@ -409,8 +419,6 @@ export default function SiteSettingsPage() {
collect_device_info: formData.collect_device_info, collect_device_info: formData.collect_device_info,
collect_geo_data: formData.collect_geo_data, collect_geo_data: formData.collect_geo_data,
collect_screen_resolution: formData.collect_screen_resolution, collect_screen_resolution: formData.collect_screen_resolution,
// Performance insights setting
enable_performance_insights: formData.enable_performance_insights,
// Bot and noise filtering // Bot and noise filtering
filter_bots: formData.filter_bots, filter_bots: formData.filter_bots,
// Hide unknown locations // Hide unknown locations
@@ -429,7 +437,6 @@ export default function SiteSettingsPage() {
collect_device_info: formData.collect_device_info, collect_device_info: formData.collect_device_info,
collect_geo_data: formData.collect_geo_data, collect_geo_data: formData.collect_geo_data,
collect_screen_resolution: formData.collect_screen_resolution, collect_screen_resolution: formData.collect_screen_resolution,
enable_performance_insights: formData.enable_performance_insights,
filter_bots: formData.filter_bots, filter_bots: formData.filter_bots,
hide_unknown_locations: formData.hide_unknown_locations, hide_unknown_locations: formData.hide_unknown_locations,
data_retention_months: formData.data_retention_months data_retention_months: formData.data_retention_months
@@ -497,7 +504,6 @@ export default function SiteSettingsPage() {
collect_device_info: formData.collect_device_info, collect_device_info: formData.collect_device_info,
collect_geo_data: formData.collect_geo_data, collect_geo_data: formData.collect_geo_data,
collect_screen_resolution: formData.collect_screen_resolution, collect_screen_resolution: formData.collect_screen_resolution,
enable_performance_insights: formData.enable_performance_insights,
filter_bots: formData.filter_bots, filter_bots: formData.filter_bots,
hide_unknown_locations: formData.hide_unknown_locations, hide_unknown_locations: formData.hide_unknown_locations,
data_retention_months: formData.data_retention_months data_retention_months: formData.data_retention_months
@@ -509,6 +515,29 @@ export default function SiteSettingsPage() {
if (site?.domain) document.title = `Settings · ${site.domain} | Pulse` if (site?.domain) document.title = `Settings · ${site.domain} | Pulse`
}, [site?.domain]) }, [site?.domain])
// Handle GSC OAuth callback query params
useEffect(() => {
const gsc = searchParams.get('gsc')
if (!gsc) return
switch (gsc) {
case 'connected':
toast.success('Google Search Console connected successfully')
mutateGSCStatus()
break
case 'denied':
toast.error('Google authorization was denied')
break
case 'no_property':
toast.error('No matching Search Console property found for this site')
break
case 'error':
toast.error('Failed to connect Google Search Console')
break
}
setActiveTab('integrations')
window.history.replaceState({}, '', window.location.pathname)
}, [searchParams, mutateGSCStatus])
const showSkeleton = useMinimumLoading(siteLoading && !site) const showSkeleton = useMinimumLoading(siteLoading && !site)
const fadeClass = useSkeletonFade(showSkeleton) const fadeClass = useSkeletonFade(showSkeleton)
@@ -522,7 +551,7 @@ export default function SiteSettingsPage() {
</div> </div>
<div className="flex flex-col md:flex-row gap-8"> <div className="flex flex-col md:flex-row gap-8">
<nav className="w-full md:w-64 flex-shrink-0 space-y-1"> <nav className="w-full md:w-64 flex-shrink-0 space-y-1">
{Array.from({ length: 5 }).map((_, i) => ( {Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-12 animate-pulse rounded-xl bg-neutral-100 dark:bg-neutral-800" /> <div key={i} className="h-12 animate-pulse rounded-xl bg-neutral-100 dark:bg-neutral-800" />
))} ))}
</nav> </nav>
@@ -593,7 +622,7 @@ export default function SiteSettingsPage() {
: 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800' : 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800'
}`} }`}
> >
<SettingsIcon className="w-5 h-5" /> <ShieldCheck className="w-5 h-5" />
Data & Privacy Data & Privacy
</button> </button>
<button <button
@@ -622,6 +651,19 @@ export default function SiteSettingsPage() {
<PaperPlaneTilt className="w-5 h-5" /> <PaperPlaneTilt className="w-5 h-5" />
Reports Reports
</button> </button>
<button
onClick={() => setActiveTab('integrations')}
role="tab"
aria-selected={activeTab === 'integrations'}
className={`w-full flex items-center gap-3 px-4 py-3 text-sm font-medium rounded-xl transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:ring-offset-2 ${
activeTab === 'integrations'
? 'bg-brand-orange/10 text-brand-orange'
: 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800'
}`}
>
<Plugs className="w-5 h-5" />
Integrations
</button>
</nav> </nav>
{/* Content Area */} {/* Content Area */}
@@ -1063,30 +1105,6 @@ export default function SiteSettingsPage() {
</div> </div>
</div> </div>
{/* Performance Insights Toggle */}
<div className="space-y-4 pt-6 border-t border-neutral-100 dark:border-neutral-800">
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">Performance Insights</h3>
<div className="p-6 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800">
<div className="flex items-center justify-between">
<div>
<h4 className="font-medium text-neutral-900 dark:text-white">Performance Insights (Add-on)</h4>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-0.5">
Track Core Web Vitals (LCP, CLS, INP) to monitor site performance
</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={formData.enable_performance_insights}
onChange={(e) => setFormData({ ...formData, enable_performance_insights: e.target.checked })}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-neutral-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-brand-orange/20 dark:peer-focus:ring-brand-orange/20 rounded-full peer dark:bg-neutral-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-neutral-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-neutral-600 peer-checked:bg-brand-orange"></div>
</label>
</div>
</div>
</div>
{/* Data Retention */} {/* Data Retention */}
<div className="space-y-4 pt-6 border-t border-neutral-100 dark:border-neutral-800"> <div className="space-y-4 pt-6 border-t border-neutral-100 dark:border-neutral-800">
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">Data Retention</h3> <h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">Data Retention</h3>
@@ -1403,6 +1421,405 @@ export default function SiteSettingsPage() {
)} )}
</div> </div>
)} )}
{activeTab === 'integrations' && (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-1">Integrations</h2>
<p className="text-sm text-neutral-500 dark:text-neutral-400">Connect external services to enrich your analytics data.</p>
</div>
{/* Google Search Console */}
<div className="rounded-xl border border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-900/50 p-6">
{!gscStatus?.connected ? (
<div className="space-y-4">
<div className="flex items-start gap-4">
<div className="p-2.5 bg-white dark:bg-neutral-800 rounded-lg border border-neutral-200 dark:border-neutral-700 flex-shrink-0">
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="none">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1Z" fill="#4285F4"/>
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23Z" fill="#34A853"/>
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62Z" fill="#FBBC05"/>
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53Z" fill="#EA4335"/>
</svg>
</div>
<div>
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">Google Search Console</h3>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mt-1">
See which search queries bring visitors to your site, with impressions, clicks, CTR, and ranking position.
</p>
</div>
</div>
<div className="flex items-start gap-2 p-3 bg-white dark:bg-neutral-800/50 rounded-lg border border-neutral-200 dark:border-neutral-700">
<svg className="w-4 h-4 text-neutral-400 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z" />
</svg>
<p className="text-xs text-neutral-500 dark:text-neutral-400">
Pulse only requests read-only access. Your tokens are encrypted at rest and all data can be fully removed at any time.
</p>
</div>
{canEdit && (
<button
onClick={async () => {
setGscConnecting(true)
try {
const { auth_url } = await getGSCAuthURL(siteId)
window.location.href = auth_url
} catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || 'Failed to start Google authorization')
setGscConnecting(false)
}
}}
disabled={gscConnecting}
className="inline-flex items-center gap-2 px-4 py-2.5 bg-brand-orange text-white text-sm font-medium rounded-xl hover:bg-brand-orange/90 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:ring-offset-2 disabled:opacity-50"
>
{gscConnecting && <SpinnerGap className="w-4 h-4 animate-spin" />}
Connect Google Search Console
</button>
)}
</div>
) : (
<div className="space-y-4">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-4">
<div className="p-2.5 bg-white dark:bg-neutral-800 rounded-lg border border-neutral-200 dark:border-neutral-700 flex-shrink-0">
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="none">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1Z" fill="#4285F4"/>
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23Z" fill="#34A853"/>
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62Z" fill="#FBBC05"/>
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53Z" fill="#EA4335"/>
</svg>
</div>
<div>
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">Google Search Console</h3>
<div className="flex items-center gap-2 mt-1.5">
<span className={`inline-flex items-center gap-1.5 text-xs font-medium ${
gscStatus.status === 'active'
? 'text-green-600 dark:text-green-400'
: gscStatus.status === 'syncing'
? 'text-amber-600 dark:text-amber-400'
: 'text-red-600 dark:text-red-400'
}`}>
<span className={`w-2 h-2 rounded-full ${
gscStatus.status === 'active'
? 'bg-green-500'
: gscStatus.status === 'syncing'
? 'bg-amber-500 animate-pulse'
: 'bg-red-500'
}`} />
{gscStatus.status === 'active' ? 'Connected' : gscStatus.status === 'syncing' ? 'Syncing...' : 'Error'}
</span>
</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{gscStatus.google_email && (
<div className="p-3 bg-white dark:bg-neutral-800/50 rounded-lg border border-neutral-200 dark:border-neutral-700">
<p className="text-xs text-neutral-500 dark:text-neutral-400">Google Account</p>
<p className="text-sm font-medium text-neutral-900 dark:text-white mt-0.5 truncate">{gscStatus.google_email}</p>
</div>
)}
{gscStatus.gsc_property && (
<div className="p-3 bg-white dark:bg-neutral-800/50 rounded-lg border border-neutral-200 dark:border-neutral-700">
<p className="text-xs text-neutral-500 dark:text-neutral-400">Property</p>
<p className="text-sm font-medium text-neutral-900 dark:text-white mt-0.5 truncate">{gscStatus.gsc_property}</p>
</div>
)}
{gscStatus.last_synced_at && (
<div className="p-3 bg-white dark:bg-neutral-800/50 rounded-lg border border-neutral-200 dark:border-neutral-700">
<p className="text-xs text-neutral-500 dark:text-neutral-400">Last Synced</p>
<p className="text-sm font-medium text-neutral-900 dark:text-white mt-0.5">
{new Date(gscStatus.last_synced_at).toLocaleString('en-GB')}
</p>
</div>
)}
{gscStatus.created_at && (
<div className="p-3 bg-white dark:bg-neutral-800/50 rounded-lg border border-neutral-200 dark:border-neutral-700">
<p className="text-xs text-neutral-500 dark:text-neutral-400">Connected Since</p>
<p className="text-sm font-medium text-neutral-900 dark:text-white mt-0.5">
{new Date(gscStatus.created_at).toLocaleString('en-GB')}
</p>
</div>
)}
</div>
{gscStatus.status === 'error' && gscStatus.error_message && (
<div className="p-3 bg-red-50 dark:bg-red-900/10 rounded-lg border border-red-200 dark:border-red-900/30">
<p className="text-sm text-red-700 dark:text-red-300">{gscStatus.error_message}</p>
</div>
)}
{canEdit && (
<div className="pt-2 border-t border-neutral-200 dark:border-neutral-700">
<button
onClick={async () => {
if (!confirm('Disconnect Google Search Console? All search data will be removed from Pulse.')) return
setGscDisconnecting(true)
try {
await disconnectGSC(siteId)
mutateGSCStatus()
toast.success('Google Search Console disconnected')
} catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || 'Failed to disconnect')
} finally {
setGscDisconnecting(false)
}
}}
disabled={gscDisconnecting}
className="inline-flex items-center gap-2 text-sm font-medium text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 transition-colors disabled:opacity-50"
>
{gscDisconnecting && <SpinnerGap className="w-4 h-4 animate-spin" />}
Disconnect
</button>
</div>
)}
</div>
)}
</div>
{/* BunnyCDN */}
<div className="rounded-xl border border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-900/50 p-6">
{!bunnyStatus?.connected ? (
<div className="space-y-4">
<div className="flex items-start gap-4">
<div className="p-2.5 bg-white dark:bg-neutral-800 rounded-lg border border-neutral-200 dark:border-neutral-700 flex-shrink-0">
<svg className="w-6 h-6" viewBox="0 0 23 26" fill="none">
<path fillRule="evenodd" clipRule="evenodd" d="M9.94 7.77l5.106.883c-3.83-.663-4.065-3.85-9.218-6.653-.562 1.859.603 5.21 4.112 5.77z" fill="url(#b1)"/>
<path fillRule="evenodd" clipRule="evenodd" d="M5.828 2c5.153 2.803 5.388 5.99 9.218 6.653 1.922.332.186 3.612-1.864 3.266 3.684 1.252 7.044-2.085 5.122-3.132L5.828 2z" fill="url(#b2)"/>
<path fillRule="evenodd" clipRule="evenodd" d="M13.186 11.92c-.241-.041-.486-.131-.731-.284-1.542-.959-3.093-1.269-4.496-1.118 2.93.359 5.716 4.196 5.37 7.036.06.97-.281 1.958-1.021 2.699l-1.69 1.69c1.303.858 3.284-.037 3.889-1.281l3.41-7.014c.836-.198 6.176-1.583 3.767-3.024l-3.37-1.833c1.907 1.05-1.449 4.378-5.125 3.129z" fill="url(#b3)"/>
<path fillRule="evenodd" clipRule="evenodd" d="M7.953 10.518c-4.585.499-7.589 5.94-3.506 9.873l3.42 3.42c-2.243-2.243-2.458-5.525-1.073-7.806.149-.255.333-.495.551-.713 1.37-1.37 3.59-1.37 4.96 0 .629.628.969 1.436 1.02 2.26.346-2.84-2.439-6.675-5.367-7.035h-.005z" fill="url(#b4)"/>
<path fillRule="evenodd" clipRule="evenodd" d="M7.868 23.812l1.925 1.925c.643-.511 1.028-2.01.031-3.006l-2.48-2.48c-1.151-1.151-1.334-2.903-.55-4.246-1.385 2.281-1.17 5.563 1.074 7.807z" fill="url(#b5)"/>
<path fillRule="evenodd" clipRule="evenodd" d="M12.504 4.54l5.739 3.122L12.925.6c-.728.829-1.08 2.472-.421 3.94z" fill="url(#b6)"/>
<circle cx="9.825" cy="17.772" r="1.306" fill="url(#b7)"/>
<circle cx="1.507" cy="11.458" r="1.306" fill="url(#b8)"/>
<defs>
<linearGradient id="b1" x1="5.69" y1="8.5" x2="15.04" y2="8.5" gradientUnits="userSpaceOnUse"><stop stopColor="#FFA600"/><stop offset=".34" stopColor="#FF9F00"/><stop offset=".69" stopColor="#FF7300"/><stop offset="1" stopColor="#F52900"/></linearGradient>
<linearGradient id="b2" x1="5.83" y1="12.65" x2="18.87" y2="12.65" gradientUnits="userSpaceOnUse"><stop stopColor="#FFA600"/><stop offset=".34" stopColor="#FF9F00"/><stop offset=".69" stopColor="#FF7300"/><stop offset="1" stopColor="#F52900"/></linearGradient>
<linearGradient id="b3" x1="7.95" y1="22.04" x2="22.3" y2="22.04" gradientUnits="userSpaceOnUse"><stop stopColor="#FFA600"/><stop offset=".34" stopColor="#FF9F00"/><stop offset="1" stopColor="#FF6200"/></linearGradient>
<linearGradient id="b4" x1="2.51" y1="22.59" x2="13.35" y2="22.59" gradientUnits="userSpaceOnUse"><stop stopColor="#FFA600"/><stop offset=".34" stopColor="#FF9F00"/><stop offset=".69" stopColor="#FF7300"/><stop offset="1" stopColor="#F52900"/></linearGradient>
<linearGradient id="b5" x1="11.35" y1="20.74" x2="7.98" y2="17.71" gradientUnits="userSpaceOnUse"><stop stopColor="#FFA600"/><stop offset=".34" stopColor="#FF9F00"/><stop offset=".69" stopColor="#FF7300"/><stop offset="1" stopColor="#F52900"/></linearGradient>
<linearGradient id="b6" x1="12.16" y1="7.48" x2="18.24" y2="7.48" gradientUnits="userSpaceOnUse"><stop stopColor="#FFA600"/><stop offset=".34" stopColor="#FF9F00"/><stop offset="1" stopColor="#FF6200"/></linearGradient>
<linearGradient id="b7" x1="8.52" y1="19.08" x2="11.13" y2="19.08" gradientUnits="userSpaceOnUse"><stop stopColor="#FFA600"/><stop offset=".34" stopColor="#FF9F00"/><stop offset="1" stopColor="#FF6200"/></linearGradient>
<linearGradient id="b8" x1=".2" y1="12.76" x2="2.81" y2="12.76" gradientUnits="userSpaceOnUse"><stop stopColor="#FFA600"/><stop offset=".34" stopColor="#FF9F00"/><stop offset="1" stopColor="#FF6200"/></linearGradient>
</defs>
</svg>
</div>
<div>
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">BunnyCDN</h3>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mt-1">
Monitor CDN performance with bandwidth usage, cache hit rates, response times, and geographic distribution.
</p>
</div>
</div>
<div className="flex items-start gap-2 p-3 bg-white dark:bg-neutral-800/50 rounded-lg border border-neutral-200 dark:border-neutral-700">
<svg className="w-4 h-4 text-neutral-400 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z" />
</svg>
<p className="text-xs text-neutral-500 dark:text-neutral-400">
Your API key is encrypted at rest and only used to fetch read-only statistics. You can disconnect at any time.
</p>
</div>
{canEdit && (
<div className="space-y-3">
<div className="flex gap-2">
<input
type="password"
value={bunnyApiKey}
onChange={(e) => {
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"
/>
<button
onClick={async () => {
if (!bunnyApiKey.trim()) {
toast.error('Please enter your BunnyCDN API key')
return
}
setBunnyLoadingZones(true)
setBunnyPullZones([])
setBunnySelectedZone(null)
try {
const { pull_zones, message } = await getBunnyPullZones(siteId, bunnyApiKey)
if (pull_zones.length === 0) {
toast.error(message || 'No pull zones match this site\'s domain')
} else {
setBunnyPullZones(pull_zones)
setBunnySelectedZone(pull_zones[0])
}
} catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || 'Failed to load pull zones')
} finally {
setBunnyLoadingZones(false)
}
}}
disabled={bunnyLoadingZones || !bunnyApiKey.trim()}
className="inline-flex items-center gap-2 px-4 py-2.5 bg-neutral-900 dark:bg-white text-white dark:text-neutral-900 text-sm font-medium rounded-xl hover:bg-neutral-800 dark:hover:bg-neutral-100 transition-colors disabled:opacity-50"
>
{bunnyLoadingZones && <SpinnerGap className="w-4 h-4 animate-spin" />}
Load Zones
</button>
</div>
{bunnyPullZones.length > 0 && (
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">Pull Zone</label>
<select
value={bunnySelectedZone?.id ?? ''}
onChange={(e) => {
const zone = bunnyPullZones.find(z => z.id === Number(e.target.value))
setBunnySelectedZone(zone || null)
}}
className="w-full 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"
>
{bunnyPullZones.map((zone) => (
<option key={zone.id} value={zone.id}>{zone.name}</option>
))}
</select>
</div>
<button
onClick={async () => {
if (!bunnySelectedZone) return
setBunnyConnecting(true)
try {
await connectBunny(siteId, bunnyApiKey, bunnySelectedZone.id, bunnySelectedZone.name)
mutateBunnyStatus()
setBunnyApiKey('')
setBunnyPullZones([])
setBunnySelectedZone(null)
toast.success('BunnyCDN connected successfully')
} catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || 'Failed to connect BunnyCDN')
} finally {
setBunnyConnecting(false)
}
}}
disabled={bunnyConnecting || !bunnySelectedZone}
className="inline-flex items-center gap-2 px-4 py-2.5 bg-brand-orange text-white text-sm font-medium rounded-xl hover:bg-brand-orange/90 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:ring-offset-2 disabled:opacity-50"
>
{bunnyConnecting && <SpinnerGap className="w-4 h-4 animate-spin" />}
Connect BunnyCDN
</button>
</div>
)}
</div>
)}
</div>
) : (
<div className="space-y-4">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-4">
<div className="p-2.5 bg-white dark:bg-neutral-800 rounded-lg border border-neutral-200 dark:border-neutral-700 flex-shrink-0">
<svg className="w-6 h-6" viewBox="0 0 23 26" fill="none">
<path fillRule="evenodd" clipRule="evenodd" d="M9.94 7.77l5.106.883c-3.83-.663-4.065-3.85-9.218-6.653-.562 1.859.603 5.21 4.112 5.77z" fill="url(#b1c)"/>
<path fillRule="evenodd" clipRule="evenodd" d="M5.828 2c5.153 2.803 5.388 5.99 9.218 6.653 1.922.332.186 3.612-1.864 3.266 3.684 1.252 7.044-2.085 5.122-3.132L5.828 2z" fill="url(#b2c)"/>
<path fillRule="evenodd" clipRule="evenodd" d="M13.186 11.92c-.241-.041-.486-.131-.731-.284-1.542-.959-3.093-1.269-4.496-1.118 2.93.359 5.716 4.196 5.37 7.036.06.97-.281 1.958-1.021 2.699l-1.69 1.69c1.303.858 3.284-.037 3.889-1.281l3.41-7.014c.836-.198 6.176-1.583 3.767-3.024l-3.37-1.833c1.907 1.05-1.449 4.378-5.125 3.129z" fill="url(#b3c)"/>
<path fillRule="evenodd" clipRule="evenodd" d="M7.953 10.518c-4.585.499-7.589 5.94-3.506 9.873l3.42 3.42c-2.243-2.243-2.458-5.525-1.073-7.806.149-.255.333-.495.551-.713 1.37-1.37 3.59-1.37 4.96 0 .629.628.969 1.436 1.02 2.26.346-2.84-2.439-6.675-5.367-7.035h-.005z" fill="url(#b4c)"/>
<path fillRule="evenodd" clipRule="evenodd" d="M7.868 23.812l1.925 1.925c.643-.511 1.028-2.01.031-3.006l-2.48-2.48c-1.151-1.151-1.334-2.903-.55-4.246-1.385 2.281-1.17 5.563 1.074 7.807z" fill="url(#b5c)"/>
<path fillRule="evenodd" clipRule="evenodd" d="M12.504 4.54l5.739 3.122L12.925.6c-.728.829-1.08 2.472-.421 3.94z" fill="url(#b6c)"/>
<circle cx="9.825" cy="17.772" r="1.306" fill="url(#b7c)"/>
<circle cx="1.507" cy="11.458" r="1.306" fill="url(#b8c)"/>
<defs>
<linearGradient id="b1c" x1="5.69" y1="8.5" x2="15.04" y2="8.5" gradientUnits="userSpaceOnUse"><stop stopColor="#FFA600"/><stop offset=".34" stopColor="#FF9F00"/><stop offset=".69" stopColor="#FF7300"/><stop offset="1" stopColor="#F52900"/></linearGradient>
<linearGradient id="b2c" x1="5.83" y1="12.65" x2="18.87" y2="12.65" gradientUnits="userSpaceOnUse"><stop stopColor="#FFA600"/><stop offset=".34" stopColor="#FF9F00"/><stop offset=".69" stopColor="#FF7300"/><stop offset="1" stopColor="#F52900"/></linearGradient>
<linearGradient id="b3c" x1="7.95" y1="22.04" x2="22.3" y2="22.04" gradientUnits="userSpaceOnUse"><stop stopColor="#FFA600"/><stop offset=".34" stopColor="#FF9F00"/><stop offset="1" stopColor="#FF6200"/></linearGradient>
<linearGradient id="b4c" x1="2.51" y1="22.59" x2="13.35" y2="22.59" gradientUnits="userSpaceOnUse"><stop stopColor="#FFA600"/><stop offset=".34" stopColor="#FF9F00"/><stop offset=".69" stopColor="#FF7300"/><stop offset="1" stopColor="#F52900"/></linearGradient>
<linearGradient id="b5c" x1="11.35" y1="20.74" x2="7.98" y2="17.71" gradientUnits="userSpaceOnUse"><stop stopColor="#FFA600"/><stop offset=".34" stopColor="#FF9F00"/><stop offset=".69" stopColor="#FF7300"/><stop offset="1" stopColor="#F52900"/></linearGradient>
<linearGradient id="b6c" x1="12.16" y1="7.48" x2="18.24" y2="7.48" gradientUnits="userSpaceOnUse"><stop stopColor="#FFA600"/><stop offset=".34" stopColor="#FF9F00"/><stop offset="1" stopColor="#FF6200"/></linearGradient>
<linearGradient id="b7c" x1="8.52" y1="19.08" x2="11.13" y2="19.08" gradientUnits="userSpaceOnUse"><stop stopColor="#FFA600"/><stop offset=".34" stopColor="#FF9F00"/><stop offset="1" stopColor="#FF6200"/></linearGradient>
<linearGradient id="b8c" x1=".2" y1="12.76" x2="2.81" y2="12.76" gradientUnits="userSpaceOnUse"><stop stopColor="#FFA600"/><stop offset=".34" stopColor="#FF9F00"/><stop offset="1" stopColor="#FF6200"/></linearGradient>
</defs>
</svg>
</div>
<div>
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">BunnyCDN</h3>
<div className="flex items-center gap-2 mt-1.5">
<span className={`inline-flex items-center gap-1.5 text-xs font-medium ${
bunnyStatus.status === 'active'
? 'text-green-600 dark:text-green-400'
: bunnyStatus.status === 'syncing'
? 'text-amber-600 dark:text-amber-400'
: 'text-red-600 dark:text-red-400'
}`}>
<span className={`w-2 h-2 rounded-full ${
bunnyStatus.status === 'active'
? 'bg-green-500'
: bunnyStatus.status === 'syncing'
? 'bg-amber-500 animate-pulse'
: 'bg-red-500'
}`} />
{bunnyStatus.status === 'active' ? 'Connected' : bunnyStatus.status === 'syncing' ? 'Syncing...' : 'Error'}
</span>
</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{bunnyStatus.pull_zone_name && (
<div className="p-3 bg-white dark:bg-neutral-800/50 rounded-lg border border-neutral-200 dark:border-neutral-700">
<p className="text-xs text-neutral-500 dark:text-neutral-400">Pull Zone</p>
<p className="text-sm font-medium text-neutral-900 dark:text-white mt-0.5 truncate">{bunnyStatus.pull_zone_name}</p>
</div>
)}
{bunnyStatus.last_synced_at && (
<div className="p-3 bg-white dark:bg-neutral-800/50 rounded-lg border border-neutral-200 dark:border-neutral-700">
<p className="text-xs text-neutral-500 dark:text-neutral-400">Last Synced</p>
<p className="text-sm font-medium text-neutral-900 dark:text-white mt-0.5">
{new Date(bunnyStatus.last_synced_at).toLocaleString('en-GB')}
</p>
</div>
)}
{bunnyStatus.created_at && (
<div className="p-3 bg-white dark:bg-neutral-800/50 rounded-lg border border-neutral-200 dark:border-neutral-700">
<p className="text-xs text-neutral-500 dark:text-neutral-400">Connected Since</p>
<p className="text-sm font-medium text-neutral-900 dark:text-white mt-0.5">
{new Date(bunnyStatus.created_at).toLocaleString('en-GB')}
</p>
</div>
)}
</div>
{bunnyStatus.status === 'error' && bunnyStatus.error_message && (
<div className="p-3 bg-red-50 dark:bg-red-900/10 rounded-lg border border-red-200 dark:border-red-900/30">
<p className="text-sm text-red-700 dark:text-red-300">{bunnyStatus.error_message}</p>
</div>
)}
{canEdit && (
<div className="pt-2 border-t border-neutral-200 dark:border-neutral-700">
<button
onClick={async () => {
if (!confirm('Disconnect BunnyCDN? All CDN analytics data will be removed from Pulse.')) return
setBunnyDisconnecting(true)
try {
await disconnectBunny(siteId)
mutateBunnyStatus()
toast.success('BunnyCDN disconnected')
} catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || 'Failed to disconnect')
} finally {
setBunnyDisconnecting(false)
}
}}
disabled={bunnyDisconnecting}
className="inline-flex items-center gap-2 text-sm font-medium text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 transition-colors disabled:opacity-50"
>
{bunnyDisconnecting && <SpinnerGap className="w-4 h-4 animate-spin" />}
Disconnect
</button>
</div>
)}
</div>
)}
</div>
</div>
)}
</motion.div> </motion.div>
</div> </div>
</div> </div>

View File

@@ -67,6 +67,18 @@ export function IntegrationGuide({ integration, children }: IntegrationGuideProp
<div className="prose prose-neutral dark:prose-invert max-w-none"> <div className="prose prose-neutral dark:prose-invert max-w-none">
{children} {children}
<hr className="my-8 border-neutral-200 dark:border-neutral-800" />
<h3>Optional: Frustration Tracking</h3>
<p>
Detect rage clicks and dead clicks by adding the frustration tracking
add-on after the core script:
</p>
<pre><code>{`<script defer src="https://pulse.ciphera.net/script.frustration.js"></script>`}</code></pre>
<p>
No extra configuration needed. Add <code>data-no-rage</code> or{' '}
<code>data-no-dead</code> to disable individual signals.
</p>
</div> </div>
{/* * --- Related Integrations --- */} {/* * --- Related Integrations --- */}

View File

@@ -45,7 +45,7 @@ export default function FrustrationByPageTable({ pages, loading }: FrustrationBy
{loading ? ( {loading ? (
<SkeletonRows /> <SkeletonRows />
) : hasData ? ( ) : hasData ? (
<div> <div className="overflow-x-auto -mx-6 px-6">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-2 -mx-2 mb-2 text-xs font-medium text-neutral-400 dark:text-neutral-500 uppercase tracking-wider"> <div className="flex items-center justify-between px-2 -mx-2 mb-2 text-xs font-medium text-neutral-400 dark:text-neutral-500 uppercase tracking-wider">
<span>Page</span> <span>Page</span>
@@ -60,7 +60,7 @@ export default function FrustrationByPageTable({ pages, loading }: FrustrationBy
{/* Rows */} {/* Rows */}
<div className="space-y-0.5"> <div className="space-y-0.5">
{pages.map((page) => { {pages.map((page) => {
const barWidth = (page.total / maxTotal) * 100 const barWidth = (page.total / maxTotal) * 75
return ( return (
<div <div
key={page.page_path} key={page.page_path}
@@ -68,11 +68,11 @@ export default function FrustrationByPageTable({ pages, loading }: FrustrationBy
> >
{/* Background bar */} {/* Background bar */}
<div <div
className="absolute inset-y-0 left-0 bg-brand-orange/5 dark:bg-brand-orange/10 rounded-lg transition-all" className="absolute inset-y-0 left-0 bg-brand-orange/15 dark:bg-brand-orange/25 rounded-lg transition-all"
style={{ width: `${barWidth}%` }} style={{ width: `${barWidth}%` }}
/> />
<span <span
className="relative text-sm text-neutral-900 dark:text-white truncate max-w-[300px]" className="relative text-sm text-neutral-900 dark:text-white truncate max-w-[200px] sm:max-w-[300px]"
title={page.page_path} title={page.page_path}
> >
{page.page_path} {page.page_path}

View File

@@ -81,7 +81,7 @@ function Row({
return ( return (
<div className="flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors"> <div className="flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 min-w-0 overflow-hidden">
<SelectorCell selector={item.selector} /> <SelectorCell selector={item.selector} />
<span <span
className="text-xs text-neutral-400 dark:text-neutral-500 truncate shrink-0" className="text-xs text-neutral-400 dark:text-neutral-500 truncate shrink-0"
@@ -184,8 +184,11 @@ export default function FrustrationTable({
No {title.toLowerCase()} detected No {title.toLowerCase()} detected
</h4> </h4>
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-md"> <p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-md">
{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:
</p> </p>
<code className="text-xs bg-neutral-100 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300 px-3 py-2 rounded-lg font-mono break-all">
{'<script defer src="https://pulse.ciphera.net/script.frustration.js"></script>'}
</code>
</div> </div>
)} )}
</div> </div>

View File

@@ -127,6 +127,7 @@ export default function Campaigns({ siteId, dateRange, filters, onFilter }: Camp
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col"> <div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Megaphone className="w-5 h-5 text-neutral-400 dark:text-neutral-500" weight="bold" />
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white"> <h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
Campaigns Campaigns
</h3> </h3>
@@ -154,13 +155,19 @@ export default function Campaigns({ siteId, dateRange, filters, onFilter }: Camp
) : hasData ? ( ) : hasData ? (
<> <>
{displayedData.map((item) => { {displayedData.map((item) => {
const maxVis = displayedData[0]?.visitors ?? 0
const barWidth = maxVis > 0 ? (item.visitors / maxVis) * 75 : 0
return ( return (
<div <div
key={`${item.source}|${item.medium}|${item.campaign}`} key={`${item.source}|${item.medium}|${item.campaign}`}
onClick={() => onFilter?.({ dimension: 'utm_source', operator: 'is', values: [item.source] })} onClick={() => 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' : ''}`}
> >
<div className="flex-1 text-neutral-900 dark:text-white flex items-center gap-3 min-w-0"> <div
className="absolute inset-y-0.5 left-0.5 bg-brand-orange/15 dark:bg-brand-orange/40 rounded-md transition-all"
style={{ width: `${barWidth}%` }}
/>
<div className="relative flex-1 text-neutral-900 dark:text-white flex items-center gap-3 min-w-0">
{renderSourceIcon(item.source)} {renderSourceIcon(item.source)}
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate font-medium text-sm" title={item.source}> <div className="truncate font-medium text-sm" title={item.source}>
@@ -173,7 +180,7 @@ export default function Campaigns({ siteId, dateRange, filters, onFilter }: Camp
</div> </div>
</div> </div>
</div> </div>
<div className="flex items-center gap-2 ml-4"> <div className="relative flex items-center gap-2 ml-4">
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200"> <span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
{totalVisitors > 0 ? `${Math.round((item.visitors / totalVisitors) * 100)}%` : ''} {totalVisitors > 0 ? `${Math.round((item.visitors / totalVisitors) * 100)}%` : ''}
</span> </span>
@@ -199,13 +206,13 @@ export default function Campaigns({ siteId, dateRange, filters, onFilter }: Camp
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs"> <p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs">
Add UTM parameters to your links to see campaign performance here. Add UTM parameters to your links to see campaign performance here.
</p> </p>
<Link <button
href="/installation" onClick={() => 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" 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
<ArrowRightIcon className="w-4 h-4" /> <ArrowRightIcon className="w-4 h-4" />
</Link> </button>
</div> </div>
)} )}
</div> </div>

View File

@@ -10,6 +10,7 @@ import { Select, DownloadIcon, PlusIcon, XIcon } from '@ciphera-net/ui'
import { Checkbox } from '@ciphera-net/ui' import { Checkbox } from '@ciphera-net/ui'
import { ArrowUpRight, ArrowDownRight } from '@phosphor-icons/react' import { ArrowUpRight, ArrowDownRight } from '@phosphor-icons/react'
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { AnimatedNumber } from '@/components/ui/animated-number'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { formatTime, formatDateShort, formatDate } from '@/lib/utils/formatDate' import { formatTime, formatDateShort, formatDate } from '@/lib/utils/formatDate'
@@ -350,7 +351,7 @@ export default function Chart({
> >
<div className={cn('text-[10px] font-semibold uppercase tracking-widest mb-2', metric === m.key ? 'text-brand-orange' : 'text-neutral-400 dark:text-neutral-500')}>{m.label}</div> <div className={cn('text-[10px] font-semibold uppercase tracking-widest mb-2', metric === m.key ? 'text-brand-orange' : 'text-neutral-400 dark:text-neutral-500')}>{m.label}</div>
<div className="flex items-baseline gap-2"> <div className="flex items-baseline gap-2">
<span className="text-2xl font-bold text-neutral-900 dark:text-white">{m.format(m.value)}</span> <AnimatedNumber value={m.value} format={m.format} className="text-2xl font-bold text-neutral-900 dark:text-white" />
{m.change !== null && ( {m.change !== null && (
<span className={cn('flex items-center gap-0.5 text-sm font-semibold', m.isPositive ? 'text-[#10B981]' : 'text-[#EF4444]')}> <span className={cn('flex items-center gap-0.5 text-sm font-semibold', m.isPositive ? 'text-[#10B981]' : 'text-[#EF4444]')}>
{m.isPositive ? <ArrowUpRight weight="bold" className="size-3.5" /> : <ArrowDownRight weight="bold" className="size-3.5" />} {m.isPositive ? <ArrowUpRight weight="bold" className="size-3.5" /> : <ArrowDownRight weight="bold" className="size-3.5" />}

View File

@@ -6,8 +6,9 @@ import { logger } from '@/lib/utils/logger'
import { formatNumber } from '@ciphera-net/ui' import { formatNumber } from '@ciphera-net/ui'
import { useTabListKeyboard } from '@/lib/hooks/useTabListKeyboard' import { useTabListKeyboard } from '@/lib/hooks/useTabListKeyboard'
import { TopPage, getTopPages, getEntryPages, getExitPages } from '@/lib/api/stats' import { TopPage, getTopPages, getEntryPages, getExitPages } from '@/lib/api/stats'
import { FrameCornersIcon } from '@phosphor-icons/react' import Link from 'next/link'
import { Modal, ArrowUpRightIcon, LayoutDashboardIcon } from '@ciphera-net/ui' import { Files, FrameCornersIcon } from '@phosphor-icons/react'
import { Modal, ArrowUpRightIcon, ArrowRightIcon, LayoutDashboardIcon } from '@ciphera-net/ui'
import { ListSkeleton } from '@/components/skeletons' import { ListSkeleton } from '@/components/skeletons'
import VirtualList from './VirtualList' import VirtualList from './VirtualList'
import { type DimensionFilter } from '@/lib/filters' import { type DimensionFilter } from '@/lib/filters'
@@ -101,6 +102,7 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain,
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col"> <div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Files className="w-5 h-5 text-neutral-400 dark:text-neutral-500" weight="bold" />
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white"> <h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
Pages Pages
</h3> </h3>
@@ -114,7 +116,7 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain,
</button> </button>
)} )}
</div> </div>
<div className="flex gap-1" role="tablist" aria-label="Pages view tabs" onKeyDown={handleTabKeyDown}> <div className="flex gap-1 overflow-x-auto scrollbar-hide" role="tablist" aria-label="Pages view tabs" onKeyDown={handleTabKeyDown}>
{(['top_pages', 'entry_pages', 'exit_pages'] as Tab[]).map((tab) => ( {(['top_pages', 'entry_pages', 'exit_pages'] as Tab[]).map((tab) => (
<button <button
key={tab} key={tab}
@@ -147,34 +149,42 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain,
</div> </div>
) : hasData ? ( ) : hasData ? (
<> <>
{displayedData.map((page) => ( {displayedData.map((page, idx) => {
<div const maxPv = displayedData[0]?.pageviews ?? 0
key={page.path} const barWidth = maxPv > 0 ? (page.pageviews / maxPv) * 75 : 0
onClick={() => onFilter?.({ dimension: 'page', operator: 'is', values: [page.path] })} return (
className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors${onFilter ? ' cursor-pointer' : ''}`} <div
> key={page.path}
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center"> onClick={() => onFilter?.({ dimension: 'page', operator: 'is', values: [page.path] })}
<span className="truncate">{page.path}</span> className={`relative flex items-center justify-between h-9 group hover:bg-neutral-50/50 dark:hover:bg-neutral-800/50 rounded-lg px-2 -mx-2 transition-colors${onFilter ? ' cursor-pointer' : ''}`}
<a >
href={`https://${domain.replace(/^https?:\/\//, '')}${page.path}`} <div
target="_blank" className="absolute inset-y-0.5 left-0.5 bg-brand-orange/15 dark:bg-brand-orange/40 rounded-md transition-all"
rel="noopener noreferrer" style={{ width: `${barWidth}%` }}
onClick={e => e.stopPropagation()} />
className="ml-2 flex-shrink-0" <div className="relative flex-1 truncate text-neutral-900 dark:text-white flex items-center">
> <span className="truncate">{page.path}</span>
<ArrowUpRightIcon className="w-3 h-3 text-neutral-400 opacity-0 group-hover:opacity-100 transition-opacity hover:text-brand-orange" /> <a
</a> href={`https://${domain.replace(/^https?:\/\//, '')}${page.path}`}
target="_blank"
rel="noopener noreferrer"
onClick={e => e.stopPropagation()}
className="ml-2 flex-shrink-0"
>
<ArrowUpRightIcon className="w-3 h-3 text-neutral-400 opacity-0 group-hover:opacity-100 transition-opacity hover:text-brand-orange" />
</a>
</div>
<div className="relative flex items-center gap-2 ml-4">
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
{totalPageviews > 0 ? `${Math.round((page.pageviews / totalPageviews) * 100)}%` : ''}
</span>
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
{formatNumber(page.pageviews)}
</span>
</div>
</div> </div>
<div className="flex items-center gap-2 ml-4"> )
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200"> })}
{totalPageviews > 0 ? `${Math.round((page.pageviews / totalPageviews) * 100)}%` : ''}
</span>
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
{formatNumber(page.pageviews)}
</span>
</div>
</div>
))}
{Array.from({ length: emptySlots }).map((_, i) => ( {Array.from({ length: emptySlots }).map((_, i) => (
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" /> <div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
))} ))}
@@ -190,6 +200,13 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain,
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs"> <p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs">
Your most visited pages will appear here as traffic arrives. Your most visited pages will appear here as traffic arrives.
</p> </p>
<Link
href="/installation"
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"
>
Install tracking script
<ArrowRightIcon className="w-4 h-4" />
</Link>
</div> </div>
)} )}
</div> </div>

View File

@@ -57,6 +57,8 @@ const BASE_DOTS_PATH = (() => {
interface DottedMapProps { interface DottedMapProps {
data: Array<{ country: string; pageviews: number }> data: Array<{ country: string; pageviews: number }>
className?: string className?: string
/** Custom formatter for tooltip values. Defaults to formatNumber. */
formatValue?: (value: number) => string
} }
function getCountryName(code: string): string { function getCountryName(code: string): string {
@@ -68,7 +70,7 @@ function getCountryName(code: string): string {
} }
} }
export default function DottedMap({ data, className }: DottedMapProps) { export default function DottedMap({ data, className, formatValue = formatNumber }: DottedMapProps) {
const [tooltip, setTooltip] = useState<{ x: number; y: number; country: string; pageviews: number } | null>(null) const [tooltip, setTooltip] = useState<{ x: number; y: number; country: string; pageviews: number } | null>(null)
const markerData = useMemo(() => { const markerData = useMemo(() => {
@@ -152,7 +154,7 @@ export default function DottedMap({ data, className }: DottedMapProps) {
style={{ left: tooltip.x, top: tooltip.y }} style={{ left: tooltip.x, top: tooltip.y }}
> >
<span>{getCountryName(tooltip.country)}</span> <span>{getCountryName(tooltip.country)}</span>
<span className="ml-1.5 text-brand-orange font-bold">{formatNumber(tooltip.pageviews)}</span> <span className="ml-1.5 text-brand-orange font-bold">{formatValue(tooltip.pageviews)}</span>
</div> </div>
)} )}
</div> </div>

View File

@@ -51,6 +51,8 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to
const [filename, setFilename] = useState(`pulse_export_${formatDateISO(new Date())}`) const [filename, setFilename] = useState(`pulse_export_${formatDateISO(new Date())}`)
const [includeHeader, setIncludeHeader] = useState(true) const [includeHeader, setIncludeHeader] = useState(true)
const [isExporting, setIsExporting] = useState(false) const [isExporting, setIsExporting] = useState(false)
const [exportDone, setExportDone] = useState(false)
const [exportProgress, setExportProgress] = useState({ step: 0, total: 1, label: '' })
const [selectedFields, setSelectedFields] = useState<Record<keyof DailyStat, boolean>>({ const [selectedFields, setSelectedFields] = useState<Record<keyof DailyStat, boolean>>({
date: true, date: true,
pageviews: true, pageviews: true,
@@ -63,8 +65,24 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to
setSelectedFields((prev) => ({ ...prev, [field]: checked })) 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 = () => { const handleExport = () => {
setIsExporting(true) setIsExporting(true)
setExportProgress({ step: 0, total: 1, label: 'Preparing...' })
// Let the browser paint the loading state before starting heavy work // Let the browser paint the loading state before starting heavy work
requestAnimationFrame(() => { requestAnimationFrame(() => {
setTimeout(async () => { setTimeout(async () => {
@@ -100,6 +118,7 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to
mimeType = 'text/csv;charset=utf-8;' mimeType = 'text/csv;charset=utf-8;'
extension = 'csv' extension = 'csv'
} else if (format === 'xlsx') { } else if (format === 'xlsx') {
await updateProgress(1, 2, 'Building spreadsheet...')
const ws = XLSX.utils.json_to_sheet(exportData) const ws = XLSX.utils.json_to_sheet(exportData)
const wb = XLSX.utils.book_new() const wb = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(wb, ws, 'Data') 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) document.body.appendChild(link)
link.click() link.click()
document.body.removeChild(link) document.body.removeChild(link)
onClose() finishExport()
return return
} else if (format === 'pdf') { } 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() const doc = new jsPDF()
// Header Section // Header Section
await updateProgress(++currentStep, totalSteps, 'Building header...')
try { try {
// Logo // Logo
const logoData = await loadImage('/pulse_icon_no_margins.png') 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 startY = 65 // Move table down
} }
await updateProgress(++currentStep, totalSteps, 'Generating data table...')
// Check if data is hourly (same date for multiple rows) // 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] 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 // Top Pages Table
if (topPages && topPages.length > 0) { if (topPages && topPages.length > 0) {
await updateProgress(++currentStep, totalSteps, 'Adding top pages...')
// Check if we need a new page // Check if we need a new page
if (finalY + 40 > doc.internal.pageSize.height) { if (finalY + 40 > doc.internal.pageSize.height) {
doc.addPage() doc.addPage()
@@ -286,6 +310,7 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to
// Top Referrers Table // Top Referrers Table
if (topReferrers && topReferrers.length > 0) { if (topReferrers && topReferrers.length > 0) {
await updateProgress(++currentStep, totalSteps, 'Adding top referrers...')
// Check if we need a new page // Check if we need a new page
if (finalY + 40 > doc.internal.pageSize.height) { if (finalY + 40 > doc.internal.pageSize.height) {
doc.addPage() doc.addPage()
@@ -315,6 +340,7 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to
// Campaigns Table // Campaigns Table
if (campaigns && campaigns.length > 0) { if (campaigns && campaigns.length > 0) {
await updateProgress(++currentStep, totalSteps, 'Adding campaigns...')
if (finalY + 40 > doc.internal.pageSize.height) { if (finalY + 40 > doc.internal.pageSize.height) {
doc.addPage() doc.addPage()
finalY = 20 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`) doc.save(`${filename || 'export'}.pdf`)
onClose() finishExport()
return return
} else { } else {
content = JSON.stringify(exportData, null, 2) content = JSON.stringify(exportData, null, 2)
@@ -359,7 +386,7 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to
link.click() link.click()
document.body.removeChild(link) document.body.removeChild(link)
onClose() finishExport()
} catch (e) { } catch (e) {
console.error('Export failed:', e) console.error('Export failed:', e)
} finally { } finally {
@@ -450,13 +477,29 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to
</div> </div>
)} )}
{/* Progress Bar */}
{(isExporting || exportDone) && (
<div className="space-y-2 pt-2">
<div className="flex items-center justify-between text-xs text-neutral-500 dark:text-neutral-400">
<span>{exportDone ? 'Export complete' : exportProgress.label}</span>
<span>{exportDone ? '100%' : `${Math.round((exportProgress.step / exportProgress.total) * 100)}%`}</span>
</div>
<div className="h-1.5 w-full rounded-full bg-neutral-100 dark:bg-neutral-800 overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-300 ease-out ${exportDone ? 'bg-green-500' : 'bg-brand-orange'}`}
style={{ width: exportDone ? '100%' : `${(exportProgress.step / exportProgress.total) * 100}%` }}
/>
</div>
</div>
)}
{/* Actions */} {/* Actions */}
<div className="flex justify-end gap-3 pt-4"> <div className="flex justify-end gap-3 pt-4">
<Button variant="secondary" onClick={onClose} disabled={isExporting}> <Button variant="secondary" onClick={onClose} disabled={isExporting}>
Cancel Cancel
</Button> </Button>
<Button variant="primary" onClick={handleExport} disabled={isExporting}> <Button variant="primary" onClick={handleExport} disabled={isExporting || exportDone}>
{isExporting ? 'Exporting...' : 'Export Data'} {exportDone ? '✓ Done' : isExporting ? 'Exporting...' : 'Export Data'}
</Button> </Button>
</div> </div>
</div> </div>

View File

@@ -2,6 +2,7 @@
import Link from 'next/link' import Link from 'next/link'
import { formatNumber } from '@ciphera-net/ui' import { formatNumber } from '@ciphera-net/ui'
import { Target } from '@phosphor-icons/react'
import { BookOpenIcon, ArrowRightIcon } from '@ciphera-net/ui' import { BookOpenIcon, ArrowRightIcon } from '@ciphera-net/ui'
import type { GoalCountStat } from '@/lib/api/stats' import type { GoalCountStat } from '@/lib/api/stats'
@@ -21,9 +22,12 @@ export default function GoalStats({ goalCounts, onSelectEvent }: GoalStatsProps)
return ( return (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col"> <div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white"> <div className="flex items-center gap-2">
Goals & Events <Target className="w-5 h-5 text-neutral-400 dark:text-neutral-500" weight="bold" />
</h3> <h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
Goals & Events
</h3>
</div>
</div> </div>
{hasData ? ( {hasData ? (

View File

@@ -11,10 +11,11 @@ import iso3166 from 'iso-3166-2'
const DottedMap = dynamic(() => import('./DottedMap'), { ssr: false }) const DottedMap = dynamic(() => import('./DottedMap'), { ssr: false })
const Globe = dynamic(() => import('./Globe'), { 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 { ListSkeleton } from '@/components/skeletons'
import VirtualList from './VirtualList' 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 { getCountries, getCities, getRegions } from '@/lib/api/stats'
import { type DimensionFilter } from '@/lib/filters' import { type DimensionFilter } from '@/lib/filters'
@@ -219,6 +220,7 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
<div ref={containerRef} className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col"> <div ref={containerRef} className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<MapPin className="w-5 h-5 text-neutral-400 dark:text-neutral-500" weight="bold" />
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white"> <h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
Locations Locations
</h3> </h3>
@@ -232,7 +234,7 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
</button> </button>
)} )}
</div> </div>
<div className="flex gap-1" role="tablist" aria-label="Location view tabs" onKeyDown={handleTabKeyDown}> <div className="flex gap-1 overflow-x-auto scrollbar-hide" role="tablist" aria-label="Location view tabs" onKeyDown={handleTabKeyDown}>
{(['map', 'globe', 'countries', 'regions', 'cities'] as Tab[]).map((tab) => ( {(['map', 'globe', 'countries', 'regions', 'cities'] as Tab[]).map((tab) => (
<button <button
key={tab} key={tab}
@@ -279,6 +281,13 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs"> <p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs">
Visitor locations will appear here based on anonymous geographic data. Visitor locations will appear here based on anonymous geographic data.
</p> </p>
<Link
href="/installation"
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"
>
Install tracking script
<ArrowRightIcon className="w-4 h-4" />
</Link>
</div> </div>
) )
) : ( ) : (
@@ -288,13 +297,19 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
const dim = TAB_TO_DIMENSION[activeTab] const dim = TAB_TO_DIMENSION[activeTab]
const filterValue = activeTab === 'countries' ? item.country : activeTab === 'regions' ? item.region : item.city const filterValue = activeTab === 'countries' ? item.country : activeTab === 'regions' ? item.region : item.city
const canFilter = onFilter && dim && filterValue const canFilter = onFilter && dim && filterValue
const maxPv = displayedData[0]?.pageviews ?? 0
const barWidth = maxPv > 0 ? (item.pageviews / maxPv) * 75 : 0
return ( return (
<div <div
key={`${item.country ?? ''}-${item.region ?? ''}-${item.city ?? ''}`} key={`${item.country ?? ''}-${item.region ?? ''}-${item.city ?? ''}`}
onClick={() => canFilter && onFilter({ dimension: dim, operator: 'is', values: [filterValue!] })} onClick={() => canFilter && onFilter({ dimension: dim, operator: 'is', values: [filterValue!] })}
className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors${canFilter ? ' cursor-pointer' : ''}`} className={`relative flex items-center justify-between h-9 group hover:bg-neutral-50/50 dark:hover:bg-neutral-800/50 rounded-lg px-2 -mx-2 transition-colors${canFilter ? ' cursor-pointer' : ''}`}
> >
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3"> <div
className="absolute inset-y-0.5 left-0.5 bg-brand-orange/15 dark:bg-brand-orange/40 rounded-md transition-all"
style={{ width: `${barWidth}%` }}
/>
<div className="relative flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
<span className="shrink-0">{getFlagComponent(item.country ?? '')}</span> <span className="shrink-0">{getFlagComponent(item.country ?? '')}</span>
<span className="truncate"> <span className="truncate">
{activeTab === 'countries' ? getCountryName(item.country ?? '') : {activeTab === 'countries' ? getCountryName(item.country ?? '') :
@@ -302,7 +317,7 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
getCityName(item.city ?? '')} getCityName(item.city ?? '')}
</span> </span>
</div> </div>
<div className="flex items-center gap-2 ml-4"> <div className="relative flex items-center gap-2 ml-4">
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200"> <span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
{totalPageviews > 0 ? `${Math.round((item.pageviews / totalPageviews) * 100)}%` : ''} {totalPageviews > 0 ? `${Math.round((item.pageviews / totalPageviews) * 100)}%` : ''}
</span> </span>

View File

@@ -1,6 +1,7 @@
'use client' 'use client'
import { useState, useEffect, useMemo, useRef, type CSSProperties } from 'react' import { useState, useEffect, useMemo, useRef, type CSSProperties } from 'react'
import { Clock } from '@phosphor-icons/react'
import { motion, AnimatePresence } from 'framer-motion' import { motion, AnimatePresence } from 'framer-motion'
import { logger } from '@/lib/utils/logger' import { logger } from '@/lib/utils/logger'
import { getDailyStats } from '@/lib/api/stats' import { getDailyStats } from '@/lib/api/stats'
@@ -128,7 +129,10 @@ export default function PeakHours({ siteId, dateRange }: PeakHoursProps) {
return ( return (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col"> <div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
<div className="flex items-center justify-between mb-1"> <div className="flex items-center justify-between mb-1">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">Peak Hours</h3> <div className="flex items-center gap-2">
<Clock className="w-5 h-5 text-neutral-400 dark:text-neutral-500" weight="bold" />
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">Peak Hours</h3>
</div>
</div> </div>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-5"> <p className="text-sm text-neutral-500 dark:text-neutral-400 mb-5">
When your visitors are most active When your visitors are most active

View File

@@ -1,254 +0,0 @@
'use client'
import { useState, useEffect } from 'react'
import { motion } from 'framer-motion'
import { ChevronDownIcon } from '@ciphera-net/ui'
import { PerformanceStats as Stats, PerformanceByPageStat, getPerformanceByPage } from '@/lib/api/stats'
import { Select } from '@ciphera-net/ui'
import { TableSkeleton } from '@/components/skeletons'
interface Props {
stats: Stats
performanceByPage?: PerformanceByPageStat[] | null
siteId?: string
startDate?: string
endDate?: string
getPerformanceByPage?: typeof getPerformanceByPage
}
function MetricCard({ label, value, unit, score }: { label: string, value: number, unit: string, score: 'good' | 'needs-improvement' | 'poor' }) {
const colors = {
good: 'text-green-600 bg-green-50 dark:bg-green-900/20 dark:text-green-400 border-green-200 dark:border-green-800',
'needs-improvement': 'text-yellow-600 bg-yellow-50 dark:bg-yellow-900/20 dark:text-yellow-400 border-yellow-200 dark:border-yellow-800',
poor: 'text-red-600 bg-red-50 dark:bg-red-900/20 dark:text-red-400 border-red-200 dark:border-red-800',
}
return (
<div className={`p-4 rounded-lg border ${colors[score]}`}>
<div className="text-sm font-medium opacity-80 mb-1">{label}</div>
<div className="text-2xl font-bold">
{value}
<span className="text-sm font-normal ml-1 opacity-70">{unit}</span>
</div>
</div>
)
}
export default function PerformanceStats({ stats, performanceByPage, siteId, startDate, endDate, getPerformanceByPage }: Props) {
// * Scoring Logic (based on Google Web Vitals)
type Score = 'good' | 'needs-improvement' | 'poor'
const getScore = (metric: 'lcp' | 'cls' | 'inp', value: number): Score => {
if (metric === 'lcp') return value <= 2500 ? 'good' : value <= 4000 ? 'needs-improvement' : 'poor'
if (metric === 'cls') return value <= 0.1 ? 'good' : value <= 0.25 ? 'needs-improvement' : 'poor'
if (metric === 'inp') return value <= 200 ? 'good' : value <= 500 ? 'needs-improvement' : 'poor'
return 'good'
}
// * Overall performance: worst of LCP, CLS, INP (matches Googles “field” rating)
const getOverallScore = (s: { lcp: number; cls: number; inp: number }): Score => {
const lcp = getScore('lcp', s.lcp)
const cls = getScore('cls', s.cls)
const inp = getScore('inp', s.inp)
if (lcp === 'poor' || cls === 'poor' || inp === 'poor') return 'poor'
if (lcp === 'needs-improvement' || cls === 'needs-improvement' || inp === 'needs-improvement') return 'needs-improvement'
return 'good'
}
const overallScore = getOverallScore(stats)
const overallLabel = { good: 'Good', 'needs-improvement': 'Needs improvement', poor: 'Poor' }[overallScore]
const overallBadgeClass = {
good: 'text-green-700 dark:text-green-400 bg-green-100 dark:bg-green-900/30 border-green-200 dark:border-green-800',
'needs-improvement': 'text-yellow-700 dark:text-yellow-400 bg-yellow-100 dark:bg-yellow-900/30 border-yellow-200 dark:border-yellow-800',
poor: 'text-red-700 dark:text-red-400 bg-red-100 dark:bg-red-900/30 border-red-200 dark:border-red-800',
}[overallScore]
const [mainExpanded, setMainExpanded] = useState(false)
const [sortBy, setSortBy] = useState<'lcp' | 'cls' | 'inp'>('lcp')
const [overrideRows, setOverrideRows] = useState<PerformanceByPageStat[] | null>(null)
const [loadingTable, setLoadingTable] = useState(false)
const [worstPagesOpen, setWorstPagesOpen] = useState(false)
// * When props.performanceByPage changes (e.g. date range), clear override so we use dashboard data
useEffect(() => {
setOverrideRows(null)
}, [performanceByPage])
const rows = overrideRows ?? performanceByPage ?? []
const canRefetch = Boolean(getPerformanceByPage && siteId && startDate && endDate)
const handleSortChange = (value: string) => {
const v = value as 'lcp' | 'cls' | 'inp'
setSortBy(v)
if (!getPerformanceByPage || !siteId || !startDate || !endDate) return
setLoadingTable(true)
getPerformanceByPage(siteId, startDate, endDate, { sort: v, limit: 20 })
.then(setOverrideRows)
.finally(() => setLoadingTable(false))
}
const cellScoreClass = (score: 'good' | 'needs-improvement' | 'poor') => {
const m: Record<string, string> = {
good: 'text-green-600 dark:text-green-400',
'needs-improvement': 'text-yellow-600 dark:text-yellow-400',
poor: 'text-red-600 dark:text-red-400',
}
return m[score] ?? ''
}
const formatMetric = (metric: 'lcp' | 'cls' | 'inp', val: number | null) => {
if (val == null) return '—'
if (metric === 'cls') return val.toFixed(3)
return `${Math.round(val)} ms`
}
const getCellClass = (metric: 'lcp' | 'cls' | 'inp', val: number | null) => {
if (val == null) return 'text-neutral-400 dark:text-neutral-500'
return cellScoreClass(getScore(metric, val))
}
const summaryText = `LCP ${Math.round(stats.lcp)} ms · CLS ${Number(stats.cls.toFixed(3))} · INP ${Math.round(stats.inp)} ms`
return (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
{/* * One-line summary: Performance score + metric summary. Click to expand. */}
<button
type="button"
onClick={() => setMainExpanded((o) => !o)}
className="flex w-full items-center justify-between gap-4 text-left rounded cursor-pointer hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-400 dark:focus-visible:ring-neutral-500 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-neutral-900"
aria-expanded={mainExpanded}
>
<div className="flex items-center gap-2 min-w-0">
<ChevronDownIcon
className={`w-4 h-4 shrink-0 text-neutral-500 transition-transform ${mainExpanded ? '' : '-rotate-90'}`}
aria-hidden
/>
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">Performance</span>
<span className={`shrink-0 rounded-md border px-2 py-0.5 text-xs font-medium ${overallBadgeClass}`}>
{overallLabel}
</span>
</div>
<span className="text-xs text-neutral-500 truncate" title={summaryText}>
{summaryText}
</span>
</button>
{/* * Expanded: full LCP/CLS/INP cards, footnote, and Worst pages (collapsible) */}
<motion.div
initial={false}
animate={{ height: mainExpanded ? 'auto' : 0, opacity: mainExpanded ? 1 : 0 }}
transition={{ duration: 0.25, ease: 'easeInOut' }}
style={{ overflow: 'hidden' }}
>
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-800">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<MetricCard
label="Largest Contentful Paint (LCP)"
value={Math.round(stats.lcp)}
unit="ms"
score={getScore('lcp', stats.lcp)}
/>
<MetricCard
label="Cumulative Layout Shift (CLS)"
value={Number(stats.cls.toFixed(3))}
unit=""
score={getScore('cls', stats.cls)}
/>
<MetricCard
label="Interaction to Next Paint (INP)"
value={Math.round(stats.inp)}
unit="ms"
score={getScore('inp', stats.inp)}
/>
</div>
<div className="mt-4 text-xs text-neutral-500">
* Averages calculated from real user sessions. Lower is better.
</div>
{/* * Worst pages by metric collapsed by default */}
<div className="mt-6 pt-6 border-t border-neutral-200 dark:border-neutral-800">
<div className="flex items-center justify-between gap-4 mb-3">
<button
type="button"
onClick={() => setWorstPagesOpen((o) => !o)}
className="flex items-center gap-2 text-left rounded cursor-pointer hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-400 dark:focus-visible:ring-neutral-500 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-neutral-900"
aria-expanded={worstPagesOpen}
>
<ChevronDownIcon
className={`w-4 h-4 shrink-0 text-neutral-500 transition-transform ${worstPagesOpen ? '' : '-rotate-90'}`}
aria-hidden
/>
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
Worst pages by metric
</span>
</button>
{worstPagesOpen && canRefetch && (
<Select
value={sortBy}
onChange={handleSortChange}
options={[
{ value: 'lcp', label: 'Sort by LCP (worst)' },
{ value: 'cls', label: 'Sort by CLS (worst)' },
{ value: 'inp', label: 'Sort by INP (worst)' },
]}
variant="input"
align="right"
className="min-w-[180px]"
/>
)}
</div>
<motion.div
initial={false}
animate={{
height: worstPagesOpen ? 'auto' : 0,
opacity: worstPagesOpen ? 1 : 0,
}}
transition={{ duration: 0.25, ease: 'easeInOut' }}
style={{ overflow: 'hidden' }}
>
{loadingTable ? (
<div className="py-4"><TableSkeleton rows={5} cols={5} /></div>
) : rows.length === 0 ? (
<div className="py-6 text-center text-neutral-500 text-sm">
No per-page metrics yet. Data appears as visitors are tracked with performance insights enabled.
</div>
) : (
<div className="overflow-x-auto -mx-1">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-neutral-200 dark:border-neutral-700">
<th className="text-left py-2 px-2 font-medium text-neutral-600 dark:text-neutral-400">Path</th>
<th className="text-right py-2 px-2 font-medium text-neutral-600 dark:text-neutral-400">Samples</th>
<th className="text-right py-2 px-2 font-medium text-neutral-600 dark:text-neutral-400">LCP</th>
<th className="text-right py-2 px-2 font-medium text-neutral-600 dark:text-neutral-400">CLS</th>
<th className="text-right py-2 px-2 font-medium text-neutral-600 dark:text-neutral-400">INP</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.path} className="border-b border-neutral-100 dark:border-neutral-800/80">
<td className="py-2 px-2 text-neutral-900 dark:text-white font-mono truncate max-w-[200px]" title={r.path}>
{r.path || '/'}
</td>
<td className="py-2 px-2 text-right text-neutral-600 dark:text-neutral-400">{r.samples}</td>
<td className={`py-2 px-2 text-right font-mono ${getCellClass('lcp', r.lcp)}`}>
{formatMetric('lcp', r.lcp)}
</td>
<td className={`py-2 px-2 text-right font-mono ${getCellClass('cls', r.cls)}`}>
{formatMetric('cls', r.cls)}
</td>
<td className={`py-2 px-2 text-right font-mono ${getCellClass('inp', r.inp)}`}>
{formatMetric('inp', r.inp)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</motion.div>
</div>
</div>
</motion.div>
</div>
)
}

View File

@@ -1,3 +1,7 @@
'use client'
import { AnimatedNumber } from '@/components/ui/animated-number'
interface RealtimeVisitorsProps { interface RealtimeVisitorsProps {
count: number count: number
} }
@@ -14,7 +18,7 @@ export default function RealtimeVisitors({ count }: RealtimeVisitorsProps) {
<div className="h-2 w-2 bg-green-500 rounded-full animate-pulse"></div> <div className="h-2 w-2 bg-green-500 rounded-full animate-pulse"></div>
</div> </div>
<div className="text-3xl font-bold text-neutral-900 dark:text-white"> <div className="text-3xl font-bold text-neutral-900 dark:text-white">
{count} <AnimatedNumber value={count} format={(v) => v.toLocaleString()} />
</div> </div>
</div> </div>
) )

View File

@@ -0,0 +1,129 @@
'use client'
import Link from 'next/link'
import { MagnifyingGlass, CaretUp, CaretDown } from '@phosphor-icons/react'
import { useGSCStatus, useGSCOverview, useGSCTopQueries } from '@/lib/swr/dashboard'
interface SearchPerformanceProps {
siteId: string
dateRange: { start: string; end: string }
}
function ChangeArrow({ current, previous, invert = false }: { current: number; previous: number; invert?: boolean }) {
if (!previous || previous === 0) return null
const improved = invert ? current < previous : current > previous
const same = current === previous
if (same) return null
return improved ? (
<CaretUp className="w-3 h-3 text-emerald-500" weight="fill" />
) : (
<CaretDown className="w-3 h-3 text-red-500" weight="fill" />
)
}
export default function SearchPerformance({ siteId, dateRange }: SearchPerformanceProps) {
const { data: gscStatus } = useGSCStatus(siteId)
const { data: overview, isLoading: overviewLoading } = useGSCOverview(siteId, dateRange.start, dateRange.end)
const { data: queriesData, isLoading: queriesLoading } = useGSCTopQueries(siteId, dateRange.start, dateRange.end, 5, 0)
// Don't render if GSC is not connected or no data
if (!gscStatus?.connected) return null
const isLoading = overviewLoading || queriesLoading
const queries = queriesData?.queries ?? []
const hasData = overview && (overview.total_clicks > 0 || overview.total_impressions > 0)
// Hide panel entirely if loaded but no data
if (!isLoading && !hasData) return null
return (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<MagnifyingGlass className="w-5 h-5 text-neutral-400 dark:text-neutral-500" weight="bold" />
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
Search
</h3>
</div>
<Link
href={`/sites/${siteId}/search`}
className="text-xs font-medium text-neutral-400 dark:text-neutral-500 hover:text-brand-orange dark:hover:text-brand-orange transition-colors"
>
View all &rarr;
</Link>
</div>
{isLoading ? (
/* Loading skeleton */
<div className="flex-1 space-y-4">
<div className="flex items-center gap-6">
<div className="h-4 w-20 bg-neutral-100 dark:bg-neutral-800 rounded animate-pulse" />
<div className="h-4 w-24 bg-neutral-100 dark:bg-neutral-800 rounded animate-pulse" />
<div className="h-4 w-20 bg-neutral-100 dark:bg-neutral-800 rounded animate-pulse" />
</div>
<div className="space-y-2 mt-4">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-9 bg-neutral-100 dark:bg-neutral-800 rounded-lg animate-pulse" />
))}
</div>
</div>
) : (
<>
{/* Inline stats row */}
<div className="flex items-center gap-5 mb-4">
<div className="flex items-center gap-1.5">
<span className="text-xs text-neutral-500 dark:text-neutral-400">Clicks</span>
<span className="text-sm font-semibold text-neutral-900 dark:text-white">
{(overview?.total_clicks ?? 0).toLocaleString()}
</span>
<ChangeArrow current={overview?.total_clicks ?? 0} previous={overview?.prev_clicks ?? 0} />
</div>
<div className="flex items-center gap-1.5">
<span className="text-xs text-neutral-500 dark:text-neutral-400">Impressions</span>
<span className="text-sm font-semibold text-neutral-900 dark:text-white">
{(overview?.total_impressions ?? 0).toLocaleString()}
</span>
<ChangeArrow current={overview?.total_impressions ?? 0} previous={overview?.prev_impressions ?? 0} />
</div>
<div className="flex items-center gap-1.5">
<span className="text-xs text-neutral-500 dark:text-neutral-400">Avg Position</span>
<span className="text-sm font-semibold text-neutral-900 dark:text-white">
{(overview?.avg_position ?? 0).toFixed(1)}
</span>
<ChangeArrow current={overview?.avg_position ?? 0} previous={overview?.prev_avg_position ?? 0} invert />
</div>
</div>
{/* Top 5 queries list */}
<div className="space-y-1 flex-1">
{queries.length > 0 ? (
queries.map((q) => (
<div
key={q.query}
className="flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800/50 rounded-lg px-2 -mx-2 transition-colors"
>
<span className="text-sm text-neutral-900 dark:text-white truncate flex-1 min-w-0" title={q.query}>
{q.query}
</span>
<div className="flex items-center gap-3 ml-4 shrink-0">
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
{q.clicks.toLocaleString()}
</span>
<span className="text-xs text-neutral-400 dark:text-neutral-500 bg-neutral-100 dark:bg-neutral-800 px-1.5 py-0.5 rounded font-medium">
{q.position.toFixed(1)}
</span>
</div>
</div>
))
) : (
<div className="flex-1 flex items-center justify-center py-6">
<p className="text-sm text-neutral-400 dark:text-neutral-500">No search data yet</p>
</div>
)}
</div>
</>
)}
</div>
)
}

View File

@@ -21,6 +21,8 @@ export default function SiteNav({ siteId }: SiteNavProps) {
{ label: 'Journeys', href: `/sites/${siteId}/journeys` }, { label: 'Journeys', href: `/sites/${siteId}/journeys` },
{ label: 'Funnels', href: `/sites/${siteId}/funnels` }, { label: 'Funnels', href: `/sites/${siteId}/funnels` },
{ label: 'Behavior', href: `/sites/${siteId}/behavior` }, { label: 'Behavior', href: `/sites/${siteId}/behavior` },
{ label: 'Search', href: `/sites/${siteId}/search` },
{ label: 'CDN', href: `/sites/${siteId}/cdn` },
{ label: 'Uptime', href: `/sites/${siteId}/uptime` }, { label: 'Uptime', href: `/sites/${siteId}/uptime` },
...(canEdit ? [{ label: 'Settings', href: `/sites/${siteId}/settings` }] : []), ...(canEdit ? [{ label: 'Settings', href: `/sites/${siteId}/settings` }] : []),
] ]
@@ -33,8 +35,8 @@ export default function SiteNav({ siteId }: SiteNavProps) {
} }
return ( return (
<div className="border-b border-neutral-200 dark:border-neutral-800 mb-6"> <div className="mb-6 overflow-x-auto scrollbar-hide">
<nav className="flex gap-1" role="tablist" aria-label="Site navigation" onKeyDown={handleTabKeyDown}> <nav className="flex gap-1 min-w-max border-b border-neutral-200 dark:border-neutral-800" role="tablist" aria-label="Site navigation" onKeyDown={handleTabKeyDown}>
{tabs.map((tab) => ( {tabs.map((tab) => (
<Link <Link
key={tab.href} key={tab.href}
@@ -42,7 +44,7 @@ export default function SiteNav({ siteId }: SiteNavProps) {
role="tab" role="tab"
aria-selected={isActive(tab.href)} aria-selected={isActive(tab.href)}
tabIndex={isActive(tab.href) ? 0 : -1} tabIndex={isActive(tab.href) ? 0 : -1}
className={`relative px-3 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange rounded-t cursor-pointer -mb-px ${ className={`relative shrink-0 whitespace-nowrap px-3 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange rounded-t cursor-pointer -mb-px ${
isActive(tab.href) isActive(tab.href)
? 'text-neutral-900 dark:text-white' ? 'text-neutral-900 dark:text-white'
: 'text-neutral-400 dark:text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300' : 'text-neutral-400 dark:text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300'

View File

@@ -6,8 +6,9 @@ import { logger } from '@/lib/utils/logger'
import { formatNumber } from '@ciphera-net/ui' import { formatNumber } from '@ciphera-net/ui'
import { useTabListKeyboard } from '@/lib/hooks/useTabListKeyboard' import { useTabListKeyboard } from '@/lib/hooks/useTabListKeyboard'
import { getBrowserIcon, getOSIcon, getDeviceIcon } from '@/lib/utils/icons' import { getBrowserIcon, getOSIcon, getDeviceIcon } from '@/lib/utils/icons'
import { Monitor, FrameCornersIcon } from '@phosphor-icons/react' import Link from 'next/link'
import { Modal, GridIcon } from '@ciphera-net/ui' import { Monitor, DeviceMobile, FrameCornersIcon } from '@phosphor-icons/react'
import { Modal, GridIcon, ArrowRightIcon } from '@ciphera-net/ui'
import { ListSkeleton } from '@/components/skeletons' import { ListSkeleton } from '@/components/skeletons'
import VirtualList from './VirtualList' import VirtualList from './VirtualList'
import { getBrowsers, getOS, getDevices, getScreenResolutions } from '@/lib/api/stats' import { getBrowsers, getOS, getDevices, getScreenResolutions } from '@/lib/api/stats'
@@ -131,6 +132,7 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col"> <div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<DeviceMobile className="w-5 h-5 text-neutral-400 dark:text-neutral-500" weight="bold" />
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white"> <h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
Technology Technology
</h3> </h3>
@@ -144,7 +146,7 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
</button> </button>
)} )}
</div> </div>
<div className="flex gap-1" role="tablist" aria-label="Technology view tabs" onKeyDown={handleTabKeyDown}> <div className="flex gap-1 overflow-x-auto scrollbar-hide" role="tablist" aria-label="Technology view tabs" onKeyDown={handleTabKeyDown}>
{(['browsers', 'os', 'devices', 'screens'] as Tab[]).map((tab) => ( {(['browsers', 'os', 'devices', 'screens'] as Tab[]).map((tab) => (
<button <button
key={tab} key={tab}
@@ -180,17 +182,23 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
{displayedData.map((item) => { {displayedData.map((item) => {
const dim = TAB_TO_DIMENSION[activeTab] const dim = TAB_TO_DIMENSION[activeTab]
const canFilter = onFilter && dim const canFilter = onFilter && dim
const maxPv = displayedData[0]?.pageviews ?? 0
const barWidth = maxPv > 0 ? (item.pageviews / maxPv) * 75 : 0
return ( return (
<div <div
key={item.name} key={item.name}
onClick={() => canFilter && onFilter({ dimension: dim, operator: 'is', values: [item.name] })} onClick={() => canFilter && onFilter({ dimension: dim, operator: 'is', values: [item.name] })}
className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors${canFilter ? ' cursor-pointer' : ''}`} className={`relative flex items-center justify-between h-9 group hover:bg-neutral-50/50 dark:hover:bg-neutral-800/50 rounded-lg px-2 -mx-2 transition-colors${canFilter ? ' cursor-pointer' : ''}`}
> >
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3"> <div
className="absolute inset-y-0.5 left-0.5 bg-brand-orange/15 dark:bg-brand-orange/40 rounded-md transition-all"
style={{ width: `${barWidth}%` }}
/>
<div className="relative flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
{item.icon && <span className="text-lg">{item.icon}</span>} {item.icon && <span className="text-lg">{item.icon}</span>}
<span className="truncate">{capitalize(item.name)}</span> <span className="truncate">{capitalize(item.name)}</span>
</div> </div>
<div className="flex items-center gap-2 ml-4"> <div className="relative flex items-center gap-2 ml-4">
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200"> <span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
{totalPageviews > 0 ? `${Math.round((item.pageviews / totalPageviews) * 100)}%` : ''} {totalPageviews > 0 ? `${Math.round((item.pageviews / totalPageviews) * 100)}%` : ''}
</span> </span>
@@ -216,6 +224,13 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs"> <p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs">
Browser, OS, and device information will appear as visitors arrive. Browser, OS, and device information will appear as visitors arrive.
</p> </p>
<Link
href="/installation"
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"
>
Install tracking script
<ArrowRightIcon className="w-4 h-4" />
</Link>
</div> </div>
)} )}
</div> </div>

View File

@@ -5,8 +5,9 @@ import { logger } from '@/lib/utils/logger'
import Image from 'next/image' import Image from 'next/image'
import { formatNumber } from '@ciphera-net/ui' import { formatNumber } from '@ciphera-net/ui'
import { getReferrerDisplayName, getReferrerFavicon, getReferrerIcon, mergeReferrersByDisplayName } from '@/lib/utils/icons' import { getReferrerDisplayName, getReferrerFavicon, getReferrerIcon, mergeReferrersByDisplayName } from '@/lib/utils/icons'
import { FrameCornersIcon } from '@phosphor-icons/react' import Link from 'next/link'
import { Modal, GlobeIcon } from '@ciphera-net/ui' import { ArrowSquareOut, FrameCornersIcon } from '@phosphor-icons/react'
import { Modal, GlobeIcon, ArrowRightIcon } from '@ciphera-net/ui'
import { ListSkeleton } from '@/components/skeletons' import { ListSkeleton } from '@/components/skeletons'
import VirtualList from './VirtualList' import VirtualList from './VirtualList'
import { getTopReferrers, TopReferrer } from '@/lib/api/stats' import { getTopReferrers, TopReferrer } from '@/lib/api/stats'
@@ -89,6 +90,7 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col"> <div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ArrowSquareOut className="w-5 h-5 text-neutral-400 dark:text-neutral-500" weight="bold" />
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white"> <h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
Referrers Referrers
</h3> </h3>
@@ -111,26 +113,34 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
</div> </div>
) : hasData ? ( ) : hasData ? (
<> <>
{displayedReferrers.map((ref) => ( {displayedReferrers.map((ref) => {
<div const maxPv = displayedReferrers[0]?.pageviews ?? 0
key={ref.referrer} const barWidth = maxPv > 0 ? (ref.pageviews / maxPv) * 75 : 0
onClick={() => onFilter?.({ dimension: 'referrer', operator: 'is', values: [ref.referrer] })} return (
className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors${onFilter ? ' cursor-pointer' : ''}`} <div
> key={ref.referrer}
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3"> onClick={() => onFilter?.({ dimension: 'referrer', operator: 'is', values: [ref.referrer] })}
{renderReferrerIcon(ref.referrer)} className={`relative flex items-center justify-between h-9 group hover:bg-neutral-50/50 dark:hover:bg-neutral-800/50 rounded-lg px-2 -mx-2 transition-colors${onFilter ? ' cursor-pointer' : ''}`}
<span className="truncate" title={ref.referrer}>{getReferrerDisplayName(ref.referrer)}</span> >
<div
className="absolute inset-y-0.5 left-0.5 bg-brand-orange/15 dark:bg-brand-orange/40 rounded-md transition-all"
style={{ width: `${barWidth}%` }}
/>
<div className="relative flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
{renderReferrerIcon(ref.referrer)}
<span className="truncate" title={ref.referrer}>{getReferrerDisplayName(ref.referrer)}</span>
</div>
<div className="relative flex items-center gap-2 ml-4">
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
{totalPageviews > 0 ? `${Math.round((ref.pageviews / totalPageviews) * 100)}%` : ''}
</span>
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
{formatNumber(ref.pageviews)}
</span>
</div>
</div> </div>
<div className="flex items-center gap-2 ml-4"> )
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200"> })}
{totalPageviews > 0 ? `${Math.round((ref.pageviews / totalPageviews) * 100)}%` : ''}
</span>
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
{formatNumber(ref.pageviews)}
</span>
</div>
</div>
))}
{Array.from({ length: emptySlots }).map((_, i) => ( {Array.from({ length: emptySlots }).map((_, i) => (
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" /> <div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
))} ))}
@@ -146,6 +156,13 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs"> <p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs">
Traffic sources will appear here when visitors come from external sites. Traffic sources will appear here when visitors come from external sites.
</p> </p>
<Link
href="/installation"
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"
>
Install tracking script
<ArrowRightIcon className="w-4 h-4" />
</Link>
</div> </div>
)} )}
</div> </div>

View File

@@ -0,0 +1,619 @@
'use client'
import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { TreeStructure } from '@phosphor-icons/react'
import type { PathTransition } from '@/lib/api/journeys'
// ─── Types ──────────────────────────────────────────────────────────
interface ColumnJourneyProps {
transitions: PathTransition[]
totalSessions: number
depth: number
}
interface ColumnPage {
path: string
sessionCount: number
}
interface Column {
index: number
totalSessions: number
dropOffPercent: number
pages: ColumnPage[]
}
interface LineDef {
sourceY: number
destY: number
sourceX: number
destX: number
weight: number
}
// ─── Constants ──────────────────────────────────────────────────────
const COLUMN_COLORS = [
'#FD5E0F', '#3B82F6', '#10B981', '#F59E0B', '#8B5CF6',
'#EC4899', '#06B6D4', '#EF4444', '#84CC16', '#F97316', '#6366F1',
]
const MAX_NODES_PER_COLUMN = 10
function colorForColumn(col: number): string {
return COLUMN_COLORS[col % COLUMN_COLORS.length]
}
// ─── Helpers ────────────────────────────────────────────────────────
function smartLabel(path: string): string {
if (path === '/' || path === '(other)') return path
const segments = path.replace(/\/$/, '').split('/')
if (segments.length <= 2) return path
return `…/${segments[segments.length - 1]}`
}
// ─── Animated count hook ────────────────────────────────────────────
function useAnimatedCount(target: number, duration = 400): number {
const [display, setDisplay] = useState(0)
const prevTarget = useRef(target)
useEffect(() => {
const from = prevTarget.current
prevTarget.current = target
if (from === target) {
setDisplay(target)
return
}
const start = performance.now()
let raf: number
const tick = (now: number) => {
const t = Math.min((now - start) / duration, 1)
const eased = 1 - Math.pow(1 - t, 3) // ease-out cubic
setDisplay(Math.round(from + (target - from) * eased))
if (t < 1) raf = requestAnimationFrame(tick)
}
raf = requestAnimationFrame(tick)
return () => cancelAnimationFrame(raf)
}, [target, duration])
return display
}
// ─── Data transformation ────────────────────────────────────────────
function buildColumns(
transitions: PathTransition[],
depth: number,
): Column[] {
const numCols = depth + 1
const columns: Column[] = []
for (let col = 0; col < numCols; col++) {
const pageMap = new Map<string, number>()
if (col === 0) {
for (const t of transitions) {
if (t.step_index === 0) {
pageMap.set(t.from_path, (pageMap.get(t.from_path) ?? 0) + t.session_count)
}
}
} else {
for (const t of transitions) {
if (t.step_index === col - 1) {
pageMap.set(t.to_path, (pageMap.get(t.to_path) ?? 0) + t.session_count)
}
}
}
let pages = Array.from(pageMap.entries())
.map(([path, sessionCount]) => ({ path, sessionCount }))
.sort((a, b) => b.sessionCount - a.sessionCount)
if (pages.length > MAX_NODES_PER_COLUMN) {
const kept = pages.slice(0, MAX_NODES_PER_COLUMN)
const otherCount = pages
.slice(MAX_NODES_PER_COLUMN)
.reduce((sum, p) => sum + p.sessionCount, 0)
kept.push({ path: '(other)', sessionCount: otherCount })
pages = kept
}
const totalSessions = pages.reduce((sum, p) => sum + p.sessionCount, 0)
const prevTotal = col > 0 ? columns[col - 1].totalSessions : totalSessions
const dropOffPercent =
col === 0 || prevTotal === 0
? 0
: Math.round(((totalSessions - prevTotal) / prevTotal) * 100)
columns.push({ index: col, totalSessions, dropOffPercent, pages })
}
// Trim empty trailing columns
while (columns.length > 1 && columns[columns.length - 1].pages.length === 0) {
columns.pop()
}
return columns
}
// ─── Sub-components ─────────────────────────────────────────────────
function AnimatedDropOff({ percent }: { percent: number }) {
const displayed = useAnimatedCount(percent)
if (displayed === 0 && percent === 0) return null
return (
<span
className={`text-xs font-medium ${
percent < 0 ? 'text-red-500' : 'text-emerald-500'
}`}
>
{percent > 0 ? '+' : displayed < 0 ? '' : ''}
{displayed}%
</span>
)
}
function ColumnHeader({
column,
}: {
column: Column
}) {
return (
<div className="flex flex-col items-center gap-0.5 mb-4">
<span className="text-xs font-medium text-neutral-400 dark:text-neutral-500 uppercase tracking-wider">
{column.index === 0 ? 'Entry' : `Step ${column.index}`}
</span>
<div className="flex items-baseline gap-1.5">
<span className="text-sm font-semibold text-neutral-900 dark:text-white tabular-nums">
{column.totalSessions.toLocaleString()} visitors
</span>
{column.dropOffPercent !== 0 && (
<AnimatedDropOff percent={column.dropOffPercent} />
)}
</div>
</div>
)
}
function PageRow({
page,
colIndex,
rowIndex,
columnTotal,
maxCount,
isSelected,
isOther,
isMounted,
onClick,
}: {
page: ColumnPage
colIndex: number
rowIndex: number
columnTotal: number
maxCount: number
isSelected: boolean
isOther: boolean
isMounted: boolean
onClick: () => void
}) {
const pct = columnTotal > 0 ? Math.round((page.sessionCount / columnTotal) * 100) : 0
const barWidth = maxCount > 0 ? (page.sessionCount / maxCount) * 100 : 0
return (
<button
type="button"
disabled={isOther}
onClick={onClick}
title={page.path}
data-col={colIndex}
data-path={page.path}
className={`
group flex items-center justify-between w-full relative
h-9 px-3 rounded-lg text-left transition-all duration-200
${isOther ? 'cursor-default' : 'cursor-pointer'}
${isSelected
? 'bg-brand-orange/10 dark:bg-brand-orange/10'
: isOther
? ''
: 'hover:bg-neutral-50 dark:hover:bg-neutral-800/50 hover:-translate-y-px hover:shadow-sm'
}
`}
>
{/* Background bar — animates width on mount */}
{!isOther && barWidth > 0 && (
<div
className="absolute top-0.5 bottom-0.5 left-0.5 rounded-md transition-all duration-500 ease-out"
style={{
width: isMounted ? `calc(${barWidth}% - 4px)` : '0%',
transitionDelay: `${rowIndex * 30}ms`,
backgroundColor: isSelected ? 'rgba(253, 94, 15, 0.15)' : 'rgba(253, 94, 15, 0.08)',
}}
/>
)}
<span
className={`relative flex-1 truncate text-sm ${
isSelected
? 'text-neutral-900 dark:text-white font-medium'
: isOther
? 'italic text-neutral-400 dark:text-neutral-500'
: 'text-neutral-900 dark:text-white'
}`}
>
{isOther ? page.path : smartLabel(page.path)}
</span>
<div className="relative flex items-center gap-2 ml-2 shrink-0">
{!isOther && (
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
{pct}%
</span>
)}
<span
className={`text-sm tabular-nums font-semibold ${
isOther
? 'text-neutral-400 dark:text-neutral-500'
: 'text-neutral-600 dark:text-neutral-400'
}`}
>
{page.sessionCount.toLocaleString()}
</span>
</div>
</button>
)
}
function JourneyColumn({
column,
selectedPath,
exitCount,
onSelect,
}: {
column: Column
selectedPath: string | undefined
exitCount: number
onSelect: (path: string) => void
}) {
// Animation #2 & #3: trigger bar grow after mount
const [isMounted, setIsMounted] = useState(false)
useEffect(() => {
const raf = requestAnimationFrame(() => setIsMounted(true))
return () => {
cancelAnimationFrame(raf)
setIsMounted(false)
}
}, [column.pages])
if (column.pages.length === 0 && exitCount === 0) {
return (
<div
className="w-56 shrink-0"
style={{
animation: `col-enter 300ms ease-out ${column.index * 50}ms backwards`,
}}
>
<ColumnHeader column={column} />
<div className="flex items-center justify-center h-16 px-2">
<span className="text-xs text-neutral-400 dark:text-neutral-500">
No onward traffic
</span>
</div>
</div>
)
}
const maxCount = Math.max(...column.pages.map((p) => p.sessionCount), 0)
return (
<div
className="w-56 shrink-0 px-3"
style={{
animation: `col-enter 300ms ease-out ${column.index * 50}ms backwards`,
}}
>
<ColumnHeader column={column} />
<div className="space-y-0.5 max-h-[500px] overflow-y-auto">
{column.pages.map((page, rowIndex) => {
const isOther = page.path === '(other)'
return (
<PageRow
key={page.path}
page={page}
colIndex={column.index}
rowIndex={rowIndex}
columnTotal={column.totalSessions}
maxCount={maxCount}
isSelected={selectedPath === page.path}
isOther={isOther}
isMounted={isMounted}
onClick={() => {
if (!isOther) onSelect(page.path)
}}
/>
)
})}
{/* Animation #5: exit card slides in */}
{exitCount > 0 && (
<div
data-col={column.index}
data-path="(exit)"
className="flex items-center justify-between w-full relative h-9 px-3 rounded-lg bg-red-500/15 dark:bg-red-500/15"
style={{ animation: 'exit-reveal 300ms ease-out backwards' }}
>
<div
className="absolute top-0.5 bottom-0.5 left-0.5 rounded-md"
style={{
width: `calc(100% - 4px)`,
backgroundColor: 'rgba(239, 68, 68, 0.15)',
}}
/>
<span className="relative text-sm text-red-500 dark:text-red-400 font-medium">
(exit)
</span>
<span className="relative text-sm tabular-nums font-semibold text-red-500 dark:text-red-400">
{exitCount.toLocaleString()}
</span>
</div>
)}
</div>
</div>
)
}
// ─── Connection Lines ───────────────────────────────────────────────
function ConnectionLines({
containerRef,
selections,
columns,
transitions,
}: {
containerRef: React.RefObject<HTMLDivElement | null>
selections: Map<number, string>
columns: Column[]
transitions: PathTransition[]
}) {
const [lines, setLines] = useState<(LineDef & { color: string; length: number })[]>([])
const [dimensions, setDimensions] = useState({ width: 0, height: 0 })
useLayoutEffect(() => {
const container = containerRef.current
if (!container || selections.size === 0) {
setLines([])
return
}
const containerRect = container.getBoundingClientRect()
setDimensions({
width: container.scrollWidth,
height: container.scrollHeight,
})
const newLines: (LineDef & { color: string; length: number })[] = []
for (const [colIdx, selectedPath] of selections) {
const nextCol = columns[colIdx + 1]
if (!nextCol) continue
const sourceEl = container.querySelector(
`[data-col="${colIdx}"][data-path="${CSS.escape(selectedPath)}"]`
) as HTMLElement | null
if (!sourceEl) continue
const sourceRect = sourceEl.getBoundingClientRect()
const sourceY =
sourceRect.top + sourceRect.height / 2 - containerRect.top + container.scrollTop
const sourceX = sourceRect.right - containerRect.left + container.scrollLeft + 4
const relevantTransitions = transitions.filter(
(t) => t.step_index === colIdx && t.from_path === selectedPath
)
const color = colorForColumn(colIdx)
const maxCount = relevantTransitions.length > 0
? Math.max(...relevantTransitions.map((rt) => rt.session_count))
: 1
for (const t of relevantTransitions) {
const destEl = container.querySelector(
`[data-col="${colIdx + 1}"][data-path="${CSS.escape(t.to_path)}"]`
) as HTMLElement | null
if (!destEl) continue
const destRect = destEl.getBoundingClientRect()
const destY =
destRect.top + destRect.height / 2 - containerRect.top + container.scrollTop
const destX = destRect.left - containerRect.left + container.scrollLeft - 4
const weight = Math.max(1, Math.min(4, (t.session_count / maxCount) * 4))
// Approximate bezier curve length for animation
const dx = destX - sourceX
const dy = destY - sourceY
const length = Math.sqrt(dx * dx + dy * dy) * 1.2
newLines.push({ sourceY, destY, sourceX, destX, weight, color, length })
}
// Draw line to exit card if it exists
const exitEl = container.querySelector(
`[data-col="${colIdx + 1}"][data-path="(exit)"]`
) as HTMLElement | null
if (exitEl) {
const exitRect = exitEl.getBoundingClientRect()
const exitY =
exitRect.top + exitRect.height / 2 - containerRect.top + container.scrollTop
const exitX = exitRect.left - containerRect.left + container.scrollLeft
const dx = exitX - sourceX
const dy = exitY - sourceY
const length = Math.sqrt(dx * dx + dy * dy) * 1.2
newLines.push({ sourceY, destY: exitY, sourceX, destX: exitX, weight: 1, color: '#ef4444', length })
}
}
setLines(newLines)
}, [selections, columns, transitions, containerRef])
if (lines.length === 0) return null
return (
<svg
className="absolute top-0 left-0 pointer-events-none"
width={dimensions.width}
height={dimensions.height}
style={{ overflow: 'visible' }}
>
{lines.map((line, i) => {
const midX = (line.sourceX + line.destX) / 2
return (
<path
key={i}
d={`M ${line.sourceX},${line.sourceY} C ${midX},${line.sourceY} ${midX},${line.destY} ${line.destX},${line.destY}`}
stroke={line.color}
strokeWidth={line.weight}
strokeOpacity={0.35}
fill="none"
strokeDasharray={line.length}
strokeDashoffset={line.length}
style={{
animation: `draw-line 400ms ease-out ${i * 50}ms forwards`,
}}
/>
)
})}
<style>
{`@keyframes draw-line {
to { stroke-dashoffset: 0; }
}`}
</style>
</svg>
)
}
// ─── Exit count helper ──────────────────────────────────────────────
function getExitCount(
colIdx: number,
selectedPath: string,
columns: Column[],
transitions: PathTransition[],
): number {
const col = columns[colIdx]
const page = col?.pages.find((p) => p.path === selectedPath)
if (!page) return 0
const outbound = transitions
.filter((t) => t.step_index === colIdx && t.from_path === selectedPath)
.reduce((sum, t) => sum + t.session_count, 0)
return Math.max(0, page.sessionCount - outbound)
}
// ─── Main Component ─────────────────────────────────────────────────
export default function ColumnJourney({
transitions,
totalSessions,
depth,
}: ColumnJourneyProps) {
const [selections, setSelections] = useState<Map<number, string>>(new Map())
const containerRef = useRef<HTMLDivElement>(null)
// Clear selections when data changes
const transitionsKey = useMemo(
() => transitions.length + '-' + depth,
[transitions.length, depth]
)
const prevKeyRef = useRef(transitionsKey)
if (prevKeyRef.current !== transitionsKey) {
prevKeyRef.current = transitionsKey
if (selections.size > 0) setSelections(new Map())
}
const columns = useMemo(
() => buildColumns(transitions, depth),
[transitions, depth]
)
const handleSelect = useCallback(
(colIndex: number, path: string) => {
setSelections((prev) => {
const next = new Map(prev)
if (next.get(colIndex) === path) {
next.delete(colIndex)
} else {
next.set(colIndex, path)
}
for (const key of Array.from(next.keys())) {
if (key > colIndex) next.delete(key)
}
return next
})
},
[]
)
// ─── Empty state ────────────────────────────────────────────────
if (!transitions.length) {
return (
<div className="h-[400px] flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
<TreeStructure className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
</div>
<h4 className="font-semibold text-neutral-900 dark:text-white">
No journey data yet
</h4>
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs">
Navigation flows will appear here as visitors browse through your site.
</p>
</div>
)
}
return (
<div className="relative">
<style>
{`@keyframes col-enter {
from { opacity: 0; transform: translateX(-8px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes exit-reveal {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}`}
</style>
<div
ref={containerRef}
className="overflow-x-auto -mx-6 px-6 pb-2 relative"
>
<div className="flex min-w-fit py-2">
{columns.map((col, i) => {
const prevSelection = selections.get(col.index - 1)
const exitCount = prevSelection
? getExitCount(col.index - 1, prevSelection, columns, transitions)
: 0
return (
<Fragment key={col.index}>
{i > 0 && (
<div className="w-px shrink-0 mx-3 bg-neutral-100 dark:bg-neutral-800" />
)}
<JourneyColumn
column={col}
selectedPath={selections.get(col.index)}
exitCount={exitCount}
onSelect={(path) => handleSelect(col.index, path)}
/>
</Fragment>
)
})}
</div>
<ConnectionLines
containerRef={containerRef}
selections={selections}
columns={columns}
transitions={transitions}
/>
</div>
</div>
)
}

View File

@@ -1,457 +0,0 @@
'use client'
import { useCallback, useMemo, useRef, useState } from 'react'
import { useTheme } from '@ciphera-net/ui'
import { TreeStructure } from '@phosphor-icons/react'
import { sankey, sankeyJustify } from 'd3-sankey'
import type {
SankeyNode as D3SankeyNode,
SankeyLink as D3SankeyLink,
SankeyExtraProperties,
} from 'd3-sankey'
import type { PathTransition } from '@/lib/api/journeys'
// ─── Types ──────────────────────────────────────────────────────────
interface SankeyDiagramProps {
transitions: PathTransition[]
totalSessions: number
depth: number
onNodeClick?: (path: string) => void
}
interface NodeExtra extends SankeyExtraProperties {
id: string
label: string
color: string
}
interface LinkExtra extends SankeyExtraProperties {
value: number
}
type LayoutNode = D3SankeyNode<NodeExtra, LinkExtra>
type LayoutLink = D3SankeyLink<NodeExtra, LinkExtra>
// ─── Constants ──────────────────────────────────────────────────────
const COLUMN_COLORS = [
'#FD5E0F', // brand orange (entry)
'#3B82F6', // blue
'#10B981', // emerald
'#F59E0B', // amber
'#8B5CF6', // violet
'#EC4899', // pink
'#06B6D4', // cyan
'#EF4444', // red
'#84CC16', // lime
'#F97316', // orange again
'#6366F1', // indigo
]
const EXIT_GREY = '#52525b'
const SVG_W = 1100
const MARGIN = { top: 24, right: 140, bottom: 24, left: 10 }
const MAX_NODES_PER_COLUMN = 5
function colorForColumn(col: number): string {
return COLUMN_COLORS[col % COLUMN_COLORS.length]
}
// ─── Smart label: show last meaningful path segment ─────────────────
function smartLabel(path: string): string {
if (path === '/' || path === '(exit)') return path
// Remove trailing slash, split, take last 2 segments
const segments = path.replace(/\/$/, '').split('/')
if (segments.length <= 2) return path
// Show /last-segment for short paths, or …/last-segment for deep ones
const last = segments[segments.length - 1]
return `…/${last}`
}
function truncateLabel(s: string, max: number) {
return s.length > max ? s.slice(0, max - 1) + '\u2026' : s
}
function estimateTextWidth(s: string) {
return s.length * 7
}
// ─── Data transformation ────────────────────────────────────────────
function buildSankeyData(transitions: PathTransition[], depth: number) {
const numCols = depth + 1
const nodeMap = new Map<string, NodeExtra>()
const links: Array<{ source: string; target: string; value: number }> = []
const flowOut = new Map<string, number>()
const flowIn = new Map<string, number>()
for (const t of transitions) {
if (t.step_index >= numCols || t.step_index + 1 >= numCols) continue
const fromId = `${t.step_index}:${t.from_path}`
const toId = `${t.step_index + 1}:${t.to_path}`
if (!nodeMap.has(fromId)) {
nodeMap.set(fromId, { id: fromId, label: t.from_path, color: colorForColumn(t.step_index) })
}
if (!nodeMap.has(toId)) {
nodeMap.set(toId, { id: toId, label: t.to_path, color: colorForColumn(t.step_index + 1) })
}
links.push({ source: fromId, target: toId, value: t.session_count })
flowOut.set(fromId, (flowOut.get(fromId) ?? 0) + t.session_count)
flowIn.set(toId, (flowIn.get(toId) ?? 0) + t.session_count)
}
// ─── Cap nodes per column: keep top N by flow, merge rest into (other) ──
const columns = new Map<number, string[]>()
for (const [nodeId] of nodeMap) {
if (nodeId === 'exit') continue
const col = parseInt(nodeId.split(':')[0], 10)
if (!columns.has(col)) columns.set(col, [])
columns.get(col)!.push(nodeId)
}
for (const [col, nodeIds] of columns) {
if (nodeIds.length <= MAX_NODES_PER_COLUMN) continue
// Sort by total flow (max of in/out) descending
nodeIds.sort((a, b) => {
const flowA = Math.max(flowIn.get(a) ?? 0, flowOut.get(a) ?? 0)
const flowB = Math.max(flowIn.get(b) ?? 0, flowOut.get(b) ?? 0)
return flowB - flowA
})
const keep = new Set(nodeIds.slice(0, MAX_NODES_PER_COLUMN))
const otherId = `${col}:(other)`
nodeMap.set(otherId, { id: otherId, label: '(other)', color: colorForColumn(col) })
// Redirect links from/to pruned nodes to (other)
for (let i = 0; i < links.length; i++) {
const l = links[i]
if (!keep.has(l.source) && nodeIds.includes(l.source)) {
links[i] = { ...l, source: otherId }
}
if (!keep.has(l.target) && nodeIds.includes(l.target)) {
links[i] = { ...l, target: otherId }
}
}
// Remove pruned nodes
for (const id of nodeIds) {
if (!keep.has(id)) nodeMap.delete(id)
}
}
// Deduplicate links after merging (same source→target pairs)
const linkMap = new Map<string, { source: string; target: string; value: number }>()
for (const l of links) {
const key = `${l.source}->${l.target}`
const existing = linkMap.get(key)
if (existing) {
existing.value += l.value
} else {
linkMap.set(key, { ...l })
}
}
// Recalculate flowOut/flowIn after merge
flowOut.clear()
flowIn.clear()
for (const l of linkMap.values()) {
flowOut.set(l.source, (flowOut.get(l.source) ?? 0) + l.value)
flowIn.set(l.target, (flowIn.get(l.target) ?? 0) + l.value)
}
// Add exit nodes for flows that don't continue
for (const [nodeId] of nodeMap) {
if (nodeId === 'exit') continue
const col = parseInt(nodeId.split(':')[0], 10)
if (col >= numCols - 1) continue
const totalIn = flowIn.get(nodeId) ?? 0
const totalOut = flowOut.get(nodeId) ?? 0
const flow = Math.max(totalIn, totalOut)
const exitCount = flow - totalOut
if (exitCount > 0) {
const exitId = 'exit'
if (!nodeMap.has(exitId)) {
nodeMap.set(exitId, { id: exitId, label: '(exit)', color: EXIT_GREY })
}
const key = `${nodeId}->exit`
const existing = linkMap.get(key)
if (existing) {
existing.value += exitCount
} else {
linkMap.set(key, { source: nodeId, target: exitId, value: exitCount })
}
}
}
return {
nodes: Array.from(nodeMap.values()),
links: Array.from(linkMap.values()),
}
}
// ─── SVG path for a link ribbon ─────────────────────────────────────
function ribbonPath(link: LayoutLink): string {
const src = link.source as LayoutNode
const tgt = link.target as LayoutNode
const sx = src.x1!
const tx = tgt.x0!
const w = link.width!
// d3-sankey y0/y1 are the CENTER of the link band, not the top
const sy = link.y0! - w / 2
const ty = link.y1! - w / 2
const mx = (sx + tx) / 2
return [
`M${sx},${sy}`,
`C${mx},${sy} ${mx},${ty} ${tx},${ty}`,
`L${tx},${ty + w}`,
`C${mx},${ty + w} ${mx},${sy + w} ${sx},${sy + w}`,
'Z',
].join(' ')
}
// ─── Component ──────────────────────────────────────────────────────
export default function SankeyDiagram({
transitions,
totalSessions,
depth,
onNodeClick,
}: SankeyDiagramProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
const [hovered, setHovered] = useState<{ type: 'link' | 'node'; id: string } | null>(null)
const svgRef = useRef<SVGSVGElement>(null)
const data = useMemo(
() => buildSankeyData(transitions, depth),
[transitions, depth],
)
// Dynamic SVG height based on max nodes in any column
const svgH = useMemo(() => {
const columns = new Map<number, number>()
for (const node of data.nodes) {
if (node.id === 'exit') continue
const col = parseInt(node.id.split(':')[0], 10)
columns.set(col, (columns.get(col) ?? 0) + 1)
}
const maxNodes = Math.max(1, ...columns.values())
// Base 400 + 50px per node beyond 4
return Math.max(400, Math.min(800, 400 + Math.max(0, maxNodes - 4) * 50))
}, [data])
const layout = useMemo(() => {
if (!data.links.length) return null
const generator = sankey<NodeExtra, LinkExtra>()
.nodeId((d) => d.id)
.nodeWidth(18)
.nodePadding(16)
.nodeAlign(sankeyJustify)
.extent([
[MARGIN.left, MARGIN.top],
[SVG_W - MARGIN.right, svgH - MARGIN.bottom],
])
return generator({
nodes: data.nodes.map((d) => ({ ...d })),
links: data.links.map((d) => ({ ...d })),
})
}, [data, svgH])
// Single event handler on SVG — reads data-* attrs from e.target
const handleMouseOver = useCallback((e: React.MouseEvent<SVGSVGElement>) => {
const target = e.target as SVGElement
const el = target.closest('[data-node-id], [data-link-id]') as SVGElement | null
if (!el) return
const nodeId = el.getAttribute('data-node-id')
const linkId = el.getAttribute('data-link-id')
if (nodeId) {
setHovered((prev) => (prev?.type === 'node' && prev.id === nodeId) ? prev : { type: 'node', id: nodeId })
} else if (linkId) {
setHovered((prev) => (prev?.type === 'link' && prev.id === linkId) ? prev : { type: 'link', id: linkId })
}
}, [])
const handleMouseLeave = useCallback(() => {
setHovered(null)
}, [])
// ─── Empty state ────────────────────────────────────────────────
if (!transitions.length || !layout) {
return (
<div className="h-[400px] flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
<TreeStructure className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
</div>
<h4 className="font-semibold text-neutral-900 dark:text-white">
No journey data yet
</h4>
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs">
Navigation flows will appear here as visitors browse through your site.
</p>
</div>
)
}
// ─── Colors ─────────────────────────────────────────────────────
const labelColor = isDark ? '#e5e5e5' : '#404040'
const labelBg = isDark ? 'rgba(23, 23, 23, 0.9)' : 'rgba(255, 255, 255, 0.9)'
const nodeStroke = isDark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.1)'
return (
<svg
ref={svgRef}
viewBox={`0 0 ${SVG_W} ${svgH}`}
preserveAspectRatio="xMidYMid meet"
className="w-full"
role="img"
aria-label="User journey Sankey diagram"
onMouseMove={handleMouseOver}
onMouseLeave={handleMouseLeave}
>
{/* Links */}
<g>
{layout.links.map((link, i) => {
const src = link.source as LayoutNode
const tgt = link.target as LayoutNode
const srcId = String(src.id)
const tgtId = String(tgt.id)
const linkId = `${srcId}->${tgtId}`
let isHighlighted = false
if (hovered?.type === 'link') {
isHighlighted = hovered.id === linkId
} else if (hovered?.type === 'node') {
isHighlighted = srcId === hovered.id || tgtId === hovered.id
}
let opacity = isDark ? 0.45 : 0.5
if (hovered) {
opacity = isHighlighted ? 0.75 : 0.08
}
return (
<path
key={i}
d={ribbonPath(link)}
fill={src.color}
opacity={opacity}
style={{ transition: 'opacity 0.15s ease' }}
data-link-id={linkId}
>
<title>
{src.label} {tgt.label}:{' '}
{(link.value as number).toLocaleString()} sessions
</title>
</path>
)
})}
</g>
{/* Nodes */}
<g>
{layout.nodes.map((node) => {
const nodeId = String(node.id)
const isExit = nodeId === 'exit'
const w = isExit ? 8 : (node.x1 ?? 0) - (node.x0 ?? 0)
const h = (node.y1 ?? 0) - (node.y0 ?? 0)
const x = isExit ? (node.x0 ?? 0) + 5 : (node.x0 ?? 0)
return (
<rect
key={nodeId}
x={x}
y={node.y0}
width={w}
height={h}
fill={node.color}
stroke={nodeStroke}
strokeWidth={1}
rx={2}
className={
onNodeClick && !isExit ? 'cursor-pointer' : 'cursor-default'
}
data-node-id={nodeId}
onClick={() => {
if (onNodeClick && !isExit) onNodeClick(node.label)
}}
>
<title>
{node.label} {(node.value ?? 0).toLocaleString()} sessions
</title>
</rect>
)
})}
</g>
{/* Labels — only for nodes tall enough to avoid overlap */}
<g>
{layout.nodes.map((node) => {
const x0 = node.x0 ?? 0
const x1 = node.x1 ?? 0
const y0 = node.y0 ?? 0
const y1 = node.y1 ?? 0
const nodeH = y1 - y0
if (nodeH < 36) return null // hide labels for small nodes — hover for details
const rawLabel = smartLabel(node.label)
const label = truncateLabel(rawLabel, 24)
const textW = estimateTextWidth(label)
const padX = 6
const rectW = textW + padX * 2
const rectH = 20
const isRight = x1 > SVG_W - MARGIN.right - 60
const textX = isRight ? x0 - 6 : x1 + 6
const textY = y0 + nodeH / 2
const anchor = isRight ? 'end' : 'start'
const bgX = isRight ? textX - textW - padX : textX - padX
const bgY = textY - rectH / 2
const nodeId = String(node.id)
const isExit = nodeId === 'exit'
return (
<g key={`label-${nodeId}`} data-node-id={nodeId}>
<rect
x={bgX}
y={bgY}
width={rectW}
height={rectH}
rx={3}
fill={labelBg}
/>
<text
x={textX}
y={textY}
dy="0.35em"
textAnchor={anchor}
fill={labelColor}
fontSize={12}
fontFamily="system-ui, -apple-system, sans-serif"
className={
onNodeClick && !isExit ? 'cursor-pointer' : 'cursor-default'
}
onClick={() => {
if (onNodeClick && !isExit) onNodeClick(node.label)
}}
>
{label}
</text>
</g>
)
})}
</g>
</svg>
)
}

View File

@@ -0,0 +1,551 @@
'use client'
import * as d3 from 'd3'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { TreeStructure, X } from '@phosphor-icons/react'
import type { PathTransition } from '@/lib/api/journeys'
// ─── Types ──────────────────────────────────────────────────────────
interface SankeyJourneyProps {
transitions: PathTransition[]
totalSessions: number
depth: number
}
interface SNode {
id: string
name: string
step: number
height: number
x: number
y: number
count: number
inLinks: SLink[]
outLinks: SLink[]
}
interface SLink {
source: string
target: string
value: number
sourceY?: number
targetY?: number
}
// ─── Constants ──────────────────────────────────────────────────────
const NODE_WIDTH = 30
const NODE_GAP = 20
const MIN_NODE_HEIGHT = 2
const MAX_LINK_HEIGHT = 100
const LINK_OPACITY = 0.3
const LINK_HOVER_OPACITY = 0.6
const MAX_NODES_PER_STEP = 25
const COLOR_PALETTE = [
'hsl(160, 45%, 40%)', 'hsl(220, 45%, 50%)', 'hsl(270, 40%, 50%)',
'hsl(25, 50%, 50%)', 'hsl(340, 40%, 50%)', 'hsl(190, 45%, 45%)',
'hsl(45, 45%, 50%)', 'hsl(0, 45%, 50%)',
]
// ─── Helpers ────────────────────────────────────────────────────────
function pathFromId(id: string): string {
const idx = id.indexOf(':')
return idx >= 0 ? id.slice(idx + 1) : id
}
function stepFromId(id: string): number {
const idx = id.indexOf(':')
return idx >= 0 ? parseInt(id.slice(0, idx), 10) : 0
}
function firstSegment(path: string): string {
const parts = path.split('/').filter(Boolean)
return parts.length > 0 ? `/${parts[0]}` : path
}
function smartLabel(path: string): string {
if (path === '/' || path === '(other)') return path
const segments = path.replace(/\/$/, '').split('/')
if (segments.length <= 2) return path
return `.../${segments[segments.length - 1]}`
}
// ─── Data Transformation ────────────────────────────────────────────
function buildData(
transitions: PathTransition[],
filterPath?: string,
): { nodes: SNode[]; links: SLink[] } {
if (transitions.length === 0) return { nodes: [], links: [] }
// Group transitions by step, count per path per step
const stepPaths = new Map<number, Map<string, number>>()
for (const t of transitions) {
if (!stepPaths.has(t.step_index)) stepPaths.set(t.step_index, new Map())
const fromMap = stepPaths.get(t.step_index)!
fromMap.set(t.from_path, (fromMap.get(t.from_path) ?? 0) + t.session_count)
const nextStep = t.step_index + 1
if (!stepPaths.has(nextStep)) stepPaths.set(nextStep, new Map())
const toMap = stepPaths.get(nextStep)!
toMap.set(t.to_path, (toMap.get(t.to_path) ?? 0) + t.session_count)
}
// Keep top N per step, rest → (other)
const topPaths = new Map<number, Set<string>>()
for (const [step, pm] of stepPaths) {
const sorted = Array.from(pm.entries()).sort((a, b) => b[1] - a[1])
topPaths.set(step, new Set(sorted.slice(0, MAX_NODES_PER_STEP).map(([p]) => p)))
}
// Build links
const linkMap = new Map<string, number>()
for (const t of transitions) {
const fromTop = topPaths.get(t.step_index)!
const toTop = topPaths.get(t.step_index + 1)!
const fp = fromTop.has(t.from_path) ? t.from_path : '(other)'
const tp = toTop.has(t.to_path) ? t.to_path : '(other)'
if (fp === '(other)' && tp === '(other)') continue
const src = `${t.step_index}:${fp}`
const tgt = `${t.step_index + 1}:${tp}`
const key = `${src}|${tgt}`
linkMap.set(key, (linkMap.get(key) ?? 0) + t.session_count)
}
let links: SLink[] = Array.from(linkMap.entries()).map(([k, v]) => {
const [source, target] = k.split('|')
return { source, target, value: v }
})
// Collect node IDs
const nodeIdSet = new Set<string>()
for (const l of links) { nodeIdSet.add(l.source); nodeIdSet.add(l.target) }
let nodes: SNode[] = Array.from(nodeIdSet).map((id) => ({
id,
name: pathFromId(id),
step: stepFromId(id),
height: 0, x: 0, y: 0, count: 0,
inLinks: [], outLinks: [],
}))
// Filter by path (BFS forward + backward)
if (filterPath) {
const matchIds = nodes.filter((n) => n.name === filterPath).map((n) => n.id)
if (matchIds.length === 0) return { nodes: [], links: [] }
const fwd = new Map<string, Set<string>>()
const bwd = new Map<string, Set<string>>()
for (const l of links) {
if (!fwd.has(l.source)) fwd.set(l.source, new Set())
fwd.get(l.source)!.add(l.target)
if (!bwd.has(l.target)) bwd.set(l.target, new Set())
bwd.get(l.target)!.add(l.source)
}
const reachable = new Set<string>(matchIds)
let queue = [...matchIds]
while (queue.length > 0) {
const next: string[] = []
for (const id of queue) {
for (const nb of fwd.get(id) ?? []) {
if (!reachable.has(nb)) { reachable.add(nb); next.push(nb) }
}
}
queue = next
}
queue = [...matchIds]
while (queue.length > 0) {
const next: string[] = []
for (const id of queue) {
for (const nb of bwd.get(id) ?? []) {
if (!reachable.has(nb)) { reachable.add(nb); next.push(nb) }
}
}
queue = next
}
links = links.filter((l) => reachable.has(l.source) && reachable.has(l.target))
const kept = new Set<string>()
for (const l of links) { kept.add(l.source); kept.add(l.target) }
nodes = nodes.filter((n) => kept.has(n.id))
}
return { nodes, links }
}
// ─── Component ──────────────────────────────────────────────────────
export default function SankeyJourney({
transitions,
totalSessions,
depth,
}: SankeyJourneyProps) {
const [filterPath, setFilterPath] = useState<string | null>(null)
const [isDark, setIsDark] = useState(false)
const svgRef = useRef<SVGSVGElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const [containerWidth, setContainerWidth] = useState(900)
// Detect dark mode
useEffect(() => {
const el = document.documentElement
setIsDark(el.classList.contains('dark'))
const obs = new MutationObserver(() => setIsDark(el.classList.contains('dark')))
obs.observe(el, { attributes: true, attributeFilter: ['class'] })
return () => obs.disconnect()
}, [])
// Measure container
useEffect(() => {
const el = containerRef.current
if (!el) return
const measure = () => setContainerWidth(el.clientWidth)
measure()
const obs = new ResizeObserver(measure)
obs.observe(el)
return () => obs.disconnect()
}, [])
const data = useMemo(
() => buildData(transitions, filterPath ?? undefined),
[transitions, filterPath],
)
// Clear filter on data change
const transKey = transitions.length + '-' + depth
const [prevKey, setPrevKey] = useState(transKey)
if (prevKey !== transKey) {
setPrevKey(transKey)
if (filterPath !== null) setFilterPath(null)
}
const handleNodeClick = useCallback((path: string) => {
if (path === '(other)') return
setFilterPath((prev) => (prev === path ? null : path))
}, [])
// ─── D3 Rendering ──────────────────────────────────────────────
useEffect(() => {
if (!svgRef.current || data.nodes.length === 0) return
const svg = d3.select(svgRef.current)
svg.selectAll('*').remove()
const { nodes, links } = data
const linkColor = isDark ? 'rgba(163,163,163,0.5)' : 'rgba(82,82,82,0.5)'
const textColor = isDark ? '#e5e5e5' : '#171717'
// Wire up node ↔ link references
for (const n of nodes) { n.inLinks = []; n.outLinks = []; n.count = 0 }
const nodeMap = new Map(nodes.map((n) => [n.id, n]))
for (const l of links) {
const src = nodeMap.get(l.source)
const tgt = nodeMap.get(l.target)
if (src) src.outLinks.push(l)
if (tgt) tgt.inLinks.push(l)
}
for (const n of nodes) {
const inVal = n.inLinks.reduce((s, l) => s + l.value, 0)
const outVal = n.outLinks.reduce((s, l) => s + l.value, 0)
n.count = n.step === 0 ? outVal : Math.max(inVal, outVal)
}
// Calculate node heights (proportional to value)
const maxVal = d3.max(links, (l) => l.value) || 1
const heightScale = d3.scaleLinear().domain([0, maxVal]).range([0, MAX_LINK_HEIGHT])
for (const n of nodes) {
const inVal = n.inLinks.reduce((s, l) => s + l.value, 0)
const outVal = n.outLinks.reduce((s, l) => s + l.value, 0)
n.height = Math.max(heightScale(Math.max(inVal, outVal)), MIN_NODE_HEIGHT)
}
// Group by step, determine layout
const byStep = d3.group(nodes, (n) => n.step)
const numSteps = byStep.size
const width = containerWidth
const stepWidth = width / numSteps
// Calculate chart height from tallest column
const stepHeights = Array.from(byStep.values()).map(
(ns) => ns.reduce((s, n) => s + n.height, 0) + (ns.length - 1) * NODE_GAP,
)
const height = Math.max(200, Math.max(...stepHeights) + 20)
// Position nodes in columns, aligned from top
byStep.forEach((stepNodes, step) => {
let cy = 0
for (const n of stepNodes) {
n.x = step * stepWidth
n.y = cy + n.height / 2
cy += n.height + NODE_GAP
}
})
// Calculate link y-positions (stacked within each node)
for (const n of nodes) {
n.outLinks.sort((a, b) => b.value - a.value)
n.inLinks.sort((a, b) => b.value - a.value)
let outY = n.y - n.height / 2
for (const l of n.outLinks) {
const lh = heightScale(l.value)
l.sourceY = outY + lh / 2
outY += lh
}
let inY = n.y - n.height / 2
for (const l of n.inLinks) {
const lh = heightScale(l.value)
l.targetY = inY + lh / 2
inY += lh
}
}
// Color by first path segment
const segCounts = new Map<string, number>()
for (const n of nodes) {
const seg = firstSegment(n.name)
segCounts.set(seg, (segCounts.get(seg) ?? 0) + 1)
}
const segColors = new Map<string, string>()
let ci = 0
segCounts.forEach((count, seg) => {
if (count > 1) { segColors.set(seg, COLOR_PALETTE[ci % COLOR_PALETTE.length]); ci++ }
})
const defaultColor = isDark ? 'hsl(0, 0%, 50%)' : 'hsl(0, 0%, 45%)'
const nodeColor = (n: SNode) => segColors.get(firstSegment(n.name)) ?? defaultColor
const linkSourceColor = (l: SLink) => {
const src = nodeMap.get(l.source)
return src ? nodeColor(src) : linkColor
}
// Link path generator
const linkPath = (l: SLink) => {
const src = nodeMap.get(l.source)
const tgt = nodeMap.get(l.target)
if (!src || !tgt) return ''
const sy = l.sourceY ?? src.y
const ty = l.targetY ?? tgt.y
const sx = src.x + NODE_WIDTH
const tx = tgt.x
const gap = tx - sx
const c1x = sx + gap / 3
const c2x = tx - gap / 3
return `M ${sx},${sy} C ${c1x},${sy} ${c2x},${ty} ${tx},${ty}`
}
svg.attr('width', width).attr('height', height)
const g = svg.append('g')
// ── Draw links ────────────────────────────────────────
g.selectAll('.link')
.data(links)
.join('path')
.attr('class', 'link')
.attr('d', linkPath)
.attr('fill', 'none')
.attr('stroke', (d) => linkSourceColor(d))
.attr('stroke-width', (d) => heightScale(d.value))
.attr('opacity', LINK_OPACITY)
.attr('data-source', (d) => d.source)
.attr('data-target', (d) => d.target)
.style('pointer-events', 'none')
// ── Tooltip ───────────────────────────────────────────
const tooltip = d3.select('body').append('div')
.style('position', 'absolute')
.style('visibility', 'hidden')
.style('background', isDark ? '#262626' : '#f5f5f5')
.style('border', `1px solid ${isDark ? '#404040' : '#d4d4d4'}`)
.style('border-radius', '8px')
.style('padding', '8px 12px')
.style('font-size', '12px')
.style('color', isDark ? '#fff' : '#171717')
.style('pointer-events', 'none')
.style('z-index', '9999')
.style('box-shadow', '0 4px 12px rgba(0,0,0,0.15)')
// ── Draw nodes ────────────────────────────────────────
const nodeGs = g.selectAll('.node')
.data(nodes)
.join('g')
.attr('class', 'node')
.attr('transform', (d) => `translate(${d.x},${d.y - d.height / 2})`)
.style('cursor', 'pointer')
// Node bars
nodeGs.append('rect')
.attr('class', 'node-rect')
.attr('width', NODE_WIDTH)
.attr('height', (d) => d.height)
.attr('fill', (d) => nodeColor(d))
.attr('rx', 2)
.attr('ry', 2)
// Node labels
nodeGs.append('text')
.attr('class', 'node-text')
.attr('x', NODE_WIDTH + 6)
.attr('y', (d) => d.height / 2 + 4)
.text((d) => smartLabel(d.name))
.attr('font-size', '12px')
.attr('fill', textColor)
.attr('text-anchor', 'start')
// ── Hover: find all connected paths ───────────────────
const findConnected = (startLink: SLink, dir: 'fwd' | 'bwd') => {
const result: SLink[] = []
const visited = new Set<string>()
const queue = [startLink]
while (queue.length > 0) {
const cur = queue.shift()!
const lid = `${cur.source}|${cur.target}`
if (visited.has(lid)) continue
visited.add(lid)
result.push(cur)
if (dir === 'fwd') {
const tgt = nodeMap.get(cur.target)
if (tgt) tgt.outLinks.forEach((l) => queue.push(l))
} else {
const src = nodeMap.get(cur.source)
if (src) src.inLinks.forEach((l) => queue.push(l))
}
}
return result
}
const highlightPaths = (nodeId: string) => {
const connectedLinks: SLink[] = []
const connectedNodes = new Set<string>([nodeId])
const directLinks = links.filter((l) => l.source === nodeId || l.target === nodeId)
for (const dl of directLinks) {
connectedLinks.push(dl, ...findConnected(dl, 'fwd'), ...findConnected(dl, 'bwd'))
}
const connectedLinkIds = new Set(connectedLinks.map((l) => `${l.source}|${l.target}`))
connectedLinks.forEach((l) => { connectedNodes.add(l.source); connectedNodes.add(l.target) })
g.selectAll<SVGPathElement, SLink>('.link')
.attr('opacity', function () {
const s = d3.select(this).attr('data-source')
const t = d3.select(this).attr('data-target')
return connectedLinkIds.has(`${s}|${t}`) ? LINK_HOVER_OPACITY : 0.05
})
g.selectAll<SVGRectElement, SNode>('.node-rect')
.attr('opacity', (d) => connectedNodes.has(d.id) ? 1 : 0.15)
g.selectAll<SVGTextElement, SNode>('.node-text')
.attr('opacity', (d) => connectedNodes.has(d.id) ? 1 : 0.2)
}
const resetHighlight = () => {
g.selectAll('.link').attr('opacity', LINK_OPACITY)
.attr('stroke', (d: unknown) => linkSourceColor(d as SLink))
g.selectAll('.node-rect').attr('opacity', 1)
g.selectAll('.node-text').attr('opacity', 1)
tooltip.style('visibility', 'hidden')
}
// Node hover
nodeGs
.on('mouseenter', function (event, d) {
tooltip.style('visibility', 'visible')
.html(`<div style="font-weight:600;margin-bottom:2px">${d.name}</div><div style="opacity:0.7">${d.count.toLocaleString()} sessions</div>`)
.style('top', `${event.pageY - 10}px`).style('left', `${event.pageX + 12}px`)
highlightPaths(d.id)
})
.on('mousemove', (event) => {
tooltip.style('top', `${event.pageY - 10}px`).style('left', `${event.pageX + 12}px`)
})
.on('mouseleave', resetHighlight)
.on('click', (_, d) => handleNodeClick(d.name))
// Link hit areas (wider invisible paths for easier hovering)
g.selectAll('.link-hit')
.data(links)
.join('path')
.attr('class', 'link-hit')
.attr('d', linkPath)
.attr('fill', 'none')
.attr('stroke', 'transparent')
.attr('stroke-width', (d) => Math.max(heightScale(d.value), 14))
.attr('data-source', (d) => d.source)
.attr('data-target', (d) => d.target)
.style('cursor', 'pointer')
.on('mouseenter', function (event, d) {
const src = nodeMap.get(d.source)
const tgt = nodeMap.get(d.target)
tooltip.style('visibility', 'visible')
.html(`<div style="font-weight:600;margin-bottom:2px">${src?.name ?? '?'}${tgt?.name ?? '?'}</div><div style="opacity:0.7">${d.value.toLocaleString()} sessions</div>`)
.style('top', `${event.pageY - 10}px`).style('left', `${event.pageX + 12}px`)
// Highlight this link's connected paths
const all = [d, ...findConnected(d, 'fwd'), ...findConnected(d, 'bwd')]
const lids = new Set(all.map((l) => `${l.source}|${l.target}`))
const nids = new Set<string>()
all.forEach((l) => { nids.add(l.source); nids.add(l.target) })
g.selectAll<SVGPathElement, SLink>('.link')
.attr('opacity', function () {
const s = d3.select(this).attr('data-source')
const t = d3.select(this).attr('data-target')
return lids.has(`${s}|${t}`) ? LINK_HOVER_OPACITY : 0.05
})
g.selectAll<SVGRectElement, SNode>('.node-rect')
.attr('opacity', (nd) => nids.has(nd.id) ? 1 : 0.15)
g.selectAll<SVGTextElement, SNode>('.node-text')
.attr('opacity', (nd) => nids.has(nd.id) ? 1 : 0.2)
})
.on('mousemove', (event) => {
tooltip.style('top', `${event.pageY - 10}px`).style('left', `${event.pageX + 12}px`)
})
.on('mouseleave', resetHighlight)
return () => { tooltip.remove() }
}, [data, containerWidth, isDark, handleNodeClick])
// ─── Empty state ────────────────────────────────────────────────
if (!transitions.length || data.nodes.length === 0) {
return (
<div className="h-[400px] flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
<TreeStructure className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
</div>
<h4 className="font-semibold text-neutral-900 dark:text-white">
No journey data yet
</h4>
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs">
Navigation flows will appear here as visitors browse through your site.
</p>
</div>
)
}
return (
<div>
{filterPath && (
<div className="flex items-center gap-2 mb-3 px-3 py-2 rounded-lg bg-brand-orange/10 text-sm">
<span className="text-neutral-700 dark:text-neutral-300">
Showing flows through{' '}
<span className="font-medium text-neutral-900 dark:text-white">
{filterPath}
</span>
</span>
<button
type="button"
onClick={() => setFilterPath(null)}
className="ml-auto flex items-center gap-1 text-xs font-medium text-brand-orange hover:text-brand-orange/80 transition-colors"
>
<X weight="bold" className="w-3.5 h-3.5" />
Reset
</button>
</div>
)}
<div ref={containerRef} className="w-full overflow-hidden">
<svg ref={svgRef} className="w-full" />
</div>
</div>
)
}

View File

@@ -2,7 +2,7 @@
import type { TopPath } from '@/lib/api/journeys' import type { TopPath } from '@/lib/api/journeys'
import { TableSkeleton } from '@/components/skeletons' import { TableSkeleton } from '@/components/skeletons'
import { Path } from '@phosphor-icons/react' import { Path, ArrowRight, Clock } from '@phosphor-icons/react'
interface TopPathsTableProps { interface TopPathsTableProps {
paths: TopPath[] paths: TopPath[]
@@ -17,57 +17,98 @@ function formatDuration(seconds: number): string {
return `${m}m ${s}s` return `${m}m ${s}s`
} }
function smartLabel(path: string): string {
if (path === '/') return '/'
const segments = path.replace(/\/$/, '').split('/')
if (segments.length <= 2) return path
return `…/${segments[segments.length - 1]}`
}
function truncateSequence(seq: string[], max: number): (string | null)[] {
if (seq.length <= max) return seq
const head = seq.slice(0, 3)
const tail = seq.slice(-2)
return [...head, null, ...tail]
}
export default function TopPathsTable({ paths, loading }: TopPathsTableProps) { export default function TopPathsTable({ paths, loading }: TopPathsTableProps) {
const hasData = paths.length > 0 const hasData = paths.length > 0
const maxCount = hasData ? paths[0].session_count : 0
return ( return (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6"> <div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
<div className="flex items-center justify-between mb-1"> <div className="mb-1">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white"> <h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
Top Paths Top Paths
</h3> </h3>
</div> </div>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-4"> <p className="text-sm text-neutral-500 dark:text-neutral-400 mb-5">
Most common navigation paths across sessions Most common navigation paths across sessions
</p> </p>
{loading ? ( {loading ? (
<TableSkeleton rows={7} cols={4} /> <TableSkeleton rows={7} cols={4} />
) : hasData ? ( ) : hasData ? (
<div> <div className="space-y-0.5">
{/* Header */} {paths.map((path, i) => {
<div className="flex items-center px-2 -mx-2 mb-2 text-xs font-medium text-neutral-400 dark:text-neutral-500 uppercase tracking-wider"> const barWidth = maxCount > 0 ? (path.session_count / maxCount) * 75 : 0
<span className="w-8 text-right shrink-0">#</span> const displaySeq = truncateSequence(path.page_sequence, 7)
<span className="flex-1 ml-3">Path</span>
<span className="w-20 text-right shrink-0">Sessions</span>
<span className="w-16 text-right shrink-0">Dur.</span>
</div>
{/* Rows */} return (
<div className="space-y-0.5">
{paths.map((path, i) => (
<div <div
key={i} key={i}
className="flex items-center h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800/50 rounded-lg px-2 -mx-2 transition-colors" className="relative flex items-center h-10 group hover:bg-neutral-50 dark:hover:bg-neutral-800/50 rounded-lg px-3 -mx-3 transition-colors"
> >
<span className="w-8 text-right shrink-0 text-sm tabular-nums text-neutral-400"> {/* Background bar */}
{i + 1} <div
</span> className="absolute inset-y-0.5 left-0.5 bg-brand-orange/15 dark:bg-brand-orange/25 rounded-md transition-all"
<span style={{ width: `${barWidth}%` }}
className="flex-1 ml-3 text-sm text-neutral-900 dark:text-white truncate" />
title={path.page_sequence.join(' → ')}
> {/* Content */}
{path.page_sequence.join(' → ')} <div className="relative flex items-center justify-between w-full min-w-0">
</span> {/* Path sequence */}
<span className="w-20 text-right shrink-0 text-sm tabular-nums text-neutral-600 dark:text-neutral-400"> <div className="flex items-center min-w-0 gap-1.5 flex-1 overflow-hidden">
{path.session_count.toLocaleString()} {displaySeq.map((page, j) => (
</span> <div key={j} className="flex items-center gap-1.5 shrink-0">
<span className="w-16 text-right shrink-0 text-sm tabular-nums text-neutral-600 dark:text-neutral-400"> {j > 0 && (
{formatDuration(path.avg_duration)} <ArrowRight
</span> weight="bold"
className="w-2.5 h-2.5 text-neutral-300 dark:text-neutral-600 shrink-0"
/>
)}
{page === null ? (
<span className="text-xs text-neutral-400 dark:text-neutral-500">
</span>
) : (
<span
className="text-sm text-neutral-900 dark:text-white truncate"
title={page}
>
{smartLabel(page)}
</span>
)}
</div>
))}
</div>
{/* Stats */}
<div className="relative flex items-center gap-4 ml-4 shrink-0">
{path.avg_duration > 0 && (
<span className="hidden sm:flex items-center gap-1 text-xs text-neutral-400 dark:text-neutral-500 opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
<Clock weight="bold" className="w-3 h-3" />
{formatDuration(path.avg_duration)}
</span>
)}
<span className="text-sm tabular-nums font-semibold text-neutral-600 dark:text-neutral-400">
{path.session_count.toLocaleString()}
</span>
</div>
</div>
</div> </div>
))} )
</div> })}
</div> </div>
) : ( ) : (
<div className="flex flex-col items-center justify-center text-center px-6 py-8 gap-3"> <div className="flex flex-col items-center justify-center text-center px-6 py-8 gap-3">

View File

@@ -152,7 +152,7 @@ export default function NotificationCenter() {
id="notification-dropdown" id="notification-dropdown"
role="dialog" role="dialog"
aria-label="Notifications" aria-label="Notifications"
className="absolute right-0 top-full mt-2 w-80 sm:w-96 bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 rounded-xl shadow-xl overflow-hidden z-[100]" className="fixed left-4 right-4 top-16 sm:absolute sm:left-auto sm:right-0 sm:top-full sm:mt-2 sm:w-96 bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 rounded-xl shadow-xl overflow-hidden z-[100]"
> >
<div className="flex items-center justify-between px-4 py-3 border-b border-neutral-200 dark:border-neutral-700"> <div className="flex items-center justify-between px-4 py-3 border-b border-neutral-200 dark:border-neutral-700">
<h3 className="font-semibold text-neutral-900 dark:text-white">Notifications</h3> <h3 className="font-semibold text-neutral-900 dark:text-white">Notifications</h3>

View File

@@ -0,0 +1,189 @@
'use client'
import { useMemo } from 'react'
import { useTheme } from '@ciphera-net/ui'
import { Area, CartesianGrid, ComposedChart, Line, XAxis, YAxis } from 'recharts'
import { ChartContainer, ChartTooltip, type ChartConfig } from '@/components/ui/line-charts-6'
import { useGSCDailyTotals } from '@/lib/swr/dashboard'
import { SkeletonLine } from '@/components/skeletons'
import { formatDateShort } from '@/lib/utils/formatDate'
// ─── Config ─────────────────────────────────────────────────────
const chartConfig = {
clicks: { label: 'Clicks', color: '#FD5E0F' },
impressions: { label: 'Impressions', color: '#9CA3AF' },
} satisfies ChartConfig
// ─── Custom Tooltip ─────────────────────────────────────────────
interface TooltipProps {
active?: boolean
payload?: Array<{ dataKey: string; value: number; color: string }>
label?: string
}
function CustomTooltip({ active, payload, label }: TooltipProps) {
if (!active || !payload?.length) return null
const clicks = payload.find((p) => p.dataKey === 'clicks')
const impressions = payload.find((p) => p.dataKey === 'impressions')
return (
<div className="rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-800 p-3 shadow-sm shadow-black/5 min-w-[140px]">
<div className="text-xs text-neutral-500 dark:text-neutral-400 mb-1.5">{label}</div>
{clicks && (
<div className="flex items-center gap-2 text-sm">
<div className="size-1.5 rounded-full" style={{ backgroundColor: '#FD5E0F' }} />
<span className="text-neutral-500 dark:text-neutral-400">Clicks:</span>
<span className="font-semibold text-neutral-900 dark:text-white">{clicks.value.toLocaleString()}</span>
</div>
)}
{impressions && (
<div className="flex items-center gap-2 text-sm mt-1">
<div className="size-1.5 rounded-full" style={{ backgroundColor: '#9CA3AF' }} />
<span className="text-neutral-500 dark:text-neutral-400">Impressions:</span>
<span className="font-semibold text-neutral-900 dark:text-white">{impressions.value.toLocaleString()}</span>
</div>
)}
</div>
)
}
// ─── Component ──────────────────────────────────────────────────
interface ClicksImpressionsChartProps {
siteId: string
startDate: string
endDate: string
}
export default function ClicksImpressionsChart({ siteId, startDate, endDate }: ClicksImpressionsChartProps) {
const { resolvedTheme } = useTheme()
const { data, isLoading } = useGSCDailyTotals(siteId, startDate, endDate)
const chartData = useMemo(() => {
if (!data?.daily_totals?.length) return []
return data.daily_totals.map((item) => ({
date: formatDateShort(new Date(item.date + 'T00:00:00')),
clicks: item.clicks,
impressions: item.impressions,
}))
}, [data])
// Loading skeleton
if (isLoading) {
return (
<div className="rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4 mb-6">
<SkeletonLine className="h-4 w-36 mb-3" />
<SkeletonLine className="h-64 w-full rounded-lg" />
</div>
)
}
// No data — don't render anything
if (!chartData.length) return null
const gridStroke = resolvedTheme === 'dark' ? '#374151' : '#e5e7eb'
return (
<div className="rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4 mb-6">
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-3">
Clicks &amp; Impressions
</p>
<ChartContainer
config={chartConfig}
className="h-64 w-full [&_.recharts-curve.recharts-tooltip-cursor]:stroke-[initial]"
>
<ComposedChart
data={chartData}
margin={{ top: 8, right: 8, left: 0, bottom: 8 }}
>
<defs>
<linearGradient id="clicksFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#FD5E0F" stopOpacity={0.15} />
<stop offset="100%" stopColor="#FD5E0F" stopOpacity={0.01} />
</linearGradient>
</defs>
<CartesianGrid
horizontal={true}
vertical={false}
stroke={gridStroke}
strokeOpacity={0.7}
/>
<XAxis
dataKey="date"
axisLine={false}
tickLine={false}
tick={{ fontSize: 11, fill: 'var(--chart-axis)' }}
tickMargin={8}
minTickGap={32}
/>
<YAxis
yAxisId="left"
axisLine={false}
tickLine={false}
tick={{ fontSize: 11, fill: 'var(--chart-axis)' }}
tickMargin={8}
tickCount={5}
tickFormatter={(v: number) => v.toLocaleString()}
/>
<YAxis
yAxisId="right"
orientation="right"
axisLine={false}
tickLine={false}
tick={{ fontSize: 11, fill: 'var(--chart-axis)' }}
tickMargin={8}
tickCount={5}
tickFormatter={(v: number) => v.toLocaleString()}
/>
<ChartTooltip content={<CustomTooltip />} cursor={{ strokeDasharray: '3 3', stroke: '#9ca3af' }} />
<Area
yAxisId="left"
type="bump"
dataKey="clicks"
fill="url(#clicksFill)"
stroke="none"
/>
<Line
yAxisId="left"
type="bump"
dataKey="clicks"
stroke="#FD5E0F"
strokeWidth={2}
dot={false}
activeDot={{
r: 5,
fill: '#FD5E0F',
stroke: 'white',
strokeWidth: 2,
}}
/>
<Line
yAxisId="right"
type="bump"
dataKey="impressions"
stroke="#9CA3AF"
strokeWidth={2}
dot={false}
strokeDasharray="4 3"
activeDot={{
r: 5,
fill: '#9CA3AF',
stroke: 'white',
strokeWidth: 2,
}}
/>
</ComposedChart>
</ChartContainer>
</div>
)
}

View File

@@ -1098,7 +1098,7 @@ export default function OrganizationSettings() {
<a href={invoice.invoice_pdf} target="_blank" rel="noopener noreferrer" <a href={invoice.invoice_pdf} target="_blank" rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-2.5 py-1.5 text-xs font-medium text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange" title="Download PDF"> className="inline-flex items-center gap-2 px-2.5 py-1.5 text-xs font-medium text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange" title="Download PDF">
<DownloadIcon className="w-3.5 h-3.5" /> <DownloadIcon className="w-3.5 h-3.5" />
Download PDF <span className="hidden sm:inline">Download</span> PDF
</a> </a>
)} )}
{invoice.hosted_invoice_url && ( {invoice.hosted_invoice_url && (
@@ -1110,7 +1110,7 @@ export default function OrganizationSettings() {
}`} }`}
title={invoice.status === 'open' ? 'Pay now' : 'View invoice'}> title={invoice.status === 'open' ? 'Pay now' : 'View invoice'}>
<ExternalLinkIcon className="w-3.5 h-3.5" /> <ExternalLinkIcon className="w-3.5 h-3.5" />
{invoice.status === 'open' ? 'Pay now' : 'View invoice'} {invoice.status === 'open' ? 'Pay now' : <><span className="hidden sm:inline">View </span>Invoice</>}
</a> </a>
)} )}
</div> </div>

View File

@@ -1,18 +1,38 @@
'use client' 'use client'
/** /**
* Shared block: framework picker, tracking script snippet with copy, and integration guide links. * Shared block: script snippet with feature toggles, storage config, and framework guide link.
* Used on welcome (step 5), /sites/new (step 2), and site settings. * Used on welcome (step 5), /sites/new (step 2), and site settings.
*/ */
import { useState, useCallback } from 'react' import { useState, useCallback, useMemo } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { API_URL, APP_URL } from '@/lib/api/client' import { API_URL, APP_URL } from '@/lib/api/client'
import { integrations, getIntegration } from '@/lib/integrations' import { integrations, getIntegration } from '@/lib/integrations'
import { toast } from '@ciphera-net/ui' import { toast, Toggle, Select, CheckIcon } from '@ciphera-net/ui'
import { CheckIcon } from '@ciphera-net/ui'
const POPULAR_INTEGRATIONS = integrations.filter((i) => i.category === 'framework').slice(0, 10) const FRAMEWORKS = integrations.filter((i) => i.category === 'framework').slice(0, 10)
const STORAGE_OPTIONS = [
{ value: 'local', label: 'Cross-tab (localStorage)' },
{ value: 'session', label: 'Per-tab (sessionStorage)' },
]
const TTL_OPTIONS = [
{ value: '24', label: '24h' },
{ value: '48', label: '48h' },
{ value: '168', label: '7d' },
{ value: '720', label: '30d' },
]
const FEATURES = [
{ key: 'scroll', label: 'Scroll depth', description: 'Track 25 / 50 / 75 / 100%', attr: 'data-no-scroll' },
{ key: '404', label: '404 detection', description: 'Auto-detect error pages', attr: 'data-no-404' },
{ key: 'outbound', label: 'Outbound links', description: 'Track external link clicks', attr: 'data-no-outbound' },
{ key: 'downloads', label: 'File downloads', description: 'Track PDF, ZIP, and more', attr: 'data-no-downloads' },
] as const
type FeatureKey = (typeof FEATURES)[number]['key'] | 'frustration'
export interface ScriptSetupBlockSite { export interface ScriptSetupBlockSite {
domain: string domain: string
@@ -24,7 +44,7 @@ interface ScriptSetupBlockProps {
site: ScriptSetupBlockSite site: ScriptSetupBlockSite
/** Called when user copies the script (e.g. for analytics). */ /** Called when user copies the script (e.g. for analytics). */
onScriptCopy?: () => void onScriptCopy?: () => void
/** Show framework picker and "View all integrations" / "See full guide" links. Default true. */ /** Show framework picker. Default true. */
showFrameworkPicker?: boolean showFrameworkPicker?: boolean
/** Optional class for the root wrapper. */ /** Optional class for the root wrapper. */
className?: string className?: string
@@ -36,91 +56,214 @@ export default function ScriptSetupBlock({
showFrameworkPicker = true, showFrameworkPicker = true,
className = '', className = '',
}: ScriptSetupBlockProps) { }: ScriptSetupBlockProps) {
const [selectedIntegrationSlug, setSelectedIntegrationSlug] = useState<string | null>(null) const [features, setFeatures] = useState<Record<FeatureKey, boolean>>({
const [scriptCopied, setScriptCopied] = useState(false) scroll: true,
'404': true,
outbound: true,
downloads: true,
frustration: false,
})
const [storage, setStorage] = useState('local')
const [ttl, setTtl] = useState('24')
const [framework, setFramework] = useState('')
const [copied, setCopied] = useState(false)
// * Build the script snippet dynamically based on toggles
const scriptSnippet = useMemo(() => {
const attrs: string[] = [
'defer',
`data-domain="${site.domain}"`,
`data-api="${API_URL}"`,
]
if (storage === 'session') attrs.push('data-storage="session"')
if (storage === 'local' && ttl !== '24') attrs.push(`data-storage-ttl="${ttl}"`)
for (const f of FEATURES) {
if (!features[f.key]) attrs.push(f.attr)
}
attrs.push(`src="${APP_URL}/script.js"`)
let script = `<script ${attrs.join(' ')}></script>`
if (features.frustration) {
script += `\n<script defer src="${APP_URL}/script.frustration.js"></script>`
}
return script
}, [site.domain, features, storage, ttl])
const copyScript = useCallback(() => { const copyScript = useCallback(() => {
const script = `<script defer data-domain="${site.domain}" data-api="${API_URL}" src="${APP_URL}/script.js"></script>` navigator.clipboard.writeText(scriptSnippet)
navigator.clipboard.writeText(script) setCopied(true)
setScriptCopied(true)
toast.success('Script copied to clipboard') toast.success('Script copied to clipboard')
onScriptCopy?.() onScriptCopy?.()
setTimeout(() => setScriptCopied(false), 2000) setTimeout(() => setCopied(false), 2000)
}, [site.domain, onScriptCopy]) }, [scriptSnippet, onScriptCopy])
const toggleFeature = (key: FeatureKey) => {
setFeatures((prev) => ({ ...prev, [key]: !prev[key] }))
}
const selectedIntegration = framework ? getIntegration(framework) : null
return ( return (
<div className={className}> <div className={className}>
{/* ── Script snippet ──────────────────────────────────────────────── */}
<div className="rounded-2xl border border-neutral-200 dark:border-neutral-800 overflow-hidden">
{/* * Orange accent bar */}
<div className="h-1 bg-gradient-to-r from-brand-orange via-brand-orange/60 to-transparent" />
<div className="bg-neutral-950">
<div className="flex items-center justify-between px-5 py-3">
<div className="flex items-center gap-2">
{/* * Terminal dots */}
<div className="flex gap-1.5">
<div className="w-2.5 h-2.5 rounded-full bg-red-500/70" />
<div className="w-2.5 h-2.5 rounded-full bg-yellow-500/70" />
<div className="w-2.5 h-2.5 rounded-full bg-green-500/70" />
</div>
<span className="text-xs font-medium text-neutral-500 ml-2">
tracking script
</span>
</div>
<button
type="button"
onClick={copyScript}
className="flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-lg transition-all cursor-pointer bg-brand-orange/10 hover:bg-brand-orange/20 text-brand-orange border border-brand-orange/20"
>
{copied ? (
<>
<CheckIcon className="w-3.5 h-3.5" />
Copied
</>
) : (
<>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<rect x="9" y="9" width="13" height="13" rx="2" strokeLinecap="round" strokeLinejoin="round" />
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" strokeLinecap="round" strokeLinejoin="round" />
</svg>
Copy
</>
)}
</button>
</div>
<pre className="px-5 pb-5 text-[13px] leading-relaxed font-mono text-neutral-300 whitespace-pre-wrap break-all overflow-x-auto selection:bg-brand-orange/30">
{scriptSnippet}
</pre>
</div>
</div>
{/* ── Feature toggles ─────────────────────────────────────────────── */}
<div className="mt-6">
<h4 className="text-sm font-semibold text-neutral-900 dark:text-white mb-3">
Features
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{FEATURES.map((f) => (
<div
key={f.key}
className="flex items-center justify-between rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-4 py-3"
>
<div className="min-w-0 mr-3">
<span className="text-sm font-medium text-neutral-900 dark:text-white block">
{f.label}
</span>
<span className="text-xs text-neutral-500 dark:text-neutral-400">
{f.description}
</span>
</div>
<Toggle checked={features[f.key]} onChange={() => toggleFeature(f.key)} />
</div>
))}
</div>
{/* * Frustration — full-width, visually distinct as add-on */}
<div className="mt-3 flex items-center justify-between rounded-xl border border-dashed border-neutral-300 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-900/50 px-4 py-3">
<div className="min-w-0 mr-3">
<span className="text-sm font-medium text-neutral-900 dark:text-white block">
Frustration tracking
</span>
<span className="text-xs text-neutral-500 dark:text-neutral-400">
Rage clicks &amp; dead clicks &middot; Loads separate add-on script
</span>
</div>
<Toggle checked={features.frustration} onChange={() => toggleFeature('frustration')} />
</div>
</div>
{/* ── Storage + TTL ───────────────────────────────────────────────── */}
<div className="mt-6">
<h4 className="text-sm font-semibold text-neutral-900 dark:text-white mb-3">
Visitor identity
</h4>
<div className="flex items-end gap-3">
<div className="min-w-0">
<label className="text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-1 block">
Storage
</label>
<Select
variant="input"
value={storage}
onChange={setStorage}
options={STORAGE_OPTIONS}
/>
</div>
{storage === 'local' && (
<div>
<label className="text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-1 block">
TTL
</label>
<Select
variant="input"
value={ttl}
onChange={setTtl}
options={TTL_OPTIONS}
/>
</div>
)}
</div>
</div>
{/* ── Framework guide ─────────────────────────────────────────────── */}
{showFrameworkPicker && ( {showFrameworkPicker && (
<> <div className="mt-6">
<h3 className="text-sm font-semibold text-neutral-900 dark:text-white mb-1"> <div className="flex items-center justify-between mb-3">
Add the script to your site <h4 className="text-sm font-semibold text-neutral-900 dark:text-white">
</h3> Setup guide
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-3"> </h4>
Choose your framework for setup instructions. <Link
</p> href="/integrations"
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mb-4"> target="_blank"
{POPULAR_INTEGRATIONS.map((int) => ( rel="noopener noreferrer"
className="text-xs font-medium text-neutral-500 dark:text-neutral-400 hover:text-brand-orange transition-colors"
>
All integrations
</Link>
</div>
<div className="flex flex-wrap gap-2">
{FRAMEWORKS.map((fw) => (
<button <button
key={int.id} key={fw.id}
type="button" type="button"
onClick={() => setSelectedIntegrationSlug(selectedIntegrationSlug === int.id ? null : int.id)} onClick={() => setFramework(framework === fw.id ? '' : fw.id)}
className={`flex items-center gap-2 rounded-lg border px-3 py-2.5 text-left text-sm transition-colors ${ className={`flex items-center gap-2 rounded-lg border px-3 py-2 text-sm transition-all cursor-pointer ${
selectedIntegrationSlug === int.id framework === fw.id
? 'border-brand-orange bg-brand-orange/10 text-brand-orange' ? 'border-brand-orange bg-brand-orange/10 text-brand-orange'
: 'border-neutral-200 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-800/50 hover:bg-neutral-100 dark:hover:bg-neutral-800 text-neutral-700 dark:text-neutral-300' : 'border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 text-neutral-600 dark:text-neutral-400 hover:border-neutral-300 dark:hover:border-neutral-700 hover:text-neutral-900 dark:hover:text-white'
}`} }`}
> >
<span className="[&_svg]:h-4 [&_svg]:w-4 [&_svg]:shrink-0 flex items-center justify-center"> <span className="[&_svg]:h-4 [&_svg]:w-4 shrink-0 flex items-center">
{int.icon} {fw.icon}
</span> </span>
<span className="truncate font-medium">{int.name}</span> <span className="font-medium">{fw.name}</span>
</button> </button>
))} ))}
</div> </div>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-2"> {selectedIntegration && (
<Link href="/integrations" target="_blank" rel="noopener noreferrer" className="text-brand-orange hover:underline"> <Link
View all integrations href={`/integrations/${framework}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 mt-3 text-sm font-medium text-brand-orange hover:text-brand-orange/80 transition-colors"
>
See full {selectedIntegration.name} guide
</Link> </Link>
</p>
</>
)}
<div className="rounded-xl bg-neutral-100 dark:bg-neutral-800 p-4 relative group">
<code className="text-xs text-neutral-900 dark:text-white break-all font-mono block pr-10">
{`<script defer data-domain="${site.domain}" data-api="${API_URL}" src="${APP_URL}/script.js"></script>`}
</code>
<p className="mt-2 text-xs text-neutral-500 dark:text-neutral-400">
Default: cross-tab (localStorage). Optional: <code className="rounded px-1 bg-neutral-200 dark:bg-neutral-700">data-storage=&quot;session&quot;</code> to opt out (per-tab, ephemeral). Optional: <code className="rounded px-1 bg-neutral-200 dark:bg-neutral-700">data-storage-ttl=&quot;48&quot;</code> to set expiry in hours (default: 24).
</p>
<button
type="button"
onClick={copyScript}
className="absolute top-2 right-2 p-2 bg-white dark:bg-neutral-700 rounded-lg shadow-sm hover:bg-neutral-50 dark:hover:bg-neutral-600 transition-colors"
title="Copy script"
aria-label={scriptCopied ? 'Copied' : 'Copy script to clipboard'}
>
{scriptCopied ? (
<CheckIcon className="w-4 h-4 text-green-500" />
) : (
<svg className="w-4 h-4 text-neutral-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<rect x="9" y="9" width="13" height="13" rx="2" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)} )}
</button> </div>
</div>
{showFrameworkPicker && selectedIntegrationSlug && getIntegration(selectedIntegrationSlug) && (
<p className="mt-3 text-xs">
<Link
href={`/integrations/${selectedIntegrationSlug}`}
target="_blank"
rel="noopener noreferrer"
className="text-brand-orange hover:underline"
>
See full {getIntegration(selectedIntegrationSlug)!.name} guide
</Link>
</p>
)} )}
</div> </div>
) )

View File

@@ -0,0 +1,29 @@
'use client'
import { useEffect, useRef } from 'react'
import { useMotionValue, useSpring, useTransform, motion } from 'framer-motion'
interface AnimatedNumberProps {
value: number
format: (v: number) => string
className?: string
}
export function AnimatedNumber({ value, format, className }: AnimatedNumberProps) {
const motionValue = useMotionValue(value)
const spring = useSpring(motionValue, { stiffness: 120, damping: 20, mass: 0.5 })
const display = useTransform(spring, (v) => format(Math.round(v)))
const isFirst = useRef(true)
useEffect(() => {
if (isFirst.current) {
// Skip animation on initial render — jump straight to value
motionValue.jump(value)
isFirst.current = false
} else {
motionValue.set(value)
}
}, [value, motionValue])
return <motion.span className={className}>{display}</motion.span>
}

85
lib/api/bunny.ts Normal file
View File

@@ -0,0 +1,85 @@
import apiRequest from './client'
// ─── Types ──────────────────────────────────────────────────────────
export interface BunnyStatus {
connected: boolean
pull_zone_id?: number
pull_zone_name?: string
status?: 'active' | 'syncing' | 'error'
error_message?: string | null
last_synced_at?: string | null
created_at?: string
}
export interface BunnyOverview {
total_bandwidth: number
total_requests: number
cache_hit_rate: number
avg_origin_response: number
total_errors: number
prev_total_bandwidth: number
prev_total_requests: number
prev_cache_hit_rate: number
prev_avg_origin_response: number
prev_total_errors: number
}
export interface BunnyDailyRow {
date: string
bandwidth_used: number
bandwidth_cached: number
requests_served: number
requests_cached: number
error_3xx: number
error_4xx: number
error_5xx: number
origin_response_time_avg: number
}
export interface BunnyPullZone {
id: number
name: string
}
export interface BunnyGeoRow {
country_code: string
bandwidth: number
requests: number
}
// ─── API Functions ──────────────────────────────────────────────────
export async function getBunnyPullZones(siteId: string, apiKey: string): Promise<{ pull_zones: BunnyPullZone[], message?: string }> {
return apiRequest<{ pull_zones: BunnyPullZone[], message?: string }>(
`/sites/${siteId}/integrations/bunny/pull-zones`,
{ method: 'POST', body: JSON.stringify({ api_key: apiKey }) }
)
}
export async function connectBunny(siteId: string, apiKey: string, pullZoneId: number, pullZoneName: string): Promise<void> {
await apiRequest(`/sites/${siteId}/integrations/bunny`, {
method: 'POST',
body: JSON.stringify({ api_key: apiKey, pull_zone_id: pullZoneId, pull_zone_name: pullZoneName }),
})
}
export async function getBunnyStatus(siteId: string): Promise<BunnyStatus> {
return apiRequest<BunnyStatus>(`/sites/${siteId}/integrations/bunny/status`)
}
export async function disconnectBunny(siteId: string): Promise<void> {
await apiRequest(`/sites/${siteId}/integrations/bunny`, { method: 'DELETE' })
}
export async function getBunnyOverview(siteId: string, startDate: string, endDate: string): Promise<BunnyOverview> {
return apiRequest<BunnyOverview>(`/sites/${siteId}/bunny/overview?start_date=${startDate}&end_date=${endDate}`)
}
export async function getBunnyDailyStats(siteId: string, startDate: string, endDate: string): Promise<{ daily_stats: BunnyDailyRow[] }> {
return apiRequest<{ daily_stats: BunnyDailyRow[] }>(`/sites/${siteId}/bunny/daily-stats?start_date=${startDate}&end_date=${endDate}`)
}
export async function getBunnyTopCountries(siteId: string, startDate: string, endDate: string, limit = 20): Promise<{ countries: BunnyGeoRow[] }> {
return apiRequest<{ countries: BunnyGeoRow[] }>(`/sites/${siteId}/bunny/top-countries?start_date=${startDate}&end_date=${endDate}&limit=${limit}`)
}

View File

@@ -335,10 +335,23 @@ async function apiRequest<T>(
} }
const errorBody = await response.json().catch(() => ({})) const errorBody = await response.json().catch(() => ({}))
// * Capture Retry-After header on 429 so callers can show precise timing
if (response.status === 429) {
const retryAfter = response.headers.get('Retry-After')
if (retryAfter) {
errorBody.retryAfter = parseInt(retryAfter, 10)
}
}
const message = authMessageFromStatus(response.status) const message = authMessageFromStatus(response.status)
throw new ApiError(message, response.status, errorBody) throw new ApiError(message, response.status, errorBody)
} }
if (response.status === 204) {
return undefined as T
}
return response.json() return response.json()
})() })()

98
lib/api/gsc.ts Normal file
View File

@@ -0,0 +1,98 @@
import apiRequest from './client'
// ─── Types ──────────────────────────────────────────────────────────
export interface GSCStatus {
connected: boolean
google_email?: string
gsc_property?: string
status?: 'active' | 'syncing' | 'error'
error_message?: string | null
last_synced_at?: string | null
created_at?: string
}
export interface GSCOverview {
total_clicks: number
total_impressions: number
avg_ctr: number
avg_position: number
prev_clicks: number
prev_impressions: number
prev_avg_ctr: number
prev_avg_position: number
}
export interface GSCDataRow {
query: string
page: string
impressions: number
clicks: number
ctr: number
position: number
}
export interface GSCQueryResponse {
queries: GSCDataRow[]
total: number
}
export interface GSCPageResponse {
pages: GSCDataRow[]
total: number
}
// ─── API Functions ──────────────────────────────────────────────────
export async function getGSCAuthURL(siteId: string): Promise<{ auth_url: string }> {
return apiRequest<{ auth_url: string }>(`/sites/${siteId}/integrations/gsc/auth-url`)
}
export async function getGSCStatus(siteId: string): Promise<GSCStatus> {
return apiRequest<GSCStatus>(`/sites/${siteId}/integrations/gsc/status`)
}
export async function disconnectGSC(siteId: string): Promise<void> {
await apiRequest(`/sites/${siteId}/integrations/gsc`, {
method: 'DELETE',
})
}
export async function getGSCOverview(siteId: string, startDate: string, endDate: string): Promise<GSCOverview> {
return apiRequest<GSCOverview>(`/sites/${siteId}/gsc/overview?start_date=${startDate}&end_date=${endDate}`)
}
export async function getGSCTopQueries(siteId: string, startDate: string, endDate: string, limit = 50, offset = 0): Promise<GSCQueryResponse> {
return apiRequest<GSCQueryResponse>(`/sites/${siteId}/gsc/top-queries?start_date=${startDate}&end_date=${endDate}&limit=${limit}&offset=${offset}`)
}
export async function getGSCTopPages(siteId: string, startDate: string, endDate: string, limit = 50, offset = 0): Promise<GSCPageResponse> {
return apiRequest<GSCPageResponse>(`/sites/${siteId}/gsc/top-pages?start_date=${startDate}&end_date=${endDate}&limit=${limit}&offset=${offset}`)
}
export async function getGSCQueryPages(siteId: string, query: string, startDate: string, endDate: string): Promise<GSCPageResponse> {
return apiRequest<GSCPageResponse>(`/sites/${siteId}/gsc/query-pages?query=${encodeURIComponent(query)}&start_date=${startDate}&end_date=${endDate}`)
}
export async function getGSCPageQueries(siteId: string, page: string, startDate: string, endDate: string): Promise<GSCQueryResponse> {
return apiRequest<GSCQueryResponse>(`/sites/${siteId}/gsc/page-queries?page=${encodeURIComponent(page)}&start_date=${startDate}&end_date=${endDate}`)
}
export interface GSCDailyTotal {
date: string
clicks: number
impressions: number
}
export interface GSCNewQueries {
count: number
queries: string[]
}
export async function getGSCDailyTotals(siteId: string, startDate: string, endDate: string): Promise<{ daily_totals: GSCDailyTotal[] }> {
return apiRequest<{ daily_totals: GSCDailyTotal[] }>(`/sites/${siteId}/gsc/daily-totals?start_date=${startDate}&end_date=${endDate}`)
}
export async function getGSCNewQueries(siteId: string, startDate: string, endDate: string): Promise<GSCNewQueries> {
return apiRequest<GSCNewQueries>(`/sites/${siteId}/gsc/new-queries?start_date=${startDate}&end_date=${endDate}`)
}

View File

@@ -17,8 +17,6 @@ export interface Site {
collect_device_info?: boolean collect_device_info?: boolean
collect_geo_data?: GeoDataLevel collect_geo_data?: GeoDataLevel
collect_screen_resolution?: boolean collect_screen_resolution?: boolean
// Performance insights setting
enable_performance_insights?: boolean
// Bot and noise filtering // Bot and noise filtering
filter_bots?: boolean filter_bots?: boolean
// Hide unknown locations from stats // Hide unknown locations from stats
@@ -48,8 +46,6 @@ export interface UpdateSiteRequest {
collect_device_info?: boolean collect_device_info?: boolean
collect_geo_data?: GeoDataLevel collect_geo_data?: GeoDataLevel
collect_screen_resolution?: boolean collect_screen_resolution?: boolean
// Performance insights setting
enable_performance_insights?: boolean
// Bot and noise filtering // Bot and noise filtering
filter_bots?: boolean filter_bots?: boolean
// Hide unknown locations from stats // Hide unknown locations from stats

View File

@@ -21,20 +21,6 @@ export interface ScreenResolutionStat {
pageviews: number pageviews: number
} }
export interface PerformanceStats {
lcp: number
cls: number
inp: number
}
export interface PerformanceByPageStat {
path: string
samples: number
lcp: number | null
cls: number | null
inp: number | null
}
export interface GoalCountStat { export interface GoalCountStat {
event_name: string event_name: string
count: number count: number
@@ -225,31 +211,6 @@ export function getPublicCampaigns(siteId: string, startDate?: string, endDate?:
.then(r => r?.campaigns || []) .then(r => r?.campaigns || [])
} }
// ─── Performance By Page ────────────────────────────────────────────
export function getPerformanceByPage(
siteId: string,
startDate?: string,
endDate?: string,
opts?: { limit?: number; sort?: 'lcp' | 'cls' | 'inp' }
): Promise<PerformanceByPageStat[]> {
return apiRequest<{ performance_by_page: PerformanceByPageStat[] }>(
`/sites/${siteId}/performance-by-page${buildQuery({ startDate, endDate, limit: opts?.limit, sort: opts?.sort })}`
).then(r => r?.performance_by_page ?? [])
}
export function getPublicPerformanceByPage(
siteId: string,
startDate?: string,
endDate?: string,
opts?: { limit?: number; sort?: 'lcp' | 'cls' | 'inp' },
auth?: AuthParams
): Promise<PerformanceByPageStat[]> {
return apiRequest<{ performance_by_page: PerformanceByPageStat[] }>(
`/public/sites/${siteId}/performance-by-page${buildQuery({ startDate, endDate, limit: opts?.limit, sort: opts?.sort }, auth)}`
).then(r => r?.performance_by_page ?? [])
}
// ─── Full Dashboard ───────────────────────────────────────────────── // ─── Full Dashboard ─────────────────────────────────────────────────
export interface DashboardData { export interface DashboardData {
@@ -268,8 +229,6 @@ export interface DashboardData {
os: OSStat[] os: OSStat[]
devices: DeviceStat[] devices: DeviceStat[]
screen_resolutions: ScreenResolutionStat[] screen_resolutions: ScreenResolutionStat[]
performance?: PerformanceStats
performance_by_page?: PerformanceByPageStat[]
goal_counts?: GoalCountStat[] goal_counts?: GoalCountStat[]
} }
@@ -323,11 +282,6 @@ export interface DashboardReferrersData {
top_referrers: TopReferrer[] top_referrers: TopReferrer[]
} }
export interface DashboardPerformanceData {
performance?: PerformanceStats
performance_by_page?: PerformanceByPageStat[]
}
export interface DashboardGoalsData { export interface DashboardGoalsData {
goal_counts: GoalCountStat[] goal_counts: GoalCountStat[]
} }
@@ -387,17 +341,6 @@ export function getPublicDashboardReferrers(
return apiRequest<DashboardReferrersData>(`/public/sites/${siteId}/dashboard/referrers${buildQuery({ startDate, endDate, limit }, { password, captcha })}`) return apiRequest<DashboardReferrersData>(`/public/sites/${siteId}/dashboard/referrers${buildQuery({ startDate, endDate, limit }, { password, captcha })}`)
} }
export function getDashboardPerformance(siteId: string, startDate?: string, endDate?: string, filters?: string): Promise<DashboardPerformanceData> {
return apiRequest<DashboardPerformanceData>(`/sites/${siteId}/dashboard/performance${buildQuery({ startDate, endDate, filters })}`)
}
export function getPublicDashboardPerformance(
siteId: string, startDate?: string, endDate?: string,
password?: string, captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string }
): Promise<DashboardPerformanceData> {
return apiRequest<DashboardPerformanceData>(`/public/sites/${siteId}/dashboard/performance${buildQuery({ startDate, endDate }, { password, captcha })}`)
}
export function getDashboardGoals(siteId: string, startDate?: string, endDate?: string, limit = 10, filters?: string): Promise<DashboardGoalsData> { export function getDashboardGoals(siteId: string, startDate?: string, endDate?: string, limit = 10, filters?: string): Promise<DashboardGoalsData> {
return apiRequest<DashboardGoalsData>(`/sites/${siteId}/dashboard/goals${buildQuery({ startDate, endDate, limit, filters })}`) return apiRequest<DashboardGoalsData>(`/sites/${siteId}/dashboard/goals${buildQuery({ startDate, endDate, limit, filters })}`)
} }

View File

@@ -2,6 +2,7 @@
// * Implements stale-while-revalidate pattern for efficient data updates // * Implements stale-while-revalidate pattern for efficient data updates
import useSWR from 'swr' import useSWR from 'swr'
import { toast } from '@ciphera-net/ui'
import { import {
getDashboard, getDashboard,
getDashboardOverview, getDashboardOverview,
@@ -9,7 +10,6 @@ import {
getDashboardLocations, getDashboardLocations,
getDashboardDevices, getDashboardDevices,
getDashboardReferrers, getDashboardReferrers,
getDashboardPerformance,
getDashboardGoals, getDashboardGoals,
getCampaigns, getCampaigns,
getRealtime, getRealtime,
@@ -33,6 +33,10 @@ import { listFunnels, type Funnel } from '@/lib/api/funnels'
import { getUptimeStatus, type UptimeStatusResponse } from '@/lib/api/uptime' import { getUptimeStatus, type UptimeStatusResponse } from '@/lib/api/uptime'
import { listGoals, type Goal } from '@/lib/api/goals' import { listGoals, type Goal } from '@/lib/api/goals'
import { listReportSchedules, type ReportSchedule } from '@/lib/api/report-schedules' import { listReportSchedules, type ReportSchedule } from '@/lib/api/report-schedules'
import { getGSCStatus, getGSCOverview, getGSCTopQueries, getGSCTopPages, getGSCDailyTotals, getGSCNewQueries } from '@/lib/api/gsc'
import type { GSCStatus, GSCOverview, GSCQueryResponse, GSCPageResponse, GSCDailyTotal, GSCNewQueries } from '@/lib/api/gsc'
import { getBunnyStatus, getBunnyOverview, getBunnyDailyStats, getBunnyTopCountries } from '@/lib/api/bunny'
import type { BunnyStatus, BunnyOverview, BunnyDailyRow, BunnyGeoRow } from '@/lib/api/bunny'
import { getSubscription, type SubscriptionDetails } from '@/lib/api/billing' import { getSubscription, type SubscriptionDetails } from '@/lib/api/billing'
import type { import type {
Stats, Stats,
@@ -44,7 +48,6 @@ import type {
DashboardLocationsData, DashboardLocationsData,
DashboardDevicesData, DashboardDevicesData,
DashboardReferrersData, DashboardReferrersData,
DashboardPerformanceData,
DashboardGoalsData, DashboardGoalsData,
BehaviorData, BehaviorData,
} from '@/lib/api/stats' } from '@/lib/api/stats'
@@ -58,7 +61,6 @@ const fetchers = {
dashboardLocations: (siteId: string, start: string, end: string, filters?: string) => getDashboardLocations(siteId, start, end, undefined, undefined, filters), dashboardLocations: (siteId: string, start: string, end: string, filters?: string) => getDashboardLocations(siteId, start, end, undefined, undefined, filters),
dashboardDevices: (siteId: string, start: string, end: string, filters?: string) => getDashboardDevices(siteId, start, end, undefined, filters), dashboardDevices: (siteId: string, start: string, end: string, filters?: string) => getDashboardDevices(siteId, start, end, undefined, filters),
dashboardReferrers: (siteId: string, start: string, end: string, filters?: string) => getDashboardReferrers(siteId, start, end, undefined, filters), dashboardReferrers: (siteId: string, start: string, end: string, filters?: string) => getDashboardReferrers(siteId, start, end, undefined, filters),
dashboardPerformance: (siteId: string, start: string, end: string, filters?: string) => getDashboardPerformance(siteId, start, end, filters),
dashboardGoals: (siteId: string, start: string, end: string, filters?: string) => getDashboardGoals(siteId, start, end, undefined, filters), dashboardGoals: (siteId: string, start: string, end: string, filters?: string) => getDashboardGoals(siteId, start, end, undefined, filters),
stats: (siteId: string, start: string, end: string, filters?: string) => getStats(siteId, start, end, filters), stats: (siteId: string, start: string, end: string, filters?: string) => getStats(siteId, start, end, filters),
dailyStats: (siteId: string, start: string, end: string, interval: 'hour' | 'day' | 'minute') => dailyStats: (siteId: string, start: string, end: string, interval: 'hour' | 'day' | 'minute') =>
@@ -78,6 +80,16 @@ const fetchers = {
uptimeStatus: (siteId: string) => getUptimeStatus(siteId), uptimeStatus: (siteId: string) => getUptimeStatus(siteId),
goals: (siteId: string) => listGoals(siteId), goals: (siteId: string) => listGoals(siteId),
reportSchedules: (siteId: string) => listReportSchedules(siteId), reportSchedules: (siteId: string) => listReportSchedules(siteId),
gscStatus: (siteId: string) => getGSCStatus(siteId),
gscOverview: (siteId: string, start: string, end: string) => getGSCOverview(siteId, start, end),
gscTopQueries: (siteId: string, start: string, end: string, limit: number, offset: number) => getGSCTopQueries(siteId, start, end, limit, offset),
gscTopPages: (siteId: string, start: string, end: string, limit: number, offset: number) => getGSCTopPages(siteId, start, end, limit, offset),
gscDailyTotals: (siteId: string, start: string, end: string) => getGSCDailyTotals(siteId, start, end),
gscNewQueries: (siteId: string, start: string, end: string) => getGSCNewQueries(siteId, start, end),
bunnyStatus: (siteId: string) => getBunnyStatus(siteId),
bunnyOverview: (siteId: string, start: string, end: string) => getBunnyOverview(siteId, start, end),
bunnyDailyStats: (siteId: string, start: string, end: string) => getBunnyDailyStats(siteId, start, end),
bunnyTopCountries: (siteId: string, start: string, end: string) => getBunnyTopCountries(siteId, start, end),
subscription: () => getSubscription(), subscription: () => getSubscription(),
} }
@@ -87,11 +99,25 @@ const dashboardSWRConfig = {
revalidateOnFocus: false, revalidateOnFocus: false,
// * Revalidate when reconnecting (fresh data after offline) // * Revalidate when reconnecting (fresh data after offline)
revalidateOnReconnect: true, revalidateOnReconnect: true,
// * Retry failed requests // * Retry failed requests (but not rate limits or auth errors)
shouldRetryOnError: true, shouldRetryOnError: true,
errorRetryCount: 3, errorRetryCount: 3,
// * Error retry interval with exponential backoff // * Error retry interval with exponential backoff
errorRetryInterval: 5000, errorRetryInterval: 5000,
// * Don't retry on 429 (rate limit) or 401/403 (auth) — retrying makes it worse
onErrorRetry: (error: any, _key: string, _config: any, revalidate: any, { retryCount }: { retryCount: number }) => {
if (error?.status === 429) {
const retryAfter = error?.data?.retryAfter
const message = retryAfter
? `Too many requests. Please try again in ${retryAfter} seconds.`
: 'Too many requests. Please wait a moment and try again.'
toast.error(message, { id: 'rate-limit' })
return
}
if (error?.status === 401 || error?.status === 403) return
if (retryCount >= 3) return
setTimeout(() => revalidate({ retryCount }), 5000 * Math.pow(2, retryCount))
},
} }
// * Hook for site data (loads once, refreshes rarely) // * Hook for site data (loads once, refreshes rarely)
@@ -240,19 +266,6 @@ export function useDashboardReferrers(siteId: string, start: string, end: string
) )
} }
// * Hook for focused dashboard performance data
export function useDashboardPerformance(siteId: string, start: string, end: string, filters?: string) {
return useSWR<DashboardPerformanceData>(
siteId && start && end ? ['dashboardPerformance', siteId, start, end, filters] : null,
() => fetchers.dashboardPerformance(siteId, start, end, filters),
{
...dashboardSWRConfig,
refreshInterval: 60 * 1000,
dedupingInterval: 10 * 1000,
}
)
}
// * Hook for focused dashboard goals data // * Hook for focused dashboard goals data
export function useDashboardGoals(siteId: string, start: string, end: string, filters?: string) { export function useDashboardGoals(siteId: string, start: string, end: string, filters?: string) {
return useSWR<DashboardGoalsData>( return useSWR<DashboardGoalsData>(
@@ -397,6 +410,100 @@ export function useReportSchedules(siteId: string) {
) )
} }
// * Hook for GSC connection status
export function useGSCStatus(siteId: string) {
return useSWR<GSCStatus>(
siteId ? ['gscStatus', siteId] : null,
() => fetchers.gscStatus(siteId),
{
...dashboardSWRConfig,
refreshInterval: 60 * 1000,
dedupingInterval: 30 * 1000,
}
)
}
// * Hook for GSC overview metrics (clicks, impressions, CTR, position)
export function useGSCOverview(siteId: string, start: string, end: string) {
return useSWR<GSCOverview>(
siteId && start && end ? ['gscOverview', siteId, start, end] : null,
() => fetchers.gscOverview(siteId, start, end),
dashboardSWRConfig
)
}
// * Hook for GSC top queries
export function useGSCTopQueries(siteId: string, start: string, end: string, limit = 50, offset = 0) {
return useSWR<GSCQueryResponse>(
siteId && start && end ? ['gscTopQueries', siteId, start, end, limit, offset] : null,
() => fetchers.gscTopQueries(siteId, start, end, limit, offset),
dashboardSWRConfig
)
}
// * Hook for GSC top pages
export function useGSCTopPages(siteId: string, start: string, end: string, limit = 50, offset = 0) {
return useSWR<GSCPageResponse>(
siteId && start && end ? ['gscTopPages', siteId, start, end, limit, offset] : null,
() => fetchers.gscTopPages(siteId, start, end, limit, offset),
dashboardSWRConfig
)
}
// * Hook for GSC daily totals (clicks & impressions per day)
export function useGSCDailyTotals(siteId: string, start: string, end: string) {
return useSWR<{ daily_totals: GSCDailyTotal[] }>(
siteId && start && end ? ['gscDailyTotals', siteId, start, end] : null,
() => fetchers.gscDailyTotals(siteId, start, end),
dashboardSWRConfig
)
}
// * Hook for GSC new queries (queries that appeared in the current period)
export function useGSCNewQueries(siteId: string, start: string, end: string) {
return useSWR<GSCNewQueries>(
siteId && start && end ? ['gscNewQueries', siteId, start, end] : null,
() => fetchers.gscNewQueries(siteId, start, end),
dashboardSWRConfig
)
}
// * Hook for BunnyCDN connection status
export function useBunnyStatus(siteId: string) {
return useSWR<BunnyStatus>(
siteId ? ['bunnyStatus', siteId] : null,
() => fetchers.bunnyStatus(siteId),
{ ...dashboardSWRConfig, refreshInterval: 60 * 1000, dedupingInterval: 30 * 1000 }
)
}
// * Hook for BunnyCDN overview metrics (bandwidth, requests, cache hit rate)
export function useBunnyOverview(siteId: string, startDate: string, endDate: string) {
return useSWR<BunnyOverview>(
siteId && startDate && endDate ? ['bunnyOverview', siteId, startDate, endDate] : null,
() => fetchers.bunnyOverview(siteId, startDate, endDate),
dashboardSWRConfig
)
}
// * Hook for BunnyCDN daily stats (bandwidth & requests per day)
export function useBunnyDailyStats(siteId: string, startDate: string, endDate: string) {
return useSWR<{ daily_stats: BunnyDailyRow[] }>(
siteId && startDate && endDate ? ['bunnyDailyStats', siteId, startDate, endDate] : null,
() => fetchers.bunnyDailyStats(siteId, startDate, endDate),
dashboardSWRConfig
)
}
// * Hook for BunnyCDN top countries by bandwidth
export function useBunnyTopCountries(siteId: string, startDate: string, endDate: string) {
return useSWR<{ countries: BunnyGeoRow[] }>(
siteId && startDate && endDate ? ['bunnyTopCountries', siteId, startDate, endDate] : null,
() => fetchers.bunnyTopCountries(siteId, startDate, endDate),
dashboardSWRConfig
)
}
// * Hook for subscription details (changes rarely) // * Hook for subscription details (changes rarely)
export function useSubscription() { export function useSubscription() {
return useSWR<SubscriptionDetails>( return useSWR<SubscriptionDetails>(

View File

@@ -21,7 +21,6 @@ export function generatePrivacySnippet(site: Site): string {
const device = site.collect_device_info ?? true const device = site.collect_device_info ?? true
const geo = site.collect_geo_data || 'full' const geo = site.collect_geo_data || 'full'
const screen = site.collect_screen_resolution ?? true const screen = site.collect_screen_resolution ?? true
const perf = site.enable_performance_insights ?? false
const filterBots = site.filter_bots ?? true const filterBots = site.filter_bots ?? true
const retentionMonths = site.data_retention_months ?? 6 const retentionMonths = site.data_retention_months ?? 6
@@ -32,7 +31,6 @@ export function generatePrivacySnippet(site: Site): string {
if (geo === 'full') parts.push('country, region, and city') if (geo === 'full') parts.push('country, region, and city')
else if (geo === 'country') parts.push('country') else if (geo === 'country') parts.push('country')
if (screen) parts.push('screen resolution') if (screen) parts.push('screen resolution')
if (perf) parts.push('Core Web Vitals (e.g. page load performance)')
const list = const list =
parts.length > 0 parts.length > 0

View File

@@ -13,6 +13,7 @@ const PUBLIC_ROUTES = new Set([
'/changelog', '/changelog',
'/installation', '/installation',
'/script.js', // * Tracking script must load without auth for embedded sites (Shopify, etc.) '/script.js', // * Tracking script must load without auth for embedded sites (Shopify, etc.)
'/script.frustration.js', // * Frustration tracking add-on (rage clicks, dead clicks)
]) ])
const PUBLIC_PREFIXES = [ const PUBLIC_PREFIXES = [

685
package-lock.json generated
View File

@@ -1,25 +1,25 @@
{ {
"name": "pulse-frontend", "name": "pulse-frontend",
"version": "0.14.0-alpha", "version": "0.15.0-alpha",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "pulse-frontend", "name": "pulse-frontend",
"version": "0.14.0-alpha", "version": "0.15.0-alpha",
"dependencies": { "dependencies": {
"@ciphera-net/ui": "^0.2.5", "@ciphera-net/ui": "^0.2.7",
"@ducanh2912/next-pwa": "^10.2.9", "@ducanh2912/next-pwa": "^10.2.9",
"@phosphor-icons/react": "^2.1.10", "@phosphor-icons/react": "^2.1.10",
"@simplewebauthn/browser": "^13.2.2", "@simplewebauthn/browser": "^13.2.2",
"@stripe/react-stripe-js": "^5.6.0", "@stripe/react-stripe-js": "^5.6.0",
"@stripe/stripe-js": "^8.7.0", "@stripe/stripe-js": "^8.7.0",
"@tanstack/react-virtual": "^3.13.21", "@tanstack/react-virtual": "^3.13.21",
"axios": "^1.13.2", "@types/d3": "^7.4.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"cobe": "^0.6.5", "cobe": "^0.6.5",
"country-flag-icons": "^1.6.4", "country-flag-icons": "^1.6.4",
"d3-sankey": "^0.12.3", "d3": "^7.9.0",
"d3-scale": "^4.0.2", "d3-scale": "^4.0.2",
"framer-motion": "^12.23.26", "framer-motion": "^12.23.26",
"html-to-image": "^1.11.13", "html-to-image": "^1.11.13",
@@ -42,7 +42,6 @@
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@types/d3-sankey": "^0.12.5",
"@types/d3-scale": "^4.0.9", "@types/d3-scale": "^4.0.9",
"@types/node": "^20.14.12", "@types/node": "^20.14.12",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
@@ -1669,9 +1668,9 @@
} }
}, },
"node_modules/@ciphera-net/ui": { "node_modules/@ciphera-net/ui": {
"version": "0.2.5", "version": "0.2.7",
"resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.2.5/01371025a6706621b7a2c353cb1b07a239961fc3", "resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.2.7/f5f170676cdd1bf53c091a0baa98c2d55a7c999f",
"integrity": "sha512-Ybd3zZLqpdv/dktNylT/jOm9OTMVST35+19QY+DTvDeluF3B4bN2YA7S85V7PpXGmZBmnPQX3U8qP4t2HwyyMw==", "integrity": "sha512-yvag9cYfX6c8aZ3bKI+i3l9ALJBXg7XL6soIjd65F7NyZN+1mEo1Fb+ARfWgjdNa5HjfexAnEOOVpjwMNPFCfg==",
"dependencies": { "dependencies": {
"@phosphor-icons/react": "^2.1.10", "@phosphor-icons/react": "^2.1.10",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
@@ -5595,24 +5594,159 @@
"assertion-error": "^2.0.1" "assertion-error": "^2.0.1"
} }
}, },
"node_modules/@types/d3": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
"integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==",
"license": "MIT",
"dependencies": {
"@types/d3-array": "*",
"@types/d3-axis": "*",
"@types/d3-brush": "*",
"@types/d3-chord": "*",
"@types/d3-color": "*",
"@types/d3-contour": "*",
"@types/d3-delaunay": "*",
"@types/d3-dispatch": "*",
"@types/d3-drag": "*",
"@types/d3-dsv": "*",
"@types/d3-ease": "*",
"@types/d3-fetch": "*",
"@types/d3-force": "*",
"@types/d3-format": "*",
"@types/d3-geo": "*",
"@types/d3-hierarchy": "*",
"@types/d3-interpolate": "*",
"@types/d3-path": "*",
"@types/d3-polygon": "*",
"@types/d3-quadtree": "*",
"@types/d3-random": "*",
"@types/d3-scale": "*",
"@types/d3-scale-chromatic": "*",
"@types/d3-selection": "*",
"@types/d3-shape": "*",
"@types/d3-time": "*",
"@types/d3-time-format": "*",
"@types/d3-timer": "*",
"@types/d3-transition": "*",
"@types/d3-zoom": "*"
}
},
"node_modules/@types/d3-array": { "node_modules/@types/d3-array": {
"version": "3.2.2", "version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/d3-axis": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz",
"integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-brush": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz",
"integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-chord": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz",
"integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==",
"license": "MIT"
},
"node_modules/@types/d3-color": { "node_modules/@types/d3-color": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/d3-contour": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz",
"integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==",
"license": "MIT",
"dependencies": {
"@types/d3-array": "*",
"@types/geojson": "*"
}
},
"node_modules/@types/d3-delaunay": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
"integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==",
"license": "MIT"
},
"node_modules/@types/d3-dispatch": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz",
"integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==",
"license": "MIT"
},
"node_modules/@types/d3-drag": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-dsv": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz",
"integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==",
"license": "MIT"
},
"node_modules/@types/d3-ease": { "node_modules/@types/d3-ease": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/d3-fetch": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz",
"integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==",
"license": "MIT",
"dependencies": {
"@types/d3-dsv": "*"
}
},
"node_modules/@types/d3-force": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz",
"integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==",
"license": "MIT"
},
"node_modules/@types/d3-format": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz",
"integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==",
"license": "MIT"
},
"node_modules/@types/d3-geo": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz",
"integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/d3-hierarchy": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz",
"integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": { "node_modules/@types/d3-interpolate": {
"version": "3.0.4", "version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
@@ -5628,32 +5762,23 @@
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/d3-sankey": { "node_modules/@types/d3-polygon": {
"version": "0.12.5", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-sankey/-/d3-sankey-0.12.5.tgz", "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz",
"integrity": "sha512-/3RZSew0cLAtzGQ+C89hq/Rp3H20QJuVRSqFy6RKLe7E0B8kd2iOS1oBsodrgds4PcNVpqWhdUEng/SHvBcJ6Q==", "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/d3-shape": "^1"
}
},
"node_modules/@types/d3-sankey/node_modules/@types/d3-path": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.11.tgz",
"integrity": "sha512-4pQMp8ldf7UaB/gR8Fvvy69psNHkTpD/pVw3vmEi8iZAB9EPMBruB1JvHO4BIq9QkUUd2lV1F5YXpMNj7JPBpw==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/d3-sankey/node_modules/@types/d3-shape": { "node_modules/@types/d3-quadtree": {
"version": "1.3.12", "version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-1.3.12.tgz", "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz",
"integrity": "sha512-8oMzcd4+poSLGgV0R1Q1rOlx/xdmozS4Xab7np0eamFFUYq71AU9pOCJEFnkXW2aI/oXdVYJzw6pssbSut7Z9Q==", "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==",
"dev": true, "license": "MIT"
"license": "MIT", },
"dependencies": { "node_modules/@types/d3-random": {
"@types/d3-path": "^1" "version": "3.0.3",
} "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz",
"integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==",
"license": "MIT"
}, },
"node_modules/@types/d3-scale": { "node_modules/@types/d3-scale": {
"version": "4.0.9", "version": "4.0.9",
@@ -5664,6 +5789,18 @@
"@types/d3-time": "*" "@types/d3-time": "*"
} }
}, },
"node_modules/@types/d3-scale-chromatic": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
"integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==",
"license": "MIT"
},
"node_modules/@types/d3-selection": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
"license": "MIT"
},
"node_modules/@types/d3-shape": { "node_modules/@types/d3-shape": {
"version": "3.1.8", "version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
@@ -5679,12 +5816,37 @@
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/d3-time-format": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz",
"integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==",
"license": "MIT"
},
"node_modules/@types/d3-timer": { "node_modules/@types/d3-timer": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/d3-transition": {
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-zoom": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
"license": "MIT",
"dependencies": {
"@types/d3-interpolate": "*",
"@types/d3-selection": "*"
}
},
"node_modules/@types/debug": { "node_modules/@types/debug": {
"version": "4.1.12", "version": "4.1.12",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
@@ -5736,6 +5898,12 @@
"@types/estree": "*" "@types/estree": "*"
} }
}, },
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"license": "MIT"
},
"node_modules/@types/hast": { "node_modules/@types/hast": {
"version": "3.0.4", "version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
@@ -7095,12 +7263,6 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/at-least-node": { "node_modules/at-least-node": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz",
@@ -7172,17 +7334,6 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/axios": {
"version": "1.13.5",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
"integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/axobject-query": { "node_modules/axobject-query": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
@@ -7670,18 +7821,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/comma-separated-tokens": { "node_modules/comma-separated-tokens": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
@@ -7866,6 +8005,47 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/d3": {
"version": "7.9.0",
"resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz",
"integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==",
"license": "ISC",
"dependencies": {
"d3-array": "3",
"d3-axis": "3",
"d3-brush": "3",
"d3-chord": "3",
"d3-color": "3",
"d3-contour": "4",
"d3-delaunay": "6",
"d3-dispatch": "3",
"d3-drag": "3",
"d3-dsv": "3",
"d3-ease": "3",
"d3-fetch": "3",
"d3-force": "3",
"d3-format": "3",
"d3-geo": "3",
"d3-hierarchy": "3",
"d3-interpolate": "3",
"d3-path": "3",
"d3-polygon": "3",
"d3-quadtree": "3",
"d3-random": "3",
"d3-scale": "4",
"d3-scale-chromatic": "3",
"d3-selection": "3",
"d3-shape": "3",
"d3-time": "3",
"d3-time-format": "4",
"d3-timer": "3",
"d3-transition": "3",
"d3-zoom": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-array": { "node_modules/d3-array": {
"version": "3.2.4", "version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
@@ -7878,6 +8058,43 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/d3-axis": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz",
"integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-brush": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz",
"integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "3",
"d3-transition": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-chord": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz",
"integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==",
"license": "ISC",
"dependencies": {
"d3-path": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": { "node_modules/d3-color": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
@@ -7887,6 +8104,121 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/d3-contour": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz",
"integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==",
"license": "ISC",
"dependencies": {
"d3-array": "^3.2.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-delaunay": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
"integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==",
"license": "ISC",
"dependencies": {
"delaunator": "5"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dispatch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-drag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-selection": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dsv": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz",
"integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==",
"license": "ISC",
"dependencies": {
"commander": "7",
"iconv-lite": "0.6",
"rw": "1"
},
"bin": {
"csv2json": "bin/dsv2json.js",
"csv2tsv": "bin/dsv2dsv.js",
"dsv2dsv": "bin/dsv2dsv.js",
"dsv2json": "bin/dsv2json.js",
"json2csv": "bin/json2dsv.js",
"json2dsv": "bin/json2dsv.js",
"json2tsv": "bin/json2dsv.js",
"tsv2csv": "bin/dsv2dsv.js",
"tsv2json": "bin/dsv2json.js"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dsv/node_modules/commander": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
"license": "MIT",
"engines": {
"node": ">= 10"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-fetch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz",
"integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==",
"license": "ISC",
"dependencies": {
"d3-dsv": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-force": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
"integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-quadtree": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": { "node_modules/d3-format": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
@@ -7896,6 +8228,27 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/d3-geo": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
"integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2.5.0 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-hierarchy": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz",
"integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": { "node_modules/d3-interpolate": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
@@ -7917,46 +8270,33 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/d3-sankey": { "node_modules/d3-polygon": {
"version": "0.12.3", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz",
"integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==",
"license": "BSD-3-Clause", "license": "ISC",
"dependencies": { "engines": {
"d3-array": "1 - 2", "node": ">=12"
"d3-shape": "^1.2.0"
} }
}, },
"node_modules/d3-sankey/node_modules/d3-array": { "node_modules/d3-quadtree": {
"version": "2.12.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
"integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
"license": "BSD-3-Clause", "license": "ISC",
"dependencies": { "engines": {
"internmap": "^1.0.0" "node": ">=12"
} }
}, },
"node_modules/d3-sankey/node_modules/d3-path": { "node_modules/d3-random": {
"version": "1.0.9", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz",
"integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==",
"license": "BSD-3-Clause" "license": "ISC",
}, "engines": {
"node_modules/d3-sankey/node_modules/d3-shape": { "node": ">=12"
"version": "1.3.7",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz",
"integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==",
"license": "BSD-3-Clause",
"dependencies": {
"d3-path": "1"
} }
}, },
"node_modules/d3-sankey/node_modules/internmap": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz",
"integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==",
"license": "ISC"
},
"node_modules/d3-scale": { "node_modules/d3-scale": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
@@ -7973,6 +8313,29 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/d3-scale-chromatic": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
"integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-interpolate": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-selection": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": { "node_modules/d3-shape": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
@@ -8018,6 +8381,41 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/d3-transition": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-dispatch": "1 - 3",
"d3-ease": "1 - 3",
"d3-interpolate": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"d3-selection": "2 - 3"
}
},
"node_modules/d3-zoom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "2 - 3",
"d3-transition": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/damerau-levenshtein": { "node_modules/damerau-levenshtein": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@@ -8221,13 +8619,13 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/delayed-stream": { "node_modules/delaunator": {
"version": "1.0.0", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", "integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==",
"license": "MIT", "license": "ISC",
"engines": { "dependencies": {
"node": ">=0.4.0" "robust-predicates": "^3.0.2"
} }
}, },
"node_modules/dequal": { "node_modules/dequal": {
@@ -9318,26 +9716,6 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/for-each": { "node_modules/for-each": {
"version": "0.3.5", "version": "0.3.5",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
@@ -9353,22 +9731,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/frac": { "node_modules/frac": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
@@ -9894,6 +10256,18 @@
"node": ">= 14" "node": ">= 14"
} }
}, },
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/idb": { "node_modules/idb": {
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
@@ -12546,12 +12920,6 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/punycode": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -13122,6 +13490,12 @@
"node": ">= 0.8.15" "node": ">= 0.8.15"
} }
}, },
"node_modules/robust-predicates": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz",
"integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==",
"license": "Unlicense"
},
"node_modules/rollup": { "node_modules/rollup": {
"version": "2.79.2", "version": "2.79.2",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz",
@@ -13161,6 +13535,12 @@
"queue-microtask": "^1.2.2" "queue-microtask": "^1.2.2"
} }
}, },
"node_modules/rw": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
"license": "BSD-3-Clause"
},
"node_modules/safe-array-concat": { "node_modules/safe-array-concat": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz",
@@ -13233,6 +13613,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/saxes": { "node_modules/saxes": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
@@ -14891,15 +15277,6 @@
"d3-timer": "^3.0.1" "d3-timer": "^3.0.1"
} }
}, },
"node_modules/victory-vendor/node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "7.3.1", "version": "7.3.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",

View File

@@ -12,18 +12,18 @@
"test:watch": "vitest" "test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"@ciphera-net/ui": "^0.2.5", "@ciphera-net/ui": "^0.2.7",
"@ducanh2912/next-pwa": "^10.2.9", "@ducanh2912/next-pwa": "^10.2.9",
"@phosphor-icons/react": "^2.1.10", "@phosphor-icons/react": "^2.1.10",
"@simplewebauthn/browser": "^13.2.2", "@simplewebauthn/browser": "^13.2.2",
"@stripe/react-stripe-js": "^5.6.0", "@stripe/react-stripe-js": "^5.6.0",
"@stripe/stripe-js": "^8.7.0", "@stripe/stripe-js": "^8.7.0",
"@tanstack/react-virtual": "^3.13.21", "@tanstack/react-virtual": "^3.13.21",
"axios": "^1.13.2", "@types/d3": "^7.4.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"cobe": "^0.6.5", "cobe": "^0.6.5",
"country-flag-icons": "^1.6.4", "country-flag-icons": "^1.6.4",
"d3-sankey": "^0.12.3", "d3": "^7.9.0",
"d3-scale": "^4.0.2", "d3-scale": "^4.0.2",
"framer-motion": "^12.23.26", "framer-motion": "^12.23.26",
"html-to-image": "^1.11.13", "html-to-image": "^1.11.13",
@@ -52,7 +52,6 @@
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@types/d3-sankey": "^0.12.5",
"@types/d3-scale": "^4.0.9", "@types/d3-scale": "^4.0.9",
"@types/node": "^20.14.12", "@types/node": "^20.14.12",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",

View File

@@ -0,0 +1,313 @@
/**
* Pulse Frustration Tracking Add-on
* Detects rage clicks and dead clicks. Requires the core Pulse script.
* Opt-out: add data-no-rage or data-no-dead to either script tag.
*/
(function() {
'use strict';
// * Capture own script tag for opt-out attributes (must be at parse time)
// * Fallback to querySelector for dynamic insertion (e.g. tag managers) where currentScript is null
var addonScript = document.currentScript || document.querySelector('script[src*="script.frustration"]');
var MAX_WAIT = 5000;
var POLL_INTERVAL = 50;
var waited = 0;
function init() {
var pulse = window.pulse;
if (!pulse || typeof pulse.track !== 'function' || typeof pulse.cleanPath !== 'function') {
waited += POLL_INTERVAL;
if (waited < MAX_WAIT) {
setTimeout(init, POLL_INTERVAL);
} else {
if (typeof console !== 'undefined' && console.warn) {
console.warn('Pulse frustration add-on: core script not detected. Frustration tracking disabled.');
}
}
return;
}
var trackCustomEvent = pulse.track;
var cleanPath = pulse.cleanPath;
// * Check opt-out attributes on both the add-on script tag and the core script tag
var coreScript = document.querySelector('script[data-domain]');
function hasOptOut(attr) {
return (addonScript && addonScript.hasAttribute(attr)) ||
(coreScript && coreScript.hasAttribute(attr));
}
// * Strip HTML tags from a string (used for sanitizing attribute values)
function stripHtml(str) {
if (typeof str !== 'string') return '';
return str.replace(/<[^>]*>/g, '').trim();
}
// * Build a compact element identifier string for frustration tracking
// * Format: tag#id.class1.class2[href="/path"]
function getElementIdentifier(el) {
if (!el || !el.tagName) return '';
var result = el.tagName.toLowerCase();
// * Add #id if present
if (el.id) {
result += '#' + stripHtml(el.id);
}
// * Add classes (handle SVG elements where className is SVGAnimatedString)
var rawClassName = el.className;
if (rawClassName && typeof rawClassName !== 'string' && rawClassName.baseVal !== undefined) {
rawClassName = rawClassName.baseVal;
}
if (typeof rawClassName === 'string' && rawClassName.trim()) {
var classes = rawClassName.trim().split(/\s+/);
var filtered = [];
for (var ci = 0; ci < classes.length && filtered.length < 5; ci++) {
var cls = classes[ci];
if (cls.length > 50) continue;
if (/^(ng-|js-|is-|has-|animate)/.test(cls)) continue;
filtered.push(cls);
}
if (filtered.length > 0) {
result += '.' + filtered.join('.');
}
}
// * Add key attributes
var attrs = ['href', 'role', 'type', 'name', 'data-action'];
for (var ai = 0; ai < attrs.length; ai++) {
var attrName = attrs[ai];
var attrVal = el.getAttribute(attrName);
if (attrVal !== null && attrVal !== '') {
var sanitized = stripHtml(attrVal);
if (sanitized.length > 50) sanitized = sanitized.substring(0, 50);
result += '[' + attrName + '="' + sanitized + '"]';
}
}
// * Truncate to max 200 chars
if (result.length > 200) {
result = result.substring(0, 200);
}
return result;
}
// * Auto-track rage clicks (rapid repeated clicks on the same element)
// * Fires rage_click when same element is clicked 3+ times within 800ms
if (!hasOptOut('data-no-rage')) {
var rageClickHistory = {}; // * selector -> { times: [timestamps], lastFired: 0 }
var RAGE_CLICK_THRESHOLD = 3;
var RAGE_CLICK_WINDOW = 800;
var RAGE_CLICK_DEBOUNCE = 5000;
var RAGE_CLEANUP_INTERVAL = 10000;
// * Cleanup stale rage click entries every 10 seconds
setInterval(function() {
var now = Date.now();
for (var key in rageClickHistory) {
if (!rageClickHistory.hasOwnProperty(key)) continue;
var entry = rageClickHistory[key];
// * Remove if last click was more than 10 seconds ago
if (entry.times.length === 0 || now - entry.times[entry.times.length - 1] > RAGE_CLEANUP_INTERVAL) {
delete rageClickHistory[key];
}
}
}, RAGE_CLEANUP_INTERVAL);
document.addEventListener('click', function(e) {
var el = e.target;
if (!el || !el.tagName) return;
var selector = getElementIdentifier(el);
if (!selector) return;
var now = Date.now();
var currentPath = cleanPath();
if (!rageClickHistory[selector]) {
rageClickHistory[selector] = { times: [], lastFired: 0 };
}
var entry = rageClickHistory[selector];
// * Add current click timestamp
entry.times.push(now);
// * Remove clicks outside the time window
while (entry.times.length > 0 && now - entry.times[0] > RAGE_CLICK_WINDOW) {
entry.times.shift();
}
// * Check if rage click threshold is met
if (entry.times.length >= RAGE_CLICK_THRESHOLD) {
// * Skip if user is selecting text (triple-click to select paragraph)
try {
var sel = window.getSelection();
if (sel && sel.toString().trim().length > 0) {
entry.times = [];
return;
}
} catch (ex) {}
// * Debounce: max one rage_click per element per 5 seconds
if (now - entry.lastFired >= RAGE_CLICK_DEBOUNCE) {
var clickCount = entry.times.length;
trackCustomEvent('rage_click', {
selector: selector,
click_count: String(clickCount),
page_path: currentPath,
x: String(Math.round(e.clientX)),
y: String(Math.round(e.clientY))
});
entry.lastFired = now;
}
// * Reset tracker after firing or debounce skip
entry.times = [];
}
}, true); // * Capture phase
}
// * Auto-track dead clicks (clicks on interactive elements that produce no effect)
// * Fires dead_click when an interactive element is clicked but no DOM change, navigation,
// * or network request occurs within 1 second
if (!hasOptOut('data-no-dead')) {
var INTERACTIVE_SELECTOR = 'a,button,input,select,textarea,[role="button"],[role="link"],[role="tab"],[role="menuitem"],[onclick],[tabindex]:not([tabindex="-1"])';
var DEAD_CLICK_DEBOUNCE = 10000;
var DEAD_CLEANUP_INTERVAL = 30000;
var deadClickDebounce = {}; // * selector -> lastFiredTimestamp
// * Cleanup stale dead click debounce entries every 30 seconds
setInterval(function() {
var now = Date.now();
for (var key in deadClickDebounce) {
if (!deadClickDebounce.hasOwnProperty(key)) continue;
if (now - deadClickDebounce[key] > DEAD_CLEANUP_INTERVAL) {
delete deadClickDebounce[key];
}
}
}, DEAD_CLEANUP_INTERVAL);
// * Polyfill check for Element.matches
var matchesFn = (function() {
var ep = Element.prototype;
return ep.matches || ep.msMatchesSelector || ep.webkitMatchesSelector || null;
})();
// * Find the nearest interactive element by walking up max 3 levels
function findInteractiveElement(el) {
if (!matchesFn) return null;
var depth = 0;
var current = el;
while (current && depth <= 3) {
if (current.nodeType === 1 && matchesFn.call(current, INTERACTIVE_SELECTOR)) {
return current;
}
current = current.parentElement;
depth++;
}
return null;
}
document.addEventListener('click', function(e) {
var target = findInteractiveElement(e.target);
if (!target) return;
// * Skip form inputs — clicking to focus/interact is expected, not a dead click
var tag = target.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
var selector = getElementIdentifier(target);
if (!selector) return;
var now = Date.now();
// * Debounce: max one dead_click per element per 10 seconds
if (deadClickDebounce[selector] && now - deadClickDebounce[selector] < DEAD_CLICK_DEBOUNCE) {
return;
}
var currentPath = cleanPath();
var clickX = String(Math.round(e.clientX));
var clickY = String(Math.round(e.clientY));
var effectDetected = false;
var hrefBefore = location.href;
var mutationObs = null;
var perfObs = null;
var cleanupTimer = null;
var popstateHandler = null;
var hashchangeHandler = null;
function cleanup() {
if (mutationObs) { try { mutationObs.disconnect(); } catch (ex) {} mutationObs = null; }
if (perfObs) { try { perfObs.disconnect(); } catch (ex) {} perfObs = null; }
if (cleanupTimer) { clearTimeout(cleanupTimer); cleanupTimer = null; }
if (popstateHandler) { window.removeEventListener('popstate', popstateHandler); popstateHandler = null; }
if (hashchangeHandler) { window.removeEventListener('hashchange', hashchangeHandler); hashchangeHandler = null; }
}
function onEffect() {
effectDetected = true;
cleanup();
}
// * Set up MutationObserver to detect DOM changes on the element, its parent, and body
if (typeof MutationObserver !== 'undefined') {
try {
mutationObs = new MutationObserver(function() {
onEffect();
});
var mutOpts = { childList: true, attributes: true, characterData: true, subtree: true };
mutationObs.observe(target, mutOpts);
var parent = target.parentElement;
if (parent && parent.tagName !== 'HTML' && parent.tagName !== 'BODY') {
mutationObs.observe(parent, { childList: true });
}
// * Also observe body for top-level DOM changes (modals, drawers, overlays, toasts)
mutationObs.observe(document.body, { childList: true, attributes: true });
} catch (ex) {
mutationObs = null;
}
}
// * Set up PerformanceObserver to detect network requests
if (typeof PerformanceObserver !== 'undefined') {
try {
perfObs = new PerformanceObserver(function() {
onEffect();
});
perfObs.observe({ type: 'resource' });
} catch (ex) {
perfObs = null;
}
}
// * Listen for SPA navigation events (popstate, hashchange)
popstateHandler = function() { onEffect(); };
hashchangeHandler = function() { onEffect(); };
window.addEventListener('popstate', popstateHandler);
window.addEventListener('hashchange', hashchangeHandler);
// * After 1 second, check if any effect was detected
cleanupTimer = setTimeout(function() {
cleanup();
// * Also check if navigation occurred
if (effectDetected || location.href !== hrefBefore) return;
deadClickDebounce[selector] = Date.now();
trackCustomEvent('dead_click', {
selector: selector,
page_path: currentPath,
x: clickX,
y: clickY
});
}, 1000);
}, true); // * Capture phase
}
}
// * Start immediately — if core is already loaded, init succeeds on the first call
init();
})();

View File

@@ -18,6 +18,7 @@
return; return;
} }
// * Get domain from script tag // * Get domain from script tag
const script = document.currentScript || document.querySelector('script[data-domain]'); const script = document.currentScript || document.querySelector('script[data-domain]');
if (!script || !script.getAttribute('data-domain')) { if (!script || !script.getAttribute('data-domain')) {
@@ -32,80 +33,25 @@
const ttlHours = storageMode === 'local' ? parseFloat(script.getAttribute('data-storage-ttl') || '24') : 0; const ttlHours = storageMode === 'local' ? parseFloat(script.getAttribute('data-storage-ttl') || '24') : 0;
const ttlMs = ttlHours > 0 ? ttlHours * 60 * 60 * 1000 : 0; const ttlMs = ttlHours > 0 ? ttlHours * 60 * 60 * 1000 : 0;
// * Performance Monitoring (Core Web Vitals) State
let currentEventId = null; let currentEventId = null;
let metrics = { lcp: 0, cls: 0, inp: 0 };
let lcpObserved = false;
let clsObserved = false;
let performanceInsightsEnabled = false;
// * Time-on-page tracking: records when the current pageview started // * Time-on-page tracking: records when the current pageview started
var pageStartTime = 0; var pageStartTime = 0;
// * Minimal Web Vitals Observer var metricsSent = false;
function observeMetrics() {
try {
if (typeof PerformanceObserver === 'undefined') return;
// * LCP (Largest Contentful Paint) - fires when the browser has determined the LCP element (often 24s+ after load)
new PerformanceObserver((entryList) => {
const entries = entryList.getEntries();
const lastEntry = entries[entries.length - 1];
if (lastEntry) {
metrics.lcp = lastEntry.startTime;
lcpObserved = true;
}
}).observe({ type: 'largest-contentful-paint', buffered: true });
// * CLS (Cumulative Layout Shift) - accumulates when elements shift after load
new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
if (!entry.hadRecentInput) {
metrics.cls += entry.value;
clsObserved = true;
}
}
}).observe({ type: 'layout-shift', buffered: true });
// * INP (Interaction to Next Paint) - Simplified (track max duration)
new PerformanceObserver((entryList) => {
const entries = entryList.getEntries();
for (const entry of entries) {
// * Track longest interaction
if (entry.duration > metrics.inp) metrics.inp = entry.duration;
}
}).observe({ type: 'event', buffered: true, durationThreshold: 16 });
} catch (e) {
// * Browser doesn't support PerformanceObserver or specific entry types
}
}
function sendMetrics() { function sendMetrics() {
if (!currentEventId) return; if (!currentEventId || metricsSent) return;
// * Calculate time-on-page in seconds (always sent, even without performance insights) // * Calculate time-on-page in seconds
var durationSec = pageStartTime > 0 ? Math.round((Date.now() - pageStartTime) / 1000) : 0; var durationSec = pageStartTime > 0 ? Math.round((Date.now() - pageStartTime) / 1000) : 0;
var payload = { event_id: currentEventId }; // * Skip if nothing to send (no duration)
if (durationSec <= 0) return;
// * Always include duration if we have a valid measurement metricsSent = true;
if (durationSec > 0) payload.duration = durationSec;
// * Only include Web Vitals when performance insights are enabled var data = JSON.stringify({ event_id: currentEventId, duration: durationSec });
if (performanceInsightsEnabled) {
payload.inp = metrics.inp;
// * Only include LCP/CLS when the browser actually reported them. Sending 0 overwrites
// * the DB before LCP/CLS have fired (they fire late). The backend does partial updates
// * and leaves unset fields unchanged.
if (lcpObserved) payload.lcp = metrics.lcp;
if (clsObserved) payload.cls = metrics.cls;
}
// * Skip if nothing to send (no duration and no vitals)
if (!payload.duration && !performanceInsightsEnabled) return;
var data = JSON.stringify(payload);
if (navigator.sendBeacon) { if (navigator.sendBeacon) {
navigator.sendBeacon(apiUrl + '/api/v1/metrics', new Blob([data], {type: 'application/json'})); navigator.sendBeacon(apiUrl + '/api/v1/metrics', new Blob([data], {type: 'application/json'}));
@@ -119,17 +65,13 @@
} }
} }
// * Start observing metrics immediately (buffered observers will capture early metrics)
// * Metrics will only be sent if performance insights are enabled (checked in sendMetrics)
observeMetrics();
// * Send metrics when user leaves or hides the page // * Send metrics when user leaves or hides the page
document.addEventListener('visibilitychange', () => { // * visibilitychange is the primary signal, pagehide is the fallback
if (document.visibilityState === 'hidden') { // * for browsers/scenarios where visibilitychange doesn't fire (tab close, mobile app kill)
// * Delay metrics slightly so in-flight LCP/CLS callbacks can run before we send document.addEventListener('visibilitychange', function() {
setTimeout(sendMetrics, 150); if (document.visibilityState === 'hidden') sendMetrics();
}
}); });
window.addEventListener('pagehide', sendMetrics);
// * Memory cache for session ID (fallback if storage is unavailable) // * Memory cache for session ID (fallback if storage is unavailable)
let cachedSessionId = null; let cachedSessionId = null;
@@ -307,9 +249,6 @@
// * Track pageview // * Track pageview
function trackPageview() { function trackPageview() {
var routeChangeTime = performance.now();
var isSpaNav = !!currentEventId;
const path = cleanPath(); const path = cleanPath();
// * Skip if same path was just tracked (refresh dedup) // * Skip if same path was just tracked (refresh dedup)
@@ -322,9 +261,6 @@
sendMetrics(); sendMetrics();
} }
metrics = { lcp: 0, cls: 0, inp: 0 };
lcpObserved = false;
clsObserved = false;
currentEventId = null; currentEventId = null;
pageStartTime = 0; pageStartTime = 0;
// * Only send external referrer on the first pageview (landing page). // * Only send external referrer on the first pageview (landing page).
@@ -348,11 +284,6 @@
height: window.innerHeight || window.screen.height, height: window.innerHeight || window.screen.height,
}; };
// * Skip bots with no screen dimensions (0x0)
if (screenSize.width === 0 && screenSize.height === 0) {
return;
}
const payload = { const payload = {
domain: domain, domain: domain,
path: path, path: path,
@@ -376,21 +307,7 @@
if (data && data.id) { if (data && data.id) {
currentEventId = data.id; currentEventId = data.id;
pageStartTime = Date.now(); pageStartTime = Date.now();
// * For SPA navigations the browser never emits a new largest-contentful-paint metricsSent = false;
// * (LCP is only for full document loads). After the new view has had time to
// * paint, we record time-from-route-change as an LCP proxy so /products etc.
// * get a value. If the user navigates away before the delay, we leave LCP unset.
if (isSpaNav) {
var thatId = data.id;
// * Run soon so we set lcpObserved before the user leaves; 500ms was too long
// * and we often sent metrics (next nav or visibilitychange+150ms) before it ran.
setTimeout(function() {
if (!lcpObserved && currentEventId === thatId) {
metrics.lcp = Math.round(performance.now() - routeChangeTime);
lcpObserved = true;
}
}, 100);
}
} }
}).catch(() => { }).catch(() => {
// * Silently fail - don't interrupt user experience // * Silently fail - don't interrupt user experience
@@ -496,6 +413,7 @@
// * Expose pulse.track() for custom events (e.g. pulse.track('signup_click')) // * Expose pulse.track() for custom events (e.g. pulse.track('signup_click'))
window.pulse = window.pulse || {}; window.pulse = window.pulse || {};
window.pulse.track = trackCustomEvent; window.pulse.track = trackCustomEvent;
window.pulse.cleanPath = cleanPath;
// * Auto-track 404 error pages (on by default) // * Auto-track 404 error pages (on by default)
// * Detects pages where document.title contains "404" or "not found" // * Detects pages where document.title contains "404" or "not found"
@@ -553,255 +471,6 @@
}, { passive: true }); }, { passive: true });
} }
// * Strip HTML tags from a string (used for sanitizing attribute values)
function stripHtml(str) {
if (typeof str !== 'string') return '';
return str.replace(/<[^>]*>/g, '').trim();
}
// * Build a compact element identifier string for frustration tracking
// * Format: tag#id.class1.class2[href="/path"]
function getElementIdentifier(el) {
if (!el || !el.tagName) return '';
var result = el.tagName.toLowerCase();
// * Add #id if present
if (el.id) {
result += '#' + stripHtml(el.id);
}
// * Add classes (handle SVG elements where className is SVGAnimatedString)
var rawClassName = el.className;
if (rawClassName && typeof rawClassName !== 'string' && rawClassName.baseVal !== undefined) {
rawClassName = rawClassName.baseVal;
}
if (typeof rawClassName === 'string' && rawClassName.trim()) {
var classes = rawClassName.trim().split(/\s+/);
var filtered = [];
for (var ci = 0; ci < classes.length && filtered.length < 5; ci++) {
var cls = classes[ci];
if (cls.length > 50) continue;
if (/^(ng-|js-|is-|has-|animate)/.test(cls)) continue;
filtered.push(cls);
}
if (filtered.length > 0) {
result += '.' + filtered.join('.');
}
}
// * Add key attributes
var attrs = ['href', 'role', 'type', 'name', 'data-action'];
for (var ai = 0; ai < attrs.length; ai++) {
var attrName = attrs[ai];
var attrVal = el.getAttribute(attrName);
if (attrVal !== null && attrVal !== '') {
var sanitized = stripHtml(attrVal);
if (sanitized.length > 50) sanitized = sanitized.substring(0, 50);
result += '[' + attrName + '="' + sanitized + '"]';
}
}
// * Truncate to max 200 chars
if (result.length > 200) {
result = result.substring(0, 200);
}
return result;
}
// * Auto-track rage clicks (rapid repeated clicks on the same element)
// * Fires rage_click when same element is clicked 3+ times within 800ms
// * Opt-out: add data-no-rage to the script tag
if (!script.hasAttribute('data-no-rage')) {
var rageClickHistory = {}; // * selector -> { times: [timestamps], lastFired: 0 }
var RAGE_CLICK_THRESHOLD = 3;
var RAGE_CLICK_WINDOW = 800;
var RAGE_CLICK_DEBOUNCE = 5000;
var RAGE_CLEANUP_INTERVAL = 10000;
// * Cleanup stale rage click entries every 10 seconds
setInterval(function() {
var now = Date.now();
for (var key in rageClickHistory) {
if (!rageClickHistory.hasOwnProperty(key)) continue;
var entry = rageClickHistory[key];
// * Remove if last click was more than 10 seconds ago
if (entry.times.length === 0 || now - entry.times[entry.times.length - 1] > RAGE_CLEANUP_INTERVAL) {
delete rageClickHistory[key];
}
}
}, RAGE_CLEANUP_INTERVAL);
document.addEventListener('click', function(e) {
var el = e.target;
if (!el || !el.tagName) return;
var selector = getElementIdentifier(el);
if (!selector) return;
var now = Date.now();
var currentPath = cleanPath();
if (!rageClickHistory[selector]) {
rageClickHistory[selector] = { times: [], lastFired: 0 };
}
var entry = rageClickHistory[selector];
// * Add current click timestamp
entry.times.push(now);
// * Remove clicks outside the time window
while (entry.times.length > 0 && now - entry.times[0] > RAGE_CLICK_WINDOW) {
entry.times.shift();
}
// * Check if rage click threshold is met
if (entry.times.length >= RAGE_CLICK_THRESHOLD) {
// * Debounce: max one rage_click per element per 5 seconds
if (now - entry.lastFired >= RAGE_CLICK_DEBOUNCE) {
var clickCount = entry.times.length;
trackCustomEvent('rage_click', {
selector: selector,
click_count: String(clickCount),
page_path: currentPath,
x: String(Math.round(e.clientX)),
y: String(Math.round(e.clientY))
});
entry.lastFired = now;
}
// * Reset tracker after firing or debounce skip
entry.times = [];
}
}, true); // * Capture phase
}
// * Auto-track dead clicks (clicks on interactive elements that produce no effect)
// * Fires dead_click when an interactive element is clicked but no DOM change, navigation,
// * or network request occurs within 1 second
// * Opt-out: add data-no-dead to the script tag
if (!script.hasAttribute('data-no-dead')) {
var INTERACTIVE_SELECTOR = 'a,button,input,select,textarea,[role="button"],[role="link"],[role="tab"],[role="menuitem"],[onclick],[tabindex]';
var DEAD_CLICK_DEBOUNCE = 10000;
var DEAD_CLEANUP_INTERVAL = 30000;
var deadClickDebounce = {}; // * selector -> lastFiredTimestamp
// * Cleanup stale dead click debounce entries every 30 seconds
setInterval(function() {
var now = Date.now();
for (var key in deadClickDebounce) {
if (!deadClickDebounce.hasOwnProperty(key)) continue;
if (now - deadClickDebounce[key] > DEAD_CLEANUP_INTERVAL) {
delete deadClickDebounce[key];
}
}
}, DEAD_CLEANUP_INTERVAL);
// * Polyfill check for Element.matches
var matchesFn = (function() {
var ep = Element.prototype;
return ep.matches || ep.msMatchesSelector || ep.webkitMatchesSelector || null;
})();
// * Find the nearest interactive element by walking up max 3 levels
function findInteractiveElement(el) {
if (!matchesFn) return null;
var depth = 0;
var current = el;
while (current && depth <= 3) {
if (current.nodeType === 1 && matchesFn.call(current, INTERACTIVE_SELECTOR)) {
return current;
}
current = current.parentElement;
depth++;
}
return null;
}
document.addEventListener('click', function(e) {
var target = findInteractiveElement(e.target);
if (!target) return;
// * Skip form inputs — clicking to focus/interact is expected, not a dead click
var tag = target.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
var selector = getElementIdentifier(target);
if (!selector) return;
var now = Date.now();
// * Debounce: max one dead_click per element per 10 seconds
if (deadClickDebounce[selector] && now - deadClickDebounce[selector] < DEAD_CLICK_DEBOUNCE) {
return;
}
var currentPath = cleanPath();
var clickX = String(Math.round(e.clientX));
var clickY = String(Math.round(e.clientY));
var effectDetected = false;
var hrefBefore = location.href;
var mutationObs = null;
var perfObs = null;
var cleanupTimer = null;
function cleanup() {
if (mutationObs) { try { mutationObs.disconnect(); } catch (ex) {} mutationObs = null; }
if (perfObs) { try { perfObs.disconnect(); } catch (ex) {} perfObs = null; }
if (cleanupTimer) { clearTimeout(cleanupTimer); cleanupTimer = null; }
}
function onEffect() {
effectDetected = true;
cleanup();
}
// * Set up MutationObserver to detect DOM changes on the element and its parent
if (typeof MutationObserver !== 'undefined') {
try {
mutationObs = new MutationObserver(function() {
onEffect();
});
var mutOpts = { childList: true, attributes: true, characterData: true, subtree: true };
mutationObs.observe(target, mutOpts);
var parent = target.parentElement;
if (parent && parent.tagName !== 'HTML' && parent.tagName !== 'BODY') {
mutationObs.observe(parent, { childList: true });
}
} catch (ex) {
mutationObs = null;
}
}
// * Set up PerformanceObserver to detect network requests
if (typeof PerformanceObserver !== 'undefined') {
try {
perfObs = new PerformanceObserver(function() {
onEffect();
});
perfObs.observe({ type: 'resource' });
} catch (ex) {
perfObs = null;
}
}
// * After 1 second, check if any effect was detected
cleanupTimer = setTimeout(function() {
cleanup();
// * Also check if navigation occurred
if (effectDetected || location.href !== hrefBefore) return;
deadClickDebounce[selector] = Date.now();
trackCustomEvent('dead_click', {
selector: selector,
page_path: currentPath,
x: clickX,
y: clickY
});
}, 1000);
}, true); // * Capture phase
}
// * Auto-track outbound link clicks and file downloads (on by default) // * Auto-track outbound link clicks and file downloads (on by default)
// * Opt-out: add data-no-outbound or data-no-downloads to the script tag // * Opt-out: add data-no-outbound or data-no-downloads to the script tag
var trackOutbound = !script.hasAttribute('data-no-outbound'); var trackOutbound = !script.hasAttribute('data-no-outbound');

View File

@@ -82,6 +82,15 @@
transform-style: preserve-3d; transform-style: preserve-3d;
} }
/* * Scrollbar hide - for horizontal scroll navs */
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
/* * Animations */ /* * Animations */
@keyframes float { @keyframes float {
0%, 100% { transform: translateY(0px); } 0%, 100% { transform: translateY(0px); }

View File

@@ -27,11 +27,16 @@ const config: Config = {
'0%': { opacity: '0' }, '0%': { opacity: '0' },
'100%': { opacity: '1' }, '100%': { opacity: '1' },
}, },
shimmer: {
'0%': { transform: 'translateX(-100%)' },
'100%': { transform: 'translateX(400%)' },
},
}, },
animation: { animation: {
'cell-highlight': 'cell-highlight 0.5s ease forwards', 'cell-highlight': 'cell-highlight 0.5s ease forwards',
'cell-flash': 'cell-flash 0.6s ease forwards', 'cell-flash': 'cell-flash 0.6s ease forwards',
'fade-in': 'fade-in 150ms ease-out', 'fade-in': 'fade-in 150ms ease-out',
shimmer: 'shimmer 1.2s ease-in-out infinite',
}, },
fontFamily: { fontFamily: {
sans: ['var(--font-plus-jakarta-sans)', 'system-ui', 'sans-serif'], sans: ['var(--font-plus-jakarta-sans)', 'system-ui', 'sans-serif'],