diff --git a/CHANGELOG.md b/CHANGELOG.md index 7985ffdfc..4fd9ede19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Improved +- **The Behavior page now tells you where the frustration is.** Click any row in "Frustration by page" to focus the whole page on it — the rage click and dead click tables instantly narrow to that page's elements, and the filter lives in the URL so you can share a link straight to "here's what's broken on /pricing". The old donut chart is replaced by a real day-by-day trend of rage and dead clicks, and scroll depth is now four clean bars instead of a radar chart. Stat changes versus the previous period only appear when there's enough history to compare against, so you'll no longer see a meaningless "-100%" on a brand-new site. +- **The Search page is now the full Search Console experience.** Alongside top queries and pages you can now browse clicks by country, by device, and — most usefully — an Opportunities view: queries ranking just off the first page where a better title or snippet could win real clicks, with the potential shown next to each. Expanding a query now also charts how its ranking position moved over the period. Clicks and impressions finally get their own scales on the traffic chart, so clicks no longer flatline against the impression counts. A "new queries" chip lists exactly which searches appeared this period, the page shows when your data last synced, and the view, page number, and expanded row all live in the URL. +- **The CDN page now shows what your cache is actually doing.** The bandwidth chart splits traffic into "served from cache" versus "fetched from your origin", with the cache hit rate for each day in the tooltip — so you can see your CDN earning its keep (or not). Requests get the same cached/uncached split, a new chart tracks your origin's response time over the period, error bars now use honest colors (redirects are no longer painted as errors), and the geographic breakdown is a ranked list of datacenters with their real share of traffic. +- **Funnels got a full page of their own.** Every funnel now opens as a real page with its own link you can share or bookmark. The list shows compact summary rows — the actual pages and events in each funnel, the conversion rate with a comparison to the previous period, and a small trend line — instead of expanding accordions. The detail page has a stats band (conversion, visitors, converted, biggest drop-off, median time), a visual funnel canvas where each step is a column filled to its conversion level with connecting bands between steps, and everything — date range, filters, selected step — lives in the URL so a refresh or a shared link lands exactly where you were. +- **Funnel editing no longer resets your conversion window.** Editing a funnel used to silently rewrite its conversion window to 30 days no matter what you had configured. The create/edit dialog now has a real window control (with 24h, 72h, 7d, 14d and 30d presets) that saves exactly what it shows. +- **Funnel steps suggest your real pages and events.** When building a funnel, the path field now suggests your most-visited pages and the event field suggests your tracked events — with visit counts — so you don't have to remember exact paths. Free text still works. +- **Journeys controls got out of the data's way.** The oversized control block is now one compact row — steppers for depth and paths, a searchable entry-point picker, and a Columns/Flow switch. Connections between steps are always visible as subtle ribbons, and hovering any page lights up its full path through the site. +- **Pin and share a journey path.** Clicking a page in either journeys view pins it as a "lens" — the whole chain through that page highlights, an exit card shows where people left, and the selection lives in the URL so you can share exactly what you're looking at. From a pinned lens you can create a funnel of that path in one click, and funnel steps link back to journeys starting from that page. +- **No more flashing while journeys and funnels load.** Changing depth, entry point, or date range used to swap the entire page to a loading skeleton on every tweak. Now the data stays visible and morphs to the new values, with a small "Updating…" chip when a fetch takes longer than usual. Failed loads show a clear error with a Retry button instead of pretending you have no data, and an entry filter with no matches says so instead of showing the generic empty state. - **Redesigned Search card on the dashboard.** The Search section of the dashboard has been completely refreshed to match the rest of Pulse. Search queries now show proportional bars so you can visually compare which queries get the most impressions. Hovering a row reveals the impression share percentage. Position badges are now color-coded — green for page 1 rankings, orange for page 2, and red for queries buried beyond page 5. You can switch between your top search queries and top pages using tabs, and expand the full list in a searchable popup without leaving the dashboard. - **Smaller, faster tracking script.** The tracking script is now about 20% smaller. Logic like page path cleaning, referrer filtering, error page detection, and input validation has been moved from your browser to the Pulse server. This means the script loads faster on every page, and Pulse can improve these features without needing you to update anything. - **Automatic 404 page detection.** Pulse now detects error pages (404 / "Page Not Found") automatically on the server by reading your page title — no extra setup needed. Previously this ran in the browser and couldn't be improved without updating the script. Now Pulse can recognize more error page patterns over time, including pages in other languages, without any changes on your end. diff --git a/app/about/page.tsx b/app/about/page.tsx index 5945706f9..f178d5c10 100644 --- a/app/about/page.tsx +++ b/app/about/page.tsx @@ -3,7 +3,12 @@ import { motion } from 'framer-motion' import { CheckCircleIcon, XIcon } from '@ciphera-net/facet' -function ComparisonTable({ title, competitors }: { title: string, competitors: { name: string, isPulse: boolean, features: Record }[] }) { +// * Boolean cells render ✓/✗ and assume true = good. Where the raw boolean would invert +// * sentiment (e.g. "Cookie Banner Required" — false is the win), use { text, good } so +// * the colour follows the meaning, not the boolean. +type FeatureValue = boolean | string | { text: string; good: boolean } + +function ComparisonTable({ title, competitors }: { title: string, competitors: { name: string, isPulse: boolean, features: Record }[] }) { const allFeatures = [ "Cookie Banner Required", "GDPR Compliant", @@ -36,7 +41,9 @@ function ComparisonTable({ title, competitors }: { title: string, competitors: { const val = comp.features[feature] return ( - {val === true ? ( + {typeof val === 'object' ? ( + {val.text} + ) : val === true ? ( ) : val === false ? ( @@ -103,7 +110,7 @@ export default function AboutPage() { name: "Pulse", isPulse: true, features: { - "Cookie Banner Required": false, + "Cookie Banner Required": { text: "None", good: true }, "GDPR Compliant": true, "Script Size": "< 1 KB", "Data Ownership": "Yours", @@ -115,7 +122,7 @@ export default function AboutPage() { name: "Google Analytics 4", isPulse: false, features: { - "Cookie Banner Required": true, + "Cookie Banner Required": { text: "Required", good: false }, "GDPR Compliant": "Complex", "Script Size": "45 KB+", "Data Ownership": "Google's", @@ -134,7 +141,7 @@ export default function AboutPage() { name: "Pulse", isPulse: true, features: { - "Cookie Banner Required": false, + "Cookie Banner Required": { text: "None", good: true }, "GDPR Compliant": true, "Script Size": "< 1 KB", "Data Ownership": "Yours", @@ -146,7 +153,7 @@ export default function AboutPage() { name: "Plausible", isPulse: false, features: { - "Cookie Banner Required": false, + "Cookie Banner Required": { text: "None", good: true }, "GDPR Compliant": true, "Script Size": "< 1 KB", "Data Ownership": "Yours", diff --git a/app/admin/orgs/[id]/page.tsx b/app/admin/orgs/[id]/page.tsx index a1dd90e28..e345bec86 100644 --- a/app/admin/orgs/[id]/page.tsx +++ b/app/admin/orgs/[id]/page.tsx @@ -136,14 +136,14 @@ export default function AdminOrgDetailPage() { Period End: - {org.current_period_end ? formatDateTime(new Date(org.current_period_end)) : '-'} + {org.current_period_end ? formatDateTime(new Date(org.current_period_end)) : '—'} Customer ID: - {org.billing_customer_id || '-'} + {org.billing_customer_id || '—'} Subscription ID: - {org.billing_subscription_id || '-'} + {org.billing_subscription_id || '—'} @@ -207,21 +207,21 @@ export default function AdminOrgDetailPage() { diff --git a/app/admin/orgs/page.tsx b/app/admin/orgs/page.tsx index 9d499e169..1ef628315 100644 --- a/app/admin/orgs/page.tsx +++ b/app/admin/orgs/page.tsx @@ -82,7 +82,7 @@ export default function AdminOrgsPage() { - {org.subscription_status || '-'} + {org.subscription_status || '—'} {new Intl.NumberFormat().format(org.pageview_limit)} diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 2fc06679e..8dbd65672 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -7,7 +7,7 @@ export default function AdminDashboard() {

Organizations

Manage organization plans and limits

@@ -17,7 +17,7 @@ export default function AdminDashboard() {

Quarantine & Reputation

Monitor quarantined traffic and domain reputation

diff --git a/app/admin/quarantine/page.tsx b/app/admin/quarantine/page.tsx index 25d7a4b91..98477be38 100644 --- a/app/admin/quarantine/page.tsx +++ b/app/admin/quarantine/page.tsx @@ -275,7 +275,7 @@ function EventsTab() { {ev.path} - {ev.referrer || '-'} + {ev.referrer || '—'} @@ -568,7 +568,7 @@ export default function AdminQuarantinePage() { onClick={() => setTab(t.key)} className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${ tab === t.key - ? 'border-white text-white' + ? 'border-brand-orange text-white' : 'border-transparent text-neutral-400 hover:text-neutral-200' } ease-apple`} > diff --git a/app/api/favicon/route.ts b/app/api/favicon/route.ts new file mode 100644 index 000000000..195f81a84 --- /dev/null +++ b/app/api/favicon/route.ts @@ -0,0 +1,60 @@ +import { NextRequest, NextResponse } from 'next/server' + +/** + * Same-origin favicon proxy. + * + * Browsers previously loaded favicons straight from Google's s2 service, + * which sent every referrer/page domain — plus the user's IP — to Google + * from inside the authenticated app. The upstream fetch now happens + * server-side, so the only party that ever contacts Google is our origin + * on a CDN cache miss; the user's browser only talks to Pulse. CSP has + * dropped the google/gstatic allowances, so a regression fails loudly. + */ + +const UPSTREAM = 'https://www.google.com/s2/favicons' + +// * Sizes actually used by the app (see FAVICON_SERVICE_URL consumers). +const ALLOWED_SIZES = new Set(['16', '32', '64', '128']) + +// * Bare hostname only — no scheme, port, path, or IP-literal shapes beyond +// * dotted labels. Anything else 400s and the falls back to its icon. +const DOMAIN_RE = /^(?=.{4,253}$)[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$/ + +export async function GET(request: NextRequest) { + const domain = request.nextUrl.searchParams.get('domain')?.trim().toLowerCase() ?? '' + const sz = request.nextUrl.searchParams.get('sz') ?? '32' + + if (!DOMAIN_RE.test(domain) || !ALLOWED_SIZES.has(sz)) { + return new NextResponse(null, { status: 400 }) + } + + try { + const upstream = await fetch(`${UPSTREAM}?domain=${encodeURIComponent(domain)}&sz=${sz}`, { + signal: AbortSignal.timeout(4000), + // * The CDN caches via the response headers below; keep Next's data + // * cache out of the way so misses don't accumulate on disk. + cache: 'no-store', + }) + const contentType = upstream.headers.get('content-type') ?? '' + if (!upstream.ok || !contentType.startsWith('image/')) { + return new NextResponse(null, { + status: 404, + headers: { 'Cache-Control': 'public, max-age=3600' }, + }) + } + const body = await upstream.arrayBuffer() + return new NextResponse(body, { + headers: { + 'Content-Type': contentType, + 'Cache-Control': 'public, max-age=86400, s-maxage=604800, stale-while-revalidate=604800', + 'X-Content-Type-Options': 'nosniff', + }, + }) + } catch { + // * Upstream unreachable/timed out — short-cache the miss to absorb storms. + return new NextResponse(null, { + status: 404, + headers: { 'Cache-Control': 'public, max-age=300' }, + }) + } +} diff --git a/app/auth/callback/page.tsx b/app/auth/callback/page.tsx index 2a7b54961..984ef84f8 100644 --- a/app/auth/callback/page.tsx +++ b/app/auth/callback/page.tsx @@ -76,7 +76,12 @@ function AuthCallbackContent() { if (processedRef.current && !isRetrying) return const code = searchParams.get('code') - if (!code) return + if (!code) { + // * No code param (stale link, prefetch, or a direct visit) — without an + // * error the loading overlay would spin forever. + setError('This sign-in link is missing its code — it may have expired. Please log in again.') + return + } const state = searchParams.get('state') const storedState = localStorage.getItem('oauth_state') diff --git a/app/changelog/page.tsx b/app/changelog/page.tsx index f178f16d1..64facbb15 100644 --- a/app/changelog/page.tsx +++ b/app/changelog/page.tsx @@ -8,13 +8,60 @@ export const metadata: Metadata = { description: 'Release history and notable changes for Pulse, privacy-first web analytics.', } +// * How many release sections (incl. Unreleased) render expanded; every other +// * version collapses to its heading so the page stays a few screens tall +// * instead of ~29k px. +const EXPANDED_SECTIONS = 2 + +const PROSE_CLASSES = `prose prose-invert max-w-none + prose-headings:font-semibold prose-headings:tracking-tight + prose-a:text-brand-orange prose-a:no-underline hover:prose-a:underline + prose-ul:my-4 prose-li:my-0.5` + +// * A collapsed release: the `## [x.y.z] - date` heading becomes the summary +// * row; the body only renders (and only costs height) once opened. +function CollapsedRelease({ section, linkDefs }: { section: string; linkDefs: string }) { + const newline = section.indexOf('\n') + const heading = (newline === -1 ? section : section.slice(0, newline)).replace(/^## /, '').replace(/[[\]]/g, '') + const body = newline === -1 ? '' : section.slice(newline + 1) + return ( +
+ + {heading} + ↓ + +
+ {[body, linkDefs].join('\n')} +
+
+ ) +} + /** * Reads CHANGELOG.md from the project root and renders it on the /changelog page. * Content is loaded at build time; redeploy to show new releases. */ export default function ChangelogPage() { const changelogPath = path.join(process.cwd(), 'CHANGELOG.md') - const content = fs.readFileSync(changelogPath, 'utf-8') + const raw = fs.readFileSync(changelogPath, 'utf-8') + + // * The page hero renders its own "Changelog" title — drop the markdown H1 + // * (and its intro paragraph lives in the hero copy too) to avoid the + // * duplicate heading. + // * Dates: headings use ISO `- YYYY-MM-DD`; display is DD/MM/YYYY app-wide. + const content = raw + .replace(/^# Changelog\s*\n/, '') + .replace(/\b(\d{4})-(\d{2})-(\d{2})\b/g, '$3/$2/$1') + + // * Keep-a-changelog link definitions sit at the file tail; every rendered + // * chunk needs them appended or its `[0.x.y]` reference links go dead. + const linkDefs = (content.match(/^\[[^\]]+\]:\s+\S+$/gm) ?? []).join('\n') + const body = content.replace(/^\[[^\]]+\]:\s+\S+$/gm, '').trimEnd() + + // * Split on version headings; index 0 is the preamble before the first `## `. + const [preamble, ...sections] = body.split(/\n(?=## )/) + const recent = sections.slice(0, EXPANDED_SECTIONS) + const older = sections.slice(EXPANDED_SECTIONS) return (
@@ -33,14 +80,16 @@ export default function ChangelogPage() { {' '} and 0.x.y versioning while in initial development.

-
- {content} +
+ {[preamble, ...recent, linkDefs].join('\n')}
+ {older.length > 0 && ( +
+ {older.map((section) => ( + + ))} +
+ )}
) } diff --git a/app/features/page.tsx b/app/features/page.tsx index c73ad001d..694a4ded3 100644 --- a/app/features/page.tsx +++ b/app/features/page.tsx @@ -410,7 +410,7 @@ export default function FeaturesPage() {

- Hobby plan includes 1 site and 10,000 pageviews/month + Hobby plan includes 1 site and 5,000 pageviews/month

diff --git a/app/installation/page.tsx b/app/installation/page.tsx index 36e750f3c..14d147c8d 100644 --- a/app/installation/page.tsx +++ b/app/installation/page.tsx @@ -66,7 +66,7 @@ export default function InstallationPage() {

Custom events (goals)

- Track custom events (e.g. signup, purchase) with pulse.track('event_name'). Use letters, numbers, and underscores only. Define goals in your site Settings → Goals & Events to see counts in the dashboard. + Track custom events (e.g. signup, purchase) with pulse.track('event_name'). Use letters, numbers, and underscores only. Define goals in your site Settings → Goals to see counts in the dashboard.

diff --git a/app/integrations/page.tsx b/app/integrations/page.tsx index f2193ba3b..9d63f56b0 100644 --- a/app/integrations/page.tsx +++ b/app/integrations/page.tsx @@ -235,7 +235,7 @@ export default function IntegrationsPage() { whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }} transition={{ duration: 0.4 }} - className="text-lg font-semibold text-neutral-400 mb-6 tracking-wide uppercase flex items-center gap-2" + className="text-lg font-semibold text-neutral-400 mb-6 flex items-center gap-2" > @@ -276,39 +276,65 @@ export default function IntegrationsPage() { whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }} transition={{ duration: 0.4 }} - className="text-lg font-semibold text-neutral-400 mb-6 tracking-wide uppercase" + className="text-lg font-semibold text-neutral-400 mb-6" > {group.label} -
- {group.items.map((integration, i) => ( - -
+ {group.items.map((integration, i) => ( + -
-
+
+
{integration.icon}
+ + {integration.name} +
+ + ))} +
+ ) : ( +
+ {group.items.map((integration, i) => ( + +
+
+
+ {integration.icon} +
+
-

- {integration.name} -

-

- {integration.description} -

-
-
- ))} -
+

+ {integration.name} +

+

+ {integration.description} +

+
+
+ ))} +
+ )}
))} diff --git a/app/join/[code]/page.tsx b/app/join/[code]/page.tsx index 5679f7eaa..7ca4cf08d 100644 --- a/app/join/[code]/page.tsx +++ b/app/join/[code]/page.tsx @@ -138,7 +138,7 @@ function JoinContent() { href="/" className="block w-full rounded-none bg-neutral-800 px-4 py-2.5 text-center text-sm font-medium text-white hover:bg-neutral-700 transition-colors" > - Go to dashboard + Go to Pulse
@@ -171,7 +171,7 @@ function JoinContent() { href="/" className="block w-full rounded-none bg-neutral-800 px-4 py-2.5 text-center text-sm font-medium text-white hover:bg-neutral-700 transition-colors" > - Go to dashboard + Go to Pulse
@@ -199,7 +199,7 @@ function JoinContent() { href="/" className="block w-full rounded-none bg-brand-orange px-4 py-2.5 text-center text-sm font-semibold text-white hover:bg-brand-orange/90 transition-colors" > - Go to dashboard + Go to Pulse diff --git a/app/setup/done/page.tsx b/app/setup/done/page.tsx index fcf39edc1..fcbe7da21 100644 --- a/app/setup/done/page.tsx +++ b/app/setup/done/page.tsx @@ -100,8 +100,8 @@ export default function SetupDonePage() { if (site) sessionStorage.setItem('pulse_active_site', site.id) }} > -
- +
+

Set up a goal

@@ -113,8 +113,8 @@ export default function SetupDonePage() { href="/settings/organization/members" className="flex items-center gap-3 p-3 rounded-none border border-neutral-800 hover:border-neutral-700 hover:bg-neutral-800/30 transition-all" > -
- +
+

Invite your team

@@ -128,8 +128,8 @@ export default function SetupDonePage() { rel="noopener noreferrer" className="flex items-center gap-3 p-3 rounded-none border border-neutral-800 hover:border-neutral-700 hover:bg-neutral-800/30 transition-all" > -
- +
+

Read the docs

diff --git a/app/setup/install/page.tsx b/app/setup/install/page.tsx index 58adf73f0..3d406bc57 100644 --- a/app/setup/install/page.tsx +++ b/app/setup/install/page.tsx @@ -69,7 +69,7 @@ export default function SetupInstallPage() {

{site ? `Add this snippet to "${site.name}" to start collecting data.` - : 'Add the Pulse snippet to your site.'} + : 'Each site gets its own snippet once it exists.'}

@@ -126,9 +126,23 @@ export default function SetupInstallPage() { )} {!site && ( - + /* * The heading promises a snippet — when no site is attached to this + * setup session there is none to show, so say that instead of a + * silent empty gap above a bare Continue button. */ +
+
+

+ No site is attached to this setup session, so there's no snippet to + show yet. Create your site first and the snippet will appear here. +

+
+ + +
)} + )} +
+ + +
+ + + {/* Frustration by page — the lens navigation */} + + + + + {/* Daily trend + scroll depth */} + + + + + + ) : null} +
) } diff --git a/app/sites/[id]/cdn/page.tsx b/app/sites/[id]/cdn/page.tsx index 960079633..7972fe780 100644 --- a/app/sites/[id]/cdn/page.tsx +++ b/app/sites/[id]/cdn/page.tsx @@ -1,104 +1,89 @@ 'use client' -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import dynamic from 'next/dynamic' import Link from 'next/link' import { useParams } from 'next/navigation' import { motion } from 'framer-motion' -import { DURATION_BASE, EASE_APPLE } from '@/lib/motion' +import { scaleLinear, scaleTime } from 'd3-scale' +import { area as d3Area, curveMonotoneX } from 'd3-shape' +import { bisector } from 'd3-array' import * as Flags from 'country-flag-icons/react/3x2' -const MapView = dynamic(() => import('@/components/dashboard/MapView'), { ssr: false }) -import { getDateRange, formatDate } from '@/lib/utils/format' +import { DURATION_BASE, EASE_APPLE } from '@/lib/motion' +import { formatNumber } from '@/lib/utils/format' +import { extractCountryCode, extractCity } from '@/lib/utils/bunnyDatacenter' +import { formatDate, formatDateShort } from '@/lib/utils/formatDate' +import { guardedPctChange, type PctChangeResult } from '@/lib/utils/pctChange' +import { useUrlDateRange, type Period } from '@/lib/hooks/useUrlDateRange' +import { useSite, useBunnyStatus, useBunnyOverview, useBunnyDailyStats, useBunnyTopCountries } from '@/lib/swr/dashboard' +import { LinePath, ParentSize, localPoint } from '@/lib/charts/primitives' +import type { BunnyGeoRow } from '@/lib/api/bunny' + import DateRangePicker from '@/components/ui/DateRangePicker' -import { PERIOD_TO_API } from '@/lib/constants/periods' -import { ArrowSquareOut, CloudArrowUp, CloudArrowDown, ChartLine, Warning } from '@phosphor-icons/react' import { AreaChart, Area, Grid, XAxis, YAxis, ChartTooltip } from '@/components/ui/area-chart' import { BarChart, Bar, Grid as BarGrid, BarXAxis, BarValueAxis, ChartTooltip as BarTooltip } from '@/components/ui/bar-chart' +import { AnimatedNumber } from '@/components/ui/animated-number' +import { UpdatingChip } from '@/components/ui/UpdatingChip' +import { ErrorCard } from '@/components/ui/ErrorCard' import { EmptyState } from '@/components/ui/EmptyState' -import { useDashboard, useBunnyStatus, useBunnyOverview, useBunnyDailyStats, useBunnyTopCountries } from '@/lib/swr/dashboard' -import { SkeletonLine, StatCardSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons' +import { SyncStatusLine } from '@/components/integrations/SyncStatusLine' +import { RowBar } from '@/components/search/rowPrimitives' +import { CDNSkeleton } from '@/components/skeletons' +import { CloudArrowUp, CloudArrowDown, ChartBar, Warning, Gauge, GlobeHemisphereWest, ArrowSquareOut } from '@phosphor-icons/react' -// ─── Helpers ──────────────────────────────────────────────────── +const MapView = dynamic(() => import('@/components/dashboard/MapView'), { ssr: false }) -// 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 -} +// ─── Palette (color budget: orange = cache doing its job, neutral = origin/cost, +// red = errors only; no orange in the errors chart) ──────────────────────────── +const CACHED = '#FD5E0F' +const ORIGIN = '#737373' +const UNCACHED = '#525252' +const ERR_3XX = '#737373' +const ERR_4XX = 'rgba(248,113,113,0.7)' +const ERR_5XX = '#ef4444' +const TOTAL_DOT = '#a3a3a3' -/** - * 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 -} +// ─── Helpers ──────────────────────────────────────────────────── -/** Get flag icon component for a country code */ +/** Flag icon for an alpha-2 country code, or null when unavailable. */ function getFlagIcon(code: string) { if (!code) return null const FlagComponent = (Flags as Record>)[code] - return FlagComponent ? : null + return FlagComponent ? : null } -/** - * Map each datacenter entry to its country's centroid for the dotted map. - * Each datacenter gets its own dot (sized by bandwidth) at the country's position. - */ -function mapToCountryCentroids(data: Array<{ country_code: string; bandwidth: number }>): Array<{ country: string; pageviews: number }> { +/** Map each datacenter entry to its country centroid for the dotted map. */ +function mapToCountryCentroids(data: BunnyGeoRow[]): Array<{ country: string; pageviews: number }> { return data - .map((row) => ({ - country: extractCountryCode(row.country_code), - pageviews: row.bandwidth, - })) + .map((row) => ({ country: extractCountryCode(row.country_code), pageviews: row.bandwidth })) .filter((d) => d.country !== '') } +/** Bytes → "1.5 GB". Kept local — CDN is its only consumer. */ function formatBytes(bytes: number): string { - if (bytes === 0) return '0 B' + if (bytes <= 0) return '0 B' const units = ['B', 'KB', 'MB', 'GB', 'TB'] - const i = Math.floor(Math.log(bytes) / Math.log(1024)) + const i = Math.min(units.length - 1, 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 `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}` -} - -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 } +const evenTicks = (min: number, max: number, count: number) => + Array.from({ length: count }, (_, i) => min + (max - min) * (i / (count - 1))) + +const cascade = (delay: number) => ({ + initial: { opacity: 0, y: 8 }, + animate: { opacity: 1, y: 0 }, + transition: { duration: DURATION_BASE, ease: EASE_APPLE, delay }, +}) + +interface BandwidthPoint { + date: Date + total: number + cached: number + origin: number + hitRate: number } // ─── Page ─────────────────────────────────────────────────────── @@ -107,99 +92,99 @@ export default function CDNPage() { const params = useParams() const siteId = params.id as string - const [period, setPeriod] = useState('30') - const [dateRange, setDateRange] = useState(() => getDateRange(30)) - - const shiftPeriod = useCallback((direction: -1 | 1) => { - const shift = (date: string, days: number) => { - const d = new Date(date + 'T00:00:00') - d.setDate(d.getDate() + days) - return formatDate(d) - } - const startDate = new Date(dateRange.start + 'T00:00:00') - const endDate = new Date(dateRange.end + 'T00:00:00') - const spanDays = Math.round((endDate.getTime() - startDate.getTime()) / 86400000) + 1 - const offsetDays = spanDays * direction - const newRange = { start: shift(dateRange.start, offsetDays), end: shift(dateRange.end, offsetDays) } - const today = formatDate(new Date()) - if (newRange.end > today) return - setDateRange(newRange) - setPeriod('custom') - }, [dateRange]) - - const apiPeriod = period !== 'custom' ? (PERIOD_TO_API[period] || undefined) : undefined - - // Data fetching - const { data: bunnyStatus } = useBunnyStatus(siteId) - const { data: dashboard } = useDashboard(siteId, dateRange.start, dateRange.end, undefined, undefined, apiPeriod) - 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 { period, dateRange, setPeriod, shiftPeriod } = useUrlDateRange() - const showSkeleton = useMinimumLoading(!bunnyStatus) - const fadeClass = useSkeletonFade(showSkeleton) + const { data: bunnyStatus } = useBunnyStatus(siteId) + const connected = bunnyStatus?.connected + const { data: site } = useSite(siteId) + const { + data: overview, + isLoading: overviewLoading, + isValidating: overviewValidating, + error: overviewError, + mutate: mutateOverview, + } = useBunnyOverview(siteId, dateRange.start, dateRange.end) + const { + data: dailyStats, + isValidating: dailyValidating, + error: dailyError, + mutate: mutateDaily, + } = useBunnyDailyStats(siteId, dateRange.start, dateRange.end) + const { + data: topCountries, + isValidating: countriesValidating, + error: countriesError, + mutate: mutateCountries, + } = useBunnyTopCountries(siteId, dateRange.start, dateRange.end) + + // Chart series — memoized before any early return so hook order is stable. + const daily = useMemo(() => dailyStats?.daily_stats ?? [], [dailyStats]) + const bandwidthPoints = useMemo( + () => + daily.map((d) => { + const total = d.bandwidth_used + const cached = d.bandwidth_cached + // Origin = the bandwidth the CDN could NOT serve from cache (the cost). + const origin = Math.max(0, total - cached) + // Per-day hit rate is request-based, matching the "Cache hit rate" KPI's + // definition — a consistent meaning of "hit rate" page-wide. + const hitRate = d.requests_served > 0 ? (d.requests_cached / d.requests_served) * 100 : 0 + return { date: new Date(d.date + 'T00:00:00'), total, cached, origin, hitRate } + }), + [daily], + ) + const requestData = useMemo( + () => + daily.map((d) => ({ + date: formatDateShort(new Date(d.date + 'T00:00:00')), + cached: d.requests_cached, + uncached: Math.max(0, d.requests_served - d.requests_cached), + served: d.requests_served, + })), + [daily], + ) + const errorData = useMemo( + () => + daily.map((d) => ({ + date: formatDateShort(new Date(d.date + 'T00:00:00')), + '3xx': d.error_3xx, + '4xx': d.error_4xx, + '5xx': d.error_5xx, + })), + [daily], + ) - // Document title useEffect(() => { - const domain = dashboard?.site?.domain - document.title = domain ? `CDN \u00b7 ${domain} | Pulse` : 'CDN | Pulse' - }, [dashboard?.site?.domain]) + const domain = site?.domain + document.title = domain ? `CDN · ${domain} | Pulse` : 'CDN | Pulse' + }, [site?.domain]) - // ─── Loading skeleton ───────────────────────────────────── - - if (showSkeleton) { - return ( -
-
-
- - -
- -
-
- - - - - -
-
- - -
-
-
- - -
-
- - -
-
-
- ) + // ─── Route-level state: skeleton only on the very first load ── + if (bunnyStatus === undefined || (connected && overview === undefined && overviewLoading)) { + return } // ─── Not connected state ────────────────────────────────── - if (bunnyStatus && !bunnyStatus.connected) { return ( -
-
+
+ {/* * Same page header as the connected view — without it this tab was + * the only one with no h1 and read as a different app. */} +
+

CDN Analytics

+

BunnyCDN performance, bandwidth, and cache metrics

+
+
-

- Connect BunnyCDN -

+

Connect BunnyCDN

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

Connect in Settings @@ -210,317 +195,523 @@ export default function CDNPage() { } // ─── 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) + const maxBandwidth = countries.reduce((max, row) => Math.max(max, row.bandwidth), 0) + const anyValidating = overviewValidating || dailyValidating || countriesValidating return ( -
+
{/* Header */} -
-
-

- CDN Analytics -

-

- BunnyCDN performance, bandwidth, and cache metrics -

+
+
+

CDN Analytics

+

BunnyCDN performance, bandwidth, and cache metrics

+ {bunnyStatus && ( + + )}
setPeriod(p as Period)} + onDateRangeChange={(range) => setPeriod('custom', range)} onShift={shiftPeriod} />
- {/* Overview cards */} - - - - - - - - - {/* Bandwidth chart */} - -

Bandwidth

- {daily.length > 0 ? ( - []} - xDataKey="date" - aspectRatio="auto" - className="h-[280px]" - margin={{ top: 16, right: 16, bottom: 40, left: 64 }} - > - - - formatBytes(v)} /> - - - [ - { - color: 'var(--chart-1)', - label: 'Total', - value: formatBytes((point.bandwidth_used as number) ?? 0), - }, - { - color: 'var(--chart-3)', - label: 'Cached', - value: formatBytes((point.bandwidth_cached as number) ?? 0), - }, - ]} - /> - - ) : ( - } - title="No bandwidth data" - description="Bandwidth stats will appear here once your CDN starts serving traffic." - className="h-[280px]" + {/* Content — one chip covers range changes, the ErrorCard covers failures */} +
+ + {overviewError ? ( + { void mutateOverview() }} /> - )} - - - {/* Requests + Errors charts side by side */} - - {/* Requests chart */} -
-

Requests

- {daily.length > 0 ? ( - []} - xDataKey="date" - aspectRatio="auto" - className="h-[220px]" - margin={{ top: 16, right: 16, bottom: 40, left: 56 }} - > - - - formatNumber(v)} /> - - [ - { - color: 'var(--chart-1)', - label: 'Requests', - value: formatNumber((point.requests_served as number) ?? 0), - }, - ]} + ) : overview ? ( + <> + {/* KPI band */} + + } /> - - ) : ( - } - title="No request data" - description="Request counts will appear here once your CDN starts serving traffic." - className="h-[220px]" - /> - )} -
- - {/* Errors chart */} -
-

Errors

- {daily.length > 0 ? ( - ({ - date: d.date, - '3xx': d.error_3xx, - '4xx': d.error_4xx, - '5xx': d.error_5xx, - }))} - xDataKey="date" - aspectRatio="auto" - className="h-[220px]" - margin={{ top: 16, right: 16, bottom: 40, left: 56 }} - > - - - formatNumber(v)} /> - - - - [ - { - color: 'var(--chart-5)', - label: '3xx', - value: formatNumber((point['3xx'] as number) ?? 0), - }, - { - color: 'var(--chart-1)', - label: '4xx', - value: formatNumber((point['4xx'] as number) ?? 0), - }, - { - color: 'var(--color-error)', - label: '5xx', - value: formatNumber((point['5xx'] as number) ?? 0), - }, - ]} + formatNumber(Math.round(v))} + delta={} /> - - ) : ( - } - title="No error data" - description="Error rates will appear here once your CDN starts serving traffic." - className="h-[220px]" - /> - )} -
-
- - {/* Traffic Distribution */} - -

Traffic Distribution

- {countries.length > 0 ? ( - <> -
- -
-
- {countries.map((row, index) => { - const pct = totalBandwidth > 0 ? (row.bandwidth / totalBandwidth) * 100 : 0 - const cc = extractCountryCode(row.country_code) - const city = extractCity(row.country_code) - return ( - -
- {cc && getFlagIcon(cc)} -
- {city} -
- - {formatBytes(row.bandwidth)} - + `${v.toFixed(1)}%`} + // Delta in POINTS (difference of the two rates), not a %-of-%. + // Suppressed when the previous window had too few requests to compare. + delta={} + /> + `${v.toFixed(0)}ms`} + // Inverted metric — a faster (lower) origin is good (green). + delta={} + /> + formatNumber(Math.round(v))} + // Inverted metric — fewer errors is good (green). + delta={} + /> + + + {/* Bandwidth + Requests/Errors/Latency all read the same daily payload, + * so a single ErrorCard covers them (no per-card duplication). */} + {dailyError ? ( + + { void mutateDaily() }} + className="rounded-none border border-border bg-card py-16" + /> + + ) : ( + <> + {/* Bandwidth — stacked cached (orange) over origin (neutral) */} + +
+ Bandwidth +
+ +
-
-
+
+ {bandwidthPoints.length > 0 ? ( + + {({ width, height }) => (width > 0 && height > 0 ? : null)} + + ) : ( + } + title="No bandwidth data" + description="Bandwidth stats will appear here once your CDN starts serving traffic." + className="h-full" /> + )} +
+ + + {/* Requests · Errors · Origin latency */} + + {/* Requests — stacked cached / uncached */} +
+ Requests +
+ {daily.length > 0 ? ( + + + + formatNumber(Math.round(v))} /> + + + [ + { color: CACHED, label: 'Cached', value: formatNumber((point.cached as number) ?? 0) }, + { color: UNCACHED, label: 'Uncached', value: formatNumber((point.uncached as number) ?? 0) }, + { color: TOTAL_DOT, label: 'Total', value: formatNumber((point.served as number) ?? 0) }, + ]} + /> + + ) : ( + } + title="No request data" + description="Request counts will appear here once your CDN starts serving traffic." + className="h-[220px]" + /> + )}
-
- {pct.toFixed(1)}% of total traffic +
+ + {/* Errors — 3xx neutral (redirects), 4xx/5xx red; no orange here */} +
+ Errors +
+ {daily.length > 0 && errorData.every((d) => d['3xx'] + d['4xx'] + d['5xx'] === 0) ? ( +
+

No errors in this period.

+
+ ) : daily.length > 0 ? ( + + + + formatNumber(Math.round(v))} /> + + + + [ + { color: ERR_3XX, label: '3xx', value: formatNumber((point['3xx'] as number) ?? 0) }, + { color: ERR_4XX, label: '4xx', value: formatNumber((point['4xx'] as number) ?? 0) }, + { color: ERR_5XX, label: '5xx', value: formatNumber((point['5xx'] as number) ?? 0) }, + ]} + /> + + ) : ( + } + title="No error data" + description="Error rates will appear here once your CDN starts serving traffic." + className="h-[220px]" + /> + )}
- - ) - })} -
+
+ + {/* Origin latency — single orange line */} +
+ Origin latency +
+ {daily.length > 0 ? ( + []} + xDataKey="date" + aspectRatio="auto" + className="h-[220px]" + margin={{ top: 16, right: 16, bottom: 40, left: 56 }} + > + + + `${v.toFixed(0)}ms`} /> + + [ + { color: CACHED, label: 'Origin latency', value: `${Math.round((point.origin_response_time_avg as number) ?? 0)}ms` }, + ]} + /> + + ) : ( + } + title="No latency data" + description="Origin response times will appear here once your CDN starts serving traffic." + className="h-[220px]" + /> + )} +
+
+
+ + )} + + {/* Traffic distribution — map kept, datacenter grid → ranked rows */} + + Traffic distribution + {countriesError ? ( + { void mutateCountries() }} + className="py-16" + /> + ) : countries.length > 0 ? ( + <> +
+ +
+
+ {countries.map((row) => ( + + ))} +
+ + ) : ( + } + title="No geographic data" + description="Traffic distribution will appear here once your CDN starts serving traffic." + className="h-[360px]" + /> + )} +
- ) : ( -
- No geographic data for this period. -
- )} - + ) : null} +
) } -// ─── Sub-components ───────────────────────────────────────────── +// ─── KPI band ─────────────────────────────────────────────────── -function OverviewCard({ +// Guarded percentage delta — arrow + magnitude, green good / red bad. Inverted +// metrics (origin latency, errors) flip which direction is good. 0% stays neutral. +function PctDelta({ change, invert = false }: { change: PctChangeResult; invert?: boolean }) { + if (!change || change.type !== 'pct') return null + if (change.value === 0) { + return 0% + } + const up = change.value > 0 + const good = invert ? !up : up + return ( + + {up ? '↑' : '↓'} {Math.abs(change.value)}% + + ) +} + +// Cache-hit delta in points (pt). null = suppressed (tiny previous window). +function PointsDelta({ points }: { points: number | null }) { + if (points === null) return null + const rounded = Math.round(points * 10) / 10 + if (rounded === 0) { + return 0 pt + } + const up = rounded > 0 + return ( + + {up ? '↑' : '↓'} {Math.abs(rounded).toFixed(1)} pt + + ) +} + +function KpiCard({ label, value, - change, - invertColor = false, + format, + delta, }: { label: string - value: string - change: { value: number; positive: boolean } | null - invertColor?: boolean + value: number + format: (v: number) => string + delta: React.ReactNode }) { - // 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 ( +
+
+ {label} + {delta} +
+ +
+ ) +} + +// ─── Bandwidth chart (dedicated stacked-area SVG on chart tokens) ─ +// The shared derives one yScale from its Area children and overlays +// them — it cannot stack. Design D9 wants a true cached-over-origin stack, so +// (like T4's SearchTrafficChart) this composes the same low-level primitives and +// tokens: cached is the filled base band (0 → cached), origin is stacked on top +// (cached → total), and the neutral outline traces the total. + +const B_MARGIN = { top: 8, right: 16, bottom: 28, left: 64 } + +function BandwidthBody({ width, height, data }: { width: number; height: number; data: BandwidthPoint[] }) { + const innerWidth = width - B_MARGIN.left - B_MARGIN.right + const innerHeight = height - B_MARGIN.top - B_MARGIN.bottom + const [hoverIndex, setHoverIndex] = useState(null) + + const xScale = useMemo( + () => scaleTime().domain([data[0].date, data[data.length - 1].date]).range([0, Math.max(1, innerWidth)]), + [data, innerWidth], + ) + const yScale = useMemo( + () => + scaleLinear() + // Guard the domain against a glitch where cached briefly exceeds used. + .domain([0, Math.max(1, ...data.map((d) => Math.max(d.total, d.cached))) * 1.1]) + .range([Math.max(1, innerHeight), 0]) + .nice(), + [data, innerHeight], + ) + const [yMin, yMax] = yScale.domain() + const yTicks = useMemo(() => evenTicks(yMin, yMax, 5), [yMin, yMax]) + const xTicks = useMemo(() => { + const n = Math.min(5, data.length) + if (n <= 1) return data.map((_, i) => i) + return Array.from({ length: n }, (_, k) => Math.round((k * (data.length - 1)) / (n - 1))) + }, [data]) + + const cachedPath = useMemo( + () => + d3Area() + .x((d) => xScale(d.date)) + .y0(yScale(0)) + .y1((d) => yScale(d.cached)) + .curve(curveMonotoneX)(data) ?? '', + [data, xScale, yScale], + ) + const originPath = useMemo( + () => + d3Area() + .x((d) => xScale(d.date)) + .y0((d) => yScale(d.cached)) + .y1((d) => yScale(d.cached + d.origin)) + .curve(curveMonotoneX)(data) ?? '', + [data, xScale, yScale], + ) + + const bisectDate = useMemo(() => bisector((d) => d.date).left, []) + const handleMove = useCallback( + (event: React.MouseEvent) => { + const point = localPoint(event) + if (!point) return + const x0 = xScale.invert(point.x - B_MARGIN.left) + const i = bisectDate(data, x0, 1) + const d0 = data[i - 1] + const d1 = data[i] + let idx = i - 1 + if (d0 && d1) idx = x0.getTime() - d0.date.getTime() > d1.date.getTime() - x0.getTime() ? i : i - 1 + setHoverIndex(Math.max(0, Math.min(data.length - 1, idx))) + }, + [xScale, data, bisectDate], + ) + + if (innerWidth <= 0 || innerHeight <= 0) return null + + const hovered = hoverIndex != null ? data[hoverIndex] : null + const hoverX = hovered ? xScale(hovered.date) : 0 + const tooltipX = hoverX + B_MARGIN.left + const flip = tooltipX > width * 0.66 return ( -
-

{label}

-

{value}

- {changeLabel && ( -

- {changeLabel} vs previous period -

+ <> + + + {hovered && ( +
+
+
{formatDate(hovered.date)}
+
+ + + +
+ Hit rate + {Math.round(hovered.hitRate)}% +
+
+
+
)} + + ) +} + +function TooltipRow({ color, label, value }: { color: string; label: string; value: string }) { + return ( +
+
+ + {label} +
+ {value} +
+ ) +} + +function LegendDot({ color, label }: { color: string; label: string }) { + return ( + + + {label} + + ) +} + +// ─── Traffic distribution — ranked row ─────────────────────────── + +function DistributionRow({ row, maxBandwidth, totalBandwidth }: { row: BunnyGeoRow; maxBandwidth: number; totalBandwidth: number }) { + const cc = extractCountryCode(row.country_code) + const city = extractCity(row.country_code) + // Bar length ranks by share of the busiest datacenter; the hover-% is share of + // total traffic (the meaningful figure), full precision in the title. + const share = maxBandwidth > 0 ? row.bandwidth / maxBandwidth : 0 + const pct = totalBandwidth > 0 ? (row.bandwidth / totalBandwidth) * 100 : 0 + return ( +
+ + + {getFlagIcon(cc) || + + + {pct.toFixed(1)}% + + {formatBytes(row.bandwidth)} +
) } diff --git a/app/sites/[id]/funnels/[funnelId]/edit/page.tsx b/app/sites/[id]/funnels/[funnelId]/edit/page.tsx index cb372f179..6c1f431dd 100644 --- a/app/sites/[id]/funnels/[funnelId]/edit/page.tsx +++ b/app/sites/[id]/funnels/[funnelId]/edit/page.tsx @@ -2,7 +2,8 @@ import { useParams, redirect } from 'next/navigation' +// * Editing lives in the modal on the detail page — this route just lands there. export default function EditFunnelPage() { const params = useParams() - redirect(`/sites/${params.id}/funnels`) + redirect(`/sites/${params.id}/funnels/${params.funnelId}`) } diff --git a/app/sites/[id]/funnels/[funnelId]/page.tsx b/app/sites/[id]/funnels/[funnelId]/page.tsx index a714621ae..dff58166d 100644 --- a/app/sites/[id]/funnels/[funnelId]/page.tsx +++ b/app/sites/[id]/funnels/[funnelId]/page.tsx @@ -1,9 +1,429 @@ 'use client' -import { useParams, redirect } from 'next/navigation' +import { useCallback, useEffect, useMemo, useState } from 'react' +import Link from 'next/link' +import { useParams, usePathname, useRouter, useSearchParams } from 'next/navigation' +import { ArrowLeft, FunnelSimple } from '@phosphor-icons/react' +import { toast, Button } from '@ciphera-net/facet' +import { updateFunnel, deleteFunnel, type CreateFunnelRequest } from '@/lib/api/funnels' +import { ApiError } from '@/lib/api/client' +import { useFunnelDetail, useFunnelStats } from '@/lib/swr/dashboard' +import { useUrlDateRange, type Period } from '@/lib/hooks/useUrlDateRange' +import { previousDateRange } from '@/lib/hooks/periodUrl' +import { useFilterSuggestions } from '@/lib/hooks/useFilterSuggestions' +import { type DimensionFilter, serializeFilters, parseFiltersFromURL } from '@/lib/filters' +import FilterButton from '@/components/dashboard/FilterButton' +import FilterPills from '@/components/dashboard/FilterPills' +import FilterBuilder from '@/components/dashboard/filter/FilterBuilder' +import { useFilterBuilder } from '@/components/dashboard/filter/useFilterBuilder' +import { guardedPctChange, type PctChangeResult } from '@/lib/utils/pctChange' +import { formatNumber, formatConvertTime } from '@/lib/utils/format' +import { formatDate as formatDisplayDate } from '@/lib/utils/formatDate' +import DateRangePicker from '@/components/ui/DateRangePicker' +import { AnimatedNumber } from '@/components/ui/animated-number' +import { ErrorCard } from '@/components/ui/ErrorCard' +import { EmptyState } from '@/components/ui/EmptyState' +import { UpdatingChip } from '@/components/ui/UpdatingChip' +import { FunnelDetailSkeleton } from '@/components/skeletons' +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog' +import FunnelModal from '@/components/funnels/FunnelModal' +import { FunnelCanvas } from '@/components/funnels/FunnelCanvas' +import { FunnelStepStrip } from '@/components/funnels/FunnelStepStrip' +import { FunnelTrendChart } from '@/components/funnels/FunnelTrendChart' +import { useCan } from '@/lib/auth/permissions' + +// --------------------------------------------------------------------------- +// Funnel detail — the revived real route. T9 ships the header + KPI band and +// all the states; the canvas, step strip and trend chart land in T10–T12. +// --------------------------------------------------------------------------- + +function DeltaBadge({ change }: { change: PctChangeResult }) { + if (!change) return null + if (change.type === 'new') { + return ( + + New + + ) + } + const positive = change.value > 0 + return ( + + {positive ? '↑' : '↓'} {Math.abs(change.value)}% + + ) +} + +function KpiCard({ + label, + children, + delta, +}: { + label: string + children: React.ReactNode + delta?: PctChangeResult +}) { + return ( +
+
+ {label} + {delta != null && } +
+ {children} +
+ ) +} export default function FunnelDetailPage() { const params = useParams() + const router = useRouter() + const pathname = usePathname() + const searchParams = useSearchParams() const siteId = params.id as string - redirect(`/sites/${siteId}/funnels`) + const funnelId = params.funnelId as string + const canManage = useCan('funnels.manage') + + const { period, dateRange, setPeriod, shiftPeriod } = useUrlDateRange() + + // ── Dashboard filter system, URL-synced with the dashboard's exact codec ── + const [filters, setFilters] = useState(() => { + const raw = searchParams.get('filters') + return raw ? parseFiltersFromURL(raw) : [] + }) + const filtersParam = useMemo(() => serializeFilters(filters), [filters]) + + useEffect(() => { + const url = new URL(window.location.href) + if (filtersParam) url.searchParams.set('filters', filtersParam) + else url.searchParams.delete('filters') + window.history.replaceState({}, '', url.toString()) + }, [filtersParam]) + + const handleAddFilter = useCallback((filter: DimensionFilter) => { + setFilters(prev => { + const isDuplicate = prev.some( + f => f.dimension === filter.dimension && f.operator === filter.operator && f.values.join(';') === filter.values.join(';') + ) + if (isDuplicate) return prev + return [...prev, filter] + }) + }, []) + const handleFilterApply = useCallback((filter: DimensionFilter, editingIndex: number | null) => { + if (editingIndex !== null) { + setFilters(prev => prev.map((f, i) => (i === editingIndex ? filter : f))) + } else { + handleAddFilter(filter) + } + }, [handleAddFilter]) + + const fetchSuggestions = useFilterSuggestions(siteId, dateRange, filtersParam || undefined) + const filterBuilder = useFilterBuilder(fetchSuggestions) + + const { + data: funnel, + error: funnelError, + isLoading: funnelLoading, + mutate: retryFunnel, + } = useFunnelDetail(siteId, funnelId) + const { + data: stats, + error: statsError, + isValidating: statsValidating, + mutate: retryStats, + } = useFunnelStats(siteId, funnelId, dateRange.start, dateRange.end, filtersParam || undefined) + + const prevRange = useMemo(() => previousDateRange(dateRange), [dateRange]) + const { data: prevStats } = useFunnelStats( + siteId, + funnelId, + prevRange?.start ?? '', + prevRange?.end ?? '', + filtersParam || undefined, + ) + + const [modalOpen, setModalOpen] = useState(false) + const [confirmingDelete, setConfirmingDelete] = useState(false) + + // * Selected step lives in ?step= (1-based; default 1 stays out of the URL) + const stepCount = stats?.steps.length ?? 0 + const rawStep = parseInt(searchParams.get('step') ?? '1', 10) + const selectedStep = Math.max(1, Math.min(stepCount || 1, Number.isNaN(rawStep) ? 1 : rawStep)) + const setSelectedStep = useCallback( + (n: number) => { + const next = new URLSearchParams(searchParams.toString()) + if (n <= 1) next.delete('step') + else next.set('step', String(n)) + const qsNext = next.toString() + router.replace(qsNext ? `${pathname}?${qsNext}` : pathname, { scroll: false }) + }, + [router, pathname, searchParams], + ) + + useEffect(() => { + document.title = funnel ? `${funnel.name} · Funnels | Pulse` : 'Funnels | Pulse' + }, [funnel]) + + const qs = searchParams.toString() + const listHref = `/sites/${siteId}/funnels${qs ? `?${qs}` : ''}` + + // ── Route-level states ───────────────────────────────────────────── + if (funnelLoading && !funnel) return + + if (funnelError) { + const notFound = funnelError instanceof ApiError && funnelError.status === 404 + return ( +
+ {notFound ? ( + } + title="Funnel not found" + description="It may have been deleted, or the link points to another site's funnel." + action={{ label: 'Back to funnels', href: listHref }} + /> + ) : ( + { void retryFunnel() }} + /> + )} +
+ ) + } + + if (!funnel) return + + // ── Derived stats ────────────────────────────────────────────────── + const totalVisitors = stats?.steps[0]?.visitors ?? 0 + const converted = stats?.steps.length ? stats.steps[stats.steps.length - 1].visitors : 0 + const conversion = stats?.steps.length ? stats.steps[stats.steps.length - 1].conversion : 0 + const prevVisitors = prevStats?.steps[0]?.visitors ?? 0 + const prevConverted = prevStats?.steps.length + ? prevStats.steps[prevStats.steps.length - 1].visitors + : 0 + const prevConversion = prevStats?.steps.length + ? prevStats.steps[prevStats.steps.length - 1].conversion + : 0 + + const biggestDrop = stats?.steps.reduce<{ value: string; dropoff: number } | null>( + (worst, step, i) => { + if (i === 0) return worst + if (!worst || step.dropoff > worst.dropoff) + return { value: step.step.value, dropoff: step.dropoff } + return worst + }, + null, + ) + + const windowLabel = `${funnel.conversion_window_value}${funnel.conversion_window_unit === 'hours' ? 'h' : 'd'} window` + const createdLabel = formatDisplayDate(new Date(funnel.created_at)) + + return ( +
+ {/* Header */} +
+ + + Funnels + +
+
+

{funnel.name}

+

+ {funnel.description && {funnel.description} · } + + {windowLabel} · created {createdLabel} + +

+
+
+ filterBuilder.openEdit(filters[index], index, anchor)} + onRemove={(index) => setFilters(prev => prev.filter((_, i) => i !== index))} + onClear={() => setFilters([])} + /> + 0} + active={filterBuilder.open} + onClick={(anchor) => filterBuilder.openCreate(anchor)} + /> + setPeriod(p as Period)} + onDateRangeChange={(range) => setPeriod('custom', range)} + onShift={shiftPeriod} + /> + {canManage && ( + <> + + + + )} +
+
+
+ + {/* KPI band — the updating chip covers range changes */} +
+ + {statsError ? ( + { void retryStats() }} + /> + ) : ( +
+ + `${Math.round(v)}%`} + className="text-xl font-semibold tabular-nums text-white" + /> + + + formatNumber(Math.round(v))} + className="text-xl font-semibold tabular-nums text-white" + /> + + + formatNumber(Math.round(v))} + className="text-xl font-semibold tabular-nums text-white" + /> + + + {biggestDrop ? ( + <> + + {Math.round(biggestDrop.dropoff)}% + +

+ {biggestDrop.value} +

+ + ) : ( + — + )} +
+ + + {stats?.median_convert_seconds != null + ? formatConvertTime(stats.median_convert_seconds) + : '—'} + + +
+ )} + + {/* Canvas — step columns with connecting bands; drives ?step= */} + {!statsError && stats && stats.steps.length > 0 && ( + <> +
+ +
+
+ +
+
+ +
+ + )} +
+ + {/* Filter popover — create anchors to the button, edit to the pill */} + + + {/* Delete confirm */} + setConfirmingDelete(false)}> + + + Delete funnel + + Are you sure you want to delete “{funnel.name}”? This cannot be undone. + + + + + + + + + + {/* Edit modal */} + {modalOpen && canManage && ( + setModalOpen(false)} + initialData={funnel} + onSubmit={async (data: CreateFunnelRequest) => { + await updateFunnel(siteId, funnelId, data) + toast.success('Funnel updated') + void retryFunnel() + void retryStats() + }} + /> + )} +
+ ) } diff --git a/app/sites/[id]/funnels/new/page.tsx b/app/sites/[id]/funnels/new/page.tsx index 95d3eb130..36ad44ef1 100644 --- a/app/sites/[id]/funnels/new/page.tsx +++ b/app/sites/[id]/funnels/new/page.tsx @@ -1,8 +1,12 @@ 'use client' -import { useParams, redirect } from 'next/navigation' +import { useParams, useSearchParams, redirect } from 'next/navigation' +// * Create lives in the modal on the list page — this route lands there, +// * forwarding ?prefill= (and any other params) so seeded creates work. export default function NewFunnelPage() { const params = useParams() - redirect(`/sites/${params.id}/funnels`) + const searchParams = useSearchParams() + const qs = searchParams.toString() + redirect(`/sites/${params.id}/funnels${qs ? `?${qs}` : ''}`) } diff --git a/app/sites/[id]/funnels/page.tsx b/app/sites/[id]/funnels/page.tsx index 6a3f04fe3..a2438cf2d 100644 --- a/app/sites/[id]/funnels/page.tsx +++ b/app/sites/[id]/funnels/page.tsx @@ -1,315 +1,77 @@ 'use client' -import { useState, useCallback, useMemo, useEffect } from 'react' -import { useParams } from 'next/navigation' -import { motion, AnimatePresence } from 'framer-motion' -import { DURATION_BASE, EASE_APPLE, TIMING } from '@/lib/motion' -import { deleteFunnel, createFunnel, updateFunnel, type Funnel, type FunnelStats, type CreateFunnelRequest } from '@/lib/api/funnels' -import { useSite, useFunnels, useFunnelStats } from '@/lib/swr/dashboard' -import { toast, PlusIcon, ArrowRightIcon, TrashIcon, Button } from '@ciphera-net/facet' -import { formatNumber } from '@/lib/utils/format' -import { FunnelsListSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons' +import { useState, useEffect } from 'react' +import { useParams, useSearchParams } from 'next/navigation' +import { motion } from 'framer-motion' +import { DURATION_BASE, EASE_APPLE } from '@/lib/motion' +import { deleteFunnel, createFunnel, updateFunnel, type Funnel, type CreateFunnelRequest } from '@/lib/api/funnels' +import { useSite, useFunnels } from '@/lib/swr/dashboard' +import { toast, PlusIcon, Button } from '@ciphera-net/facet' +import { FunnelsListSkeleton } from '@/components/skeletons' import { EmptyState } from '@/components/ui/EmptyState' -import { AnimatedNumber } from '@/components/ui/animated-number' -import { FunnelSimple, PencilSimple } from '@phosphor-icons/react' +import { ErrorCard } from '@/components/ui/ErrorCard' +import { FunnelSummaryCard } from '@/components/funnels/FunnelSummaryCard' +import { FunnelSimple } from '@phosphor-icons/react' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog' -import FunnelModal from '@/components/funnels/FunnelModal' -import { getDateRange, formatDate } from '@/lib/utils/dateRanges' +import FunnelModal, { type FunnelPrefill } from '@/components/funnels/FunnelModal' import DateRangePicker from '@/components/ui/DateRangePicker' +import { useUrlDateRange, type Period } from '@/lib/hooks/useUrlDateRange' import { useCan } from '@/lib/auth/permissions' -import { pctChange, type PctChangeResult } from '@/lib/utils/pctChange' -const DAY_MS = 86400000 -function formatConvertTime(seconds: number): string { - if (seconds < 60) return `${Math.round(seconds)}s` - if (seconds < 3600) return `${Math.round(seconds / 60)}m` - const h = Math.floor(seconds / 3600) - const m = Math.round((seconds % 3600) / 60) - return m > 0 ? `${h}h ${m}m` : `${h}h` -} - -function ChangeIndicator({ change }: { change: PctChangeResult }) { - if (!change) return null - if (change.type === 'new') return ( - New - ) - const isPositive = change.value > 0 - return ( - - {isPositive ? '↑' : '↓'} {Math.abs(change.value)}% - - ) -} - -function FunnelCard({ funnel, siteId, dateRange, onDelete, onEdit, canManage }: { - funnel: Funnel - siteId: string - dateRange: { start: string; end: string } - onDelete: (funnel: { id: string; name: string }) => void - onEdit: (funnel: Funnel) => void - canManage: boolean -}) { - const [expanded, setExpanded] = useState(false) - const [expandedStep, setExpandedStep] = useState(null) - - const { data: stats } = useFunnelStats(siteId, funnel.id, dateRange.start, dateRange.end) - - const prevRange = useMemo((): { start: string; end: string } | null => { - if (!expanded) return null - const s = new Date(dateRange.start) - const e = new Date(dateRange.end) - const duration = e.getTime() - s.getTime() - if (duration > 366 * DAY_MS) return null - const prevEnd = new Date(s.getTime() - DAY_MS) - const prevStart = new Date(prevEnd.getTime() - duration) - if (prevStart.getFullYear() < 2020) return null - return { start: prevStart.toISOString().split('T')[0], end: prevEnd.toISOString().split('T')[0] } - }, [dateRange, expanded]) - - const { data: prevStats } = useFunnelStats(siteId, funnel.id, prevRange?.start ?? '', prevRange?.end ?? '') - const totalVisitors = stats?.steps[0]?.visitors ?? 0 - const convertedVisitors = stats?.steps.length ? stats.steps[stats.steps.length - 1].visitors : 0 - const overallConversion = stats?.steps.length ? stats.steps[stats.steps.length - 1].conversion : 0 - const prevConversion = prevStats?.steps.length ? prevStats.steps[prevStats.steps.length - 1].conversion : 0 - const prevTotalVisitors = prevStats?.steps[0]?.visitors ?? 0 - - const biggestDropoff = stats?.steps.reduce<{ name: string; dropoff: number } | null>((worst, step, i) => { - if (i === 0) return worst - if (!worst || step.dropoff > worst.dropoff) return { name: step.step.name, dropoff: step.dropoff } - return worst - }, null) - - return ( -
- {/* Header — always visible */} - - - - )} - - - -
-
- - - {/* Expanded content */} - - {expanded && ( - -
- {/* Stat cards */} - {stats && ( -
-
-
- Conversion - -
- `${Math.round(v)}%`} className="text-xl font-bold text-white tabular-nums" /> -
-
-
- Visitors - -
- formatNumber(Math.round(v))} className="text-xl font-bold text-white tabular-nums" /> -
-
- Converted - formatNumber(Math.round(v))} className="text-xl font-bold text-white tabular-nums" /> -
-
- Biggest Dropoff - {biggestDropoff ? ( - <> - {Math.round(biggestDropoff.dropoff)}% -

{biggestDropoff.name}

- - ) : ( - 0% - )} -
-
- Median Time - - {stats.median_convert_seconds != null - ? formatConvertTime(stats.median_convert_seconds) - : '—'} - -
-
- )} - - {/* Step breakdown */} - {stats && stats.steps.length > 0 && ( -
- {stats.steps.map((step, i) => { - const isStepExpanded = expandedStep === i - const barWidth = totalVisitors > 0 ? (step.visitors / totalVisitors) * 100 : 0 - const dropped = i > 0 ? stats.steps[i - 1].visitors - step.visitors : 0 - const stepExitPages = step.exit_pages && step.exit_pages.length > 0 - - return ( -
- - - - {isStepExpanded && ( - -
- {stepExitPages ? ( -
-

Where visitors went after dropping off

-
- {step.exit_pages!.map(ep => { - const maxEp = step.exit_pages![0].visitors - const epBar = maxEp > 0 ? (ep.visitors / maxEp) * 60 : 0 - return ( -
-
- {ep.path} - {formatNumber(ep.visitors)} -
- ) - })} -
-
- ) : i === 0 ? ( -

Entry step — all visitors start here.

- ) : ( -

No exit page data for this step.

- )} -
- - )} - -
- ) - })} -
- )} - -
- - )} - -
- ) +// * ?prefill= seeds the create modal (journeys lens +// * cross-link). Parsed defensively — malformed input just opens nothing. +function parsePrefill(raw: string | null): FunnelPrefill | null { + if (!raw) return null + try { + const p = JSON.parse(raw) + if (!p || typeof p !== 'object') return null + const rawSteps = Array.isArray(p.steps) ? p.steps.slice(0, 8) : [] + const steps = rawSteps + .filter((s: unknown): s is Record => !!s && typeof s === 'object') + .map((s: Record, i: number) => ({ + name: typeof s.name === 'string' && s.name ? s.name : `Step ${i + 1}`, + value: typeof s.value === 'string' ? s.value : '', + type: s.type === 'contains' || s.type === 'regex' ? (s.type as string) : 'exact', + category: s.category === 'event' ? ('event' as const) : ('page' as const), + })) + return { + name: typeof p.name === 'string' ? p.name : undefined, + description: typeof p.description === 'string' ? p.description : undefined, + steps: steps.length > 0 ? steps : undefined, + } + } catch { + return null + } } export default function FunnelsPage() { const params = useParams() + const searchParams = useSearchParams() const siteId = params.id as string const canManageFunnels = useCan('funnels.manage') const { data: site } = useSite(siteId) - const { data: funnels = [], isLoading, mutate } = useFunnels(siteId) + const { data: funnels, error: funnelsError, isLoading, mutate } = useFunnels(siteId) + const { period, dateRange, setPeriod, shiftPeriod } = useUrlDateRange() const [deletingFunnel, setDeletingFunnel] = useState<{ id: string; name: string } | null>(null) - const [dateRange, setDateRange] = useState(() => getDateRange(30)) - const [period, setPeriod] = useState('30') - const [modalOpen, setModalOpen] = useState(false) + const [prefill, setPrefill] = useState(() => parsePrefill(searchParams.get('prefill'))) + const [modalOpen, setModalOpen] = useState(() => parsePrefill(searchParams.get('prefill')) !== null) const [editingFunnel, setEditingFunnel] = useState(null) + // * Consume the prefill param once — the modal is seeded, the URL cleaned + useEffect(() => { + if (!searchParams.get('prefill')) return + const url = new URL(window.location.href) + url.searchParams.delete('prefill') + window.history.replaceState({}, '', url.toString()) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + useEffect(() => { const domain = site?.domain document.title = domain ? `Funnels · ${domain} | Pulse` : 'Funnels | Pulse' }, [site?.domain]) - const shiftPeriod = useCallback((direction: -1 | 1) => { - const shift = (date: string, days: number) => { - const d = new Date(date + 'T00:00:00') - d.setDate(d.getDate() + days) - return formatDate(d) - } - const startDate = new Date(dateRange.start + 'T00:00:00') - const endDate = new Date(dateRange.end + 'T00:00:00') - const spanDays = Math.round((endDate.getTime() - startDate.getTime()) / DAY_MS) + 1 - const offsetDays = spanDays * direction - const newRange = { start: shift(dateRange.start, offsetDays), end: shift(dateRange.end, offsetDays) } - const today = formatDate(new Date()) - if (newRange.end > today) return - setDateRange(newRange) - setPeriod('custom') - }, [dateRange]) - const handleDelete = async () => { if (!deletingFunnel) return try { @@ -322,13 +84,16 @@ export default function FunnelsPage() { } } - const showSkeleton = useMinimumLoading(isLoading && !funnels.length) - const fadeClass = useSkeletonFade(showSkeleton) + // * First-ever load only — refetches keep the previous list mounted. + if (isLoading && !funnels) return - if (showSkeleton) return + // * Detail links carry the current range so the page opens where you were. + const qs = searchParams.toString() + const hrefQuery = qs ? `?${qs}` : '' + const list = funnels ?? [] return ( -
+

Funnels

@@ -338,20 +103,28 @@ export default function FunnelsPage() { setPeriod(p as Period)} + onDateRangeChange={(range) => setPeriod('custom', range)} onShift={shiftPeriod} /> - {canManageFunnels && ( + {/* * The empty state below carries its own create CTA — showing this + * header button too meant two identical orange CTAs on one screen. */} + {canManageFunnels && list.length > 0 && ( )}
- {funnels.length === 0 ? ( + {funnelsError ? ( + { void mutate() }} + /> + ) : list.length === 0 ? ( } title="No funnels yet" @@ -359,15 +132,23 @@ export default function FunnelsPage() { action={canManageFunnels ? { label: 'Create funnel', onClick: () => { setEditingFunnel(null); setModalOpen(true) } } : undefined} /> ) : ( -
- {funnels.map((funnel, index) => ( +
+ {list.map((funnel, index) => ( - { setEditingFunnel(f); setModalOpen(true) }} canManage={canManageFunnels} /> + { setEditingFunnel(f); setModalOpen(true) }} + onDelete={setDeletingFunnel} + /> ))}
@@ -389,8 +170,10 @@ export default function FunnelsPage() { {modalOpen && canManageFunnels && ( { setModalOpen(false); setEditingFunnel(null) }} + siteId={siteId} + onClose={() => { setModalOpen(false); setEditingFunnel(null); setPrefill(null) }} initialData={editingFunnel ?? undefined} + prefill={!editingFunnel ? prefill ?? undefined : undefined} onSubmit={async (data: CreateFunnelRequest) => { if (editingFunnel) { await updateFunnel(siteId, editingFunnel.id, data) diff --git a/app/sites/[id]/journeys/page.tsx b/app/sites/[id]/journeys/page.tsx index e0d348ea0..db326c7ce 100644 --- a/app/sites/[id]/journeys/page.tsx +++ b/app/sites/[id]/journeys/page.tsx @@ -1,17 +1,31 @@ 'use client' import { useCallback, useEffect } from 'react' -import { useParams } from 'next/navigation' -import Select from '@/components/ui/select' +import { useParams, useRouter } from 'next/navigation' +import { FlowArrow, FunnelSimple, Rows, TreeStructure, X } from '@phosphor-icons/react' +import { aggregateJourney } from '@/lib/journeys/aggregate' +import { buildLinks, spineThrough } from '@/lib/journeys/chain' import { formatDate } from '@/lib/utils/dateRanges' +import { formatDate as formatDisplayDate } from '@/lib/utils/formatDate' import DateRangePicker from '@/components/ui/DateRangePicker' import ColumnJourney from '@/components/journeys/ColumnJourney' import SankeyJourney from '@/components/journeys/SankeyJourney' -import { Slider } from '@/components/ui/slider' -import { Label } from '@/components/ui/label' -import { JourneysSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons' +import { StepperControl } from '@/components/ui/stepper-control' +import { Segmented } from '@/components/ui/segmented' +import { EntryCombobox } from '@/components/journeys/EntryCombobox' +import { ErrorCard } from '@/components/ui/ErrorCard' +import { EmptyState } from '@/components/ui/EmptyState' +import { UpdatingChip } from '@/components/ui/UpdatingChip' +import { JourneysSkeleton } from '@/components/skeletons' +import FilterButton from '@/components/dashboard/FilterButton' +import FilterPills from '@/components/dashboard/FilterPills' +import FilterBuilder from '@/components/dashboard/filter/FilterBuilder' +import { useFilterBuilder } from '@/components/dashboard/filter/useFilterBuilder' +import { useFilterSuggestions } from '@/lib/hooks/useFilterSuggestions' +import type { DimensionFilter } from '@/lib/filters' import { useJourneyFilters, + JOURNEY_FILTER_DIMENSIONS, DEPTH_MIN, DEPTH_MAX, DEPTH_STEP, @@ -28,6 +42,7 @@ import { export default function JourneysPage() { const params = useParams() + const router = useRouter() const siteId = params.id as string const filters = useJourneyFilters() @@ -51,18 +66,43 @@ export default function JourneysPage() { filters.setPeriod('custom', newRange) }, [filters.dateRange, filters.setPeriod]) - const { data: transitionsData, isLoading: transitionsLoading } = useJourneyTransitions( + const { + data: transitionsData, + error: transitionsError, + isLoading: transitionsLoading, + isValidating: transitionsValidating, + mutate: retryTransitions, + } = useJourneyTransitions( siteId, filters.dateRange.start, filters.dateRange.end, filters.committedDepth, 1, filters.entryPath || undefined, + filters.filtersParam || undefined, ) const { data: entryPoints } = useJourneyEntryPoints( siteId, filters.dateRange.start, filters.dateRange.end, + filters.filtersParam || undefined, + ) + + // ── Dashboard filter system restricted to journeys dimensions ── + const fetchSuggestions = useFilterSuggestions(siteId, filters.dateRange, filters.filtersParam || undefined) + const filterBuilder = useFilterBuilder(fetchSuggestions) + const handleFilterApply = useCallback( + (filter: DimensionFilter, editingIndex: number | null) => { + if (editingIndex !== null) { + filters.setDimensionFilters(filters.dimensionFilters.map((f, i) => (i === editingIndex ? filter : f))) + } else { + const dup = filters.dimensionFilters.some( + (f) => f.dimension === filter.dimension && f.operator === filter.operator && f.values.join(';') === filter.values.join(';'), + ) + if (!dup) filters.setDimensionFilters([...filters.dimensionFilters, filter]) + } + }, + [filters], ) const { data: dashboard } = useDashboard( siteId, @@ -75,23 +115,35 @@ export default function JourneysPage() { document.title = domain ? `Journeys \u00b7 ${domain} | Pulse` : 'Journeys | Pulse' }, [dashboard?.site?.domain]) - const showSkeleton = useMinimumLoading(transitionsLoading && !transitionsData) - const fadeClass = useSkeletonFade(showSkeleton) - - const entryPointOptions = [ - { value: '', label: 'All entry points' }, - ...(entryPoints ?? []).map((ep) => ({ - value: ep.path, - label: `${ep.path} (${ep.session_count.toLocaleString()})`, - })), - ] + // * First-ever load only — keepPreviousData keeps the canvas mounted with + // * stale data on every later refetch, so this is true once per mount. + const showSkeleton = transitionsLoading && !transitionsData if (showSkeleton) return const totalSessions = transitionsData?.total_sessions ?? 0 + const transitions = transitionsData?.transitions ?? [] + const periodLabel = `${formatDisplayDate(new Date(filters.dateRange.start + 'T00:00:00'))} – ${formatDisplayDate(new Date(filters.dateRange.end + 'T00:00:00'))}` + + // * Lens → funnel cross-link: the heaviest chain through the lens becomes + // * a prefilled create-funnel modal on the funnels page. + const createFunnelFromLens = () => { + if (!filters.lens) return + const columns = aggregateJourney(transitions, { + depth: filters.committedDepth, + maxPagesPerStep: filters.committedDensity, + }) + const spine = spineThrough(buildLinks(transitions, columns), filters.lens, 6) + const values = spine.length > 0 ? spine : [filters.lens] + const prefill = { + name: `Journey via ${filters.lens}`, + steps: values.map((value) => ({ value, type: 'exact', category: 'page' })), + } + router.push(`/sites/${siteId}/funnels?prefill=${encodeURIComponent(JSON.stringify(prefill))}`) + } return ( -
+
{/* Header */}
@@ -112,58 +164,65 @@ export default function JourneysPage() {
{/* Single card: toolbar + chart */} -
- {/* Toolbar */} -
-
-
-
- - - {filters.depth} steps - -
- filters.setDepth(v)} - min={DEPTH_MIN} - max={DEPTH_MAX} - step={DEPTH_STEP} - aria-label={`${filters.depth} steps deep`} - className="[&_[role=slider]]:h-6 [&_[role=slider]]:w-2.5 [&_[role=slider]]:border-[3px] [&_[role=slider]]:border-background [&_[role=slider]]:bg-primary [&_[role=slider]]:ring-offset-0" - /> -
-
-
- - - {filters.density} paths - -
- filters.setDensity(v)} - min={DENSITY_MIN} - max={DENSITY_MAX} - step={DENSITY_STEP} - aria-label={`${filters.density} paths per step`} - className="[&_[role=slider]]:h-6 [&_[role=slider]]:w-2.5 [&_[role=slider]]:border-[3px] [&_[role=slider]]:border-background [&_[role=slider]]:bg-primary [&_[role=slider]]:ring-offset-0" - /> -
+
+ {/* Toolbar — one h-10 row; wraps to two rows +
+ +
- -
- setSearch(e.target.value)} - onKeyDown={e => { - if (e.key === 'Enter') { - e.preventDefault() - if (filtered.length === 1 && !values.includes(filtered[0].value)) { - toggle(filtered[0].value) - setSearch('') - } else { - handleAddCustom() - } - } - }} - placeholder={`Search ${dimLabel.toLowerCase()}…`} - className="w-full px-2.5 py-1.5 text-sm bg-neutral-800 border border-neutral-700 rounded-none text-white placeholder-neutral-500 focus:outline-none focus:ring-1 focus:ring-brand-orange/40 focus:border-brand-orange/40 transition-colors ease-apple" - /> -
- - {isFetching ? ( -
-
-
- ) : filtered.length > 0 ? ( -
- {filtered.map(s => { - const checked = values.includes(s.value) - return ( - - ) - })} -
- ) : search.trim() ? ( -
- -
- ) : ( -
- No suggestions available -
- )} -
- )} -
- ) -} - -// --------------------------------------------------------------------------- -// FilterModal (default export) -// --------------------------------------------------------------------------- - -export default function FilterModal({ - open, - initialDimension, - initialFilter, - onSave, - onClose, - onFetchSuggestions, -}: FilterModalProps) { - const [dimension, setDimension] = useState( - initialFilter?.dimension ?? initialDimension ?? null - ) - const [operator, setOperator] = useState( - initialFilter?.operator ?? 'is' - ) - const [values, setValues] = useState(initialFilter?.values ?? []) - - // Sync state when modal opens (handles re-use of the same instance) - useEffect(() => { - if (open) { - setDimension(initialFilter?.dimension ?? initialDimension ?? null) - setOperator(initialFilter?.operator ?? 'is') - setValues(initialFilter?.values ?? []) - } - }, [open, initialFilter, initialDimension]) - - // Escape closes - useEffect(() => { - if (!open) return - function onEsc(e: KeyboardEvent) { - if (e.key === 'Escape') onClose() - } - document.addEventListener('keydown', onEsc) - return () => document.removeEventListener('keydown', onEsc) - }, [open, onClose]) - - const handleDimensionChange = useCallback((dim: string) => { - setDimension(dim) - setValues([]) - }, []) - - const handleSave = useCallback(() => { - if (!dimension || values.length === 0) return - onSave({ dimension, operator, values }) - onClose() - }, [dimension, operator, values, onSave, onClose]) - - const canSave = dimension !== null && values.length > 0 - - const dimensionOptions = DIMENSIONS.map(d => ({ value: d, label: DIMENSION_LABELS[d] })) - const operatorOptions = OPERATORS.map(o => ({ value: o, label: OPERATOR_LABELS[o] })) - - const modal = ( - - {open && ( - { if (e.target === e.currentTarget) onClose() }} - > - - {/* Header */} -
-

- {initialFilter ? 'Edit Filter' : 'Add Filter'} -

- -
- - {/* Fields */} -
- {/* Dimension */} -
- - -
- - {/* Operator */} -
- - setOperator(val as DimensionFilter['operator'])} - /> -
- - {/* Values */} -
- - -
-
- - {/* Actions */} -
- - -
-
-
- )} -
- ) - - if (typeof document === 'undefined') return null - return createPortal(modal, document.body) -} diff --git a/components/dashboard/FilterPill.tsx b/components/dashboard/FilterPill.tsx index 52626eb05..5d1c7a01b 100644 --- a/components/dashboard/FilterPill.tsx +++ b/components/dashboard/FilterPill.tsx @@ -7,7 +7,8 @@ import { EASE_APPLE } from '@/lib/motion' interface FilterPillProps { filter: DimensionFilter - onEdit: () => void + /** Receives the pill's label element so the filter popover can anchor to it. */ + onEdit: (anchor: HTMLElement) => void onRemove: () => void } @@ -25,22 +26,24 @@ export default function FilterPill({ filter, onEdit, onRemove }: FilterPillProps animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.9 }} transition={{ duration: 0.15, ease: EASE_APPLE }} - className="inline-flex items-center h-7 rounded-none bg-brand-orange/10 text-brand-orange text-xs font-medium border border-brand-orange/20" + // * h-10 matches the Filter button and date-range control — the pill sits + // * in the same toolbar row and must share its rhythm. + className="inline-flex items-center h-10 rounded-none bg-brand-orange/10 text-brand-orange text-sm font-medium border border-brand-orange/20" > ) diff --git a/components/dashboard/FilterPills.tsx b/components/dashboard/FilterPills.tsx index 436d2356d..aa19f8630 100644 --- a/components/dashboard/FilterPills.tsx +++ b/components/dashboard/FilterPills.tsx @@ -6,7 +6,7 @@ import FilterPill from './FilterPill' interface FilterPillsProps { filters: DimensionFilter[] - onEdit: (index: number) => void + onEdit: (index: number, anchor: HTMLElement) => void onRemove: (index: number) => void onClear: () => void } @@ -21,7 +21,7 @@ export default function FilterPills({ filters, onEdit, onRemove, onClear }: Filt onEdit(index)} + onEdit={anchor => onEdit(index, anchor)} onRemove={() => onRemove(index)} /> ))} diff --git a/components/dashboard/ScrollDepth.tsx b/components/dashboard/ScrollDepth.tsx deleted file mode 100644 index de7b3b281..000000000 --- a/components/dashboard/ScrollDepth.tsx +++ /dev/null @@ -1,79 +0,0 @@ -'use client' - -import { PolarAngleAxis, PolarGrid, Radar, RadarChart, Tooltip } from 'recharts' -import { ArrowDown } from '@phosphor-icons/react' -import { EmptyState } from '@/components/ui/EmptyState' -import type { ScrollDepthDistribution } from '@/lib/api/stats' - -interface ScrollDepthProps { - scrollDepth?: ScrollDepthDistribution -} - -const THRESHOLDS = [25, 50, 75, 100] as const - -export default function ScrollDepth({ scrollDepth }: ScrollDepthProps) { - const total = scrollDepth?.total_sessions ?? 0 - const hasData = total > 0 - - const chartData = THRESHOLDS.map((threshold) => { - const count = scrollDepth?.[`scroll_${threshold}` as keyof ScrollDepthDistribution] as number ?? 0 - return { - label: `${threshold}%`, - value: total > 0 ? Math.round((count / total) * 100) : 0, - } - }) - - return ( -
-
-

- Scroll Depth -

-
-

- % of visitors who scrolled this far -

- - {hasData ? ( -
- - - - [`${value}%`, 'Reached']} - /> - - -
- ) : ( - } - title="No scrolls recorded yet" - description="Scroll tracking is automatic — depth data appears once visitors start reading your pages." - /> - )} -
- ) -} diff --git a/components/dashboard/filter/DimensionStage.tsx b/components/dashboard/filter/DimensionStage.tsx new file mode 100644 index 000000000..5ca8853a2 --- /dev/null +++ b/components/dashboard/filter/DimensionStage.tsx @@ -0,0 +1,210 @@ +'use client' + +import { useMemo, useRef, useState, useEffect } from 'react' +import { motion } from 'framer-motion' +import { + MagnifyingGlass, + FileText, + ArrowSquareOut, + Broadcast, + Tag, + Compass, + Cpu, + DeviceMobile, + FrameCorners, + GlobeHemisphereWest, + MapPin, + Buildings, + CursorClick, + type Icon, +} from '@phosphor-icons/react' +import { DIMENSION_CATEGORIES, DIMENSION_LABELS } from '@/lib/filters' +import { DURATION_FAST, EASE_APPLE } from '@/lib/motion' +import { moveHighlight } from './useFilterBuilder' + +// * Mirrors the dashboard's tool iconography so the popover reads as the same +// * product surface (flags/brand icons appear once a dimension is picked). +const DIMENSION_ICONS: Record = { + page: FileText, + referrer: ArrowSquareOut, + channel: Broadcast, + utm_source: Tag, + utm_medium: Tag, + utm_campaign: Tag, + utm_term: Tag, + utm_content: Tag, + browser: Compass, + os: Cpu, + device: DeviceMobile, + screen_resolution: FrameCorners, + country: GlobeHemisphereWest, + region: MapPin, + city: Buildings, + event_name: CursorClick, +} + +// --------------------------------------------------------------------------- +// DimensionStage — stage 1 of the filter popover: type-to-search over the +// dimension list with full keyboard navigation. Orange dots mark dimensions +// that already carry an active filter. +// --------------------------------------------------------------------------- + +export interface DimensionStageProps { + activeDimensions: Set + onPick: (dimension: string) => void + onClose: () => void + /** Optional allowlist — when set, only these dimensions are offered (additive; + * omitted on the dashboard, so it's unaffected). */ + allowed?: readonly string[] +} + +export default function DimensionStage({ activeDimensions, onPick, onClose, allowed }: DimensionStageProps) { + const [search, setSearch] = useState('') + const [highlight, setHighlight] = useState(0) + const listRef = useRef(null) + const inputRef = useRef(null) + + // * Self-focus on mount — the popover only focuses on open, so without this + // * the keyboard is stranded after Backspace/back returns to this stage. + useEffect(() => { + inputRef.current?.focus() + }, []) + + const query = search.trim().toLowerCase() + const allowSet = useMemo(() => (allowed ? new Set(allowed) : null), [allowed]) + + // * Groups with their matching dimensions; `flat` is the keyboard order. + const { groups, flat } = useMemo(() => { + const groups = DIMENSION_CATEGORIES + .map(cat => ({ + label: cat.label, + dimensions: cat.dimensions.filter( + d => (!allowSet || allowSet.has(d)) && DIMENSION_LABELS[d].toLowerCase().includes(query), + ), + })) + .filter(g => g.dimensions.length > 0) + return { groups, flat: groups.flatMap(g => g.dimensions) } + }, [query, allowSet]) + + // * Clamp the highlight when the filtered list shrinks. + useEffect(() => { + setHighlight(h => (flat.length === 0 ? -1 : Math.min(Math.max(h, 0), flat.length - 1))) + }, [flat.length]) + + useEffect(() => { + if (highlight < 0) return + listRef.current + ?.querySelector(`[data-index="${highlight}"]`) + ?.scrollIntoView({ block: 'nearest' }) + }, [highlight]) + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault() + setHighlight(h => moveHighlight(h, 1, flat.length)) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + setHighlight(h => moveHighlight(h, -1, flat.length)) + } else if (e.key === 'Enter') { + e.preventDefault() + const dim = flat[highlight] ?? flat[0] + if (dim) onPick(dim) + } else if (e.key === 'Backspace' && search === '') { + e.preventDefault() + onClose() + } + } + + const renderItem = (dim: string, index: number) => { + const DimIcon = DIMENSION_ICONS[dim] + return ( + // * Tiny mount cascade (capped at 120ms) — rows that stay mounted while + // * typing never re-animate, so keystroke filtering stays instant. + onPick(dim)} + onMouseEnter={() => setHighlight(index)} + className={`w-full flex items-center gap-2.5 px-3 py-1.5 text-sm text-left rounded-none transition-colors cursor-pointer ease-apple ${ + index === highlight ? 'bg-white/[0.06] text-white' : 'text-neutral-300' + }`} + > + {DimIcon && ( + + )} + {DIMENSION_LABELS[dim]} + {activeDimensions.has(dim) && ( + + )} + + ) + } + + // Flat index bookkeeping across groups for keyboard order. + let runningIndex = -1 + + return ( +
+
+ + = 0 && flat[highlight] ? `filter-dim-${flat[highlight]}` : undefined} + value={search} + onChange={e => { setSearch(e.target.value); setHighlight(0) }} + onKeyDown={handleKeyDown} + placeholder="Filter by…" + className="w-full pl-8 pr-2.5 py-1.5 text-sm bg-neutral-800 border border-neutral-700 rounded-none text-white placeholder-neutral-500 focus:outline-none focus:ring-1 focus:ring-brand-orange/40 focus:border-brand-orange/40 transition-colors ease-apple" + /> +
+ +
+ {flat.length === 0 ? ( +
+ No matching dimensions +
+ ) : query ? ( + // * While searching: one flat result list — headers just add noise. + flat.map(dim => { + runningIndex += 1 + return renderItem(dim, runningIndex) + }) + ) : ( + groups.map(group => ( +
+
+ {group.label} +
+ {group.dimensions.map(dim => { + runningIndex += 1 + return renderItem(dim, runningIndex) + })} +
+ )) + )} +
+
+ ) +} diff --git a/components/dashboard/filter/FilterBuilder.tsx b/components/dashboard/filter/FilterBuilder.tsx new file mode 100644 index 000000000..cbd097c39 --- /dev/null +++ b/components/dashboard/filter/FilterBuilder.tsx @@ -0,0 +1,68 @@ +'use client' + +import { useMemo } from 'react' +import type { DimensionFilter } from '@/lib/filters' +import FilterPopover from './FilterPopover' +import DimensionStage from './DimensionStage' +import ValueStage from './ValueStage' +import { isDuplicateFilter, type UseFilterBuilder } from './useFilterBuilder' + +// --------------------------------------------------------------------------- +// FilterBuilder — assembles the popover with its two stages around the +// useFilterBuilder hook the page owns. The page keeps owning the committed +// filters, URL sync and suggestion fetching; this component only renders the +// draft surface. +// --------------------------------------------------------------------------- + +export interface FilterBuilderProps { + builder: UseFilterBuilder + filters: DimensionFilter[] + onApply: (filter: DimensionFilter, editingIndex: number | null) => void + /** Optional dimension allowlist forwarded to the dimension stage. */ + allowedDimensions?: readonly string[] +} + +export default function FilterBuilder({ builder, filters, onApply, allowedDimensions }: FilterBuilderProps) { + const { open, anchor, draft, dispatch, close, fetchSuggestions } = builder + + const activeDimensions = useMemo(() => new Set(filters.map(f => f.dimension)), [filters]) + const duplicate = isDuplicateFilter(draft, filters) + + const apply = () => { + if (!draft.dimension || draft.values.length === 0 || duplicate) return + onApply( + { dimension: draft.dimension, operator: draft.operator, values: draft.values }, + draft.editingIndex, + ) + close() + } + + return ( + + {draft.stage === 'dimension' ? ( + dispatch({ type: 'pick_dimension', dimension })} + onClose={close} + allowed={allowedDimensions} + /> + ) : ( + dispatch({ type: 'back' })} + onOperatorChange={operator => dispatch({ type: 'set_operator', operator })} + onValuesChange={values => dispatch({ type: 'set_values', values })} + onApply={apply} + fetchSuggestions={fetchSuggestions} + /> + )} + + ) +} diff --git a/components/dashboard/filter/FilterPopover.tsx b/components/dashboard/filter/FilterPopover.tsx new file mode 100644 index 000000000..a271298fd --- /dev/null +++ b/components/dashboard/filter/FilterPopover.tsx @@ -0,0 +1,179 @@ +'use client' + +import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react' +import { createPortal } from 'react-dom' +import { motion, AnimatePresence } from 'framer-motion' +import { DURATION_FAST, EASE_APPLE } from '@/lib/motion' + +// --------------------------------------------------------------------------- +// FilterPopover — the anchored surface the whole filter flow lives in. +// Positions below the anchor (Filter button or a pill), flips above when the +// viewport bottom is closer than the panel height, clamps horizontally, traps +// focus while open, and returns focus to the anchor on close. +// --------------------------------------------------------------------------- + +const VIEWPORT_MARGIN = 16 +const ANCHOR_GAP = 6 + +export interface FilterPopoverProps { + open: boolean + anchor: HTMLElement | null + /** Accessible name — "Add filter" or "Edit filter". */ + label: string + /** Changes when the visible stage changes so position/measure re-runs. */ + contentKey: string + onClose: () => void + children: ReactNode +} + +export default function FilterPopover({ open, anchor, label, contentKey, onClose, children }: FilterPopoverProps) { + const panelRef = useRef(null) + const measureRef = useRef(null) + const [pos, setPos] = useState<{ left: number; top: number } | null>(null) + const [resizeTick, setResizeTick] = useState(0) + const [contentHeight, setContentHeight] = useState(null) + const [isMorphing, setIsMorphing] = useState(false) + + // * Track the active stage's rendered height so the panel morphs between + // * stage sizes (and grows smoothly when suggestions arrive) instead of + // * snapping. Null until first measure — the initial open keeps its natural + // * height with no morph. + useEffect(() => { + if (!open) { + setContentHeight(null) + return + } + const el = measureRef.current + if (!el) return + const observer = new ResizeObserver(() => setContentHeight(el.offsetHeight)) + observer.observe(el) + setContentHeight(el.offsetHeight) + return () => observer.disconnect() + }, [open, contentKey]) + + // * Position before paint: below the anchor, flipped above when it would + // * overflow, clamped to the viewport with a fixed margin. + useLayoutEffect(() => { + if (!open || !anchor || !panelRef.current) return + const a = anchor.getBoundingClientRect() + const p = panelRef.current.getBoundingClientRect() + + let top = a.bottom + ANCHOR_GAP + if (top + p.height > window.innerHeight - VIEWPORT_MARGIN) { + const above = a.top - ANCHOR_GAP - p.height + top = above >= VIEWPORT_MARGIN + ? above + : Math.max(VIEWPORT_MARGIN, window.innerHeight - VIEWPORT_MARGIN - p.height) + } + + const left = Math.max( + VIEWPORT_MARGIN, + Math.min(a.left, window.innerWidth - VIEWPORT_MARGIN - p.width), + ) + setPos({ left, top }) + }, [open, anchor, contentKey, resizeTick]) + + useEffect(() => { + if (!open) return + const onResize = () => setResizeTick(t => t + 1) + window.addEventListener('resize', onResize) + return () => window.removeEventListener('resize', onResize) + }, [open]) + + // * Focus in, trap Tab, close on Esc/outside, restore focus on close. + useEffect(() => { + if (!open) return + const panel = panelRef.current + const previouslyFocused = document.activeElement as HTMLElement | null + + const initial = panel?.querySelector('[data-autofocus]') + ?? panel?.querySelector('input, button:not([disabled])') + initial?.focus() + + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.stopPropagation() + onClose() + return + } + if (e.key !== 'Tab' || !panel) return + const focusables = Array.from( + panel.querySelectorAll('input, button:not([disabled]), [tabindex]:not([tabindex="-1"])'), + ).filter(el => el.offsetParent !== null) + if (focusables.length === 0) return + const first = focusables[0] + const last = focusables[focusables.length - 1] + if (e.shiftKey && document.activeElement === first) { + e.preventDefault() + last.focus() + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault() + first.focus() + } + } + + const onDown = (e: MouseEvent) => { + const target = e.target as Node + if (!panel?.contains(target) && !anchor?.contains(target)) onClose() + } + + document.addEventListener('keydown', onKey) + document.addEventListener('mousedown', onDown) + return () => { + document.removeEventListener('keydown', onKey) + document.removeEventListener('mousedown', onDown) + previouslyFocused?.focus?.() + } + }, [open, anchor, onClose]) + + useEffect(() => { + if (!open) setPos(null) + }, [open]) + + if (typeof document === 'undefined') return null + + return createPortal( + + {open && anchor && ( + + setIsMorphing(true)} + onAnimationComplete={() => setIsMorphing(false)} + // * Clip only mid-morph — at rest the operator chip's menu must be + // * free to overhang the panel. + style={{ overflow: isMorphing ? 'hidden' : undefined }} + > +
+ + + {children} + + +
+
+
+ )} +
, + document.body, + ) +} diff --git a/components/dashboard/filter/OperatorChip.tsx b/components/dashboard/filter/OperatorChip.tsx new file mode 100644 index 000000000..815b4b337 --- /dev/null +++ b/components/dashboard/filter/OperatorChip.tsx @@ -0,0 +1,100 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' +import { motion, AnimatePresence } from 'framer-motion' +import { CaretDown, Check } from '@phosphor-icons/react' +import { OPERATORS, OPERATOR_LABELS, type DimensionFilter } from '@/lib/filters' +import { DURATION_FAST, EASE_APPLE } from '@/lib/motion' + +// --------------------------------------------------------------------------- +// OperatorChip — compact token that names the operator and opens a 4-option +// mini-menu. With the menu closed, arrow keys cycle the operator directly +// (Tab → arrows is the keyboard path; no menu round-trip needed). +// --------------------------------------------------------------------------- + +export interface OperatorChipProps { + operator: DimensionFilter['operator'] + onChange: (operator: DimensionFilter['operator']) => void +} + +export default function OperatorChip({ operator, onChange }: OperatorChipProps) { + const [open, setOpen] = useState(false) + const ref = useRef(null) + + useEffect(() => { + if (!open) return + const onDown = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) + } + document.addEventListener('mousedown', onDown) + return () => document.removeEventListener('mousedown', onDown) + }, [open]) + + const cycle = (delta: 1 | -1) => { + const index = OPERATORS.indexOf(operator) + onChange(OPERATORS[(index + delta + OPERATORS.length) % OPERATORS.length]) + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown' || e.key === 'ArrowRight') { + e.preventDefault() + cycle(1) + } else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') { + e.preventDefault() + cycle(-1) + } else if (e.key === 'Escape' && open) { + e.stopPropagation() + setOpen(false) + } + } + + return ( +
+ + + + {open && ( + + {OPERATORS.map(op => ( + + ))} + + )} + +
+ ) +} diff --git a/components/dashboard/filter/ValuePicker.tsx b/components/dashboard/filter/ValuePicker.tsx new file mode 100644 index 000000000..b9d4467d4 --- /dev/null +++ b/components/dashboard/filter/ValuePicker.tsx @@ -0,0 +1,240 @@ +'use client' + +import { useState, useRef, useEffect, useLayoutEffect } from 'react' +import { motion } from 'framer-motion' +import { DIMENSION_LABELS, type FilterSuggestion } from '@/lib/filters' +import { getFilterValueIcon } from '@/lib/utils/icons' +import { DURATION_FAST, EASE_APPLE } from '@/lib/motion' + +// --------------------------------------------------------------------------- +// ValuePicker — inline value search + multi-select list with live counts. +// +// Owns search text, suggestion fetching (with an explicit failed state — a +// dead request must not read as "no suggestions"), and the custom-value +// affordance; the parent owns the selected values. +// --------------------------------------------------------------------------- + +export interface ValuePickerProps { + dimension: string | null + values: string[] + onChange: (values: string[]) => void + onFetchSuggestions?: (dimension: string) => Promise + autoFocus?: boolean + /** Enter with an empty search box — the popover uses it to apply the draft. */ + onSubmit?: () => void + /** Backspace with an empty search box — the popover uses it to go back a stage. */ + onBackspaceWhenEmpty?: () => void +} + +export default function ValuePicker({ dimension, values, onChange, onFetchSuggestions, autoFocus, onSubmit, onBackspaceWhenEmpty }: ValuePickerProps) { + const [search, setSearch] = useState('') + const [suggestions, setSuggestions] = useState([]) + // * Lazily true so the very first paint is already the loading state — a + // * post-paint effect flashed the "no values recorded" empty message for a + // * frame before every fetch. + const [isFetching, setIsFetching] = useState(() => Boolean(dimension && onFetchSuggestions)) + const [hasFetched, setHasFetched] = useState(false) + const [showSpinner, setShowSpinner] = useState(false) + const [fetchFailed, setFetchFailed] = useState(false) + const [retryTick, setRetryTick] = useState(0) + const inputRef = useRef(null) + + // * Fetch on mount and whenever the dimension changes. Layout effect so the + // * dimension-change reset lands before paint — no stale-list or + // * empty-state flash between stages. + useLayoutEffect(() => { + setSuggestions([]) + setSearch('') + setFetchFailed(false) + setHasFetched(false) + if (!dimension || !onFetchSuggestions) { + setIsFetching(false) + return + } + let cancelled = false + setIsFetching(true) + onFetchSuggestions(dimension) + .then(data => { if (!cancelled) { setSuggestions(data); setIsFetching(false); setHasFetched(true) } }) + .catch(() => { if (!cancelled) { setFetchFailed(true); setIsFetching(false); setHasFetched(true) } }) + return () => { cancelled = true } + }, [dimension, onFetchSuggestions, retryTick]) + + // * Spinner only earns its paint after 150ms — cache hits and fast + // * responses resolve without ever flashing it. + useEffect(() => { + if (!isFetching) { + setShowSpinner(false) + return + } + const timer = setTimeout(() => setShowSpinner(true), 150) + return () => clearTimeout(timer) + }, [isFetching]) + + useEffect(() => { + if (autoFocus) inputRef.current?.focus() + }, [autoFocus]) + + const filtered = suggestions.filter(s => + s.label.toLowerCase().includes(search.toLowerCase()) || + s.value.toLowerCase().includes(search.toLowerCase()) + ) + + // * Chosen values missing from the suggestion list (custom entries, values + // * outside the top 100, or a failed fetch) must stay visible and removable. + const selectedExtras = values.filter(v => + !suggestions.some(s => s.value === v) && + v.toLowerCase().includes(search.toLowerCase()) + ) + + function toggle(val: string) { + onChange(values.includes(val) ? values.filter(v => v !== val) : [...values, val]) + } + + function handleAddCustom() { + const trimmed = search.trim() + if (!trimmed || values.includes(trimmed)) return + onChange([...values, trimmed]) + setSearch('') + } + + const dimLabel = dimension ? (DIMENSION_LABELS[dimension] ?? dimension) : 'value' + + const renderRow = (value: string, label: string, checked: boolean, count?: number) => ( + + ) + + return ( + <> +
+ setSearch(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') { + e.preventDefault() + if (search.trim() === '') { + onSubmit?.() + } else if (filtered.length === 1 && !values.includes(filtered[0].value)) { + toggle(filtered[0].value) + setSearch('') + } else { + handleAddCustom() + } + } else if (e.key === 'Backspace' && search === '') { + e.preventDefault() + onBackspaceWhenEmpty?.() + } + }} + placeholder={`Search ${dimLabel.toLowerCase()}…`} + className="w-full px-2.5 py-1.5 text-sm bg-neutral-800 border border-neutral-700 rounded-none text-white placeholder-neutral-500 focus:outline-none focus:ring-1 focus:ring-brand-orange/40 focus:border-brand-orange/40 transition-colors ease-apple" + /> +
+ + {isFetching ? ( + /* * Fixed-height box keeps the panel's morph pointed at one target + * while loading; the spinner fades in only for slow fetches. */ +
+ {showSpinner && ( + + )} +
+ ) : (filtered.length > 0 || selectedExtras.length > 0) ? ( + /* * Gentle rise-in when the list first arrives (post-fetch); typing to + * filter keeps this container mounted, so keystrokes stay instant. */ + + {selectedExtras.map(v => renderRow(v, v, true))} + {filtered.map(s => renderRow(s.value, s.label, values.includes(s.value), s.count))} + {fetchFailed && ( +
+ Suggestions unavailable + +
+ )} +
+ ) : fetchFailed && !search.trim() ? ( + /* * A failed fetch must not masquerade as "no suggestions" — typing a + * custom value still works either way. */ +
+

Couldn't load suggestions

+ +
+ ) : search.trim() ? ( +
+ +
+ ) : (hasFetched || !onFetchSuggestions) ? ( +
+ No {dimLabel.toLowerCase()} recorded in this period — type a value to filter anyway +
+ ) : ( + /* * Pre-fetch frames (dimension just changed): hold the loading box's + * height so nothing flashes before the layout effect kicks in. */ +
+ )} + + ) +} diff --git a/components/dashboard/filter/ValueStage.tsx b/components/dashboard/filter/ValueStage.tsx new file mode 100644 index 000000000..01cc9b5dd --- /dev/null +++ b/components/dashboard/filter/ValueStage.tsx @@ -0,0 +1,104 @@ +'use client' + +import { CaretLeft } from '@phosphor-icons/react' +import { DIMENSION_LABELS, type DimensionFilter, type FilterSuggestion } from '@/lib/filters' +import type { FilterDraft } from './useFilterBuilder' +import OperatorChip from './OperatorChip' +import ValuePicker from './ValuePicker' + +// --------------------------------------------------------------------------- +// ValueStage — stage 2 of the filter popover: dimension token + operator chip +// + inline value picker + apply. ⌘/Ctrl-Enter applies from anywhere in the +// stage; plain Enter applies via the picker when its search box is empty. +// --------------------------------------------------------------------------- + +export interface ValueStageProps { + draft: FilterDraft + isDuplicate: boolean + onBack: () => void + onOperatorChange: (operator: DimensionFilter['operator']) => void + onValuesChange: (values: string[]) => void + onApply: () => void + fetchSuggestions?: (dimension: string) => Promise +} + +export default function ValueStage({ + draft, + isDuplicate, + onBack, + onOperatorChange, + onValuesChange, + onApply, + fetchSuggestions, +}: ValueStageProps) { + const canApply = draft.values.length > 0 && !isDuplicate + const dimLabel = draft.dimension ? (DIMENSION_LABELS[draft.dimension] ?? draft.dimension) : '' + + const handleApply = () => { + if (canApply) onApply() + } + + return ( +
{ + if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { + e.preventDefault() + handleApply() + } + }} + > + {/* Header: back · dimension token · operator */} +
+ + + +
+ + {/* Values */} + + + {/* Apply */} +
+ {isDuplicate && ( +

+ This exact filter is already active. +

+ )} + +
+
+ ) +} diff --git a/components/dashboard/filter/__tests__/useFilterBuilder.test.ts b/components/dashboard/filter/__tests__/useFilterBuilder.test.ts new file mode 100644 index 000000000..c562e50fd --- /dev/null +++ b/components/dashboard/filter/__tests__/useFilterBuilder.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect } from 'vitest' +import { + filterBuilderReducer, + isDuplicateFilter, + moveHighlight, + INITIAL_DRAFT, + type FilterDraft, +} from '../useFilterBuilder' +import type { DimensionFilter } from '@/lib/filters' + +const referrerFilter: DimensionFilter = { + dimension: 'referrer', + operator: 'is', + values: ['linkedin.com'], +} + +describe('filterBuilderReducer', () => { + it('open_create resets to the dimension stage', () => { + const dirty: FilterDraft = { stage: 'value', dimension: 'browser', operator: 'contains', values: ['Chrome'], editingIndex: 2 } + expect(filterBuilderReducer(dirty, { type: 'open_create' })).toEqual(INITIAL_DRAFT) + }) + + it('open_edit prefills the value stage with the filter and its index', () => { + const next = filterBuilderReducer(INITIAL_DRAFT, { type: 'open_edit', filter: referrerFilter, index: 1 }) + expect(next).toEqual({ + stage: 'value', + dimension: 'referrer', + operator: 'is', + values: ['linkedin.com'], + editingIndex: 1, + }) + }) + + it('open_edit copies values so later edits cannot mutate the committed filter', () => { + const next = filterBuilderReducer(INITIAL_DRAFT, { type: 'open_edit', filter: referrerFilter, index: 0 }) + expect(next.values).not.toBe(referrerFilter.values) + }) + + it('pick_dimension advances to the value stage and clears values on change', () => { + const editing = filterBuilderReducer(INITIAL_DRAFT, { type: 'open_edit', filter: referrerFilter, index: 0 }) + const back = filterBuilderReducer(editing, { type: 'back' }) + const switched = filterBuilderReducer(back, { type: 'pick_dimension', dimension: 'browser' }) + expect(switched.stage).toBe('value') + expect(switched.dimension).toBe('browser') + expect(switched.values).toEqual([]) + }) + + it('re-picking the same dimension keeps chosen values', () => { + const editing = filterBuilderReducer(INITIAL_DRAFT, { type: 'open_edit', filter: referrerFilter, index: 0 }) + const back = filterBuilderReducer(editing, { type: 'back' }) + const samePick = filterBuilderReducer(back, { type: 'pick_dimension', dimension: 'referrer' }) + expect(samePick.values).toEqual(['linkedin.com']) + }) + + it('set_operator never clears values', () => { + const editing = filterBuilderReducer(INITIAL_DRAFT, { type: 'open_edit', filter: referrerFilter, index: 0 }) + const changed = filterBuilderReducer(editing, { type: 'set_operator', operator: 'not_contains' }) + expect(changed.operator).toBe('not_contains') + expect(changed.values).toEqual(['linkedin.com']) + }) + + it('back returns to the dimension stage without losing the draft', () => { + const editing = filterBuilderReducer(INITIAL_DRAFT, { type: 'open_edit', filter: referrerFilter, index: 0 }) + const back = filterBuilderReducer(editing, { type: 'back' }) + expect(back.stage).toBe('dimension') + expect(back.dimension).toBe('referrer') + expect(back.values).toEqual(['linkedin.com']) + }) +}) + +describe('isDuplicateFilter', () => { + const committed: DimensionFilter[] = [ + referrerFilter, + { dimension: 'browser', operator: 'is', values: ['Chrome', 'Firefox'] }, + ] + + it('flags an exact duplicate when creating', () => { + expect(isDuplicateFilter( + { dimension: 'referrer', operator: 'is', values: ['linkedin.com'], editingIndex: null }, + committed, + )).toBe(true) + }) + + it('does not flag a different operator or values', () => { + expect(isDuplicateFilter( + { dimension: 'referrer', operator: 'is_not', values: ['linkedin.com'], editingIndex: null }, + committed, + )).toBe(false) + expect(isDuplicateFilter( + { dimension: 'browser', operator: 'is', values: ['Chrome'], editingIndex: null }, + committed, + )).toBe(false) + }) + + it('never flags the filter being edited as a duplicate of itself', () => { + expect(isDuplicateFilter( + { dimension: 'referrer', operator: 'is', values: ['linkedin.com'], editingIndex: 0 }, + committed, + )).toBe(false) + }) + + it('is inert with no dimension or no values', () => { + expect(isDuplicateFilter({ dimension: null, operator: 'is', values: ['x'], editingIndex: null }, committed)).toBe(false) + expect(isDuplicateFilter({ dimension: 'referrer', operator: 'is', values: [], editingIndex: null }, committed)).toBe(false) + }) +}) + +describe('moveHighlight', () => { + it('wraps in both directions', () => { + expect(moveHighlight(0, -1, 5)).toBe(4) + expect(moveHighlight(4, 1, 5)).toBe(0) + expect(moveHighlight(2, 1, 5)).toBe(3) + }) + + it('enters the list from an unset highlight', () => { + expect(moveHighlight(-1, 1, 5)).toBe(0) + expect(moveHighlight(-1, -1, 5)).toBe(4) + }) + + it('returns -1 for an empty list', () => { + expect(moveHighlight(0, 1, 0)).toBe(-1) + }) +}) diff --git a/components/dashboard/filter/useFilterBuilder.ts b/components/dashboard/filter/useFilterBuilder.ts new file mode 100644 index 000000000..d42a0cac7 --- /dev/null +++ b/components/dashboard/filter/useFilterBuilder.ts @@ -0,0 +1,162 @@ +'use client' + +import { useCallback, useReducer, useRef, useState } from 'react' +import type { DimensionFilter, FilterSuggestion } from '@/lib/filters' + +// --------------------------------------------------------------------------- +// Filter builder draft state — a pure reducer so the transition rules are +// unit-testable, wrapped by a thin hook that adds open/anchor bookkeeping and +// a per-session suggestion cache. +// --------------------------------------------------------------------------- + +export type FilterBuilderStage = 'dimension' | 'value' + +export interface FilterDraft { + stage: FilterBuilderStage + dimension: string | null + operator: DimensionFilter['operator'] + values: string[] + /** Index into the page's committed filters when editing; null when creating. */ + editingIndex: number | null +} + +export const INITIAL_DRAFT: FilterDraft = { + stage: 'dimension', + dimension: null, + operator: 'is', + values: [], + editingIndex: null, +} + +export type FilterBuilderAction = + | { type: 'open_create' } + | { type: 'open_edit'; filter: DimensionFilter; index: number } + | { type: 'pick_dimension'; dimension: string } + | { type: 'set_operator'; operator: DimensionFilter['operator'] } + | { type: 'set_values'; values: string[] } + | { type: 'back' } + | { type: 'reset' } + +export function filterBuilderReducer(draft: FilterDraft, action: FilterBuilderAction): FilterDraft { + switch (action.type) { + case 'open_create': + case 'reset': + return INITIAL_DRAFT + case 'open_edit': + return { + stage: 'value', + dimension: action.filter.dimension, + operator: action.filter.operator, + values: [...action.filter.values], + editingIndex: action.index, + } + case 'pick_dimension': + return { + ...draft, + stage: 'value', + dimension: action.dimension, + // * Switching dimension invalidates chosen values; re-picking the same + // * one (back-and-forth) keeps them. + values: action.dimension === draft.dimension ? draft.values : [], + } + case 'set_operator': + // * Operator changes never clear values (design D3). + return { ...draft, operator: action.operator } + case 'set_values': + return { ...draft, values: action.values } + case 'back': + return { ...draft, stage: 'dimension' } + default: + return draft + } +} + +/** + * Exact-duplicate check against committed filters, mirroring the page-level + * guard (order-sensitive on values). The filter being edited is excluded so + * saving it unchanged never reads as a duplicate of itself. + */ +export function isDuplicateFilter( + draft: Pick, + filters: DimensionFilter[], +): boolean { + if (!draft.dimension || draft.values.length === 0) return false + return filters.some((f, i) => + i !== draft.editingIndex && + f.dimension === draft.dimension && + f.operator === draft.operator && + f.values.join(';') === draft.values.join(';') + ) +} + +/** Wrap-around keyboard highlight movement for a list of `length` items. */ +export function moveHighlight(current: number, delta: 1 | -1, length: number): number { + if (length <= 0) return -1 + if (current < 0) return delta === 1 ? 0 : length - 1 + return (current + delta + length) % length +} + +// --------------------------------------------------------------------------- +// Hook +// --------------------------------------------------------------------------- + +export interface UseFilterBuilder { + open: boolean + anchor: HTMLElement | null + draft: FilterDraft + dispatch: React.Dispatch + openCreate: (anchor: HTMLElement) => void + openEdit: (filter: DimensionFilter, index: number, anchor: HTMLElement) => void + close: () => void + /** Cache-first suggestion fetch for the popover session (cleared on close). */ + fetchSuggestions: ((dimension: string) => Promise) | undefined +} + +export function useFilterBuilder( + onFetchSuggestions?: (dimension: string) => Promise, +): UseFilterBuilder { + const [draft, dispatch] = useReducer(filterBuilderReducer, INITIAL_DRAFT) + const [open, setOpen] = useState(false) + const [anchor, setAnchor] = useState(null) + const cacheRef = useRef(new Map()) + + const openCreate = useCallback((anchorEl: HTMLElement) => { + cacheRef.current.clear() + dispatch({ type: 'open_create' }) + setAnchor(anchorEl) + setOpen(true) + }, []) + + const openEdit = useCallback((filter: DimensionFilter, index: number, anchorEl: HTMLElement) => { + cacheRef.current.clear() + dispatch({ type: 'open_edit', filter, index }) + setAnchor(anchorEl) + setOpen(true) + }, []) + + const close = useCallback(() => { + setOpen(false) + setAnchor(null) + dispatch({ type: 'reset' }) + }, []) + + const fetchSuggestions = useCallback(async (dimension: string): Promise => { + if (!onFetchSuggestions) return [] + const cached = cacheRef.current.get(dimension) + if (cached) return cached + const data = await onFetchSuggestions(dimension) + cacheRef.current.set(dimension, data) + return data + }, [onFetchSuggestions]) + + return { + open, + anchor, + draft, + dispatch, + openCreate, + openEdit, + close, + fetchSuggestions: onFetchSuggestions ? fetchSuggestions : undefined, + } +} diff --git a/components/funnels/FunnelCanvas.tsx b/components/funnels/FunnelCanvas.tsx new file mode 100644 index 000000000..b53b0efc4 --- /dev/null +++ b/components/funnels/FunnelCanvas.tsx @@ -0,0 +1,231 @@ +'use client' + +import { useCallback, useLayoutEffect, useRef, useState } from 'react' +import { motion } from 'framer-motion' +import { FileText, House, Lightning } from '@phosphor-icons/react' +import type { FunnelStepStats } from '@/lib/api/funnels' +import { formatNumber } from '@/lib/utils/format' +import { DURATION_BASE, EASE_APPLE } from '@/lib/motion' + +// --------------------------------------------------------------------------- +// Funnel canvas — equal-width step columns with vertical level rails filled +// bottom-up to each step's conversion-of-start, connected by SVG trapezoid +// bands (measured from the real rail rects). Clicking or arrowing between +// columns drives ?step= on the detail page; drops read red in the gutters. +// Below md the columns stack and the bands become short vertical connectors. +// --------------------------------------------------------------------------- + +interface FunnelCanvasProps { + steps: FunnelStepStats[] + /** 1-based selected step (matches ?step=). */ + selectedStep: number + onSelectStep: (step: number) => void +} + +interface BandGeometry { + key: number + points: string +} + +function stepGlyph(category: string | undefined, value: string) { + const cls = 'h-4 w-4 shrink-0 text-neutral-500' + if (category === 'event') return + if (value === '/') return + return +} + +export function FunnelCanvas({ steps, selectedStep, onSelectStep }: FunnelCanvasProps) { + const railsRef = useRef(null) + const [bands, setBands] = useState([]) + const [overlaySize, setOverlaySize] = useState({ width: 0, height: 0 }) + + const startVisitors = steps[0]?.visitors ?? 0 + const fillPct = useCallback( + (i: number) => { + if (startVisitors === 0) return 0 + // * conversion is already % of the entry step; clamp against drift + return Math.max(0, Math.min(100, steps[i].conversion)) + }, + [steps, startVisitors], + ) + + // * Bands connect adjacent fill levels — measured off the real rail rects so + // * they stay glued through resizes and stat morphs. md+ only (CSS hides the + // * overlay when columns stack). + useLayoutEffect(() => { + const container = railsRef.current + if (!container) return + const measure = () => { + const containerRect = container.getBoundingClientRect() + const rails = container.querySelectorAll('[data-rail]') + if (rails.length < 2) { + setBands([]) + return + } + setOverlaySize({ width: containerRect.width, height: containerRect.height }) + const next: BandGeometry[] = [] + for (let i = 0; i < rails.length - 1; i++) { + const a = rails[i].getBoundingClientRect() + const b = rails[i + 1].getBoundingClientRect() + const x1 = a.right - containerRect.left + const x2 = b.left - containerRect.left + const aBottom = a.bottom - containerRect.top + const bBottom = b.bottom - containerRect.top + const y1 = aBottom - (a.height * fillPct(i)) / 100 + const y2 = bBottom - (b.height * fillPct(i + 1)) / 100 + next.push({ + key: i, + points: `${x1},${y1} ${x2},${y2} ${x2},${bBottom} ${x1},${aBottom}`, + }) + } + setBands(next) + } + measure() + const observer = new ResizeObserver(measure) + observer.observe(container) + return () => observer.disconnect() + }, [steps, fillPct]) + + const onKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return + e.preventDefault() + const next = Math.max(1, Math.min(steps.length, selectedStep + (e.key === 'ArrowRight' ? 1 : -1))) + if (next !== selectedStep) { + onSelectStep(next) + railsRef.current + ?.querySelector(`button[data-step="${next}"]`) + ?.focus() + } + }, + [steps.length, selectedStep, onSelectStep], + ) + + if (steps.length === 0) return null + + return ( +
+ {startVisitors === 0 && ( +

No visitors in this period yet.

+ )} +
+ {/* Bands overlay — md+ only; hidden when columns stack */} + {bands.length > 0 && ( + + )} + + {steps.map((step, i) => { + const stepNo = i + 1 + const selected = selectedStep === stepNo + const pct = fillPct(i) + const dropped = i > 0 ? Math.max(0, steps[i - 1].visitors - step.visitors) : 0 + const customName = /^Step \d+$/.test(step.step.name) ? null : step.step.name + + return ( +
+ + + {/* Mobile connector between stacked columns */} + {i < steps.length - 1 && ( + + ) + })} +
+
+ ) +} diff --git a/components/funnels/FunnelModal.tsx b/components/funnels/FunnelModal.tsx index 37aa717ce..9b2b92c69 100644 --- a/components/funnels/FunnelModal.tsx +++ b/components/funnels/FunnelModal.tsx @@ -1,231 +1,777 @@ 'use client' -import { useState, useRef, useEffect } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { motion, AnimatePresence } from 'framer-motion' -import { DURATION_FAST, DURATION_BASE, EASE_APPLE } from '@/lib/motion' +import { DURATION_FAST, EASE_APPLE } from '@/lib/motion' import { Button, Spinner, toast } from '@ciphera-net/facet' -import { CaretUp, CaretDown, X, Plus, Trash } from '@phosphor-icons/react' +import { CaretUp, CaretDown, CircleNotch, FileText, House, Lightning, Plus, Trash } from '@phosphor-icons/react' import type { Funnel, FunnelStep, StepPropertyFilter, CreateFunnelRequest } from '@/lib/api/funnels' +import { getDashboardPages, getDashboardGoals } from '@/lib/api/stats' +import { getDateRange } from '@/lib/utils/dateRanges' +import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog' +import { Segmented } from '@/components/ui/segmented' +import Select from '@/components/ui/select' +import { formatNumber } from '@/lib/utils/format' + +// --------------------------------------------------------------------------- +// Create/Edit funnel on the real dialog primitive (focus trap, focus return, +// Esc, aria and the sanctioned shadow come free). Path and event inputs get +// data-aware suggestion panels fed once per open from the dashboard's own +// endpoints (free text always allowed), validation is inline with +// focus-to-field, and the conversion-window control submits what it shows — +// the hardcoded 30-day overwrite dies here. +// --------------------------------------------------------------------------- type StepWithoutOrder = Omit -const inputClass = 'w-full h-10 px-4 bg-transparent border border-neutral-800 rounded-none text-sm text-white placeholder:text-neutral-600 focus:outline-none focus:border-brand-orange focus:ring-4 focus:ring-brand-orange/10 transition-colors ease-apple' -const selectClass = 'h-10 px-3 bg-transparent border border-neutral-800 rounded-none text-sm text-white focus:outline-none focus:border-brand-orange focus:ring-4 focus:ring-brand-orange/10 transition-colors ease-apple appearance-none cursor-pointer' -const labelClass = 'block text-sm font-medium text-neutral-300 mb-1.5' +// * One control spec for the whole modal: h-10, border-border, sharp, and the +// * same ring-2 brand-orange focus the Segmented and Select controls use. +const inputClass = + 'h-10 w-full rounded-none border border-border bg-transparent px-3 text-sm text-white placeholder:text-neutral-600 transition-colors ease-apple focus:outline-none focus:ring-2 focus:ring-brand-orange' +// * Kills the native number spinner so the window input matches the rest. +const numberInputClass = + '[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none' +const invalidClass = 'border-red-500/60' +const labelClass = 'mb-1.5 block text-sm font-medium text-neutral-300' const MAX_STEPS = 8 const MAX_FILTERS = 10 +/** A step whose label is still the auto "Step N" default (no custom rename). */ +const isDefaultStepName = (n: string) => /^Step \d+$/.test(n.trim()) + +const WINDOW_PRESETS = [ + { label: '24h', value: 24, unit: 'hours' as const }, + { label: '72h', value: 72, unit: 'hours' as const }, + { label: '7d', value: 7, unit: 'days' as const }, + { label: '14d', value: 14, unit: 'days' as const }, + { label: '30d', value: 30, unit: 'days' as const }, +] + +interface SuggestItem { + value: string + label?: string + count: number +} + +export interface FunnelPrefill { + name?: string + description?: string + steps?: StepWithoutOrder[] +} + interface FunnelModalProps { isOpen: boolean onClose: () => void onSubmit: (data: CreateFunnelRequest) => Promise initialData?: Funnel + /** Create-mode seed (e.g. from a journeys lens) — ignored when editing. */ + prefill?: FunnelPrefill + siteId: string +} + +// ─── Suggestion input ─────────────────────────────────────────────── + +function SuggestInput({ + value, + onChange, + placeholder, + ariaLabel, + invalid, + items, + failed, + glyph, + registerRef, + onPanelOpenChange, +}: { + value: string + onChange: (v: string) => void + placeholder: string + ariaLabel: string + invalid?: boolean + /** null while the once-per-open fetch is in flight. */ + items: SuggestItem[] | null + failed?: boolean + glyph: (value: string) => React.ReactNode + registerRef?: (el: HTMLInputElement | null) => void + /** Lets the dialog keep Escape for the panel while one is open. */ + onPanelOpenChange?: (open: boolean) => void +}) { + const [open, setOpen] = useState(false) + const [highlight, setHighlight] = useState(0) + const [showSpinner, setShowSpinner] = useState(false) + const listRef = useRef(null) + // * Blur schedules a delayed close so row clicks land first; refocusing + // * within that window must cancel it or the fresh panel closes itself. + const blurTimer = useRef | null>(null) + + useEffect(() => { + if (!open) return + onPanelOpenChange?.(true) + return () => onPanelOpenChange?.(false) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]) + + useEffect( + () => () => { + if (blurTimer.current) clearTimeout(blurTimer.current) + }, + [], + ) + + // * 150ms rule — fast fetches never flash the spinner row + useEffect(() => { + if (!open || items !== null) { + setShowSpinner(false) + return + } + const t = setTimeout(() => setShowSpinner(true), 150) + return () => clearTimeout(t) + }, [open, items]) + + const query = value.trim().toLowerCase() + const filtered = (items ?? []).filter( + (it) => !query || it.value.toLowerCase().includes(query) || it.label?.toLowerCase().includes(query), + ) + + useEffect(() => { + setHighlight((h) => Math.min(Math.max(h, 0), Math.max(filtered.length - 1, 0))) + }, [filtered.length]) + + useEffect(() => { + listRef.current + ?.querySelector(`[data-index="${highlight}"]`) + ?.scrollIntoView({ block: 'nearest' }) + }, [highlight]) + + const pick = (v: string) => { + onChange(v) + setOpen(false) + } + + const onKeyDown = (e: React.KeyboardEvent) => { + if (!open) return + if (e.key === 'ArrowDown') { + e.preventDefault() + setHighlight((h) => Math.min(h + 1, filtered.length - 1)) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + setHighlight((h) => Math.max(h - 1, 0)) + } else if (e.key === 'Enter') { + if (filtered[highlight]) { + e.preventDefault() + pick(filtered[highlight].value) + } + } else if (e.key === 'Escape') { + // * Close the panel only — preventDefault keeps the dialog open + e.preventDefault() + setOpen(false) + } + } + + return ( +
+ { + onChange(e.target.value) + setOpen(true) + setHighlight(0) + }} + onFocus={() => { + if (blurTimer.current) clearTimeout(blurTimer.current) + setOpen(true) + }} + onBlur={() => { + blurTimer.current = setTimeout(() => setOpen(false), 120) + }} + onKeyDown={onKeyDown} + placeholder={placeholder} + aria-label={ariaLabel} + className={`${inputClass} ${invalid ? invalidClass : ''}`} + /> + {open && ( +
+ {showSpinner ? ( +
+ +
+ ) : failed ? ( +

+ Couldn’t load suggestions — you can still type freely. +

+ ) : filtered.length > 0 ? ( + filtered.slice(0, 50).map((it, i) => ( + + )) + ) : items !== null ? ( +

+ No matches — free text is fine. +

+ ) : null} +
+ )} +
+ ) } -export default function FunnelModal({ isOpen, onClose, onSubmit, initialData }: FunnelModalProps) { - const [name, setName] = useState(initialData?.name ?? '') - const [description, setDescription] = useState(initialData?.description ?? '') +// ─── Modal ────────────────────────────────────────────────────────── + +export default function FunnelModal({ isOpen, onClose, onSubmit, initialData, prefill, siteId }: FunnelModalProps) { + const [name, setName] = useState(initialData?.name ?? prefill?.name ?? '') + const [description, setDescription] = useState(initialData?.description ?? prefill?.description ?? '') const [steps, setSteps] = useState( - initialData?.steps.map(({ order, ...rest }) => rest) ?? [ - { name: 'Step 1', value: '/', type: 'exact' }, - { name: 'Step 2', value: '', type: 'exact' }, - ] + initialData?.steps.map(({ order: _order, ...rest }) => rest) ?? + prefill?.steps ?? [ + { name: 'Step 1', value: '/', type: 'exact' }, + { name: 'Step 2', value: '', type: 'exact' }, + ], ) + // * Window control prefills the stored values on edit; create defaults 7d + const [windowValue, setWindowValue] = useState(initialData?.conversion_window_value ?? 7) + const [windowUnit, setWindowUnit] = useState<'hours' | 'days'>(initialData?.conversion_window_unit ?? 'days') const [saving, setSaving] = useState(false) - const stepIdCounter = useRef(initialData?.steps.length ?? 2) + const [errors, setErrors] = useState>({}) + + const initialStepCount = initialData?.steps.length ?? prefill?.steps?.length ?? 2 + const stepIdCounter = useRef(initialStepCount) const [stepIds, setStepIds] = useState(() => - Array.from({ length: initialData?.steps.length ?? 2 }, (_, i) => i) + Array.from({ length: initialStepCount }, (_, i) => i), ) + // * Radix's escape listener runs on document capture — this counter lets an + // * open suggestion panel claim Escape before the dialog dismisses. + const openPanels = useRef(0) + const onPanelOpenChange = useCallback((open: boolean) => { + openPanels.current += open ? 1 : -1 + }, []) + const fieldRefs = useRef(new Map()) + const registerField = (key: string) => (el: HTMLInputElement | null) => { + if (el) fieldRefs.current.set(key, el) + else fieldRefs.current.delete(key) + } + + // * Suggestions fetched once per open — top pages + event names, 30d window + const [pageItems, setPageItems] = useState(null) + const [goalItems, setGoalItems] = useState(null) + const [pagesFailed, setPagesFailed] = useState(false) + const [goalsFailed, setGoalsFailed] = useState(false) useEffect(() => { - if (!isOpen) return - const prev = document.body.style.overflow - document.body.style.overflow = 'hidden' - const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() } - document.addEventListener('keydown', onKey) - return () => { document.body.style.overflow = prev; document.removeEventListener('keydown', onKey) } - }, [isOpen, onClose]) + if (!isOpen || !siteId) return + let alive = true + const range = getDateRange(30) + getDashboardPages(siteId, range.start, range.end, 50) + .then((d) => { + if (alive) setPageItems(d.top_pages.map((p) => ({ value: p.path, count: p.pageviews }))) + }) + .catch(() => { + if (alive) { + setPagesFailed(true) + setPageItems([]) + } + }) + getDashboardGoals(siteId, range.start, range.end, 50) + .then((d) => { + if (alive) + setGoalItems( + d.goal_counts.map((g) => ({ + value: g.event_name, + label: g.display_name ?? g.event_name, + count: g.count, + })), + ) + }) + .catch(() => { + if (alive) { + setGoalsFailed(true) + setGoalItems([]) + } + }) + return () => { + alive = false + } + }, [isOpen, siteId]) - const handleSubmit = async () => { - if (!name.trim()) { toast.error('Please enter a funnel name'); return } - if (steps.some(s => !s.name.trim())) { toast.error('Please enter a name for all steps'); return } - for (const step of steps) { + const clearError = (key: string) => + setErrors((prev) => { + if (!(key in prev)) return prev + const next = { ...prev } + delete next[key] + return next + }) + + // ── Step operations ──────────────────────────────────────────────── + const addStep = () => { + if (steps.length >= MAX_STEPS) return + stepIdCounter.current += 1 + setStepIds((p) => [...p, stepIdCounter.current]) + setSteps((p) => [...p, { name: `Step ${p.length + 1}`, value: '', type: 'exact' }]) + } + const removeStep = (i: number) => { + if (steps.length <= 1) return + setStepIds((p) => p.filter((_, j) => j !== i)) + setSteps((p) => p.filter((_, j) => j !== i)) + } + const updateStep = (i: number, patch: Partial) => { + setSteps((p) => { + const n = [...p] + const s = { ...n[i], ...patch } + n[i] = s + return n + }) + clearError(`step-${i}`) + } + const setCategory = (i: number, category: 'page' | 'event') => { + setSteps((p) => { + const n = [...p] + const s = { ...n[i], category, value: '' } + if (category === 'page') s.property_filters = undefined + else s.type = 'exact' + n[i] = s + return n + }) + clearError(`step-${i}`) + } + const moveStep = (i: number, d: -1 | 1) => { + const t = i + d + if (t < 0 || t >= steps.length) return + setSteps((p) => { + const n = [...p] + ;[n[i], n[t]] = [n[t], n[i]] + return n + }) + setStepIds((p) => { + const n = [...p] + ;[n[i], n[t]] = [n[t], n[i]] + return n + }) + } + const addFilter = (si: number) => { + setSteps((p) => { + const n = [...p] + const s = { ...n[si] } + const f = [...(s.property_filters || [])] + if (f.length >= MAX_FILTERS) return p + f.push({ key: '', operator: 'is', value: '' }) + s.property_filters = f + n[si] = s + return n + }) + } + const updateFilter = (si: number, fi: number, field: keyof StepPropertyFilter, val: string) => { + setSteps((p) => { + const n = [...p] + const s = { ...n[si] } + const f = [...(s.property_filters || [])] + f[fi] = { ...f[fi], [field]: val } + s.property_filters = f + n[si] = s + return n + }) + clearError(`step-${si}`) + } + const removeFilter = (si: number, fi: number) => { + setSteps((p) => { + const n = [...p] + const s = { ...n[si] } + const f = [...(s.property_filters || [])].filter((_, j) => j !== fi) + s.property_filters = f.length > 0 ? f : undefined + n[si] = s + return n + }) + } + + // ── Validation: inline field errors, focus the first failure ────── + const validate = useCallback((): boolean => { + const next: Record = {} + if (!name.trim()) next['name'] = 'Give the funnel a name.' + steps.forEach((step, i) => { const cat = step.category || 'page' - if (!step.value.trim()) { toast.error(cat === 'event' ? `Enter an event name for: ${step.name}` : `Enter a path for: ${step.name}`); return } - if (cat === 'page' && step.type === 'regex') { try { new RegExp(step.value) } catch { toast.error(`Invalid regex in: ${step.name}`); return } } - if (cat === 'event' && step.property_filters) { for (const f of step.property_filters) { if (!f.key.trim()) { toast.error(`Property key required in: ${step.name}`); return } } } + if (!step.value.trim()) { + next[`step-${i}`] = cat === 'event' ? 'Enter an event name.' : 'Enter a path.' + return + } + if (cat === 'page' && step.type === 'regex') { + try { + new RegExp(step.value) + } catch { + next[`step-${i}`] = 'This regex doesn’t compile.' + return + } + } + if (cat === 'event' && step.property_filters?.some((f) => !f.key.trim())) { + next[`step-${i}`] = 'Every property filter needs a key.' + } + }) + if (!Number.isFinite(windowValue) || windowValue < 1) { + next['window'] = 'The window must be at least 1.' } + setErrors(next) + const first = ['name', ...steps.map((_, i) => `step-${i}`), 'window'].find((k) => k in next) + if (first) fieldRefs.current.get(first)?.focus() + return Object.keys(next).length === 0 + }, [name, steps, windowValue]) + + const handleSubmit = async () => { + if (!validate()) return setSaving(true) try { - await onSubmit({ name, description, steps: steps.map((s, i) => ({ ...s, order: i })), conversion_window_value: 30, conversion_window_unit: 'days' }) + await onSubmit({ + name, + description, + // * A step is defined by its target — the label defaults to the value. + // * Custom names from older funnels (non "Step N") are preserved on edit. + steps: steps.map((s, i) => ({ + ...s, + order: i, + name: isDefaultStepName(s.name) ? s.value.trim() || s.name : s.name, + })), + conversion_window_value: windowValue, + conversion_window_unit: windowUnit, + }) onClose() - } catch { toast.error('Failed to save funnel') } finally { setSaving(false) } + } catch { + toast.error('Failed to save funnel') + } finally { + setSaving(false) + } } - const addStep = () => { if (steps.length >= MAX_STEPS) return; stepIdCounter.current += 1; setStepIds(p => [...p, stepIdCounter.current]); setSteps(p => [...p, { name: `Step ${p.length + 1}`, value: '', type: 'exact' }]) } - const removeStep = (i: number) => { if (steps.length <= 1) return; setStepIds(p => p.filter((_, j) => j !== i)); setSteps(p => p.filter((_, j) => j !== i)) } - const updateStep = (i: number, field: string, value: string) => { - setSteps(p => { const n = [...p]; const s = { ...n[i] }; if (field === 'category') { s.category = value as 'page' | 'event'; s.value = ''; if (value === 'page') s.property_filters = undefined; else s.type = 'exact' } else { (s as any)[field] = value }; n[i] = s; return n }) + const pathGlyph = (v: string) => { + const cls = 'h-4 w-4 shrink-0 text-neutral-500' + return v === '/' ? : } - const moveStep = (i: number, d: -1 | 1) => { const t = i + d; if (t < 0 || t >= steps.length) return; setSteps(p => { const n = [...p]; [n[i], n[t]] = [n[t], n[i]]; return n }); setStepIds(p => { const n = [...p]; [n[i], n[t]] = [n[t], n[i]]; return n }) } - const addFilter = (si: number) => { setSteps(p => { const n = [...p]; const s = { ...n[si] }; const f = [...(s.property_filters || [])]; if (f.length >= MAX_FILTERS) return p; f.push({ key: '', operator: 'is', value: '' }); s.property_filters = f; n[si] = s; return n }) } - const updateFilter = (si: number, fi: number, field: keyof StepPropertyFilter, val: string) => { setSteps(p => { const n = [...p]; const s = { ...n[si] }; const f = [...(s.property_filters || [])]; f[fi] = { ...f[fi], [field]: val }; s.property_filters = f; n[si] = s; return n }) } - const removeFilter = (si: number, fi: number) => { setSteps(p => { const n = [...p]; const s = { ...n[si] }; const f = [...(s.property_filters || [])].filter((_, j) => j !== fi); s.property_filters = f.length > 0 ? f : undefined; n[si] = s; return n }) } + const eventGlyph = () => return ( - <> - {/* Backdrop — identical to UnifiedSettingsModal */} -
- - {/* Centering wrapper — identical to UnifiedSettingsModal */} -
{ if (!open) onClose() }}> + { + if (openPanels.current > 0) e.preventDefault() + }} > - {/* Glass panel — static, not animated. Identical class to UnifiedSettingsModal. */} -
e.stopPropagation()} - > - {/* Content animates inside the static panel — identical to UnifiedSettingsModal */} - - {isOpen && ( - - {/* Header — matches UnifiedSettingsModal */} -
-
-

{initialData ? 'Edit Funnel' : 'Create Funnel'}

- -
-
+ {/* Header */} +
+ + {initialData ? 'Edit funnel' : 'Create funnel'} + +
- {/* Body */} -
-
-
- - setName(e.target.value)} placeholder="e.g. Signup Flow" maxLength={100} className={inputClass} /> -
- -
- - setDescription(e.target.value)} placeholder="Tracks users from landing page to signup" className={inputClass} /> -
- -
- -
- {steps.map((step, i) => { - const cat = step.category || 'page' - return ( -
-
-
- {i + 1} -
- - -
-
- -
-
-
- - -
- updateStep(i, 'name', e.target.value)} placeholder="Step name" className={inputClass} /> -
+ {/* Body */} +
+
+
+ + { + setName(e.target.value) + clearError('name') + }} + placeholder="e.g. Signup flow" + maxLength={100} + className={`${inputClass} ${errors['name'] ? invalidClass : ''}`} + /> + {errors['name'] &&

{errors['name']}

} +
- {cat === 'page' ? ( -
- - updateStep(i, 'value', e.target.value)} placeholder={step.type === 'exact' ? '/pricing' : 'pricing'} className={inputClass} /> -
- ) : ( - updateStep(i, 'value', e.target.value)} placeholder="e.g. signup, purchase" className={inputClass} /> - )} - - {cat === 'event' && step.property_filters && step.property_filters.length > 0 && ( -
- {step.property_filters.map((f, fi) => ( -
- updateFilter(i, fi, 'key', e.target.value)} placeholder="key" className={`${inputClass} flex-1`} /> - - updateFilter(i, fi, 'value', e.target.value)} placeholder="value" className={`${inputClass} flex-1`} /> - -
- ))} -
- )} - {cat === 'event' && (!step.property_filters || step.property_filters.length < MAX_FILTERS) && ( - - )} -
+
+ + setDescription(e.target.value)} + placeholder="Tracks users from landing page to signup" + className={inputClass} + /> +
+ +
+ +
+ + {steps.map((step, i) => { + const cat = step.category || 'page' + const stepError = errors[`step-${i}`] + const filters = step.property_filters ?? [] + return ( + +
+ {/* Number chip — aligned to the control row */} + + {i + 1} + - + {/* Type + matcher + value on one line; filters below */} +
+
+ setCategory(i, v)} + options={[ + { value: 'page', label: 'Page' }, + { value: 'event', label: 'Event' }, + ]} + /> + {cat === 'page' && ( + updateFilter(i, fi, 'key', e.target.value)} + placeholder="property" + aria-label={`Step ${i + 1} filter ${fi + 1} key`} + className={`${inputClass} flex-1`} + /> + updateFilter(i, fi, 'value', e.target.value)} + placeholder="value" + aria-label={`Step ${i + 1} filter ${fi + 1} value`} + className={`${inputClass} flex-1`} + /> + +
+ ))}
+ )} + {cat === 'event' && filters.length < MAX_FILTERS && ( + + )} + {stepError &&

{stepError}

} +
+ + {/* Reorder + delete — aligned to the control row */} +
+
+ +
- ) - })} - - {steps.length < MAX_STEPS && ( - - )} -
-
-
-
+ +
+
+ + ) + })} + + + {steps.length < MAX_STEPS && ( + + )} +
+
- {/* Footer — matches UnifiedSettingsModal save bar border */} -
- - + {/* Conversion window — one row; submits exactly what it shows */} +
+ +
+ { + setWindowValue(parseInt(e.target.value, 10)) + clearError('window') + }} + aria-label="Conversion window value" + className={`${inputClass} ${numberInputClass} !w-20 tabular-nums ${errors['window'] ? invalidClass : ''}`} + /> + +
+ + {breakdownError ? ( + { void retryBreakdown() }} + className="py-8" + /> + ) : breakdownLoading && !breakdown ? ( + + ) : entries.length > 0 ? ( +
+ {entries.map((entry) => ( + 0 ? (entry.visitors / maxEntry) * 100 : 0} + trailing={`${Math.round(entry.conversion)}% conv`} + /> + ))} +
+ ) : ( +

+ No {dimensionLabel.toLowerCase()} data for this step in this period. +

+ )} +
+
+
+ ) +} diff --git a/components/funnels/FunnelSummaryCard.tsx b/components/funnels/FunnelSummaryCard.tsx new file mode 100644 index 000000000..608eaebb2 --- /dev/null +++ b/components/funnels/FunnelSummaryCard.tsx @@ -0,0 +1,207 @@ +'use client' + +import { useMemo } from 'react' +import Link from 'next/link' +import { CaretRight, FileText, House, Lightning, PencilSimple, Trash } from '@phosphor-icons/react' +import type { Funnel, FunnelStep } from '@/lib/api/funnels' +import { useFunnelStats, useFunnelTrends } from '@/lib/swr/dashboard' +import { AnimatedNumber } from '@/components/ui/animated-number' +import { guardedPctChange, type PctChangeResult } from '@/lib/utils/pctChange' +import { previousDateRange } from '@/lib/hooks/periodUrl' + +// --------------------------------------------------------------------------- +// Funnels list row — the whole card is a link to the funnel detail route; +// edit/delete are hover/focus-revealed SIBLINGS (absolutely positioned), never +// nested inside the link. Step chips show the real targets (custom names keep +// the value in title), the conversion % is plain white with a tiny-base- +// guarded delta, and the right rail carries a lazy 96×28 trend sparkline. +// --------------------------------------------------------------------------- + +interface FunnelSummaryCardProps { + funnel: Funnel + siteId: string + dateRange: { start: string; end: string } + /** Current ?period/start/end query (with leading `?`) carried into the detail link. */ + hrefQuery: string + canManage: boolean + onEdit: (funnel: Funnel) => void + onDelete: (funnel: { id: string; name: string }) => void +} + +function stepGlyph(step: FunnelStep) { + const cls = 'h-3.5 w-3.5 shrink-0 text-neutral-500' + if (step.category === 'event') return + if (step.value === '/') return + return +} + +function stepLabel(step: FunnelStep): string { + // * Default names are "Step N" — show the real target instead. + return /^Step \d+$/.test(step.name) ? step.value : step.name +} + +function DeltaBadge({ change }: { change: PctChangeResult }) { + if (!change) return null + if (change.type === 'new') { + return ( + + New + + ) + } + const positive = change.value > 0 + return ( + + {positive ? '+' : ''}{change.value}% vs prev + + ) +} + +function Sparkline({ values }: { values: number[] }) { + if (values.length < 2) return null + const w = 96 + const h = 28 + const pad = 2 + const max = Math.max(...values, 1) + const min = Math.min(...values, 0) + const span = max - min || 1 + const points = values + .map((v, i) => { + const x = pad + (i / (values.length - 1)) * (w - pad * 2) + const y = h - pad - ((v - min) / span) * (h - pad * 2) + return `${x.toFixed(1)},${y.toFixed(1)}` + }) + .join(' ') + return ( + + ) +} + +export function FunnelSummaryCard({ + funnel, + siteId, + dateRange, + hrefQuery, + canManage, + onEdit, + onDelete, +}: FunnelSummaryCardProps) { + const { data: stats, error: statsError } = useFunnelStats( + siteId, + funnel.id, + dateRange.start, + dateRange.end, + ) + + const prevRange = useMemo(() => previousDateRange(dateRange), [dateRange]) + + const { data: prevStats } = useFunnelStats( + siteId, + funnel.id, + prevRange?.start ?? '', + prevRange?.end ?? '', + ) + const { data: trends } = useFunnelTrends(siteId, funnel.id, dateRange.start, dateRange.end) + + const conversion = stats?.steps.length ? stats.steps[stats.steps.length - 1].conversion : null + const prevConversion = prevStats?.steps.length + ? prevStats.steps[prevStats.steps.length - 1].conversion + : 0 + const prevVisitors = prevStats?.steps[0]?.visitors ?? 0 + const delta = + conversion !== null && prevStats + ? guardedPctChange(conversion, prevConversion, prevVisitors) + : null + + const windowChip = `${funnel.conversion_window_value}${funnel.conversion_window_unit === 'hours' ? 'h' : 'd'} window` + + return ( +
+ +
+
+ {/* Row 1: name + meta chips (actions overlay reserves the right edge) */} +
+

{funnel.name}

+ + {funnel.steps.length} steps · {windowChip} + +
+ + {/* Row 2: real step targets */} +
+ {funnel.steps.map((step, i) => ( + + + {stepGlyph(step)} + {stepLabel(step)} + + {i < funnel.steps.length - 1 && ( + + )} + + ))} +
+ + {/* Row 3: description */} + {funnel.description && ( +

{funnel.description}

+ )} +
+ + {/* Right rail: conversion + delta + sparkline */} +
+
+ {conversion !== null ? ( + `${Math.round(v)}%`} + className="text-xl font-semibold tabular-nums text-white" + /> + ) : ( + + — + + )} + +
+ +
+
+ + + {/* Hover/focus actions — siblings of the link, never nested interactive */} + {canManage && ( +
+ + +
+ )} +
+ ) +} diff --git a/components/funnels/FunnelTrendChart.tsx b/components/funnels/FunnelTrendChart.tsx new file mode 100644 index 000000000..c0096c9b8 --- /dev/null +++ b/components/funnels/FunnelTrendChart.tsx @@ -0,0 +1,99 @@ +'use client' + +import { useMemo } from 'react' +import { AreaChart, Area, Grid, XAxis, YAxis, ChartTooltip } from '@/components/ui/area-chart' +import type { FunnelStepStats } from '@/lib/api/funnels' +import { useFunnelTrends } from '@/lib/swr/dashboard' +import { UpdatingChip } from '@/components/ui/UpdatingChip' +import { ErrorCard } from '@/components/ui/ErrorCard' +import { formatDateShort } from '@/lib/utils/formatDate' + +// --------------------------------------------------------------------------- +// Overall funnel conversion over time — a single orange series on the shared +// area-chart primitives, numeric DD/MM axis. (Per-step series toggles were +// removed: multi-series on this chart was glitchy and the step drill-down +// already lives in the strip above.) +// --------------------------------------------------------------------------- + +const ORANGE = '#FD5E0F' + +interface FunnelTrendChartProps { + siteId: string + funnelId: string + /** Kept for API symmetry with the other detail surfaces; unused here. */ + steps?: FunnelStepStats[] + dateRange: { start: string; end: string } + filters?: string +} + +export function FunnelTrendChart({ siteId, funnelId, dateRange, filters }: FunnelTrendChartProps) { + const { + data: trends, + error, + isLoading, + isValidating, + mutate: retry, + } = useFunnelTrends(siteId, funnelId, dateRange.start, dateRange.end, filters) + + const chartData = useMemo(() => { + if (!trends?.dates?.length) return [] + return trends.dates.map((date, i) => ({ + date: date + 'T00:00:00', + overall: trends.overall[i] ?? 0, + })) + }, [trends]) + + return ( +
+ +
+ Conversion trend +
+ + {/* Stable-height chart region */} +
+ {error ? ( + { void retry() }} + className="py-8" + /> + ) : isLoading && !trends ? null : chartData.length > 0 ? ( + + + formatDateShort(date)} /> + `${Math.round(v)}%`} /> + [ + { + color: ORANGE, + label: 'Overall', + value: `${Math.round(typeof point.overall === 'number' ? point.overall : 0)}%`, + }, + ]} + /> + + + ) : ( +
+

No trend data for this period yet.

+
+ )} +
+
+ ) +} diff --git a/components/integrations/SyncStatusLine.tsx b/components/integrations/SyncStatusLine.tsx new file mode 100644 index 000000000..ee78ac964 --- /dev/null +++ b/components/integrations/SyncStatusLine.tsx @@ -0,0 +1,71 @@ +'use client' + +import { useEffect, useState } from 'react' +import Link from 'next/link' +import { CircleNotch } from '@phosphor-icons/react' +import { formatDateTime } from '@/lib/utils/formatDate' + +// --------------------------------------------------------------------------- +// Integration sync meta line — surfaces the sync state both the GSC and Bunny +// status endpoints already return, killing silent staleness (no-silent-failure +// rule). Mono last-synced stamp, a 150 ms-delayed spinner while syncing, a +// quiet red banner + Settings link on failure. Renders nothing until the +// status is known. Shared by the search and CDN shells. +// --------------------------------------------------------------------------- + +interface SyncStatusLineProps { + status?: 'active' | 'syncing' | 'error' + lastSyncedAt?: string | null + errorMessage?: string | null + settingsHref: string +} + +const SPINNER_DELAY_MS = 150 + +export function SyncStatusLine({ status, lastSyncedAt, errorMessage, settingsHref }: SyncStatusLineProps) { + // * Spinner waits out fast syncs so a sub-150 ms refresh never flashes it. + const [showSpinner, setShowSpinner] = useState(false) + + useEffect(() => { + if (status !== 'syncing') { + setShowSpinner(false) + return + } + const timer = setTimeout(() => setShowSpinner(true), SPINNER_DELAY_MS) + return () => clearTimeout(timer) + }, [status]) + + if (!status) return null + + if (status === 'error') { + return ( +

+ Sync failed{errorMessage ? ` — ${errorMessage}` : ''} + + + Fix in Settings + +

+ ) + } + + if (status === 'syncing') { + return ( +

+ {showSpinner && } + Syncing… +

+ ) + } + + // * status === 'active' — only a real timestamp is worth showing. + if (!lastSyncedAt) return null + return ( +

+ Last synced {formatDateTime(new Date(lastSyncedAt))} +

+ ) +} diff --git a/components/journeys/ColumnJourney.tsx b/components/journeys/ColumnJourney.tsx index 61d409bce..ebc0328a4 100644 --- a/components/journeys/ColumnJourney.tsx +++ b/components/journeys/ColumnJourney.tsx @@ -1,39 +1,47 @@ 'use client' -import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { motion } from 'framer-motion' +import { DotsThree, FileText, House, SignOut } from '@phosphor-icons/react' import type { PathTransition } from '@/lib/api/journeys' -import { aggregateJourney, type AggregatedStep, type AggregatedPage } from '@/lib/journeys/aggregate' -import { EmptyState } from '@/components/ui/EmptyState' -import { TreeStructure } from '@phosphor-icons/react' - -// ─── Types ────────────────────────────────────────────────────────── +import { aggregateJourney } from '@/lib/journeys/aggregate' +import { + buildLinks, + chainThrough, + chainThroughNode, + exitCount, + linkKey, + nodeId, + pathOfNode, + stepOfNode, + type Chain, + type ChainLink, +} from '@/lib/journeys/chain' +import { StepHeader } from './StepHeader' +import { DURATION_BASE, EASE_APPLE } from '@/lib/motion' + +// --------------------------------------------------------------------------- +// Columns view. Rows carry kind glyphs and proportional orange bars; ambient +// ribbons draw the top transitions in every gutter, and hovering or pinning +// the shared ?lens= raises the BFS chain through that path to orange while +// the rest dims. Rows are keyboard-navigable (arrows move, Enter pins). +// --------------------------------------------------------------------------- interface ColumnJourneyProps { transitions: PathTransition[] depth: number maxPagesPerStep?: number + lens: string | null + onLensChange: (path: string | null) => void + totalSessions: number + periodLabel: string } -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', -] - -function colorForColumn(col: number): string { - return COLUMN_COLORS[col % COLUMN_COLORS.length] -} - -// ─── Helpers ──────────────────────────────────────────────────────── +const AMBIENT_RIBBONS_PER_GUTTER = 5 +const RIBBON_MIN_WIDTH = 2 +const RIBBON_MAX_WIDTH = 12 +const ORANGE = '#FD5E0F' +const NEUTRAL_600 = '#525252' function smartLabel(path: string): string { if (path === '/' || path === '(other)') return path @@ -42,505 +50,415 @@ function smartLabel(path: string): string { return `…/${segments[segments.length - 1]}` } -// ─── Animated count hook ──────────────────────────────────────────── +function rowGlyph(path: string) { + const cls = 'h-4 w-4 shrink-0 text-neutral-500' + if (path === '/') return + if (path === '(other)') return + return +} -function useAnimatedCount(target: number, duration = 400): number { - const [display, setDisplay] = useState(0) - const prevTarget = useRef(target) +// ─── Ribbons ──────────────────────────────────────────────────────── - useEffect(() => { - const from = prevTarget.current - prevTarget.current = target - if (from === target) { - setDisplay(target) - return +interface RibbonGeometry { + key: string + d: string + width: number +} + +/** Links to draw: the top N ambient ribbons per gutter, plus the active chain. */ +function ribbonLinks(links: ChainLink[], chain: Chain | null): ChainLink[] { + const byGutter = new Map() + for (const l of links) { + const step = stepOfNode(l.source) + if (!byGutter.has(step)) byGutter.set(step, []) + byGutter.get(step)!.push(l) + } + const picked = new Map() + for (const gutter of byGutter.values()) { + gutter.sort((a, b) => b.value - a.value) + for (const l of gutter.slice(0, AMBIENT_RIBBONS_PER_GUTTER)) { + picked.set(linkKey(l.source, l.target), l) } - 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) + } + if (chain) { + for (const l of links) { + const key = linkKey(l.source, l.target) + if (chain.linkKeys.has(key)) picked.set(key, l) } - raf = requestAnimationFrame(tick) - return () => cancelAnimationFrame(raf) - }, [target, duration]) - - return display + } + return Array.from(picked.values()) } -// ─── Sub-components ───────────────────────────────────────────────── +function RibbonOverlay({ + containerRef, + links, + chain, + measureTick, +}: { + containerRef: React.RefObject + links: ChainLink[] + chain: Chain | null + measureTick: number +}) { + const [geometry, setGeometry] = useState([]) + const [size, setSize] = useState({ width: 0, height: 0 }) + + const drawn = useMemo(() => ribbonLinks(links, chain), [links, chain]) + + useLayoutEffect(() => { + const container = containerRef.current + if (!container || drawn.length === 0) { + setGeometry([]) + return + } + const containerRect = container.getBoundingClientRect() + setSize({ width: container.scrollWidth, height: container.scrollHeight }) + + const maxValue = Math.max(...drawn.map((l) => l.value), 1) + const next: RibbonGeometry[] = [] + for (const l of drawn) { + const sourceEl = container.querySelector( + `[data-col="${stepOfNode(l.source)}"][data-path="${CSS.escape(pathOfNode(l.source))}"]`, + ) + const targetEl = container.querySelector( + `[data-col="${stepOfNode(l.target)}"][data-path="${CSS.escape(pathOfNode(l.target))}"]`, + ) + if (!sourceEl || !targetEl) continue + const s = sourceEl.getBoundingClientRect() + const t = targetEl.getBoundingClientRect() + const x1 = s.right - containerRect.left + container.scrollLeft + const y1 = s.top + s.height / 2 - containerRect.top + container.scrollTop + const x2 = t.left - containerRect.left + container.scrollLeft + const y2 = t.top + t.height / 2 - containerRect.top + container.scrollTop + const midX = (x1 + x2) / 2 + const width = + RIBBON_MIN_WIDTH + (l.value / maxValue) * (RIBBON_MAX_WIDTH - RIBBON_MIN_WIDTH) + next.push({ + key: linkKey(l.source, l.target), + d: `M ${x1},${y1} C ${midX},${y1} ${midX},${y2} ${x2},${y2}`, + width, + }) + } + setGeometry(next) + }, [containerRef, drawn, measureTick]) + + if (geometry.length === 0) return null -function AnimatedDropOff({ percent }: { percent: number }) { - const displayed = useAnimatedCount(percent) - if (displayed === 0 && percent === 0) return null return ( - + {geometry.map((r, i) => { + const onChain = chain?.linkKeys.has(r.key) ?? false + const stroke = onChain ? ORANGE : NEUTRAL_600 + const opacity = chain ? (onChain ? 0.45 : 0.08) : 0.25 + return ( + + ) + })} + ) } -function ColumnHeader({ - column, -}: { - column: AggregatedStep -}) { - return ( -
- - Step {column.index + 1} - -
- - {column.visitors.toLocaleString()} visitors - - {column.dropOffPercent !== 0 && ( - - )} -
-
- ) -} +// ─── Rows ─────────────────────────────────────────────────────────── function PageRow({ - page, + path, + sessionCount, + isOther, colIndex, rowIndex, columnTotal, maxCount, - isSelected, - isOther, - isMounted, - onClick, + onChain, + isLensRow, + hasChain, + onHover, + onToggleLens, }: { - page: AggregatedPage + path: string + sessionCount: number + isOther: boolean colIndex: number rowIndex: number columnTotal: number maxCount: number - isSelected: boolean - isOther: boolean - isMounted: boolean - onClick: () => void + onChain: boolean + isLensRow: boolean + hasChain: boolean + /** Receives the specific `step:path` node id — hover is node-specific. */ + onHover: (nodeId: string | null) => void + onToggleLens: (path: string) => void }) { - const pct = columnTotal > 0 ? Math.round((page.sessionCount / columnTotal) * 100) : 0 - const barWidth = maxCount > 0 ? (page.sessionCount / maxCount) * 100 : 0 + const pct = columnTotal > 0 ? Math.round((sessionCount / columnTotal) * 100) : 0 + const barWidth = maxCount > 0 ? (sessionCount / maxCount) * 100 : 0 + const id = nodeId(colIndex, path) return ( ) } -function JourneyColumn({ - column, - selectedPath, - exitCount, - onSelect, -}: { - column: AggregatedStep - 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 ( -
- -
- - No onward traffic - -
-
- ) - } - - const maxCount = Math.max(...column.pages.map((p) => p.sessionCount), 0) - - return ( -
- -
- {column.pages.map((page, rowIndex) => { - const isOther = page.isOther - return ( - { - if (!isOther) onSelect(page.path) - }} - /> - ) - })} - {/* Animation #5: exit card slides in */} - {exitCount > 0 && ( -
-
- - (exit) - - - {exitCount.toLocaleString()} - -
- )} -
-
- ) -} - -// ─── Connection Lines ─────────────────────────────────────────────── - -function ConnectionLines({ - containerRef, - selections, - columns, - transitions, -}: { - containerRef: React.RefObject - selections: Map - columns: AggregatedStep[] - 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 ( - - {lines.map((line, i) => { - const midX = (line.sourceX + line.destX) / 2 - return ( - - ) - })} - - - ) -} - -// ─── Exit count helper ────────────────────────────────────────────── - -function getExitCount( - colIdx: number, - selectedPath: string, - columns: AggregatedStep[], - 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 ───────────────────────────────────────────────── +// ─── Main component ───────────────────────────────────────────────── export default function ColumnJourney({ transitions, depth, maxPagesPerStep = 20, + lens, + onLensChange, + totalSessions, + periodLabel, }: ColumnJourneyProps) { - const [selections, setSelections] = useState>(new Map()) const containerRef = useRef(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 [hoverId, setHoverId] = useState(null) + const [measureTick, setMeasureTick] = useState(0) const columns = useMemo( () => aggregateJourney(transitions, { depth, maxPagesPerStep }), - [transitions, depth, maxPagesPerStep] + [transitions, depth, maxPagesPerStep], + ) + const links = useMemo(() => buildLinks(transitions, columns), [transitions, columns]) + + // * Hover is node-specific (only the flow through that row); a pinned lens is + // * path-based (traces the page across every step — design §4.2). + const chain = useMemo(() => { + if (hoverId) return chainThroughNode(links, hoverId) + if (lens) return chainThrough(links, lens) + return null + }, [links, hoverId, lens]) + // * A lens whose path vanished from the current data must not dim everything. + const hasChain = (chain?.nodeIds.size ?? 0) > 0 + + // * Re-measure ribbon endpoints on container resize or inner column scroll. + useLayoutEffect(() => { + const container = containerRef.current + if (!container) return + const observer = new ResizeObserver(() => setMeasureTick((t) => t + 1)) + observer.observe(container) + return () => observer.disconnect() + }, []) + + const toggleLens = useCallback( + (path: string) => onLensChange(lens === path ? null : path), + [lens, onLensChange], ) - - 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) + const onKeyDown = useCallback( + (e: React.KeyboardEvent) => { + const target = e.target as HTMLElement + const colRaw = target.dataset?.col + const idxRaw = target.dataset?.idx + if (colRaw === undefined || idxRaw === undefined) return + let col = parseInt(colRaw, 10) + let idx = parseInt(idxRaw, 10) + switch (e.key) { + case 'ArrowDown': idx += 1; break + case 'ArrowUp': idx -= 1; break + case 'ArrowRight': col += 1; break + case 'ArrowLeft': col -= 1; break + default: return + } + e.preventDefault() + col = Math.max(0, Math.min(columns.length - 1, col)) + const rows = containerRef.current?.querySelectorAll( + `button[data-col="${col}"]:not(:disabled)`, + ) + if (!rows || rows.length === 0) return + // * data-idx counts all rows incl. disabled (other) — focus the enabled + // * row whose index is nearest the requested one. + let best: HTMLButtonElement = rows[0] + let bestDist = Infinity + for (const el of rows) { + const dist = Math.abs(parseInt(el.dataset.idx ?? '0', 10) - idx) + if (dist < bestDist) { + best = el + bestDist = dist } - return next - }) + } + best.focus() }, - [] + [columns.length], ) - // ─── Empty state ──────────────────────────────────────────────── - if (!transitions.length) { - return ( - } - title="No journey data yet" - description="Navigation flows will appear here as visitors browse through your site." - action={{ label: 'View setup guide', href: '/installation' }} - /> - ) - } + if (columns.length === 0) return null return (
-
setHoverId(null)} + onBlur={(e) => { + if (!e.currentTarget.contains(e.relatedTarget as Node)) setHoverId(null) + }} + onScrollCapture={() => setMeasureTick((t) => t + 1)} > -
- {columns.map((col, i) => { - const prevSelection = selections.get(col.index - 1) - const exitCount = prevSelection - ? getExitCount(col.index - 1, prevSelection, columns, transitions) - : 0 +
+ {columns.map((col) => { + const maxCount = Math.max(...col.pages.map((p) => p.sessionCount), 0) + // * The exit card sits on the column after the lens hop: sessions + // * that were on the lens path here and never moved on. + const exits = + lens && col.index > 0 && columns[col.index - 1].pages.some((p) => p.path === lens) + ? exitCount(transitions, col.index - 1, lens, columns) + : 0 return ( - - {i > 0 && ( -
+ +
+ +
+ {col.pages.length === 0 && exits === 0 ? ( +
+ No onward traffic +
+ ) : ( +
+ {col.pages.map((page, rowIndex) => { + const id = nodeId(col.index, page.path) + return ( + + ) + })} + {exits > 0 && ( + + + + (exit) + + + {exits.toLocaleString()} + + + )} +
)} - handleSelect(col.index, path)} - /> - +
) })}
-
+ + {/* Meta footer — sessions · effective depth · period */} +
+ {totalSessions.toLocaleString()} sessions tracked + {' · '} + {columns.length < depth + ? `Showing ${columns.length} of ${depth} steps — no traffic beyond step ${columns.length} in this period` + : `${columns.length} steps`} + {' · '} + {periodLabel} +
) } diff --git a/components/journeys/EntryCombobox.tsx b/components/journeys/EntryCombobox.tsx new file mode 100644 index 000000000..66c370d98 --- /dev/null +++ b/components/journeys/EntryCombobox.tsx @@ -0,0 +1,187 @@ +'use client' + +import { useEffect, useMemo, useRef, useState } from 'react' +import { CaretDown, Check, FileText, GlobeHemisphereWest, House, MagnifyingGlass } from '@phosphor-icons/react' +import FilterPopover from '@/components/dashboard/filter/FilterPopover' +import { moveHighlight } from '@/components/dashboard/filter/useFilterBuilder' +import type { EntryPoint } from '@/lib/api/journeys' +import { cn } from '@/lib/utils' + +// --------------------------------------------------------------------------- +// EntryCombobox — the journeys entry-point picker. A quiet h-10 trigger and a +// searchable anchored panel (FilterPopover shell: portal, flip, clamp, focus +// trap, shadow) with kind glyphs and tabular session counts per row. +// --------------------------------------------------------------------------- + +/** Case-insensitive path filter; pure for tests. */ +export function filterEntries(entries: EntryPoint[], query: string): EntryPoint[] { + const q = query.trim().toLowerCase() + if (!q) return entries + return entries.filter(e => e.path.toLowerCase().includes(q)) +} + +function pathGlyph(path: string) { + if (path === '/') return + return +} + +interface EntryComboboxProps { + /** '' means all entry points. */ + value: string + onChange: (path: string) => void + entries: EntryPoint[] + className?: string +} + +export function EntryCombobox({ value, onChange, entries, className }: EntryComboboxProps) { + const [open, setOpen] = useState(false) + const [search, setSearch] = useState('') + const [highlight, setHighlight] = useState(0) + const triggerRef = useRef(null) + const listRef = useRef(null) + + const filtered = useMemo(() => filterEntries(entries, search), [entries, search]) + // * Row 0 is "All entry points", data rows follow. + const rowCount = filtered.length + 1 + + useEffect(() => { + if (!open) { + setSearch('') + setHighlight(0) + } + }, [open]) + + useEffect(() => { + setHighlight(h => Math.min(Math.max(h, 0), rowCount - 1)) + }, [rowCount]) + + useEffect(() => { + listRef.current + ?.querySelector(`[data-index="${highlight}"]`) + ?.scrollIntoView({ block: 'nearest' }) + }, [highlight]) + + const pick = (path: string) => { + onChange(path) + setOpen(false) + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault() + setHighlight(h => moveHighlight(h, 1, rowCount)) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + setHighlight(h => moveHighlight(h, -1, rowCount)) + } else if (e.key === 'Enter') { + e.preventDefault() + if (highlight === 0) pick('') + else if (filtered[highlight - 1]) pick(filtered[highlight - 1].path) + } else if (e.key === 'Backspace' && search === '') { + e.preventDefault() + setOpen(false) + } + } + + const current = value === '' ? null : entries.find(e => e.path === value) + const triggerGlyph = value === '' + ? + : pathGlyph(value) + + return ( + <> + + + setOpen(false)} + > +
+
+ + { setSearch(e.target.value); setHighlight(0) }} + placeholder="Search entry pages…" + aria-label="Search entry pages" + className="h-10 w-full bg-transparent text-sm text-white placeholder:text-neutral-600 focus:outline-none" + /> +
+
+ + {filtered.map((entry, i) => { + const index = i + 1 + const selected = value === entry.path + return ( + + ) + })} + {filtered.length === 0 && ( +
+ No entry pages match “{search.trim()}”. +
+ )} +
+
+
+ + ) +} diff --git a/components/journeys/SankeyJourney.tsx b/components/journeys/SankeyJourney.tsx index 8a5b7abf7..686c44763 100644 --- a/components/journeys/SankeyJourney.tsx +++ b/components/journeys/SankeyJourney.tsx @@ -1,652 +1,374 @@ 'use client' -import * as d3 from 'd3' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { TreeStructure, X } from '@phosphor-icons/react' +import { motion } from 'framer-motion' import type { PathTransition } from '@/lib/api/journeys' -import { EmptyState } from '@/components/ui/EmptyState' -import { aggregateJourney } from '@/lib/journeys/aggregate' - -function escapeHtml(s: string): string { - return s - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') -} - -// ─── Types ────────────────────────────────────────────────────────── +import { + chainThroughNode, + chainThroughLink, + pathOfNode, + type Chain, + type ChainLink, +} from '@/lib/journeys/chain' +import { layoutSankey, NODE_WIDTH, type SankeyLink } from '@/lib/journeys/sankeyLayout' +import { StepHeader } from './StepHeader' +import { DURATION_BASE, EASE_APPLE } from '@/lib/motion' + +// --------------------------------------------------------------------------- +// Flow view — a declarative React SVG render of lib/journeys/sankeyLayout. +// Sharp neutral strips and quiet links; hovering a strip or a link raises its +// BFS chain to orange and dims the rest; clicking (or Enter) pins the shared +// ?lens=, which re-renders the layout as the chain subgraph. The tooltip is a +// React fixed div on the popover surface (sanctioned shadow), and strips are +// keyboard-focusable with arrow navigation. +// --------------------------------------------------------------------------- interface SankeyJourneyProps { transitions: PathTransition[] depth: number maxPagesPerStep?: number + lens: string | null + onLensChange: (path: string | null) => void + totalSessions: number + periodLabel: string } -interface SNode { - id: string - name: string - step: number - height: number +const ORANGE = '#FD5E0F' +const NEUTRAL_500 = '#737373' +const NEUTRAL_600 = '#525252' +const LINK_OPACITY = 0.2 +const LINK_CHAIN_OPACITY = 0.45 +const LINK_DIM_OPACITY = 0.08 +const NODE_DIM_OPACITY = 0.08 +const LABEL_DIM_OPACITY = 0.2 + +type Hover = + | { kind: 'node'; id: string; path: string } + | { kind: 'link'; source: string; target: string } + | null + +interface TooltipState { 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 = 14 -const NODE_HIT_WIDTH = 24 -const NODE_GAP = 20 -const MIN_NODE_HEIGHT = 3 -const MAX_LINK_HEIGHT = 50 -const MIN_LINK_HEIGHT = 1 -const LINK_OPACITY = 0.3 -const LINK_HOVER_OPACITY = 0.6 -const HEADER_HEIGHT = 56 -const LABEL_OFFSET = 4 - -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 + title: string + sub: string } 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]}` + return `…/${segments[segments.length - 1]}` } -// ─── Data Transformation ──────────────────────────────────────────── - -function buildData( - transitions: PathTransition[], - depth: number, - maxPagesPerStep: number, - filterPath?: string, -): { nodes: SNode[]; links: SLink[] } { - if (transitions.length === 0) return { nodes: [], links: [] } - - const aggregated = aggregateJourney(transitions, { depth, maxPagesPerStep }) - if (aggregated.length === 0) return { nodes: [], links: [] } - - // * Build set of which paths survived (other) rollup per step - const pathsByStep = new Map>() - for (const step of aggregated) { - pathsByStep.set(step.index, new Set(step.pages.map((p) => p.path))) - } - - // * Scope transitions to within the requested depth - const scoped = transitions.filter((t) => t.step_index + 1 < depth) - - // * Build links — use (other) as fallback when a path was rolled up - const linkMap = new Map() - for (const t of scoped) { - const fromPaths = pathsByStep.get(t.step_index) - const toPaths = pathsByStep.get(t.step_index + 1) - if (!fromPaths || !toPaths) continue - const fp = fromPaths.has(t.from_path) ? t.from_path : '(other)' - const tp = toPaths.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 } - }) - - // * Build nodes from aggregated step pages + any link endpoints (to catch (other) nodes) - const nodeIdSet = new Set() - for (const step of aggregated) { - for (const page of step.pages) { - nodeIdSet.add(`${step.index}:${page.path}`) - } - } - 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 clicked path (BFS forward + backward) — UNCHANGED from original - 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>() - const bwd = new Map>() - 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(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() - for (const l of links) { kept.add(l.source); kept.add(l.target) } - nodes = nodes.filter((n) => kept.has(n.id)) - } - - return { nodes, links } +function linkPath(l: SankeyLink): string { + const gap = l.targetX - l.sourceX + const c1 = l.sourceX + gap / 3 + const c2 = l.targetX - gap / 3 + return `M ${l.sourceX},${l.sourceY} C ${c1},${l.sourceY} ${c2},${l.targetY} ${l.targetX},${l.targetY}` } -// ─── Component ────────────────────────────────────────────────────── - export default function SankeyJourney({ transitions, depth, maxPagesPerStep = 20, + lens, + onLensChange, + totalSessions, + periodLabel, }: SankeyJourneyProps) { - const [filterPath, setFilterPath] = useState(null) - const [isDark, setIsDark] = useState(false) - const svgRef = useRef(null) const containerRef = useRef(null) + const nodeRefs = useRef(new Map()) const [containerWidth, setContainerWidth] = useState(900) + const [hover, setHover] = useState(null) + const [tooltip, setTooltip] = useState(null) + const [focusedId, setFocusedId] = useState(null) - // 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, depth, maxPagesPerStep, filterPath ?? undefined), - [transitions, depth, maxPagesPerStep, filterPath], - ) - - // Clear filter on data change - const transKey = transitions.length + '-' + depth + '-' + maxPagesPerStep - 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)) + const observer = new ResizeObserver(measure) + observer.observe(el) + return () => observer.disconnect() }, []) - // ─── 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 + link heights (proportional to value, shared scale). - // MAX_LINK_HEIGHT is intentionally small so nodes stay as Rybbit-style - // thin strips rather than tall bars. - 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) + // * Lens renders the chain subgraph; a lens with no flows in the current + // * data falls back to the full graph (the toolbar chip stays clearable). + const layout = useMemo(() => { + const opts = { depth, maxPagesPerStep, width: containerWidth } + if (lens) { + const filtered = layoutSankey(transitions, { ...opts, lens }) + if (filtered.nodes.length > 0) return filtered } - - // 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 (add header + bottom padding) - 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) + HEADER_HEIGHT + 20) - - // Position nodes in columns, aligned from top (below header row) - byStep.forEach((stepNodes, step) => { - let cy = HEADER_HEIGHT - 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() - for (const n of nodes) { - const seg = firstSegment(n.name) - segCounts.set(seg, (segCounts.get(seg) ?? 0) + 1) - } - const segColors = new Map() - 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 step column headers (match ColumnJourney style) ─ - // Visitors per step: step 0 uses outgoing totals, later steps use incoming - const stepVisitors = new Map() - for (const n of nodes) { - const val = - n.step === 0 - ? n.outLinks.reduce((s, l) => s + l.value, 0) - : n.inLinks.reduce((s, l) => s + l.value, 0) - stepVisitors.set(n.step, (stepVisitors.get(n.step) ?? 0) + val) - } - const stepIndices = Array.from(byStep.keys()).sort((a, b) => a - b) - - // Drop-off % per step vs. previous step - const stepDropOff = new Map() - for (let i = 1; i < stepIndices.length; i++) { - const prev = stepVisitors.get(stepIndices[i - 1]) ?? 0 - const curr = stepVisitors.get(stepIndices[i]) ?? 0 - if (prev > 0) { - stepDropOff.set(stepIndices[i], Math.round(((curr - prev) / prev) * 100)) - } + return layoutSankey(transitions, opts) + }, [transitions, depth, maxPagesPerStep, containerWidth, lens]) + + const chainLinks: ChainLink[] = layout.links + // * Hovering a node highlights only the flow through that specific node + // * (chainThroughNode), not every occurrence of its path. + const chain: Chain | null = useMemo(() => { + if (!hover) return null + return hover.kind === 'link' + ? chainThroughLink(chainLinks, hover.source, hover.target) + : chainThroughNode(chainLinks, hover.id) + }, [hover, chainLinks]) + + const stepOrdinals = useMemo( + () => new Map(layout.steps.map((s, ordinal) => [s.index, ordinal])), + [layout.steps], + ) + const nodesByStep = useMemo(() => { + const m = new Map() + for (const n of layout.nodes) { + if (!m.has(n.step)) m.set(n.step, []) + m.get(n.step)!.push(n.id) } + return m + }, [layout.nodes]) + + const toggleLens = useCallback( + (path: string) => { + if (path === '(other)') return + onLensChange(lens === path ? null : path) + }, + [lens, onLensChange], + ) - const headerColor = isDark ? '#737373' : '#737373' // neutral-500 - const visitorColor = isDark ? '#ffffff' : '#171717' - - const headers = g - .selectAll('.step-header') - .data(stepIndices) - .join('g') - .attr('class', 'step-header') - .attr('transform', (step) => `translate(${step * stepWidth + NODE_WIDTH / 2},0)`) - - // Line 1: "STEP N" - headers - .append('text') - .attr('y', 14) - .attr('fill', headerColor) - .attr('font-size', '11px') - .attr('font-weight', '500') - .attr('letter-spacing', '1px') - .attr('text-anchor', 'start') - .text((step) => `STEP ${step + 1}`) - - // Line 2: "X visitors" + optional drop-off - const visitorText = headers - .append('text') - .attr('y', 32) - .attr('font-size', '13px') - .attr('font-weight', '600') - .attr('text-anchor', 'start') - - visitorText - .append('tspan') - .attr('fill', visitorColor) - .text((step) => `${(stepVisitors.get(step) ?? 0).toLocaleString()} visitors`) - - visitorText - .append('tspan') - .attr('dx', 6) - .attr('font-size', '11px') - .attr('font-weight', '500') - .attr('fill', (step) => { - const pct = stepDropOff.get(step) ?? 0 - if (pct === 0) return 'transparent' - return pct < 0 ? '#ef4444' : '#10b981' - }) - .text((step) => { - const pct = stepDropOff.get(step) - if (pct === undefined || pct === 0) return '' - return pct > 0 ? `+${pct}%` : `${pct}%` - }) - - // ── 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) => Math.max(MIN_LINK_HEIGHT, heightScale(d.value))) - .attr('opacity', LINK_OPACITY) - .attr('data-source', (d) => d.source) - .attr('data-target', (d) => d.target) - .style('pointer-events', 'none') - - // ── Tooltip — matches sidebar tooltip styling ───────── - const tooltip = d3.select('body').append('div') - .style('position', 'fixed') - .style('visibility', 'hidden') - .style('background-color', '#0a0a0a') // bg-neutral-950 - .style('border', '1px solid rgba(38, 38, 38, 0.6)') // border-neutral-800/60 - .style('border-radius', '0') // rounded-none - .style('padding', '8px 12px') // px-3 py-2 - .style('font-size', '14px') // text-sm - .style('font-weight', '500') // font-medium - .style('color', '#ffffff') // text-white - .style('white-space', 'nowrap') // whitespace-nowrap - .style('pointer-events', 'none') - .style('z-index', '100') - .style('box-shadow', 'none') // - .style('transform', 'translateY(-50%)') // -translate-y-1/2 - - // ── 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 hit area (invisible, wider than visible rect for easier clicking) - nodeGs.append('rect') - .attr('class', 'node-hit') - .attr('x', -(NODE_HIT_WIDTH - NODE_WIDTH) / 2) - .attr('width', NODE_HIT_WIDTH) - .attr('height', (d) => d.height) - .attr('fill', 'transparent') - .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 + LABEL_OFFSET) - .attr('y', (d) => d.height / 2 + 4) - .text((d) => smartLabel(d.name)) - .attr('font-size', '12px') - .attr('fill', textColor) - .attr('text-anchor', 'start') + const clearHover = useCallback(() => { + setHover(null) + setTooltip(null) + }, []) - // ── Hover: find all connected paths ─────────────────── - const findConnected = (startLink: SLink, dir: 'fwd' | 'bwd') => { - const result: SLink[] = [] - const visited = new Set() - 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)) - } + const onKeyDown = useCallback( + (e: React.KeyboardEvent) => { + const target = e.target as SVGGElement + const stepRaw = target.dataset?.step + const idxRaw = target.dataset?.idx + if (stepRaw === undefined || idxRaw === undefined) return + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + const id = target.dataset.id + if (id) toggleLens(pathOfNode(id)) + return } - return result - } - - const highlightPaths = (nodeId: string) => { - const connectedLinks: SLink[] = [] - const connectedNodes = new Set([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')) + let ordinal = stepOrdinals.get(parseInt(stepRaw, 10)) ?? 0 + let idx = parseInt(idxRaw, 10) + switch (e.key) { + case 'ArrowDown': idx += 1; break + case 'ArrowUp': idx -= 1; break + case 'ArrowRight': ordinal += 1; break + case 'ArrowLeft': ordinal -= 1; break + default: return } - const connectedLinkIds = new Set(connectedLinks.map((l) => `${l.source}|${l.target}`)) - connectedLinks.forEach((l) => { connectedNodes.add(l.source); connectedNodes.add(l.target) }) - - g.selectAll('.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('.node-rect') - .attr('opacity', (d) => connectedNodes.has(d.id) ? 1 : 0.15) - g.selectAll('.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(`
${escapeHtml(d.name)}
${d.count.toLocaleString()} sessions
`) - .style('top', `${event.pageY - 10}px`).style('left', `${event.pageX + 12}px`) - highlightPaths(d.id) - }) - .on('mousemove', (event) => { - tooltip.style('top', `${event.clientY}px`).style('left', `${event.clientX + 14}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(`
${escapeHtml(src?.name ?? '?')} → ${escapeHtml(tgt?.name ?? '?')}
${d.value.toLocaleString()} sessions
`) - .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() - all.forEach((l) => { nids.add(l.source); nids.add(l.target) }) - g.selectAll('.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('.node-rect') - .attr('opacity', (nd) => nids.has(nd.id) ? 1 : 0.15) - g.selectAll('.node-text') - .attr('opacity', (nd) => nids.has(nd.id) ? 1 : 0.2) - }) - .on('mousemove', (event) => { - tooltip.style('top', `${event.clientY}px`).style('left', `${event.clientX + 14}px`) - }) - .on('mouseleave', resetHighlight) + e.preventDefault() + ordinal = Math.max(0, Math.min(layout.steps.length - 1, ordinal)) + const ids = nodesByStep.get(layout.steps[ordinal]?.index) ?? [] + if (ids.length === 0) return + idx = Math.max(0, Math.min(ids.length - 1, idx)) + nodeRefs.current.get(ids[idx])?.focus() + }, + [stepOrdinals, nodesByStep, layout.steps, toggleLens], + ) - return () => { tooltip.remove() } - }, [data, containerWidth, isDark, handleNodeClick]) + if (layout.nodes.length === 0) return null - // ─── Empty state ──────────────────────────────────────────────── - if (!transitions.length || data.nodes.length === 0) { - return ( - } - title="No journey data yet" - description="Navigation flows will appear here as visitors browse through your site." - action={{ label: 'View setup guide', href: '/installation' }} - /> - ) - } + const lastStepIndex = layout.steps[layout.steps.length - 1]?.index return (
- {filterPath && ( -
- - Showing flows through{' '} - - {filterPath} - - - + +
+ ))} +
+ +
+ + {/* Links */} + {layout.links.map((l, i) => { + const onChain = chain?.linkKeys.has(l.key) ?? false + const opacity = chain + ? onChain + ? LINK_CHAIN_OPACITY + : LINK_DIM_OPACITY + : LINK_OPACITY + return ( + + ) + })} + + {/* Link hit areas — wide invisible strokes for hover + tooltip */} + {layout.links.map((l) => ( + { + setHover({ kind: 'link', source: l.source, target: l.target }) + setTooltip({ + x: e.clientX, + y: e.clientY, + title: `${pathOfNode(l.source)} → ${pathOfNode(l.target)}`, + sub: `${l.value.toLocaleString()} sessions`, + }) + }} + onMouseMove={(e) => + setTooltip((t) => (t ? { ...t, x: e.clientX, y: e.clientY } : t)) + } + onMouseLeave={clearHover} + /> + ))} + + {/* Nodes */} + {layout.nodes.map((n) => { + const onChain = chain?.nodeIds.has(n.id) ?? false + const isLast = n.step === lastStepIndex && layout.steps.length > 1 + const ordinalIdx = nodesByStep.get(n.step)?.indexOf(n.id) ?? 0 + const rectOpacity = chain && !onChain ? NODE_DIM_OPACITY : 1 + const labelOpacity = chain && !onChain ? LABEL_DIM_OPACITY : 1 + const clickable = n.path !== '(other)' + return ( + { + if (el) nodeRefs.current.set(n.id, el) + else nodeRefs.current.delete(n.id) + }} + initial={{ x: n.x, y: n.y, opacity: 0 }} + animate={{ x: n.x, y: n.y, opacity: 1 }} + transition={{ duration: DURATION_BASE, ease: EASE_APPLE }} + tabIndex={0} + role="button" + aria-label={`${n.path} — ${n.count.toLocaleString()} sessions`} + aria-pressed={lens === n.path} + data-id={n.id} + data-step={n.step} + data-idx={ordinalIdx} + style={{ cursor: clickable ? 'pointer' : 'default', outline: 'none' }} + onMouseEnter={(e) => { + setHover({ kind: 'node', id: n.id, path: n.path }) + setTooltip({ + x: e.clientX, + y: e.clientY, + title: n.path, + sub: `${n.count.toLocaleString()} sessions`, + }) + }} + onMouseMove={(e) => + setTooltip((t) => (t ? { ...t, x: e.clientX, y: e.clientY } : t)) + } + onMouseLeave={clearHover} + onFocus={(e) => { + setFocusedId(n.id) + setHover({ kind: 'node', id: n.id, path: n.path }) + const rect = (e.currentTarget as SVGGElement).getBoundingClientRect() + setTooltip({ + x: rect.right, + y: rect.top + rect.height / 2, + title: n.path, + sub: `${n.count.toLocaleString()} sessions`, + }) + }} + onBlur={() => { + setFocusedId(null) + clearHover() + }} + onClick={() => toggleLens(n.path)} + > + {/* Wider invisible hit area */} + + + + {smartLabel(n.path)} + + + ) + })} + +
+ + {/* Tooltip — fixed, popover surface (sanctioned shadow). Anchors to the + right of the cursor normally, flips to the left in the right portion of + the viewport so it never runs off the last column. */} + {tooltip && ( +
(typeof window !== 'undefined' ? window.innerWidth : 0) * 0.62 + ? { right: (typeof window !== 'undefined' ? window.innerWidth : 0) - tooltip.x + 14, top: tooltip.y } + : { left: tooltip.x + 14, top: tooltip.y } + } + role="status" + > +
{tooltip.title}
+
{tooltip.sub}
)} -
- + {/* Meta footer — sessions · effective depth · period */} +
+ {totalSessions.toLocaleString()} sessions tracked + {' · '} + {layout.steps.length < depth + ? `Showing ${layout.steps.length} of ${depth} steps — no traffic beyond step ${layout.steps.length} in this period` + : `${layout.steps.length} steps`} + {' · '} + {periodLabel}
) diff --git a/components/journeys/StepHeader.tsx b/components/journeys/StepHeader.tsx new file mode 100644 index 000000000..f4657d8e3 --- /dev/null +++ b/components/journeys/StepHeader.tsx @@ -0,0 +1,38 @@ +'use client' + +import { AnimatedNumber } from '@/components/ui/animated-number' + +// --------------------------------------------------------------------------- +// StepHeader — the shared per-step header for both journey views (DOM in both, +// so typography stays identical and accessible). Micro-label per the label +// rule: 1–2-word uppercase data label, mono, tracking-[0.08em]. +// --------------------------------------------------------------------------- + +interface StepHeaderProps { + index: number + visitors: number + dropOffPercent: number +} + +export function StepHeader({ index, visitors, dropOffPercent }: StepHeaderProps) { + return ( +
+ + Step {index + 1} + +
+ Math.round(v).toLocaleString()} + className="text-sm font-semibold tabular-nums text-white" + /> + visitors + {dropOffPercent !== 0 && ( + + {dropOffPercent > 0 ? '+' : ''}{dropOffPercent}% + + )} +
+
+ ) +} diff --git a/components/marketing/mockups/pulse-features-carousel.tsx b/components/marketing/mockups/pulse-features-carousel.tsx index 6095c6704..20afc4fb0 100644 --- a/components/marketing/mockups/pulse-features-carousel.tsx +++ b/components/marketing/mockups/pulse-features-carousel.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useMemo, type CSSProperties } from 'react' import { cdnUrl } from '@/lib/cdn' +import { FAVICON_SERVICE_URL } from '@/lib/utils/favicon' import { Files, ArrowSquareOut, @@ -117,8 +118,6 @@ function getReferrerIcon(name: string, favicon?: string) { return } -const FAVICON_URL = 'https://www.google.com/s2/favicons' - export function ReferrersCard() { const data = [ { label: 'Direct', value: 186 }, @@ -145,7 +144,7 @@ export function ReferrersCard() { maxValue={max} icon={getReferrerIcon( d.label, - 'domain' in d ? `${FAVICON_URL}?domain=${d.domain}&sz=32` : undefined + 'domain' in d ? `${FAVICON_SERVICE_URL}?domain=${d.domain}&sz=32` : undefined )} /> ))} diff --git a/components/search/ClicksImpressionsChart.tsx b/components/search/ClicksImpressionsChart.tsx deleted file mode 100644 index 833298d7b..000000000 --- a/components/search/ClicksImpressionsChart.tsx +++ /dev/null @@ -1,94 +0,0 @@ -'use client' - -import { useMemo } from 'react' -import { AreaChart, Area, Grid, XAxis, YAxis, ChartTooltip } from '@/components/ui/area-chart' -import { useGSCDailyTotals } from '@/lib/swr/dashboard' -import { SkeletonLine } from '@/components/skeletons' - -// ─── Component ────────────────────────────────────────────────── - -interface ClicksImpressionsChartProps { - siteId: string - startDate: string - endDate: string -} - -export default function ClicksImpressionsChart({ siteId, startDate, endDate }: ClicksImpressionsChartProps) { - const { data, isLoading } = useGSCDailyTotals(siteId, startDate, endDate) - - const chartData = useMemo(() => { - if (!data?.daily_totals?.length) return [] - return data.daily_totals.map((item) => ({ - // Full ISO date string so xAccessor can parse it with new Date() - date: item.date + 'T00:00:00', - clicks: item.clicks, - impressions: item.impressions, - })) - }, [data]) - - // Loading skeleton - if (isLoading) { - return ( -
- - -
- ) - } - - // No data — don't render anything - if (!chartData.length) return null - - return ( -
-

- Clicks & Impressions -

- - - - - [ - { - color: '#FD5E0F', - label: 'Clicks', - value: typeof point.clicks === 'number' ? point.clicks : 0, - }, - { - color: '#9CA3AF', - label: 'Impressions', - value: typeof point.impressions === 'number' ? point.impressions : 0, - }, - ]} - /> - {/* Clicks: orange gradient fill + orange stroke */} - - {/* Impressions: no fill, grey stroke only */} - - -
- ) -} diff --git a/components/search/NewQueriesChip.tsx b/components/search/NewQueriesChip.tsx new file mode 100644 index 000000000..7474ee9bf --- /dev/null +++ b/components/search/NewQueriesChip.tsx @@ -0,0 +1,71 @@ +'use client' + +import { useRef, useState } from 'react' +import { useGSCNewQueries } from '@/lib/swr/dashboard' +import FilterPopover from '@/components/dashboard/filter/FilterPopover' + +// The quiet "N new queries" chip. Clicking opens a portal popover (FilterPopover +// shell: flip, clamp, focus trap, Esc, focus-return) listing the new queries the +// endpoint already returns; picking one jumps to the queries view with that row +// expanded. The old green pill is gone. + +interface NewQueriesChipProps { + siteId: string + start: string + end: string + /** Open the queries view with this query expanded. */ + onPick: (query: string) => void +} + +export function NewQueriesChip({ siteId, start, end, onPick }: NewQueriesChipProps) { + const { data } = useGSCNewQueries(siteId, start, end) + const [open, setOpen] = useState(false) + const triggerRef = useRef(null) + + const count = data?.count ?? 0 + const queries = data?.queries ?? [] + if (count === 0) return null + + return ( + <> + + + setOpen(false)} + > +
+ {queries.length > 0 ? ( + queries.map((q) => ( + + )) + ) : ( +
No new queries to list.
+ )} +
+
+ + ) +} diff --git a/components/search/PositionBadge.tsx b/components/search/PositionBadge.tsx new file mode 100644 index 000000000..b1129116f --- /dev/null +++ b/components/search/PositionBadge.tsx @@ -0,0 +1,19 @@ +import { cn } from '@/lib/utils' + +// Average search position, mono + tabular so digits align down a column. Top of +// the first page (the displayed 1-decimal value ≤ 10.4, i.e. position < 10.5) +// earns brand orange; everything else stays neutral chrome. No emerald/red — +// distance from #1 is data, not a good/bad delta. +export function PositionBadge({ position }: { position: number }) { + const topPage = position < 10.5 + return ( + + {position.toFixed(1)} + + ) +} diff --git a/components/search/QueryTrendSparkline.tsx b/components/search/QueryTrendSparkline.tsx new file mode 100644 index 000000000..7bbcdf8ac --- /dev/null +++ b/components/search/QueryTrendSparkline.tsx @@ -0,0 +1,92 @@ +'use client' + +import { useGSCQueryTrend } from '@/lib/swr/dashboard' +import { DelayedSpinner } from '@/components/ui/DelayedSpinner' +import { formatDate } from '@/lib/utils/formatDate' +import { cn } from '@/lib/utils' + +// Position trend for one query inside its expanded row. Bars are inverted so a +// TALLER bar means a BETTER (lower) rank; the header states first → last with a +// delta coloured by direction (improvement = lower position = green). Fetch is +// keyed per query (race-free) and null-keyed until the row expands. + +const BAR_MIN = 4 +const BAR_SPAN = 28 // px added on top of BAR_MIN for the best rank in range + +interface QueryTrendSparklineProps { + siteId: string + query: string + start: string + end: string +} + +export function QueryTrendSparkline({ siteId, query, start, end }: QueryTrendSparklineProps) { + const { data, error, isLoading, mutate } = useGSCQueryTrend(siteId, query, start, end) + + if (isLoading && !data) { + return ( +
+ +
+ ) + } + + if (error) { + return ( +
+ Couldn’t load the position trend. + +
+ ) + } + + const points = data ?? [] + if (points.length < 2) { + return
Not enough data for a trend.
+ } + + const first = points[0] + const last = points[points.length - 1] + const delta = last.position - first.position // negative = improved (lower is better) + const improved = delta < 0 + const worse = delta > 0 + + const positions = points.map((p) => p.position) + const maxPos = Math.max(...positions) + const minPos = Math.min(...positions) + const spread = maxPos - minPos || 1 + + return ( +
+
+ Position + {first.position.toFixed(1)} + → + {last.position.toFixed(1)} + + {improved ? '↑' : worse ? '↓' : ''}{Math.abs(delta).toFixed(1)} + +
+
+ {points.map((point, i) => { + const height = BAR_MIN + ((maxPos - point.position) / spread) * BAR_SPAN + const dateLabel = formatDate(new Date(point.date + 'T00:00:00')) + return ( +
+ ) + })} +
+
+ ) +} diff --git a/components/search/SearchExpansion.tsx b/components/search/SearchExpansion.tsx new file mode 100644 index 000000000..e597d624f --- /dev/null +++ b/components/search/SearchExpansion.tsx @@ -0,0 +1,120 @@ +'use client' + +import { useGSCQueryPages, useGSCPageQueries } from '@/lib/swr/dashboard' +import { ErrorCard } from '@/components/ui/ErrorCard' +import { DelayedSpinner } from '@/components/ui/DelayedSpinner' +import { QueryTrendSparkline } from './QueryTrendSparkline' +import { SubRow, stripProtocol } from './rowPrimitives' + +// --------------------------------------------------------------------------- +// Expanded-row panels. Each panel is bound to its OWN row key (query / page), +// not a shared piece of state — so two quick expands can never render one row's +// data under another. The fetch keys on that key too (SWR), so the drill-down +// is race-free and cached. A settled empty is real; a failure shows an inline +// ErrorCard, never a fake-empty. +// --------------------------------------------------------------------------- + +interface Range { + siteId: string + start: string + end: string +} + +// Loading / error / empty scaffold shared by both panels, kept at a stable +// minimum height so the height-morph doesn't jump when rows arrive. +function PanelBody({ + isLoading, + hasData, + error, + isEmpty, + emptyLabel, + onRetry, + children, +}: { + isLoading: boolean + hasData: boolean + error: unknown + isEmpty: boolean + emptyLabel: string + onRetry: () => void + children: React.ReactNode +}) { + return ( +
+ {isLoading && !hasData ? ( +
+ +
+ ) : error ? ( + + ) : isEmpty ? ( +

{emptyLabel}

+ ) : ( + children + )} +
+ ) +} + +export function QueryExpansion({ siteId, start, end, query }: Range & { query: string }) { + const { data, error, isLoading, mutate } = useGSCQueryPages(siteId, query, start, end) + const pages = data?.pages ?? [] + return ( +
+ +
+
Contributing pages
+ { void mutate() }} + > + {pages.map((p) => ( + + ))} + +
+
+ ) +} + +export function PageExpansion({ siteId, start, end, page }: Range & { page: string }) { + const { data, error, isLoading, mutate } = useGSCPageQueries(siteId, page, start, end) + const queries = data?.queries ?? [] + return ( +
+
Contributing queries
+ { void mutate() }} + > + {queries.map((q) => ( + + ))} + +
+ ) +} diff --git a/components/search/SearchSimpleViews.tsx b/components/search/SearchSimpleViews.tsx new file mode 100644 index 000000000..3f68f3ce6 --- /dev/null +++ b/components/search/SearchSimpleViews.tsx @@ -0,0 +1,158 @@ +'use client' + +import { GlobeHemisphereWest, Monitor, Target } from '@phosphor-icons/react' +import { cn } from '@/lib/utils' +import { formatNumber } from '@/lib/utils/format' +import { countryName } from '@/lib/utils/countryCodes' +import { useGSCTopCountries, useGSCTopDevices, useGSCOpportunities } from '@/lib/swr/dashboard' +import { EmptyState } from '@/components/ui/EmptyState' +import { PositionBadge } from './PositionBadge' +import { + RowBar, + StandardMetrics, + StandardHeader, + CountryFlag, + deviceIcon, + Pagination, + ViewBody, + formatCompact, +} from './rowPrimitives' + +// --------------------------------------------------------------------------- +// The three non-expandable views — Countries (paginated), Devices (no +// pagination) and Opportunities (limit 50, no offset). Same row grammar as the +// query/page views: a proportional bar behind the label, tabular metrics, the +// neutral/orange PositionBadge. Opportunities carries its own columns and shows +// upside in orange (never emerald — upside is data, not a good/bad delta). +// --------------------------------------------------------------------------- + +const PAGE_SIZE = 50 + +interface RangeProps { + siteId: string + dateRange: { start: string; end: string } +} +interface PagedProps extends RangeProps { + page: number + setPage: (p: number) => void +} + +// A non-interactive row (bar + hover highlight, consistent with the grammar). +function StaticRow({ children, share }: { children: React.ReactNode; share: number }) { + return ( +
+ + {children} +
+ ) +} + +export function CountriesView({ siteId, dateRange, page, setPage }: PagedProps) { + const { data, error, isLoading, mutate } = useGSCTopCountries(siteId, dateRange.start, dateRange.end, PAGE_SIZE, page * PAGE_SIZE) + const rows = data?.countries ?? [] + const maxClicks = Math.max(...rows.map((r) => r.clicks), 0) + return ( + <> + + } title="No country data in this period" description="Country breakdowns appear once Search Console reports impressions by region." className="py-10" />} + footer={} + onRetry={() => { void mutate() }} + > + {rows.map((row) => { + const name = countryName(row.country) + return ( + 0 ? row.clicks / maxClicks : 0}> + + + {name} + + + + ) + })} + + + ) +} + +export function DevicesView({ siteId, dateRange }: RangeProps) { + const { data, error, isLoading, mutate } = useGSCTopDevices(siteId, dateRange.start, dateRange.end) + const rows = data?.devices ?? [] + const maxClicks = Math.max(...rows.map((r) => r.clicks), 0) + return ( + <> + + } title="No device data in this period" description="Device breakdowns appear once Search Console reports impressions by device." className="py-10" />} + onRetry={() => { void mutate() }} + > + {rows.map((row) => { + const Icon = deviceIcon(row.device) + const label = row.device.charAt(0).toUpperCase() + row.device.slice(1).toLowerCase() + return ( + 0 ? row.clicks / maxClicks : 0}> + + + {label} + + + + ) + })} + + + ) +} + +const OPP = { position: 'w-14', impressions: 'sm:w-24', clicks: 'w-16', potential: 'w-16' } as const + +export function OpportunitiesView({ siteId, dateRange }: RangeProps) { + const { data, error, isLoading, mutate } = useGSCOpportunities(siteId, dateRange.start, dateRange.end, PAGE_SIZE) + const rows = data?.opportunities ?? [] + const maxImpr = Math.max(...rows.map((r) => r.impressions), 0) + return ( + <> +

+ Queries ranking just off the first page where better content or titles can win clicks. +

+
+ Query +
+ Position + Impressions + Clicks + Potential +
+
+ } title="No striking-distance queries in this period" description="Opportunities appear when queries rank just off the first page with clicks to win." className="py-10" />} + onRetry={() => { void mutate() }} + > + {rows.map((row) => ( + 0 ? row.impressions / maxImpr : 0}> + {row.query} +
+ + {formatNumber(row.impressions)} + {formatNumber(row.clicks)} + → {formatCompact(row.potential_clicks)} +
+
+ ))} +
+ + ) +} diff --git a/components/search/SearchTrafficChart.tsx b/components/search/SearchTrafficChart.tsx new file mode 100644 index 000000000..1c4dd2eb9 --- /dev/null +++ b/components/search/SearchTrafficChart.tsx @@ -0,0 +1,319 @@ +'use client' + +import { useCallback, useMemo, useState } from 'react' +import { scaleLinear, scaleTime } from 'd3-scale' +import { curveMonotoneX } from 'd3-shape' +import { bisector } from 'd3-array' +import { AreaClosed, LinePath, LinearGradient, ParentSize, localPoint } from '@/lib/charts/primitives' +import { useGSCDailyTotals } from '@/lib/swr/dashboard' +import { UpdatingChip } from '@/components/ui/UpdatingChip' +import { ErrorCard } from '@/components/ui/ErrorCard' +import { formatNumber } from '@/lib/utils/format' +import { formatDate, formatDateShort } from '@/lib/utils/formatDate' + +// --------------------------------------------------------------------------- +// Search traffic — clicks and impressions per day on INDEPENDENT linear scales +// (clicks left / orange area + line, impressions right / neutral line) so both +// shapes stay readable across a 10–100× magnitude gap. +// +// Chart approach (design D6): SELF-CONTAINED. The shared derives a +// single yScale from the max across all its children and exposes only +// that one scale through its context (tooltip, dots, grid and axes all read +// it) — it is single-scale by design and cannot express a second independent +// axis. So this composes the same low-level primitives (AreaClosed / LinePath / +// LinearGradient / ParentSize) and the same tokens (--chart-grid / +// --chart-axis / --chart-crosshair, bg-popover tooltip, DD/MM ticks) as the +// shared chart, with two scales it owns. Fallback (two synced panels) unneeded. +// --------------------------------------------------------------------------- + +const CLICKS = '#FD5E0F' +const IMPRESSIONS = '#737373' +const Y_TICKS = 5 +const X_TICKS = 5 +const MARGIN = { top: 8, right: 56, bottom: 28, left: 52 } + +interface SearchTrafficChartProps { + siteId: string + startDate: string + endDate: string +} + +interface Point { + date: Date + clicks: number + impressions: number +} + +const evenTicks = (min: number, max: number, count: number) => + Array.from({ length: count }, (_, i) => min + (max - min) * (i / (count - 1))) + +// * Tick positions must stay (both axes share one grid), but small integer +// * domains round several fractions to the same label — keep the first only. +const dedupeTickLabels = (ticks: number[]) => { + const seen = new Set() + return ticks.map((t) => { + const label = formatNumber(Math.round(t)) + if (seen.has(label)) return { t, label: null } + seen.add(label) + return { t, label } + }) +} + +function ChartBody({ width, height, data }: { width: number; height: number; data: Point[] }) { + const innerWidth = width - MARGIN.left - MARGIN.right + const innerHeight = height - MARGIN.top - MARGIN.bottom + const [hoverIndex, setHoverIndex] = useState(null) + + const xScale = useMemo( + () => scaleTime().domain([data[0].date, data[data.length - 1].date]).range([0, innerWidth]), + [data, innerWidth], + ) + const clicksScale = useMemo( + () => + scaleLinear() + .domain([0, Math.max(1, ...data.map((d) => d.clicks)) * 1.1]) + .range([innerHeight, 0]) + .nice(), + [data, innerHeight], + ) + const imprScale = useMemo( + () => + scaleLinear() + .domain([0, Math.max(1, ...data.map((d) => d.impressions)) * 1.1]) + .range([innerHeight, 0]) + .nice(), + [data, innerHeight], + ) + + // * Both scales share the pixel range, so evenly-spaced domain fractions land + // * on the same rows — one grid, two aligned axes. + const [clicksMin, clicksMax] = clicksScale.domain() + const [imprMin, imprMax] = imprScale.domain() + const leftTicks = useMemo(() => evenTicks(clicksMin, clicksMax, Y_TICKS), [clicksMin, clicksMax]) + const rightTicks = useMemo(() => evenTicks(imprMin, imprMax, Y_TICKS), [imprMin, imprMax]) + + const xTicks = useMemo(() => { + const n = Math.min(X_TICKS, data.length) + if (n <= 1) return data.map((_, i) => i) + return Array.from({ length: n }, (_, k) => Math.round((k * (data.length - 1)) / (n - 1))) + }, [data]) + + const bisectDate = useMemo(() => bisector((d) => d.date).left, []) + + const handleMove = useCallback( + (event: React.MouseEvent) => { + const point = localPoint(event) + if (!point) return + const x0 = xScale.invert(point.x - MARGIN.left) + const i = bisectDate(data, x0, 1) + const d0 = data[i - 1] + const d1 = data[i] + let idx = i - 1 + if (d0 && d1) { + idx = x0.getTime() - d0.date.getTime() > d1.date.getTime() - x0.getTime() ? i : i - 1 + } + setHoverIndex(Math.max(0, Math.min(data.length - 1, idx))) + }, + [xScale, data, bisectDate], + ) + + if (innerWidth <= 0 || innerHeight <= 0) return null + + const hovered = hoverIndex != null ? data[hoverIndex] : null + const hoverX = hovered ? xScale(hovered.date) : 0 + const tooltipX = hoverX + MARGIN.left + const flip = tooltipX > width * 0.66 + + return ( + <> + + + {hovered && ( +
+
+
{formatDate(hovered.date)}
+
+ + +
+
+
+ )} + + ) +} + +function TooltipRow({ color, label, value }: { color: string; label: string; value: string }) { + return ( +
+
+ + {label} +
+ {value} +
+ ) +} + +function LegendDot({ color, label }: { color: string; label: string }) { + return ( + + + {label} + + ) +} + +export default function SearchTrafficChart({ siteId, startDate, endDate }: SearchTrafficChartProps) { + const { data, error, isLoading, isValidating, mutate } = useGSCDailyTotals(siteId, startDate, endDate) + + const points = useMemo(() => { + if (!data?.daily_totals?.length) return [] + return data.daily_totals.map((t) => ({ + date: new Date(t.date + 'T00:00:00'), + clicks: t.clicks, + impressions: t.impressions, + })) + }, [data]) + + return ( +
+ +
+ Search traffic +
+ + +
+
+ + {/* Stable-height region — no unmount flash on range change (keepPreviousData) */} +
+ {error ? ( + { void mutate() }} className="py-8" /> + ) : isLoading && !data ? null : points.length > 0 ? ( + + {({ width, height }) => (width > 0 && height > 0 ? : null)} + + ) : ( +
+

No search traffic in this period.

+
+ )} +
+
+ ) +} diff --git a/components/search/SearchViews.tsx b/components/search/SearchViews.tsx new file mode 100644 index 000000000..06aa68ccc --- /dev/null +++ b/components/search/SearchViews.tsx @@ -0,0 +1,258 @@ +'use client' + +import { useCallback, useEffect, useRef } from 'react' +import { usePathname, useRouter, useSearchParams } from 'next/navigation' +import { motion, AnimatePresence } from 'framer-motion' +import { CaretDown, MagnifyingGlass, FileText, GlobeHemisphereWest, Monitor, Target } from '@phosphor-icons/react' +import { DURATION_FAST, EASE_APPLE } from '@/lib/motion' +import { cn } from '@/lib/utils' +import { useGSCTopQueries, useGSCTopPages } from '@/lib/swr/dashboard' +import { Segmented, type SegmentedOption } from '@/components/ui/segmented' +import { EmptyState } from '@/components/ui/EmptyState' +import { NewQueriesChip } from './NewQueriesChip' +import { QueryExpansion, PageExpansion } from './SearchExpansion' +import { CountriesView, DevicesView, OpportunitiesView } from './SearchSimpleViews' +import { RowBar, StandardMetrics, StandardHeader, Pagination, ViewBody, stripProtocol } from './rowPrimitives' + +// --------------------------------------------------------------------------- +// Five views, one table system. Queries · Pages · Countries · Devices · +// Opportunities behind a single Segmented control, URL-synced (?view=/?p=/ +// ?expand=). Queries/Pages rows expand (aria-expanded, Enter/Space, ↑/↓, focus +// ring); each expanded row's drill-down is SWR-keyed on its own key so the old +// shared-state race is gone. ?p= and ?expand= drop on any range or view change. +// --------------------------------------------------------------------------- + +const PAGE_SIZE = 50 + +const VIEWS = ['queries', 'pages', 'countries', 'devices', 'opportunities'] as const +type View = (typeof VIEWS)[number] + +const VIEW_OPTIONS: SegmentedOption[] = [ + { value: 'queries', label: 'Queries', icon: }, + { value: 'pages', label: 'Pages', icon: }, + { value: 'countries', label: 'Countries', icon: }, + { value: 'devices', label: 'Devices', icon: }, + { value: 'opportunities', label: 'Opportunities', icon: }, +] + +function parseView(raw: string | null): View { + return VIEWS.includes(raw as View) ? (raw as View) : 'queries' +} + +function parsePageIndex(raw: string | null): number { + const n = raw ? Number.parseInt(raw, 10) : 0 + return Number.isFinite(n) && n > 0 ? n : 0 +} + +interface RangeProps { + siteId: string + dateRange: { start: string; end: string } +} +interface ExpandableProps extends RangeProps { + page: number + setPage: (p: number) => void + expand: string | null + toggleExpand: (key: string) => void +} + +// ─── Expandable rows (Queries / Pages) ─────────────────────────── + +interface ExpRow { + key: string + label: string + title: string + clicks: number + impressions: number + ctr: number + position: number +} + +function ExpandableRows({ + rows, + expand, + toggleExpand, + renderExpansion, +}: { + rows: ExpRow[] + expand: string | null + toggleExpand: (key: string) => void + renderExpansion: (row: ExpRow) => React.ReactNode +}) { + const listRef = useRef(null) + const maxImpr = Math.max(...rows.map((r) => r.impressions), 0) + + // ↑/↓ move focus between row buttons; the native + + {isExpanded && ( + + {renderExpansion(row)} + + )} + +
+ ) + })} +
+ ) +} + +function QueriesView({ siteId, dateRange, page, setPage, expand, toggleExpand }: ExpandableProps) { + const { data, error, isLoading, mutate } = useGSCTopQueries(siteId, dateRange.start, dateRange.end, PAGE_SIZE, page * PAGE_SIZE) + const rows: ExpRow[] = (data?.queries ?? []).map((r) => ({ + key: r.query, label: r.query, title: r.query, clicks: r.clicks, impressions: r.impressions, ctr: r.ctr, position: r.position, + })) + return ( + <> + + } title="No queries in this period" description="Try a wider date range once Search Console has synced more data." className="py-10" />} + footer={} + onRetry={() => { void mutate() }} + > + } + /> + + + ) +} + +function PagesView({ siteId, dateRange, page, setPage, expand, toggleExpand }: ExpandableProps) { + const { data, error, isLoading, mutate } = useGSCTopPages(siteId, dateRange.start, dateRange.end, PAGE_SIZE, page * PAGE_SIZE) + const rows: ExpRow[] = (data?.pages ?? []).map((r) => ({ + key: r.page, label: stripProtocol(r.page), title: stripProtocol(r.page), clicks: r.clicks, impressions: r.impressions, ctr: r.ctr, position: r.position, + })) + return ( + <> + + } title="No pages in this period" description="Try a wider date range once Search Console has synced more data." className="py-10" />} + footer={} + onRetry={() => { void mutate() }} + > + } + /> + + + ) +} + +// ─── Orchestrator ──────────────────────────────────────────────── + +export default function SearchViews({ siteId, dateRange }: RangeProps) { + const router = useRouter() + const pathname = usePathname() + const searchParams = useSearchParams() + + const view = parseView(searchParams.get('view')) + const page = parsePageIndex(searchParams.get('p')) + // URLSearchParams decodes on read and encodes on write, so this is the raw + // query/page string — compared directly against a row's key. + const expand = searchParams.get('expand') + + const updateParams = useCallback( + (updates: Record) => { + const next = new URLSearchParams(searchParams.toString()) + for (const [key, value] of Object.entries(updates)) { + if (value === null || value === '') next.delete(key) + else next.set(key, value) + } + const qs = next.toString() + router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false }) + }, + [router, pathname, searchParams], + ) + + const setView = useCallback((v: View) => updateParams({ view: v === 'queries' ? null : v, p: null, expand: null }), [updateParams]) + const setPage = useCallback((p: number) => updateParams({ p: p <= 0 ? null : String(p) }), [updateParams]) + const toggleExpand = useCallback((key: string) => updateParams({ expand: expand === key ? null : key }), [updateParams, expand]) + const pickNewQuery = useCallback((query: string) => updateParams({ view: null, expand: query, p: null }), [updateParams]) + + // The range is owned by the page's useUrlDateRange and preserves other params + // on change, so drop ?p= / ?expand= here when the range actually changes. + const rangeKey = `${dateRange.start}:${dateRange.end}` + const prevRangeKey = useRef(rangeKey) + useEffect(() => { + if (prevRangeKey.current === rangeKey) return + prevRangeKey.current = rangeKey + if (searchParams.has('p') || searchParams.has('expand')) updateParams({ p: null, expand: null }) + }, [rangeKey, searchParams, updateParams]) + + return ( +
+
+
+ +
+ +
+ +
+ {view === 'queries' && } + {view === 'pages' && } + {view === 'countries' && } + {view === 'devices' && } + {view === 'opportunities' && } +
+
+ ) +} diff --git a/components/search/rowPrimitives.tsx b/components/search/rowPrimitives.tsx new file mode 100644 index 000000000..ca5facf34 --- /dev/null +++ b/components/search/rowPrimitives.tsx @@ -0,0 +1,210 @@ +'use client' + +import * as React from 'react' +import { Monitor, DeviceMobile, DeviceTablet } from '@phosphor-icons/react' +import * as Flags from 'country-flag-icons/react/3x2' +import { cn } from '@/lib/utils' +import { formatNumber } from '@/lib/utils/format' +import { alpha3ToAlpha2 } from '@/lib/utils/countryCodes' +import { SkeletonLine } from '@/components/skeletons' +import { ErrorCard } from '@/components/ui/ErrorCard' +import { PositionBadge } from './PositionBadge' + +// --------------------------------------------------------------------------- +// The one row grammar for the Search Console views. A card, mono sentence-case +// column headers, h-9 rows with a proportional orange bar behind the label and +// a fixed-width tabular metric block on the right (Clicks / Impressions / CTR / +// Position) that headers and rows share so columns always line up. Impressions +// and CTR fold away below sm to keep the label legible on narrow screens. +// --------------------------------------------------------------------------- + +// Shared column widths — identical class strings in the header and every row. +const W = { + clicks: 'w-14 sm:w-20', + impressions: 'sm:w-24', + ctr: 'sm:w-16', + position: 'w-14', +} as const + +export const formatCTR = (ctr: number) => `${(ctr * 100).toFixed(1)}%` + +// Protocol + trailing slash stripped for a cleaner page URL; full URL goes in title. +export const stripProtocol = (url: string) => url.replace(/^https?:\/\//, '').replace(/\/$/, '') + +// Compact thousands (1.2K / 3.4M) for the opportunities upside figure. +const compact = new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 }) +export const formatCompact = (n: number) => compact.format(n) + +// ─── Bar behind the label ──────────────────────────────────────── + +export function RowBar({ share }: { share: number }) { + if (!(share > 0)) return null + // A (phrasing content) so it's valid inside the row + ) +} + +export function Pagination({ page, pageSize, total, onPage }: { page: number; pageSize: number; total: number; onPage: (p: number) => void }) { + if (total <= pageSize) return null + const from = page * pageSize + 1 + const to = Math.min((page + 1) * pageSize, total) + return ( +
+ + {from.toLocaleString()}–{to.toLocaleString()} of {total.toLocaleString()} + +
+ onPage(page - 1)} /> + = total} onClick={() => onPage(page + 1)} /> +
+
+ ) +} + +// ─── First-load skeleton rows ──────────────────────────────────── + +export function ViewSkeleton({ rows = 10 }: { rows?: number }) { + return ( +
+ {Array.from({ length: rows }).map((_, i) => ( +
+ +
+ + + + +
+
+ ))} +
+ ) +} + +// ─── Stateful card body (skeleton / error / empty / rows) ──────── + +// Shared by every view: first-load skeleton (only when isLoading && !data, so +// keepPreviousData never flashes), an explicit ErrorCard + Retry (no fake-empty +// on failure), a settled EmptyState, otherwise the rows and optional footer. +export function ViewBody({ + isLoading, + hasData, + error, + isEmpty, + emptyNode, + footer, + onRetry, + children, +}: { + isLoading: boolean + hasData: boolean + error: unknown + isEmpty: boolean + emptyNode: React.ReactNode + footer?: React.ReactNode + onRetry: () => void + children: React.ReactNode +}) { + if (isLoading && !hasData) return + if (error) { + return ( + + ) + } + if (isEmpty) return <>{emptyNode} + return ( + <> + {children} + {footer} + + ) +} diff --git a/components/settings/SettingsShell.tsx b/components/settings/SettingsShell.tsx index d320f190c..c464a9de3 100644 --- a/components/settings/SettingsShell.tsx +++ b/components/settings/SettingsShell.tsx @@ -155,8 +155,11 @@ export default function SettingsShell({ children }: { children: React.ReactNode return (
- {/* Page header */} -
+ {/* Page header — sticky so long tab content keeps its context. Fixed h-16 + so the nav's sticky offset (top-16) is exactly the header's height — + a guessed pixel offset here lets the nav slide underneath. The border + marks the edge content is meant to pass under; bg must be opaque. */} +

{title}

{subtitle &&

{subtitle}

}
@@ -174,8 +177,9 @@ export default function SettingsShell({ children }: { children: React.ReactNode {/* Main layout */}
- {/* Left nav */} -

diff --git a/components/settings/unified/tabs/WorkspaceBillingTab.tsx b/components/settings/unified/tabs/WorkspaceBillingTab.tsx index 719a70fbc..0a3b27e12 100644 --- a/components/settings/unified/tabs/WorkspaceBillingTab.tsx +++ b/components/settings/unified/tabs/WorkspaceBillingTab.tsx @@ -11,6 +11,7 @@ import { formatDateLong, formatDate } from '@/lib/utils/formatDate' import { getAuthErrorMessage } from '@ciphera-net/facet' import { cdnUrl } from '@/lib/cdn' import { useCan } from '@/lib/auth/permissions' +import { formatPlanName } from '@/lib/plans' const PAYMENT_METHODS = [ { id: 'creditcard', label: 'Cards', icons: ['/icons/payment/visa.svg', '/icons/payment/mastercard.svg'] }, @@ -137,12 +138,7 @@ export default function WorkspaceBillingTab() { ) } - const planLabel = (() => { - const raw = subscription.plan_id?.startsWith('price_') ? 'Pro' - : subscription.plan_id === 'free' || !subscription.plan_id ? 'Hobby' - : subscription.plan_id - return raw === 'Free' || raw === 'Pro' ? raw : raw.charAt(0).toUpperCase() + raw.slice(1) - })() + const planLabel = formatPlanName(subscription.plan_id) const isActive = subscription.subscription_status === 'active' || subscription.subscription_status === 'trialing' const isCanceled = subscription.subscription_status === 'canceled' @@ -203,6 +199,12 @@ export default function WorkspaceBillingTab() {

Pageviews

{subscription.pageview_usage.toLocaleString()} / {subscription.pageview_limit.toLocaleString()}

+
+
= 0.9 ? 'bg-red-500' : 'bg-brand-orange'}`} + style={{ width: `${Math.min(100, (subscription.pageview_usage / subscription.pageview_limit) * 100)}%` }} + /> +
)} {subscription.current_period_end && ( diff --git a/components/settings/unified/tabs/WorkspaceMembersTab.tsx b/components/settings/unified/tabs/WorkspaceMembersTab.tsx index 9cb22bdae..eecea1e10 100644 --- a/components/settings/unified/tabs/WorkspaceMembersTab.tsx +++ b/components/settings/unified/tabs/WorkspaceMembersTab.tsx @@ -97,7 +97,15 @@ export default function WorkspaceMembersTab() {
-

{member.user_email || member.user_id}

+ {/* * Zero-PII backend: most members have no stored email/name. Show + * "You" for the signed-in member, the invite email when the backend + * has one (pending invites), and a short member id otherwise — + * never a raw 36-char UUID as a display name. */} +

+ {member.user_id === user?.id + ? 'You' + : member.user_email || `Member ${member.user_id.slice(0, 8)}`} +

diff --git a/components/settings/unified/tabs/WorkspaceRolesTab.tsx b/components/settings/unified/tabs/WorkspaceRolesTab.tsx index dddff17cb..65b655949 100644 --- a/components/settings/unified/tabs/WorkspaceRolesTab.tsx +++ b/components/settings/unified/tabs/WorkspaceRolesTab.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useCallback } from 'react' import { motion, AnimatePresence } from 'framer-motion' +import { EASE_APPLE } from '@/lib/motion' import { Button, Input, Spinner, toast, getAuthErrorMessage } from '@ciphera-net/facet' import { Checkbox } from '@/components/ui/checkbox' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' @@ -494,7 +495,7 @@ function RoleCard({ initial={{ height: 0, opacity: 0 }} animate={{ height: 'auto', opacity: 1 }} exit={{ height: 0, opacity: 0 }} - transition={{ duration: 0.2, ease: [0.32, 0.72, 0, 1] }} + transition={{ duration: 0.2, ease: EASE_APPLE }} className="overflow-hidden" >
@@ -682,7 +683,7 @@ export default function WorkspaceRolesTab() { initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -8 }} - transition={{ duration: 0.2, ease: [0.32, 0.72, 0, 1] }} + transition={{ duration: 0.2, ease: EASE_APPLE }} > + {/* * Zero renders as "—": a card full of hard 0s reads as "tracking is + * broken" when it usually just means no traffic in the window. */}

Visitors (24h)

- {statsLoading ? '--' : formatNumber(visitors24h)} + {statsLoading ? '--' : visitors24h === 0 ? '—' : formatNumber(visitors24h)}

Pageviews

- {statsLoading ? '--' : formatNumber(pageviews)} + {statsLoading ? '--' : pageviews === 0 ? '—' : formatNumber(pageviews)}

diff --git a/components/skeletons.tsx b/components/skeletons.tsx index 202292105..66310c23e 100644 --- a/components/skeletons.tsx +++ b/components/skeletons.tsx @@ -171,25 +171,26 @@ export function DashboardSkeleton() { export function JourneysSkeleton() { return (
- {/* Header */} -
+ {/* Header — title/subtitle left, date range picker right */} +
- +
- -
- {/* Controls */} -
- - +
- {/* Sankey area */} - - {/* Top paths table */} -
- - + {/* Card: one h-10 toolbar row + canvas */} +
+
+ + + + + +
+
+ +
) @@ -274,30 +275,38 @@ export function ChecksSkeleton() { export function FunnelsListSkeleton() { return (
-
-
- -
- - -
+ {/* Header — title/subtitle left, range picker + create right */} +
+
+ +
-
- {Array.from({ length: 3 }).map((_, i) => ( -
- - -
- {Array.from({ length: 3 }).map((_, j) => ( -
- - {j < 2 && } -
- ))} +
+ + +
+
+ {/* Summary rows: name+chips+desc left, conversion+sparkline right */} +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+
+
+ +
+ {Array.from({ length: 3 }).map((_, j) => ( + + ))} +
+ +
+
+ +
- ))} -
+
+ ))}
) @@ -308,18 +317,32 @@ export function FunnelsListSkeleton() { export function FunnelDetailSkeleton() { return (
-
-
- + {/* Back link + header row (name/meta left, range + actions right) */} +
+ +
- - + + +
+
+ + +
- - -
+ {/* KPI band */} +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ + +
+ ))} +
+ {/* Canvas region */} +
) } @@ -429,50 +452,211 @@ export function PricingCardsSkeleton() { ) } -// ─── Behavior page skeleton ───────────────────────────────── +// ─── Behavior page skeleton (mirrors the real KPI-band + tables layout) ── export function BehaviorSkeleton() { return ( -
+
{/* Header */} -
+
- +
- +
- {/* Summary cards (3 cols) */} -
+ {/* KPI band (3 cards) */} +
{Array.from({ length: 3 }).map((_, i) => ( -
- - - +
+ + +
))}
- {/* Rage clicks + Dead clicks side by side */} -
- - + {/* Element tables (2 cols) */} +
+ {Array.from({ length: 2 }).map((_, i) => ( +
+
+ + +
+
+ + +
+
+ {Array.from({ length: 7 }).map((_, j) => ( +
+ + +
+ ))} +
+
+ ))}
- {/* By-page table */} -
-
+ {/* Frustration by page */} +
+ +
+ + +
+
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ + +
+ ))} +
+
+ + {/* Daily trend + scroll depth */} +
+
+ + +
+
+ +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ + + +
+ ))} +
+
+
+
+ ) +} + +// ─── Search Console page skeleton (mirrors header + KPI band + chart + table) ── + +export function SearchSkeleton() { + return ( +
+ {/* Header — title/subtitle/sync line left, range picker right */} +
+
- - + +
+
- {/* Scroll depth + Frustration trend */} -
- - + {/* KPI band (4 cards) */} +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ + +
+ ))} +
+ + {/* Traffic chart card */} +
+
+ + +
+ +
+ + {/* Table block */} +
+
+ +
+
+ {Array.from({ length: 10 }).map((_, i) => ( +
+ +
+ + + + +
+
+ ))} +
+
+
+ ) +} + +// ─── CDN page skeleton (mirrors header + KPI band + charts + distribution) ── + +export function CDNSkeleton() { + return ( +
+ {/* Header — title/subtitle/sync line left, range picker right */} +
+
+ + + +
+ +
+ + {/* KPI band (5 cards) */} +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ + +
+ ))} +
+ + {/* Bandwidth chart (full width) */} +
+
+ + +
+ +
+ + {/* Requests · Errors · Origin latency */} +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ + +
+ ))} +
+ + {/* Traffic distribution — map + ranked rows */} +
+ + +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+
+ + +
+ +
+ ))} +
) diff --git a/components/ui/DateRangePicker.tsx b/components/ui/DateRangePicker.tsx index fc0851b63..28589f1de 100644 --- a/components/ui/DateRangePicker.tsx +++ b/components/ui/DateRangePicker.tsx @@ -226,7 +226,7 @@ export default function DateRangePicker({ animate={{ opacity: 1, y: 0, scale: 1 }} exit={{ opacity: 0, y: 4, scale: 0.98 }} transition={{ duration: 0.15 }} - className="fixed z-50 flex rounded-none border border-neutral-800 bg-neutral-900 overflow-hidden" + className="fixed z-50 flex rounded-none border border-neutral-800 bg-neutral-900 shadow-lg overflow-hidden" style={pos ? { left: pos.left, top: pos.top } : undefined} >
diff --git a/components/ui/DelayedSpinner.tsx b/components/ui/DelayedSpinner.tsx new file mode 100644 index 000000000..53adb3472 --- /dev/null +++ b/components/ui/DelayedSpinner.tsx @@ -0,0 +1,31 @@ +'use client' + +import { useEffect, useState } from 'react' +import { CircleNotch } from '@phosphor-icons/react' +import { cn } from '@/lib/utils' + +const SHOW_DELAY_MS = 150 + +/** + * A spinner that only appears after 150 ms so fast fetches never flash it. + * Mount it inside a stable-height box while `isLoading && !data`; unmounting + * on settle clears the timer before it fires. + */ +export function DelayedSpinner({ className }: { className?: string }) { + const [visible, setVisible] = useState(false) + + useEffect(() => { + const timer = setTimeout(() => setVisible(true), SHOW_DELAY_MS) + return () => clearTimeout(timer) + }, []) + + if (!visible) return null + + return ( + + ) +} diff --git a/components/ui/ErrorCard.tsx b/components/ui/ErrorCard.tsx new file mode 100644 index 000000000..044a9c88d --- /dev/null +++ b/components/ui/ErrorCard.tsx @@ -0,0 +1,36 @@ +'use client' + +import { WarningCircle } from '@phosphor-icons/react' +import { cn } from '@/lib/utils' + +interface ErrorCardProps { + title: string + description?: string + onRetry?: () => void + className?: string +} + +/** + * Inline error state for data surfaces (canvas regions, cards) — the + * anti-fake-empty. Route-level failures use ErrorDisplay instead. + */ +export function ErrorCard({ title, description, onRetry, className }: ErrorCardProps) { + return ( +
+
+ +
+

{title}

+ {description &&

{description}

} + {onRetry && ( + + )} +
+ ) +} diff --git a/components/ui/UpdatingChip.tsx b/components/ui/UpdatingChip.tsx new file mode 100644 index 000000000..dd1e9b499 --- /dev/null +++ b/components/ui/UpdatingChip.tsx @@ -0,0 +1,52 @@ +'use client' + +import { useEffect, useState } from 'react' +import { motion } from 'framer-motion' +import { CircleNotch } from '@phosphor-icons/react' +import { DURATION_FAST, EASE_APPLE } from '@/lib/motion' +import { cn } from '@/lib/utils' + +interface UpdatingChipProps { + /** True while a refetch is in flight. The chip waits 150 ms before showing. */ + active: boolean + className?: string +} + +const SHOW_DELAY_MS = 150 + +/** + * Quiet "Updating…" indicator for canvases that keep stale data mounted + * while revalidating. Delayed so fast fetches never flash it; resets + * before paint whenever the fetch settles. + */ +export function UpdatingChip({ active, className }: UpdatingChipProps) { + const [visible, setVisible] = useState(false) + + useEffect(() => { + if (!active) { + setVisible(false) + return + } + const timer = setTimeout(() => setVisible(true), SHOW_DELAY_MS) + return () => clearTimeout(timer) + }, [active]) + + if (!visible) return null + + return ( + + + Updating… + + ) +} diff --git a/components/ui/area-chart.tsx b/components/ui/area-chart.tsx index abae2ddd5..db7a67feb 100644 --- a/components/ui/area-chart.tsx +++ b/components/ui/area-chart.tsx @@ -1347,7 +1347,7 @@ function XAxisLabel({ } export function XAxis({ numTicks = 5, tickerHalfWidth = 50, formatLabel }: XAxisProps) { - const { xScale, margin, tooltipData, containerRef } = useChart(); + const { xScale, margin, tooltipData, containerRef, innerWidth } = useChart(); const [mounted, setMounted] = useState(false); useEffect(() => { @@ -1366,7 +1366,10 @@ export function XAxis({ numTicks = 5, tickerHalfWidth = 50, formatLabel }: XAxis const startTime = startDate.getTime(); const endTime = endDate.getTime(); const rangeMs = endTime - startTime; - const tickCount = Math.max(2, numTicks); + // numTicks is an upper bound — narrow charts (mobile 390px) fit fewer + // DD/MM labels before they collide, so budget ~80px per tick. + const widthBudget = innerWidth > 0 ? Math.floor(innerWidth / 80) : numTicks; + const tickCount = Math.max(2, Math.min(numTicks, widthBudget)); // Generate all natural boundary dates, then thin to fit numTicks const allDates: Date[] = []; @@ -1431,7 +1434,7 @@ export function XAxis({ numTicks = 5, tickerHalfWidth = 50, formatLabel }: XAxis x: (xScale(date) ?? 0) + margin.left, label: fmt(date), })); - }, [xScale, margin.left, numTicks, formatLabel]); + }, [xScale, margin.left, numTicks, formatLabel, innerWidth]); const isHovering = tooltipData !== null; const crosshairX = tooltipData ? tooltipData.x + margin.left : null; diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx deleted file mode 100644 index 4eec28f53..000000000 --- a/components/ui/avatar.tsx +++ /dev/null @@ -1,67 +0,0 @@ -'use client'; - -import * as React from 'react'; -import { cn } from '@/lib/utils'; -import { cva, VariantProps } from 'class-variance-authority'; -import { Avatar as AvatarPrimitive } from 'radix-ui'; - -const avatarStatusVariants = cva('flex items-center rounded-full size-2 border-2 border-background', { - variants: { - variant: { - online: 'bg-green-600', - offline: 'bg-zinc-300', - busy: 'bg-yellow-600', - away: 'bg-blue-600', - }, - }, - defaultVariants: { - variant: 'online', - }, -}); - -function Avatar({ className, ...props }: React.ComponentProps) { - return ( - - ); -} - -function AvatarImage({ className, ...props }: React.ComponentProps) { - return ( -
- -
- ); -} - -function AvatarFallback({ className, ...props }: React.ComponentProps) { - return ( - - ); -} - -function AvatarIndicator({ className, ...props }: React.HTMLAttributes) { - return ( -
- ); -} - -function AvatarStatus({ - className, - variant, - ...props -}: React.HTMLAttributes & VariantProps) { - return
; -} - -export { Avatar, AvatarFallback, AvatarImage, AvatarIndicator, AvatarStatus, avatarStatusVariants }; diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx index e72c4b486..41289445e 100644 --- a/components/ui/dialog.tsx +++ b/components/ui/dialog.tsx @@ -38,7 +38,7 @@ const DialogContent = React.forwardRef< - {background && ( - - )} - - - ); -} - -PatternLines.displayName = "PatternLines"; - -// ─── Types ─────────────────────────────────────────────────────────────────── - -export interface FunnelGradientStop { - offset: string | number; - color: string; -} - -export interface FunnelStage { - label: string; - value: number; - displayValue?: string; - color?: string; - gradient?: FunnelGradientStop[]; -} - -export interface FunnelChartProps { - data: FunnelStage[]; - orientation?: "horizontal" | "vertical"; - color?: string; - layers?: number; - className?: string; - style?: CSSProperties; - showPercentage?: boolean; - showValues?: boolean; - showLabels?: boolean; - hoveredIndex?: number | null; - onHoverChange?: (index: number | null) => void; - formatPercentage?: (pct: number) => string; - formatValue?: (value: number) => string; - staggerDelay?: number; - gap?: number; - renderPattern?: (id: string, color: string) => ReactNode; - edges?: "curved" | "straight"; - labelLayout?: "spread" | "grouped"; - labelOrientation?: "vertical" | "horizontal"; - labelAlign?: "center" | "start" | "end"; - grid?: - | boolean - | { - bands?: boolean; - bandColor?: string; - lines?: boolean; - lineColor?: string; - lineOpacity?: number; - lineWidth?: number; - }; -} - -// ─── Defaults ──────────────────────────────────────────────────────────────── - -const fmtPct = (p: number) => `${Math.round(p)}%`; -const fmtVal = (v: number) => v.toLocaleString("en-US"); - -const springConfig = { stiffness: 120, damping: 20, mass: 1 }; -const hoverSpring = { stiffness: 300, damping: 24 }; - -// ─── SVG Helpers ───────────────────────────────────────────────────────────── - -function hSegmentPath( - normStart: number, - normEnd: number, - segW: number, - H: number, - layerScale: number, - straight = false -) { - const my = H / 2; - const h0 = normStart * H * 0.3 * layerScale; - const h1 = normEnd * H * 0.3 * layerScale; - - if (straight) { - return `M 0 ${my - h0} L ${segW} ${my - h1} L ${segW} ${my + h1} L 0 ${my + h0} Z`; - } - - const cx = segW * 0.55; - const top = `M 0 ${my - h0} C ${cx} ${my - h0}, ${segW - cx} ${my - h1}, ${segW} ${my - h1}`; - const bot = `L ${segW} ${my + h1} C ${segW - cx} ${my + h1}, ${cx} ${my + h0}, 0 ${my + h0}`; - return `${top} ${bot} Z`; -} - -function vSegmentPath( - normStart: number, - normEnd: number, - segH: number, - W: number, - layerScale: number, - straight = false -) { - const mx = W / 2; - const w0 = normStart * W * 0.44 * layerScale; - const w1 = normEnd * W * 0.44 * layerScale; - - if (straight) { - return `M ${mx - w0} 0 L ${mx - w1} ${segH} L ${mx + w1} ${segH} L ${mx + w0} 0 Z`; - } - - const cy = segH * 0.55; - const left = `M ${mx - w0} 0 C ${mx - w0} ${cy}, ${mx - w1} ${segH - cy}, ${mx - w1} ${segH}`; - const right = `L ${mx + w1} ${segH} C ${mx + w1} ${segH - cy}, ${mx + w0} ${cy}, ${mx + w0} 0`; - return `${left} ${right} Z`; -} - -// ─── Animated Ring ─────────────────────────────────────────────────────────── - -function HRing({ - d, - color, - fill, - opacity, - hovered, - ringIndex, - totalRings, -}: { - d: string; - color: string; - fill?: string; - opacity: number; - hovered: boolean; - ringIndex: number; - totalRings: number; -}) { - const extraScale = 1 + (ringIndex / Math.max(totalRings - 1, 1)) * 0.12; - const ringSpring = { - stiffness: 300 - ringIndex * 60, - damping: 24 - ringIndex * 3, - }; - const scaleY = useSpring(1, ringSpring); - const [hasMounted, setHasMounted] = useState(false); - - useEffect(() => { - scaleY.set(hovered ? extraScale : 1); - }, [hovered, scaleY, extraScale]); - - useEffect(() => { setHasMounted(true); }, []); - - return ( - - ); -} - -function VRing({ - d, - color, - fill, - opacity, - hovered, - ringIndex, - totalRings, -}: { - d: string; - color: string; - fill?: string; - opacity: number; - hovered: boolean; - ringIndex: number; - totalRings: number; -}) { - const extraScale = 1 + (ringIndex / Math.max(totalRings - 1, 1)) * 0.12; - const ringSpring = { - stiffness: 300 - ringIndex * 60, - damping: 24 - ringIndex * 3, - }; - const scaleX = useSpring(1, ringSpring); - const [hasMounted, setHasMounted] = useState(false); - - useEffect(() => { - scaleX.set(hovered ? extraScale : 1); - }, [hovered, scaleX, extraScale]); - - useEffect(() => { setHasMounted(true); }, []); - - return ( - - ); -} - -// ─── Animated Segments ─────────────────────────────────────────────────────── - -function HSegment({ - index, - normStart, - normEnd, - segW, - fullH, - color, - layers, - staggerDelay, - hovered, - dimmed, - renderPattern, - straight, - gradientStops, -}: { - index: number; - normStart: number; - normEnd: number; - segW: number; - fullH: number; - color: string; - layers: number; - staggerDelay: number; - hovered: boolean; - dimmed: boolean; - renderPattern?: (id: string, color: string) => ReactNode; - straight: boolean; - gradientStops?: FunnelGradientStop[]; -}) { - const patternId = `funnel-h-pattern-${index}`; - const gradientId = `funnel-h-grad-${index}`; - const growProgress = useSpring(0, springConfig); - const entranceScaleX = useTransform(growProgress, [0, 1], [0, 1]); - const entranceScaleY = useTransform(growProgress, [0, 1], [0, 1]); - const dimOpacity = useSpring(1, hoverSpring); - - useEffect(() => { - dimOpacity.set(dimmed ? 0.4 : 1); - }, [dimmed, dimOpacity]); - - useEffect(() => { - const timeout = setTimeout( - () => growProgress.set(1), - index * staggerDelay * 1000 - ); - return () => clearTimeout(timeout); - }, [growProgress, index, staggerDelay]); - - const rings = Array.from({ length: layers }, (_, l) => { - const scale = 1 - (l / layers) * 0.35; - const opacity = 0.18 + (l / (layers - 1 || 1)) * 0.65; - return { - d: hSegmentPath(normStart, normEnd, segW, fullH, scale, straight), - opacity, - }; - }); - - return ( - - - - - - ); -} - -function VSegment({ - index, - normStart, - normEnd, - segH, - fullW, - color, - layers, - staggerDelay, - hovered, - dimmed, - renderPattern, - straight, - gradientStops, -}: { - index: number; - normStart: number; - normEnd: number; - segH: number; - fullW: number; - color: string; - layers: number; - staggerDelay: number; - hovered: boolean; - dimmed: boolean; - renderPattern?: (id: string, color: string) => ReactNode; - straight: boolean; - gradientStops?: FunnelGradientStop[]; -}) { - const patternId = `funnel-v-pattern-${index}`; - const gradientId = `funnel-v-grad-${index}`; - const growProgress = useSpring(0, springConfig); - const entranceScaleY = useTransform(growProgress, [0, 1], [0, 1]); - const entranceScaleX = useTransform(growProgress, [0, 1], [0, 1]); - const dimOpacity = useSpring(1, hoverSpring); - - useEffect(() => { - dimOpacity.set(dimmed ? 0.4 : 1); - }, [dimmed, dimOpacity]); - - useEffect(() => { - const timeout = setTimeout( - () => growProgress.set(1), - index * staggerDelay * 1000 - ); - return () => clearTimeout(timeout); - }, [growProgress, index, staggerDelay]); - - const rings = Array.from({ length: layers }, (_, l) => { - const scale = 1 - (l / layers) * 0.35; - const opacity = 0.18 + (l / (layers - 1 || 1)) * 0.65; - return { - d: vSegmentPath(normStart, normEnd, segH, fullW, scale, straight), - opacity, - }; - }); - - return ( - - - - - - ); -} - -// ─── Label Overlay ─────────────────────────────────────────────────────────── - -function SegmentLabel({ - stage, - pct, - isHorizontal, - showValues, - showPercentage, - showLabels, - formatPercentage, - formatValue, - index, - staggerDelay, - layout = "spread", - orientation, - align = "center", -}: { - stage: FunnelStage; - pct: number; - isHorizontal: boolean; - showValues: boolean; - showPercentage: boolean; - showLabels: boolean; - formatPercentage: (p: number) => string; - formatValue: (v: number) => string; - index: number; - staggerDelay: number; - layout?: "spread" | "grouped"; - orientation?: "vertical" | "horizontal"; - align?: "center" | "start" | "end"; -}) { - const display = stage.displayValue ?? formatValue(stage.value); - - const valueEl = showValues && ( - - {display} - - ); - const pctEl = showPercentage && ( - - {formatPercentage(pct)} - - ); - const labelEl = showLabels && ( - - {stage.label} - - ); - - if (layout === "spread") { - return ( - - {isHorizontal ? ( - <> -
- {valueEl} -
-
- {pctEl} -
-
- {labelEl} -
- - ) : ( - <> -
- {valueEl} -
-
- {pctEl} -
-
- {labelEl} -
- - )} -
- ); - } - - // Grouped layout - const resolvedOrientation = - orientation ?? (isHorizontal ? "vertical" : "horizontal"); - const isVerticalStack = resolvedOrientation === "vertical"; - - const justifyMap = { - start: "justify-start", - center: "justify-center", - end: "justify-end", - } as const; - const itemsMap = { - start: "items-start", - center: "items-center", - end: "items-end", - } as const; - - return ( - -
- {valueEl} - {pctEl} - {labelEl} -
-
- ); -} - -// ─── FunnelChart ───────────────────────────────────────────────────────────── - -export function FunnelChart({ - data, - orientation = "horizontal", - color = "var(--chart-1)", - layers = 3, - className, - style, - showPercentage = true, - showValues = true, - showLabels = true, - hoveredIndex: hoveredIndexProp, - onHoverChange, - formatPercentage = fmtPct, - formatValue = fmtVal, - staggerDelay = 0.12, - gap = 4, - renderPattern, - edges = "curved", - labelLayout = "spread", - labelOrientation, - labelAlign = "center", - grid: gridProp = false, -}: FunnelChartProps) { - const ref = useRef(null); - const [sz, setSz] = useState({ w: 0, h: 0 }); - const [internalHoveredIndex, setInternalHoveredIndex] = useState< - number | null - >(null); - - const isControlled = hoveredIndexProp !== undefined; - const hoveredIndex = isControlled ? hoveredIndexProp : internalHoveredIndex; - const setHoveredIndex = useCallback( - (index: number | null) => { - if (isControlled) { - onHoverChange?.(index); - } else { - setInternalHoveredIndex(index); - } - }, - [isControlled, onHoverChange] - ); - - const measure = useCallback(() => { - if (!ref.current) return; - const { width: w, height: h } = ref.current.getBoundingClientRect(); - if (w > 0 && h > 0) setSz({ w, h }); - }, []); - - useEffect(() => { - measure(); - const ro = new ResizeObserver(measure); - if (ref.current) ro.observe(ref.current); - return () => ro.disconnect(); - }, [measure]); - - if (!data.length) return null; - - const first = data[0]; - if (!first) return null; - - const max = first.value; - const n = data.length; - const norms = data.map((d) => d.value / max); - const horiz = orientation === "horizontal"; - const { w: W, h: H } = sz; - - const totalGap = gap * (n - 1); - const segW = (W - (horiz ? totalGap : 0)) / n; - const segH = (H - (horiz ? 0 : totalGap)) / n; - - // Grid config - const gridEnabled = gridProp !== false; - const gridCfg = typeof gridProp === "object" ? gridProp : {}; - const showBands = gridEnabled && (gridCfg.bands ?? true); - const bandColor = gridCfg.bandColor ?? "var(--color-muted)"; - const showGridLines = gridEnabled && (gridCfg.lines ?? true); - const gridLineColor = gridCfg.lineColor ?? "var(--chart-grid)"; - const gridLineOpacity = gridCfg.lineOpacity ?? 1; - const gridLineWidth = gridCfg.lineWidth ?? 1; - - return ( -
- {W > 0 && H > 0 && ( - <> - {/* Grid background bands */} - {gridEnabled && ( - - )} - - {/* Segments */} -
- {data.map((stage, i) => { - const normStart = norms[i] ?? 0; - const normEnd = norms[Math.min(i + 1, n - 1)] ?? 0; - const firstStop = stage.gradient?.[0]; - const segColor = firstStop - ? firstStop.color - : (stage.color ?? color); - - return horiz ? ( - - ) : ( - - ); - })} -
- - {/* Grid lines */} - {gridEnabled && showGridLines && ( - - )} - - {/* Label overlays — hover triggers */} - {data.map((stage, i) => { - const pct = (stage.value / max) * 100; - const posStyle: CSSProperties = horiz - ? { left: (segW + gap) * i, width: segW, top: 0, height: H } - : { top: (segH + gap) * i, height: segH, left: 0, width: W }; - const isDimmed = hoveredIndex !== null && hoveredIndex !== i; - - return ( - setHoveredIndex(i)} - onMouseLeave={() => setHoveredIndex(null)} - style={{ ...posStyle, zIndex: 20 }} - transition={SPRING} - > - - - ); - })} - - )} -
- ); -} - -FunnelChart.displayName = "FunnelChart"; - -export default FunnelChart; diff --git a/components/ui/line-chart.tsx b/components/ui/line-chart.tsx deleted file mode 100644 index 7fdd37118..000000000 --- a/components/ui/line-chart.tsx +++ /dev/null @@ -1,1027 +0,0 @@ -"use client"; - -import { curveMonotoneX } from "d3-shape"; -import { scaleLinear, scalePoint } from "d3-scale"; -import { localPoint, LinePath, GridRows, ParentSize } from "@/lib/charts/primitives"; -import { - AnimatePresence, - motion, - useSpring, -} from "framer-motion"; -import { - Children, - createContext, - isValidElement, - useCallback, - useContext, - useEffect, - useId, - useLayoutEffect, - useMemo, - useRef, - useState, - type ReactNode, - type RefObject, -} from "react"; -import { createPortal } from "react-dom"; -import useMeasure from "react-use-measure"; -import { type ClassValue, clsx } from "clsx"; -import { twMerge } from "tailwind-merge"; -import { type CurveFactory } from "d3-shape"; -import { - SPRING, - TIMING, - EASE_APPLE, - DURATION_FAST, - DURATION_SLOW, -} from "@/lib/motion"; - -// ─── Utils ──────────────────────────────────────────────────────────────────── - -function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)); -} - -// ─── CSS Vars ───────────────────────────────────────────────────────────────── - -const chartCssVars = { - background: "var(--chart-background)", - grid: "var(--chart-grid)", - crosshair: "var(--chart-crosshair)", - label: "var(--chart-label)", -}; - -const chartColors = [ - "var(--chart-1)", - "var(--chart-2)", - "var(--chart-3)", - "var(--chart-4)", - "var(--chart-5)", -]; - -// ─── Types ──────────────────────────────────────────────────────────────────── - -export interface Margin { - top: number; - right: number; - bottom: number; - left: number; -} - -interface LineConfig { - dataKey: string; - stroke: string; - strokeWidth: number; - strokeDasharray?: string; - showDots: boolean; - curve: CurveFactory; -} - -interface TooltipState { - point: Record; - index: number; - x: number; - yPositions: Record; -} - -// ─── Context ────────────────────────────────────────────────────────────────── - -interface LineChartContextValue { - data: Record[]; - xScale: ReturnType>; - yScale: ReturnType>; - width: number; - height: number; - innerWidth: number; - innerHeight: number; - margin: Margin; - xDataKey: string; - lines: LineConfig[]; - tooltipData: TooltipState | null; - containerRef: RefObject; - isLoaded: boolean; -} - -const LineChartContext = createContext(null); - -export function useLineChart(): LineChartContextValue { - const ctx = useContext(LineChartContext); - if (!ctx) { - throw new Error("useLineChart must be used within a "); - } - return ctx; -} - -// ─── Tooltip internals ──────────────────────────────────────────────────────── - -interface TooltipContentProps { - title?: string; - rows: { color: string; label: string; value: string | number }[]; - children?: ReactNode; -} - -function TooltipContent({ title, rows, children }: TooltipContentProps) { - const [measureRef, bounds] = useMeasure({ debounce: 0, scroll: false }); - const [committedHeight, setCommittedHeight] = useState(null); - const hasChildren = !!children; - - useEffect(() => { - if (bounds.height > 0) setCommittedHeight(bounds.height); - }, [bounds.height]); - - return ( - -
- {title && ( -
{title}
- )} -
- {rows.map((row) => ( -
-
- - {row.label} -
- - {typeof row.value === "number" - ? row.value.toLocaleString() - : row.value} - -
- ))} -
- - {hasChildren && ( - - {children} - - )} - -
-
- ); -} - -TooltipContent.displayName = "TooltipContent"; - -interface TooltipBoxProps { - x: number; - y: number; - visible: boolean; - containerRef: RefObject; - containerWidth: number; - containerHeight: number; - offset?: number; - className?: string; - children: ReactNode; -} - -function TooltipBox({ - x, - y, - visible, - containerRef, - containerWidth, - containerHeight, - offset = 16, - className = "", - children, -}: TooltipBoxProps) { - const tooltipRef = useRef(null); - const [tooltipWidth, setTooltipWidth] = useState(180); - const [tooltipHeight, setTooltipHeight] = useState(80); - const [mounted, setMounted] = useState(false); - - useEffect(() => { - setMounted(true); - }, []); - - useLayoutEffect(() => { - if (tooltipRef.current) { - const w = tooltipRef.current.offsetWidth; - const h = tooltipRef.current.offsetHeight; - if (w > 0 && w !== tooltipWidth) setTooltipWidth(w); - if (h > 0 && h !== tooltipHeight) setTooltipHeight(h); - } - }, [tooltipWidth, tooltipHeight]); - - const shouldFlipX = x + tooltipWidth + offset > containerWidth; - const targetX = shouldFlipX ? x - offset - tooltipWidth : x + offset; - const targetY = Math.max( - offset, - Math.min(y - tooltipHeight / 2, containerHeight - tooltipHeight - offset) - ); - - const prevFlipRef = useRef(shouldFlipX); - const [flipKey, setFlipKey] = useState(0); - useEffect(() => { - if (prevFlipRef.current !== shouldFlipX) { - setFlipKey((k) => k + 1); - prevFlipRef.current = shouldFlipX; - } - }, [shouldFlipX]); - - const springConfig = { stiffness: 100, damping: 20 }; - const animatedLeft = useSpring(targetX, springConfig); - const animatedTop = useSpring(targetY, springConfig); - - useEffect(() => { - animatedLeft.set(targetX); - }, [targetX, animatedLeft]); - useEffect(() => { - animatedTop.set(targetY); - }, [targetY, animatedTop]); - - const transformOrigin = shouldFlipX ? "right top" : "left top"; - const container = containerRef.current; - if (!(mounted && container && visible)) return null; - - return createPortal( - - - {children} - - , - container - ); -} - -TooltipBox.displayName = "TooltipBox"; - -// ─── Crosshair ──────────────────────────────────────────────────────────────── - -function Crosshair({ x, height }: { x: number; height: number }) { - const uniqueId = useId(); - const gradientId = `line-crosshair-${uniqueId}`; - const springConfig = { stiffness: 300, damping: 30 }; - const animatedX = useSpring(x, springConfig); - useEffect(() => { - animatedX.set(x); - }, [x, animatedX]); - - return ( - - - - - - - - - - - - ); -} - -// ─── HoverDot ───────────────────────────────────────────────────────────────── - -function HoverDot({ - x, - y, - color, -}: { - x: number; - y: number; - color: string; -}) { - const springConfig = { stiffness: 300, damping: 30 }; - const animX = useSpring(x, springConfig); - const animY = useSpring(y, springConfig); - useEffect(() => { - animX.set(x); - }, [x, animX]); - useEffect(() => { - animY.set(y); - }, [y, animY]); - - return ( - - ); -} - -// ─── ChartTooltip ───────────────────────────────────────────────────────────── - -export interface ChartTooltipProps { - showCrosshair?: boolean; - showDots?: boolean; - content?: (props: { - point: Record; - index: number; - }) => ReactNode; - rows?: (point: Record) => { - color: string; - label: string; - value: string | number; - }[]; - children?: ReactNode; - className?: string; -} - -export function ChartTooltip({ - showCrosshair = true, - showDots = true, - content, - rows: rowsRenderer, - children, - className = "", -}: ChartTooltipProps) { - const { - tooltipData, - width, - height, - margin, - innerHeight, - lines, - xDataKey, - containerRef, - } = useLineChart(); - - const [mounted, setMounted] = useState(false); - useEffect(() => { - setMounted(true); - }, []); - - const visible = tooltipData !== null; - const x = tooltipData?.x ?? 0; - const xWithMargin = x + margin.left; - - const tooltipRows = useMemo(() => { - if (!tooltipData) return []; - if (rowsRenderer) return rowsRenderer(tooltipData.point); - return lines.map((line) => ({ - color: line.stroke, - label: line.dataKey, - value: (tooltipData.point[line.dataKey] as number) ?? 0, - })); - }, [tooltipData, lines, rowsRenderer]); - - const title = useMemo(() => { - if (!tooltipData) return undefined; - return String(tooltipData.point[xDataKey] ?? ""); - }, [tooltipData, xDataKey]); - - const container = containerRef.current; - if (!(mounted && container)) return null; - - const firstLineY = lines[0] - ? (tooltipData?.yPositions[lines[0].dataKey] ?? 0) - : 0; - - return createPortal( - <> - {showCrosshair && visible && ( - - )} - {showDots && visible && ( - - )} - - {content ? ( - content({ - point: tooltipData?.point ?? {}, - index: tooltipData?.index ?? 0, - }) - ) : ( - - {children} - - )} - - , - container - ); -} - -ChartTooltip.displayName = "ChartTooltip"; - -// ─── Grid ───────────────────────────────────────────────────────────────────── - -export interface GridProps { - numTicks?: number; - tickValues?: number[]; - stroke?: string; - strokeDasharray?: string; - strokeOpacity?: number; - strokeWidth?: number; - fade?: boolean; -} - -export function Grid({ - numTicks = 5, - tickValues, - stroke = chartCssVars.grid, - strokeDasharray = "4,4", - strokeOpacity = 1, - strokeWidth = 1, - fade = true, -}: GridProps) { - const { yScale, innerWidth, innerHeight } = useLineChart(); - const uniqueId = useId(); - const maskId = `line-grid-mask-${uniqueId}`; - const gradientId = `line-grid-grad-${uniqueId}`; - - return ( - - {fade && ( - - - - - - - - - - - - )} - - - - - ); -} - -Grid.displayName = "Grid"; - -// ─── XAxis ──────────────────────────────────────────────────────────────────── - -export interface XAxisProps { - maxLabels?: number; - formatLabel?: (label: string) => string; -} - -export function XAxis({ maxLabels = 12, formatLabel }: XAxisProps) { - const { xScale, margin, containerRef, tooltipData } = useLineChart(); - const [mounted, setMounted] = useState(false); - useEffect(() => { - setMounted(true); - }, []); - - const labels = useMemo(() => { - const domain = xScale.domain(); - let items = domain.map((label) => ({ - label, - x: (xScale(label) ?? 0) + margin.left, - })); - if (items.length > maxLabels) { - const step = Math.ceil(items.length / maxLabels); - items = items.filter((_, i) => i % step === 0); - } - return items; - }, [xScale, margin.left, maxLabels]); - - const crosshairX = tooltipData ? tooltipData.x + margin.left : null; - const container = containerRef.current; - if (!(mounted && container)) return null; - - return createPortal( -
- {labels.map((item) => { - let opacity = 1; - if (crosshairX !== null) { - const dist = Math.abs(item.x - crosshairX); - if (dist < 30) opacity = 0; - else if (dist < 60) opacity = (dist - 30) / 30; - } - return ( -
- - {formatLabel ? formatLabel(item.label) : item.label} - -
- ); - })} -
, - container - ); -} - -XAxis.displayName = "XAxis"; - -// ─── YAxis ──────────────────────────────────────────────────────────────────── - -export interface YAxisProps { - numTicks?: number; - formatValue?: (value: number) => string; -} - -export function YAxis({ numTicks = 5, formatValue }: YAxisProps) { - const { yScale, margin, containerRef } = useLineChart(); - const [container, setContainer] = useState(null); - useEffect(() => { - setContainer(containerRef.current); - }, [containerRef]); - - const ticks = useMemo(() => { - const [min, max] = yScale.domain() as [number, number]; - const step = (max - min) / (numTicks - 1); - return Array.from({ length: numTicks }, (_, i) => { - const value = min + step * i; - const label = formatValue - ? formatValue(value) - : value >= 1000 - ? `${(value / 1000).toFixed(value % 1000 === 0 ? 0 : 1)}k` - : Math.round(value).toLocaleString(); - return { value, y: (yScale(value) ?? 0) + margin.top, label }; - }); - }, [yScale, margin.top, numTicks, formatValue]); - - if (!container) return null; - - return createPortal( -
- {ticks.map((tick) => ( -
- - {tick.label} - -
- ))} -
, - container - ); -} - -YAxis.displayName = "YAxis"; - -// ─── Line ───────────────────────────────────────────────────────────────────── - -export interface LineProps { - dataKey: string; - stroke?: string; - strokeWidth?: number; - strokeDasharray?: string; - showDots?: boolean; - curve?: CurveFactory; - colorIndex?: number; -} - -export function Line({ - dataKey, - stroke, - strokeWidth = 2, - strokeDasharray, - showDots = false, - curve = curveMonotoneX, - colorIndex = 0, -}: LineProps) { - const { data, xScale, yScale, xDataKey, innerHeight, isLoaded } = - useLineChart(); - const resolvedStroke = stroke ?? chartColors[colorIndex % chartColors.length] ?? "var(--chart-1)"; - - // Entrance animation via stroke-dashoffset - const pathRef = useRef(null); - const [pathLength, setPathLength] = useState(0); - - useEffect(() => { - if (pathRef.current) { - const len = pathRef.current.getTotalLength(); - if (len > 0) setPathLength(len); - } - }); - - const points = useMemo( - () => - data - .map((d) => { - const xVal = String(d[xDataKey] ?? ""); - const yVal = typeof d[dataKey] === "number" ? (d[dataKey] as number) : null; - if (yVal === null) return null; - const xPos = xScale(xVal); - const yPos = yScale(yVal); - if (xPos === undefined || yPos === undefined) return null; - return { x: xPos, y: yPos }; - }) - .filter((p): p is { x: number; y: number } => p !== null), - [data, dataKey, xDataKey, xScale, yScale] - ); - - if (points.length < 2) return null; - - return ( - - {/* The actual line — rendered first so we can measure totalLength */} - p !== null} - innerRef={pathRef} - stroke={resolvedStroke} - strokeDasharray={strokeDasharray} - strokeLinecap="round" - strokeLinejoin="round" - strokeWidth={strokeWidth} - x={(p) => p.x} - y={(p) => p.y} - style={ - pathLength > 0 - ? { - strokeDasharray: pathLength, - strokeDashoffset: isLoaded ? 0 : pathLength, - transition: isLoaded - ? "stroke-dashoffset 0.9s cubic-bezier(0.16, 1, 0.3, 1)" - : "none", - } - : undefined - } - fill="none" - /> - - {/* Optional static dots at each data point */} - {showDots && - points.map((p, i) => ( - - ))} - - ); -} - -Line.displayName = "Line"; - -// ─── extractLineConfigs ─────────────────────────────────────────────────────── - -function extractLineConfigs(children: ReactNode): LineConfig[] { - const configs: LineConfig[] = []; - let colorIndex = 0; - Children.forEach(children, (child) => { - if (!isValidElement(child)) return; - const type = child.type as { displayName?: string }; - if (type.displayName !== "Line") return; - const p = child.props as LineProps; - if (!p.dataKey) return; - configs.push({ - dataKey: p.dataKey, - stroke: - p.stroke ?? - chartColors[colorIndex % chartColors.length] ?? - "var(--chart-1)", - strokeWidth: p.strokeWidth ?? 2, - strokeDasharray: p.strokeDasharray, - showDots: p.showDots ?? false, - curve: p.curve ?? curveMonotoneX, - }); - colorIndex++; - }); - return configs; -} - -// ─── LineChartInner ─────────────────────────────────────────────────────────── - -interface LineChartInnerProps { - width: number; - height: number; - data: Record[]; - xDataKey: string; - margin: Margin; - containerRef: RefObject; - children: ReactNode; -} - -function LineChartInner({ - width, - height, - data, - xDataKey, - margin, - containerRef, - children, -}: LineChartInnerProps) { - const [isLoaded, setIsLoaded] = useState(false); - const [tooltipData, setTooltipData] = useState(null); - - const innerWidth = width - margin.left - margin.right; - const innerHeight = height - margin.top - margin.bottom; - - const lines = useMemo(() => extractLineConfigs(children), [children]); - - const xScale = useMemo(() => { - const domain = data.map((d) => String(d[xDataKey] ?? "")); - return scalePoint() - .range([0, innerWidth]) - .domain(domain) - .padding(0.5); - }, [data, xDataKey, innerWidth]); - - const yScale = useMemo(() => { - let min = Infinity; - let max = -Infinity; - for (const line of lines) { - for (const d of data) { - const v = d[line.dataKey]; - if (typeof v === "number") { - if (v < min) min = v; - if (v > max) max = v; - } - } - } - if (!isFinite(min) || !isFinite(max)) { - min = 0; - max = 100; - } - const padding = (max - min) * 0.1 || 10; - return scaleLinear() - .range([innerHeight, 0]) - .domain([Math.max(0, min - padding), max + padding]) - .nice(); - }, [data, lines, innerHeight]); - - // Trigger entrance animation on mount - useEffect(() => { - const timer = setTimeout(() => setIsLoaded(true), 50); - return () => clearTimeout(timer); - }, []); - - const handleMouseMove = useCallback( - (event: React.MouseEvent) => { - const point = localPoint(event); - if (!point) return; - const chartX = point.x - margin.left; - - // Snap to nearest domain point - const domain = xScale.domain(); - let nearest = domain[0] ?? ""; - let nearestDist = Infinity; - for (const label of domain) { - const px = xScale(label) ?? 0; - const dist = Math.abs(px - chartX); - if (dist < nearestDist) { - nearestDist = dist; - nearest = label; - } - } - const idx = domain.indexOf(nearest); - if (idx < 0) return; - const d = data[idx]; - if (!d) return; - - const snapX = xScale(nearest) ?? 0; - const yPositions: Record = {}; - for (const line of lines) { - const v = d[line.dataKey]; - if (typeof v === "number") yPositions[line.dataKey] = yScale(v) ?? 0; - } - setTooltipData({ point: d, index: idx, x: snapX, yPositions }); - }, - [xScale, yScale, data, lines, margin] - ); - - const handleMouseLeave = useCallback(() => { - setTooltipData(null); - }, []); - - if (width < 10 || height < 10) return null; - - const contextValue: LineChartContextValue = { - data, - xScale, - yScale, - width, - height, - innerWidth, - innerHeight, - margin, - xDataKey, - lines, - tooltipData, - containerRef, - isLoaded, - }; - - return ( - - - - ); -} - -// ─── LineChart ──────────────────────────────────────────────────────────────── - -export interface LineChartProps { - data: Record[]; - xDataKey?: string; - margin?: Partial; - className?: string; - aspectRatio?: string; - children: ReactNode; -} - -const DEFAULT_MARGIN: Margin = { top: 20, right: 24, bottom: 40, left: 48 }; - -export function LineChart({ - data, - xDataKey = "date", - margin: marginProp, - className = "", - aspectRatio = "2 / 1", - children, -}: LineChartProps) { - const containerRef = useRef(null); - const margin = { ...DEFAULT_MARGIN, ...marginProp }; - - if (!data || data.length === 0) { - return ( -
- No data -
- ); - } - - return ( -
- - {({ width, height }) => ( - - {children} - - )} - -
- ); -} - -LineChart.displayName = "LineChart"; -export default LineChart; diff --git a/components/ui/segmented.tsx b/components/ui/segmented.tsx new file mode 100644 index 000000000..6e799c7c2 --- /dev/null +++ b/components/ui/segmented.tsx @@ -0,0 +1,70 @@ +'use client' + +import * as React from 'react' +import { cn } from '@/lib/utils' + +export interface SegmentedOption { + value: T + label: string + icon?: React.ReactNode +} + +interface SegmentedProps { + value: T + onChange: (value: T) => void + options: SegmentedOption[] + ariaLabel: string + className?: string +} + +/** + * h-10 segmented control — sharp, neutral (active cell = raised neutral, not + * orange). Roving tabindex: the active cell is the tab stop; ←/→ move and + * select. + */ +export function Segmented({ value, onChange, options, ariaLabel, className }: SegmentedProps) { + const refs = React.useRef<(HTMLButtonElement | null)[]>([]) + + const onKeyDown = (e: React.KeyboardEvent, index: number) => { + let target: number | null = null + if (e.key === 'ArrowRight') target = (index + 1) % options.length + if (e.key === 'ArrowLeft') target = (index - 1 + options.length) % options.length + if (target === null) return + e.preventDefault() + onChange(options[target].value) + refs.current[target]?.focus() + } + + return ( +
+ {options.map((opt, i) => { + const active = opt.value === value + return ( + + ) + })} +
+ ) +} diff --git a/components/ui/select.tsx b/components/ui/select.tsx index bff3e9e91..9916299eb 100644 --- a/components/ui/select.tsx +++ b/components/ui/select.tsx @@ -1,6 +1,7 @@ 'use client' -import { useState, useRef, useEffect, useCallback } from 'react' +import { useState, useRef, useEffect, useCallback, useLayoutEffect } from 'react' +import { createPortal } from 'react-dom' import { motion, AnimatePresence } from 'framer-motion' import { cn } from '@ciphera-net/facet' @@ -44,17 +45,8 @@ export default function Select({ const [isOpen, setIsOpen] = useState(false) const [highlightedIndex, setHighlightedIndex] = useState(-1) const ref = useRef(null) - const listRef = useRef(null) - - useEffect(() => { - function handleClickOutside(event: MouseEvent) { - if (ref.current && !ref.current.contains(event.target as Node)) { - setIsOpen(false) - } - } - document.addEventListener('mousedown', handleClickOutside) - return () => document.removeEventListener('mousedown', handleClickOutside) - }, []) + const triggerRef = useRef(null) + const panelRef = useRef(null) const selectableOptions = options.filter((o) => !o.divider) @@ -111,8 +103,8 @@ export default function Select({ // Scroll highlighted option into view useEffect(() => { - if (isOpen && listRef.current && highlightedIndex >= 0) { - const items = listRef.current.querySelectorAll('[role="option"]') + if (isOpen && panelRef.current && highlightedIndex >= 0) { + const items = panelRef.current.querySelectorAll('[role="option"]') items[highlightedIndex]?.scrollIntoView({ block: 'nearest' }) } }, [highlightedIndex, isOpen]) @@ -123,9 +115,9 @@ export default function Select({ const triggerBase = variant === 'input' ? cn( - 'min-w-[140px] px-3.5 h-11 border border-input rounded-none bg-transparent text-foreground text-left text-sm', - 'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring transition-colors', - isOpen && 'ring-1 ring-ring border-ring' + 'min-w-0 px-3 h-10 border border-input rounded-none bg-transparent text-foreground text-left text-sm', + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange transition-colors', + isOpen && 'ring-2 ring-brand-orange border-brand-orange' ) : variant === 'ghost' ? cn( @@ -139,12 +131,54 @@ export default function Select({ ) const triggerLayout = 'w-full ' - const alignClass = align === 'left' ? 'left-0' : 'right-0' - const panelMinW = fullWidth ? 'w-full' : 'min-w-[140px] w-full' - const highlightedId = highlightedIndex >= 0 ? `select-option-${options[highlightedIndex]?.value}` : undefined - const triggerRef = useRef(null) + // * Dropdown is portalled to the body with fixed positioning so it never + // * clips inside a scrollable ancestor (dialogs, cards). Position is measured + // * from the trigger and kept in sync while open. + const [coords, setCoords] = useState<{ left: number; width: number; top?: number; bottom?: number; maxH: number }>() + + const measure = useCallback(() => { + const t = triggerRef.current?.getBoundingClientRect() + if (!t) return + const gap = 4 + const spaceBelow = window.innerHeight - t.bottom + const spaceAbove = t.top + const openUp = spaceBelow < 240 && spaceAbove > spaceBelow + const maxH = Math.min(448, (openUp ? spaceAbove : spaceBelow) - gap - 8) + setCoords({ + left: t.left, + width: t.width, + maxH, + ...(openUp ? { bottom: window.innerHeight - t.top + gap } : { top: t.bottom + gap }), + }) + }, []) + + useLayoutEffect(() => { + if (isOpen) measure() + }, [isOpen, measure]) + + useEffect(() => { + if (!isOpen) return + const onScrollOrResize = () => measure() + window.addEventListener('scroll', onScrollOrResize, true) + window.addEventListener('resize', onScrollOrResize) + return () => { + window.removeEventListener('scroll', onScrollOrResize, true) + window.removeEventListener('resize', onScrollOrResize) + } + }, [isOpen, measure]) + + // * Outside-click must ignore the portalled panel, not just the trigger. + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + const target = event.target as Node + if (ref.current?.contains(target) || panelRef.current?.contains(target)) return + setIsOpen(false) + } + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, []) return (
@@ -160,7 +194,7 @@ export default function Select({ aria-expanded={isOpen} aria-activedescendant={isOpen ? highlightedId : undefined} > - + {displayLabel} - - {isOpen && ( - { - const r = triggerRef.current!.getBoundingClientRect() - return { right: window.innerWidth - r.right, top: r.bottom + 4 } - })() : undefined} - role="listbox" - > - {options.map((option, index) => option.divider ? ( -
- ) : ( - - ))} - + {options.map((option, index) => option.divider ? ( +
+ ) : ( + + ))} + + )} + , + document.body, )} -
) } diff --git a/components/ui/slider.tsx b/components/ui/slider.tsx index 68e54a191..19b456b4f 100644 --- a/components/ui/slider.tsx +++ b/components/ui/slider.tsx @@ -58,7 +58,10 @@ const Slider = React.forwardRef< const renderThumb = (value: number) => { const thumb = ( ); diff --git a/components/ui/stepper-control.tsx b/components/ui/stepper-control.tsx new file mode 100644 index 000000000..fb1ec8995 --- /dev/null +++ b/components/ui/stepper-control.tsx @@ -0,0 +1,154 @@ +'use client' + +import * as React from 'react' +import { motion, AnimatePresence } from 'framer-motion' +import { Minus, Plus } from '@phosphor-icons/react' +import { DURATION_FAST, EASE_APPLE } from '@/lib/motion' +import { cn } from '@/lib/utils' + +/** Clamp value + delta·step onto the [min, max] grid anchored at min. */ +export function clampStep(value: number, delta: number, min: number, max: number, step: number): number { + const next = value + delta * step + const snapped = Math.round((next - min) / step) * step + min + if (snapped < min) return min + if (snapped > max) return max + return snapped +} + +interface StepperControlProps { + label: string + value: number + min: number + max: number + step?: number + onChange: (value: number) => void + format?: (value: number) => string + className?: string +} + +const HOLD_DELAY_MS = 400 +const HOLD_INTERVAL_MS = 80 + +/** + * h-10 labeled spinbutton — the toolbar-row replacement for sliders over + * small discrete ranges. Neutral chrome (label mono 12px sentence case, + * white tabular value); no orange. Keyboard: arrows step, Home/End jump. + */ +export function StepperControl({ label, value, min, max, step = 1, onChange, format, className }: StepperControlProps) { + const holdTimer = React.useRef | null>(null) + const holdInterval = React.useRef | null>(null) + // * Refs so a held button keeps stepping against the latest value + const valueRef = React.useRef(value) + valueRef.current = value + + const change = React.useCallback( + (delta: number) => { + const next = clampStep(valueRef.current, delta, min, max, step) + if (next !== valueRef.current) onChange(next) + }, + [min, max, step, onChange], + ) + + const stopHold = React.useCallback(() => { + if (holdTimer.current) clearTimeout(holdTimer.current) + if (holdInterval.current) clearInterval(holdInterval.current) + holdTimer.current = null + holdInterval.current = null + }, []) + + const startHold = React.useCallback( + (delta: number) => { + change(delta) + holdTimer.current = setTimeout(() => { + holdInterval.current = setInterval(() => change(delta), HOLD_INTERVAL_MS) + }, HOLD_DELAY_MS) + }, + [change], + ) + + React.useEffect(() => stopHold, [stopHold]) + + const onKeyDown = (e: React.KeyboardEvent) => { + switch (e.key) { + case 'ArrowUp': + case 'ArrowRight': + e.preventDefault() + change(1) + break + case 'ArrowDown': + case 'ArrowLeft': + e.preventDefault() + change(-1) + break + case 'Home': + e.preventDefault() + if (value !== min) onChange(min) + break + case 'End': + e.preventDefault() + if (value !== max) onChange(max) + break + } + } + + const display = format ? format(value) : String(value) + + return ( +
+ {label} + + + + + {display} + + + + +
+ ) +} diff --git a/components/ui/tooltip.tsx b/components/ui/tooltip.tsx index e3de54728..50584e66f 100644 --- a/components/ui/tooltip.tsx +++ b/components/ui/tooltip.tsx @@ -22,7 +22,7 @@ const TooltipContent = React.forwardRef< ref={ref} sideOffset={sideOffset} className={cn( - "relative z-50 max-w-[280px] rounded-none border border-border bg-popover px-3 py-1.5 text-sm text-popover-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + "relative z-50 max-w-[280px] rounded-none border border-border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-lg animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", className, )} {...props} diff --git a/lib/api/journeys.ts b/lib/api/journeys.ts index 1746877c1..a1d369e95 100644 --- a/lib/api/journeys.ts +++ b/lib/api/journeys.ts @@ -27,6 +27,7 @@ function buildQuery(opts: { depth?: number min_sessions?: number entry_path?: string + filters?: string }): string { const params = new URLSearchParams() if (opts.startDate) params.append('start_date', opts.startDate) @@ -34,6 +35,7 @@ function buildQuery(opts: { if (opts.depth != null) params.append('depth', opts.depth.toString()) if (opts.min_sessions != null) params.append('min_sessions', opts.min_sessions.toString()) if (opts.entry_path) params.append('entry_path', opts.entry_path) + if (opts.filters) params.append('filters', opts.filters) const query = params.toString() return query ? `?${query}` : '' } @@ -44,7 +46,7 @@ export function getJourneyTransitions( siteId: string, startDate?: string, endDate?: string, - opts?: { depth?: number; minSessions?: number; entryPath?: string } + opts?: { depth?: number; minSessions?: number; entryPath?: string; filters?: string } ): Promise { return apiRequest( `/sites/${siteId}/journeys/transitions${buildQuery({ @@ -53,6 +55,7 @@ export function getJourneyTransitions( depth: opts?.depth, min_sessions: opts?.minSessions, entry_path: opts?.entryPath, + filters: opts?.filters, })}` ).then(r => r ?? { transitions: [], total_sessions: 0 }) } @@ -60,9 +63,10 @@ export function getJourneyTransitions( export function getJourneyEntryPoints( siteId: string, startDate?: string, - endDate?: string + endDate?: string, + filters?: string ): Promise { return apiRequest<{ entry_points: EntryPoint[] }>( - `/sites/${siteId}/journeys/entry-points${buildQuery({ startDate, endDate })}` + `/sites/${siteId}/journeys/entry-points${buildQuery({ startDate, endDate, filters })}` ).then(r => r?.entry_points ?? []) } diff --git a/lib/api/stats.ts b/lib/api/stats.ts index b63ba6366..8c4eb8ee3 100644 --- a/lib/api/stats.ts +++ b/lib/api/stats.ts @@ -543,3 +543,16 @@ export function getFrustrationByPage(siteId: string, startDate?: string, endDate return apiRequest<{ pages: FrustrationByPage[] }>(`/sites/${siteId}/frustration/by-page${buildQuery({ startDate, endDate, limit })}`) .then(r => r?.pages ?? []) } + +export interface FrustrationDailyPoint { + date: string + rage_clicks: number + dead_clicks: number +} + +// * Daily rage/dead series for the behavior trend. Backend zero-fills every day +// * in range (mirrors the funnel-trends convention) so the series is dense. +export function getFrustrationDaily(siteId: string, startDate?: string, endDate?: string): Promise<{ days: FrustrationDailyPoint[] }> { + return apiRequest<{ days: FrustrationDailyPoint[] }>(`/sites/${siteId}/frustration/daily${buildQuery({ startDate, endDate })}`) + .then(r => r ?? { days: [] }) +} diff --git a/lib/filters.ts b/lib/filters.ts index f8fd6e7a2..55ae1d7c1 100644 --- a/lib/filters.ts +++ b/lib/filters.ts @@ -6,6 +6,13 @@ export interface DimensionFilter { values: string[] } +/** A suggested value for a dimension, sourced from the breakdown endpoints. */ +export interface FilterSuggestion { + value: string + label: string + count?: number +} + export const DIMENSION_LABELS: Record = { page: 'Page', referrer: 'Referrer', diff --git a/lib/hooks/__tests__/useJourneyFilters.test.tsx b/lib/hooks/__tests__/useJourneyFilters.test.tsx index 23d93b98d..a599421e3 100644 --- a/lib/hooks/__tests__/useJourneyFilters.test.tsx +++ b/lib/hooks/__tests__/useJourneyFilters.test.tsx @@ -34,6 +34,7 @@ describe('useJourneyFilters', () => { expect(result.current.depth).toBe(4) expect(result.current.density).toBe(20) expect(result.current.entryPath).toBe('') + expect(result.current.lens).toBeNull() expect(result.current.viewMode).toBe('columns') expect(result.current.period).toBe('30') expect(result.current.isDefault).toBe(true) @@ -104,7 +105,7 @@ describe('useJourneyFilters', () => { }) it('resetFilters clears all filter params', () => { - mockSearchParams = new URLSearchParams('depth=6&density=30&entry=%2F&view=flow') + mockSearchParams = new URLSearchParams('depth=6&density=30&entry=%2F&lens=%2Flogin&view=flow') const { result } = renderHook(() => useJourneyFilters()) act(() => { result.current.resetFilters() @@ -113,6 +114,7 @@ describe('useJourneyFilters', () => { expect(calledWith).not.toContain('depth=') expect(calledWith).not.toContain('density=') expect(calledWith).not.toContain('entry=') + expect(calledWith).not.toContain('lens=') expect(calledWith).not.toContain('view=') }) @@ -122,6 +124,87 @@ describe('useJourneyFilters', () => { expect(result.current.isDefault).toBe(false) }) + it('reads lens from URL when present', () => { + mockSearchParams = new URLSearchParams('lens=%2Flogin') + const { result } = renderHook(() => useJourneyFilters()) + expect(result.current.lens).toBe('/login') + expect(result.current.isDefault).toBe(false) + }) + + it('treats an empty lens param as null', () => { + mockSearchParams = new URLSearchParams('lens=') + const { result } = renderHook(() => useJourneyFilters()) + expect(result.current.lens).toBeNull() + expect(result.current.isDefault).toBe(true) + }) + + it('setLens writes the path to the URL', () => { + const { result } = renderHook(() => useJourneyFilters()) + act(() => { + result.current.setLens('/login') + }) + const calledWith = mockReplace.mock.calls[0][0] as string + expect(calledWith).toContain('lens=%2Flogin') + }) + + it('setLens(null) strips the lens param', () => { + mockSearchParams = new URLSearchParams('lens=%2Flogin&depth=6') + const { result } = renderHook(() => useJourneyFilters()) + act(() => { + result.current.setLens(null) + }) + const calledWith = mockReplace.mock.calls[0][0] as string + expect(calledWith).not.toContain('lens=') + expect(calledWith).toContain('depth=6') + }) + + it('parses dimension filters from the URL codec', () => { + mockSearchParams = new URLSearchParams('filters=country%7Cis%7CUS') + const { result } = renderHook(() => useJourneyFilters()) + expect(result.current.dimensionFilters).toEqual([ + { dimension: 'country', operator: 'is', values: ['US'] }, + ]) + expect(result.current.filtersParam).toBe('country|is|US') + expect(result.current.isDefault).toBe(false) + }) + + it('has no dimension filters by default', () => { + const { result } = renderHook(() => useJourneyFilters()) + expect(result.current.dimensionFilters).toEqual([]) + expect(result.current.filtersParam).toBe('') + }) + + it('setDimensionFilters serializes into the filters param', () => { + const { result } = renderHook(() => useJourneyFilters()) + act(() => { + result.current.setDimensionFilters([{ dimension: 'device', operator: 'is', values: ['mobile'] }]) + }) + const calledWith = mockReplace.mock.calls[0][0] as string + expect(calledWith).toContain('filters=device%7Cis%7Cmobile') + }) + + it('setDimensionFilters([]) strips the filters param', () => { + mockSearchParams = new URLSearchParams('filters=device%7Cis%7Cmobile&depth=6') + const { result } = renderHook(() => useJourneyFilters()) + act(() => { + result.current.setDimensionFilters([]) + }) + const calledWith = mockReplace.mock.calls[0][0] as string + expect(calledWith).not.toContain('filters=') + expect(calledWith).toContain('depth=6') + }) + + it('resetFilters clears the filters param too', () => { + mockSearchParams = new URLSearchParams('filters=country%7Cis%7CUS&depth=6') + const { result } = renderHook(() => useJourneyFilters()) + act(() => { + result.current.resetFilters() + }) + const calledWith = mockReplace.mock.calls[0][0] as string + expect(calledWith).not.toContain('filters=') + expect(calledWith).not.toContain('depth=') + }) + it('clamps depth on write when value exceeds max', () => { const { result } = renderHook(() => useJourneyFilters()) act(() => { diff --git a/lib/hooks/__tests__/useUrlDateRange.test.tsx b/lib/hooks/__tests__/useUrlDateRange.test.tsx new file mode 100644 index 000000000..408120153 --- /dev/null +++ b/lib/hooks/__tests__/useUrlDateRange.test.tsx @@ -0,0 +1,110 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { useUrlDateRange } from '../useUrlDateRange' + +// * Mock Next.js navigation +const mockReplace = vi.fn() +let mockSearchParams = new URLSearchParams() + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ replace: mockReplace }), + useSearchParams: () => mockSearchParams, + usePathname: () => '/sites/abc/funnels', +})) + +// * Real local-parts formatDate so shift math is exercised; range getters stubbed +vi.mock('@/lib/utils/dateRanges', () => ({ + getDateRange: (days: number) => ({ start: `start-${days}`, end: `end-${days}` }), + getThisWeekRange: () => ({ start: 'week-start', end: 'week-end' }), + getThisMonthRange: () => ({ start: 'month-start', end: 'month-end' }), + formatDate: (d: Date) => { + const p = (n: number) => String(n).padStart(2, '0') + return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}` + }, +})) + +beforeEach(() => { + mockReplace.mockClear() + mockSearchParams = new URLSearchParams() +}) + +describe('useUrlDateRange', () => { + it('defaults to period 30 with its computed range', () => { + const { result } = renderHook(() => useUrlDateRange()) + expect(result.current.period).toBe('30') + expect(result.current.dateRange).toEqual({ start: 'start-30', end: 'end-30' }) + }) + + it('reads a preset period from the URL', () => { + mockSearchParams = new URLSearchParams('period=7') + const { result } = renderHook(() => useUrlDateRange()) + expect(result.current.period).toBe('7') + expect(result.current.dateRange).toEqual({ start: 'start-7', end: 'end-7' }) + }) + + it('reads a custom range from the URL when valid', () => { + mockSearchParams = new URLSearchParams('period=custom&start=2026-01-01&end=2026-01-31') + const { result } = renderHook(() => useUrlDateRange()) + expect(result.current.period).toBe('custom') + expect(result.current.dateRange).toEqual({ start: '2026-01-01', end: '2026-01-31' }) + }) + + it('normalizes period=custom with missing or malformed dates to the default', () => { + mockSearchParams = new URLSearchParams('period=custom') + expect(renderHook(() => useUrlDateRange()).result.current.period).toBe('30') + mockSearchParams = new URLSearchParams('period=custom&start=garbage&end=2026-01-31') + expect(renderHook(() => useUrlDateRange()).result.current.period).toBe('30') + }) + + it('setPeriod custom writes period/start/end; presets strip them', () => { + const { result } = renderHook(() => useUrlDateRange()) + act(() => { + result.current.setPeriod('custom', { start: '2026-01-01', end: '2026-01-31' }) + }) + let calledWith = mockReplace.mock.calls[0][0] as string + expect(calledWith).toContain('period=custom') + expect(calledWith).toContain('start=2026-01-01') + expect(calledWith).toContain('end=2026-01-31') + + mockSearchParams = new URLSearchParams('period=custom&start=2026-01-01&end=2026-01-31') + const { result: r2 } = renderHook(() => useUrlDateRange()) + mockReplace.mockClear() + act(() => { + r2.current.setPeriod('7') + }) + calledWith = mockReplace.mock.calls[0][0] as string + expect(calledWith).toContain('period=7') + expect(calledWith).not.toContain('start=') + expect(calledWith).not.toContain('end=') + }) + + it('omits the default period from the URL', () => { + mockSearchParams = new URLSearchParams('period=7') + const { result } = renderHook(() => useUrlDateRange()) + act(() => { + result.current.setPeriod('30') + }) + const calledWith = mockReplace.mock.calls[0][0] as string + expect(calledWith).not.toContain('period=') + }) + + it('shiftPeriod moves a custom range back by its own span', () => { + mockSearchParams = new URLSearchParams('period=custom&start=2026-01-08&end=2026-01-14') + const { result } = renderHook(() => useUrlDateRange()) + act(() => { + result.current.shiftPeriod(-1) + }) + const calledWith = mockReplace.mock.calls[0][0] as string + expect(calledWith).toContain('start=2026-01-01') + expect(calledWith).toContain('end=2026-01-07') + }) + + it('shiftPeriod forward clamps at today (no-op past it)', () => { + mockSearchParams = new URLSearchParams('period=custom&start=2099-01-01&end=2099-01-07') + const { result } = renderHook(() => useUrlDateRange()) + act(() => { + result.current.shiftPeriod(1) + }) + expect(mockReplace).not.toHaveBeenCalled() + }) +}) diff --git a/lib/hooks/periodUrl.ts b/lib/hooks/periodUrl.ts new file mode 100644 index 000000000..0a203a0b3 --- /dev/null +++ b/lib/hooks/periodUrl.ts @@ -0,0 +1,124 @@ +import { + getDateRange, + getThisWeekRange, + getThisMonthRange, + getThisYearRange, + getYesterdayRange, + getLast24HoursRange, + getLast1HourRange, + formatDate, +} from '@/lib/utils/dateRanges' + +// --------------------------------------------------------------------------- +// Shared URL period grammar for date-ranged pages (journeys, funnels). +// One parse/normalize rulebook so every page reads and writes the same +// ?period=&start=&end= params. +// --------------------------------------------------------------------------- + +export type Period = + | '1h' + | '24h' + | 'today' + | 'yesterday' + | '7' + | '30' + | 'week' + | 'month' + | 'year' + | 'custom' + +export const DEFAULT_PERIOD: Period = '30' + +const PERIODS: ReadonlySet = new Set([ + '1h', + '24h', + 'today', + 'yesterday', + '7', + '30', + 'week', + 'month', + 'year', + 'custom', +]) + +export function parsePeriod(raw: string | null): Period { + if (raw && PERIODS.has(raw as Period)) return raw as Period + return DEFAULT_PERIOD +} + +export function isValidDateString(s: string | null): s is string { + if (!s) return false + return /^\d{4}-\d{2}-\d{2}$/.test(s) +} + +export function periodToDateRange(period: Period): { start: string; end: string } { + switch (period) { + case '1h': + return getLast1HourRange() + case '24h': + return getLast24HoursRange() + case 'today': { + const today = formatDate(new Date()) + return { start: today, end: today } + } + case 'yesterday': + return getYesterdayRange() + case '7': + return getDateRange(7) + case '30': + return getDateRange(30) + case 'week': + return getThisWeekRange() + case 'month': + return getThisMonthRange() + case 'year': + return getThisYearRange() + case 'custom': + // * Fallback only — actual custom range comes from the URL read path + return getDateRange(30) + } +} + +const DAY_MS = 86400000 + +/** + * The equal-length window immediately before `range`, or null when the span + * is unreasonable (>366 days) or would reach before 2020. Local date parts + * throughout — a toISOString() here shifts a day near midnight outside UTC. + */ +export function previousDateRange(range: { + start: string + end: string +}): { start: string; end: string } | null { + const s = new Date(range.start + 'T00:00:00') + const e = new Date(range.end + 'T00:00:00') + const duration = e.getTime() - s.getTime() + if (duration > 366 * DAY_MS) return null + const prevEnd = new Date(s.getTime() - DAY_MS) + const prevStart = new Date(prevEnd.getTime() - duration) + if (prevStart.getFullYear() < 2020) return null + return { start: formatDate(prevStart), end: formatDate(prevEnd) } +} + +/** + * The same range shifted by its own span, or null when the shift would land + * past today (local date parts throughout — no UTC drift). + */ +export function shiftDateRange( + range: { start: string; end: string }, + direction: -1 | 1, +): { start: string; end: string } | null { + const shift = (date: string, days: number) => { + const d = new Date(date + 'T00:00:00') + d.setDate(d.getDate() + days) + return formatDate(d) + } + const startDate = new Date(range.start + 'T00:00:00') + const endDate = new Date(range.end + 'T00:00:00') + const spanDays = Math.round((endDate.getTime() - startDate.getTime()) / DAY_MS) + 1 + const offsetDays = spanDays * direction + const next = { start: shift(range.start, offsetDays), end: shift(range.end, offsetDays) } + if (next.end > formatDate(new Date())) return null + return next +} diff --git a/lib/hooks/useFilterSuggestions.ts b/lib/hooks/useFilterSuggestions.ts new file mode 100644 index 000000000..932fa1e38 --- /dev/null +++ b/lib/hooks/useFilterSuggestions.ts @@ -0,0 +1,105 @@ +'use client' + +import { useCallback } from 'react' +import { + getTopPages, + getTopReferrers, + getCountries, + getCities, + getRegions, + getBrowsers, + getOS, + getDevices, + getCampaigns, +} from '@/lib/api/stats' +import type { FilterSuggestion } from '@/lib/filters' +import { logger } from '@/lib/utils/logger' + +// --------------------------------------------------------------------------- +// Per-dimension suggestion fetcher for the filter builder — the dashboard's +// handleFetchSuggestions, extracted so every filtered surface (dashboard, +// funnel detail, journeys) resolves values the same way: the dimension's own +// breakdown endpoint, scoped to the current range and committed filters. +// --------------------------------------------------------------------------- + +export function useFilterSuggestions( + siteId: string, + range: { start: string; end: string } | null, + filtersParam?: string, +): (dimension: string) => Promise { + const start = range?.start + const end = range?.end + + return useCallback( + async (dimension: string): Promise => { + if (!start || !end) return [] + const f = filtersParam || undefined + const limit = 100 + + try { + const regionNames = (() => { + try { + return new Intl.DisplayNames(['en'], { type: 'region' }) + } catch { + return null + } + })() + + switch (dimension) { + case 'page': { + const data = await getTopPages(siteId, start, end, limit, f) + return data.map(p => ({ value: p.path, label: p.path, count: p.pageviews })) + } + case 'referrer': { + const data = await getTopReferrers(siteId, start, end, limit, f) + return data.filter(r => r.referrer && r.referrer !== '').map(r => ({ value: r.referrer, label: r.referrer, count: r.pageviews })) + } + case 'country': { + const data = await getCountries(siteId, start, end, limit, f) + return data.filter(c => c.country && c.country !== 'Unknown').map(c => ({ value: c.country, label: regionNames?.of(c.country) ?? c.country, count: c.pageviews })) + } + case 'city': { + const data = await getCities(siteId, start, end, limit, f) + return data.filter(c => c.city && c.city !== 'Unknown').map(c => ({ value: c.city, label: c.city, count: c.pageviews })) + } + case 'region': { + const data = await getRegions(siteId, start, end, limit, f) + return data.filter(r => r.region && r.region !== 'Unknown').map(r => ({ value: r.region, label: r.region, count: r.pageviews })) + } + case 'browser': { + const data = await getBrowsers(siteId, start, end, limit, f) + return data.filter(b => b.browser && b.browser !== 'Unknown').map(b => ({ value: b.browser, label: b.browser, count: b.pageviews })) + } + case 'os': { + const data = await getOS(siteId, start, end, limit, f) + return data.filter(o => o.os && o.os !== 'Unknown').map(o => ({ value: o.os, label: o.os, count: o.pageviews })) + } + case 'device': { + const data = await getDevices(siteId, start, end, limit, f) + return data.filter(d => d.device && d.device !== 'Unknown').map(d => ({ value: d.device, label: d.device, count: d.pageviews })) + } + case 'utm_source': + case 'utm_medium': + case 'utm_campaign': { + const data = await getCampaigns(siteId, start, end, limit, f) + const map = new Map() + const field = dimension === 'utm_source' ? 'source' : dimension === 'utm_medium' ? 'medium' : 'campaign' + data.forEach(c => { + const val = c[field] + if (val) map.set(val, (map.get(val) ?? 0) + c.pageviews) + }) + return [...map.entries()].map(([v, count]) => ({ value: v, label: v, count })) + } + default: + return [] + } + } catch (err) { + // * Rethrow so the value picker renders its explicit error + retry — + // * returning [] here made a dead request look like "no suggestions". + logger.error('Filter suggestions failed', err) + throw err + } + }, + [siteId, start, end, filtersParam], + ) +} diff --git a/lib/hooks/useJourneyFilters.ts b/lib/hooks/useJourneyFilters.ts index 0f0350df9..12141089b 100644 --- a/lib/hooks/useJourneyFilters.ts +++ b/lib/hooks/useJourneyFilters.ts @@ -3,15 +3,18 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { usePathname, useRouter, useSearchParams } from 'next/navigation' import { - getDateRange, - getThisWeekRange, - getThisMonthRange, - getThisYearRange, - getYesterdayRange, - getLast24HoursRange, - getLast1HourRange, - formatDate, -} from '@/lib/utils/dateRanges' + DEFAULT_PERIOD, + isValidDateString, + parsePeriod, + periodToDateRange, + type Period, +} from './periodUrl' +import { serializeFilters, parseFiltersFromURL, type DimensionFilter } from '@/lib/filters' + +export type { Period } + +/** Journeys V1 supports only the session_flows dimensions (design §8). */ +export const JOURNEY_FILTER_DIMENSIONS = ['country', 'device', 'referrer'] as const // ─── Constants ────────────────────────────────────────────────────── @@ -26,24 +29,10 @@ export const DENSITY_STEP = 5 export const DENSITY_DEFAULT = 20 export type ViewMode = 'columns' | 'flow' -export type Period = '1h' | '24h' | 'today' | 'yesterday' | '7' | '30' | 'week' | 'month' | 'year' | 'custom' const VIEW_MODES: ReadonlySet = new Set(['columns', 'flow']) -const PERIODS: ReadonlySet = new Set([ - '1h', - '24h', - 'today', - 'yesterday', - '7', - '30', - 'week', - 'month', - 'year', - 'custom', -]) const DEFAULT_VIEW: ViewMode = 'columns' -const DEFAULT_PERIOD: Period = '30' // ─── Helpers ──────────────────────────────────────────────────────── @@ -66,44 +55,6 @@ function parseView(raw: string | null): ViewMode { return DEFAULT_VIEW } -function parsePeriod(raw: string | null): Period { - if (raw && PERIODS.has(raw as Period)) return raw as Period - return DEFAULT_PERIOD -} - -function isValidDateString(s: string | null): s is string { - if (!s) return false - return /^\d{4}-\d{2}-\d{2}$/.test(s) -} - -function periodToDateRange(period: Period) { - switch (period) { - case '1h': - return getLast1HourRange() - case '24h': - return getLast24HoursRange() - case 'today': { - const today = formatDate(new Date()) - return { start: today, end: today } - } - case 'yesterday': - return getYesterdayRange() - case '7': - return getDateRange(7) - case '30': - return getDateRange(30) - case 'week': - return getThisWeekRange() - case 'month': - return getThisMonthRange() - case 'year': - return getThisYearRange() - case 'custom': - // * Fallback only — actual custom range comes from URL read path - return getDateRange(30) - } -} - // ─── Hook ─────────────────────────────────────────────────────────── export interface JourneyFilters { @@ -112,6 +63,12 @@ export interface JourneyFilters { density: number committedDensity: number entryPath: string + /** Pinned chain path shared by both views; null = no lens. */ + lens: string | null + /** Dashboard-codec dimension filters (country/device/referrer). */ + dimensionFilters: DimensionFilter[] + /** Serialized `filters=` param, or '' when none. */ + filtersParam: string viewMode: ViewMode period: Period dateRange: { start: string; end: string } @@ -119,6 +76,8 @@ export interface JourneyFilters { setDepth: (n: number) => void setDensity: (n: number) => void setEntryPath: (p: string) => void + setLens: (path: string | null) => void + setDimensionFilters: (filters: DimensionFilter[]) => void setViewMode: (m: ViewMode) => void setPeriod: (p: Period, customRange?: { start: string; end: string }) => void resetFilters: () => void @@ -143,8 +102,16 @@ export function useJourneyFilters(): JourneyFilters { DENSITY_DEFAULT, ) const entryPath = searchParams.get('entry') ?? '' + const lens = searchParams.get('lens') || null const viewMode = parseView(searchParams.get('view')) + const rawFilters = searchParams.get('filters') + const dimensionFilters = useMemo( + () => (rawFilters ? parseFiltersFromURL(rawFilters) : []), + [rawFilters], + ) + const filtersParam = useMemo(() => serializeFilters(dimensionFilters), [dimensionFilters]) + // * Raw period value from URL (may be 'custom') const rawPeriod = parsePeriod(searchParams.get('period')) const rawStart = searchParams.get('start') @@ -217,6 +184,14 @@ export function useJourneyFilters(): JourneyFilters { (p: string) => updateUrl({ entry: p || null }), [updateUrl], ) + const setLens = useCallback( + (path: string | null) => updateUrl({ lens: path || null }), + [updateUrl], + ) + const setDimensionFilters = useCallback( + (filters: DimensionFilter[]) => updateUrl({ filters: serializeFilters(filters) || null }), + [updateUrl], + ) const setViewMode = useCallback( (m: ViewMode) => updateUrl({ view: m }), [updateUrl], @@ -238,6 +213,8 @@ export function useJourneyFilters(): JourneyFilters { depth: null, density: null, entry: null, + lens: null, + filters: null, view: null, period: null, start: null, @@ -249,6 +226,8 @@ export function useJourneyFilters(): JourneyFilters { depth === DEPTH_DEFAULT && density === DENSITY_DEFAULT && entryPath === '' && + lens === null && + dimensionFilters.length === 0 && viewMode === DEFAULT_VIEW && period === DEFAULT_PERIOD @@ -258,12 +237,17 @@ export function useJourneyFilters(): JourneyFilters { density, committedDensity, entryPath, + lens, + dimensionFilters, + filtersParam, viewMode, period, dateRange, setDepth, setDensity, setEntryPath, + setLens, + setDimensionFilters, setViewMode, setPeriod, resetFilters, diff --git a/lib/hooks/useUrlDateRange.ts b/lib/hooks/useUrlDateRange.ts new file mode 100644 index 000000000..9dfe790aa --- /dev/null +++ b/lib/hooks/useUrlDateRange.ts @@ -0,0 +1,87 @@ +'use client' + +import { useCallback, useMemo } from 'react' +import { usePathname, useRouter, useSearchParams } from 'next/navigation' +import { + DEFAULT_PERIOD, + isValidDateString, + parsePeriod, + periodToDateRange, + shiftDateRange, + type Period, +} from './periodUrl' + +export type { Period } + +// --------------------------------------------------------------------------- +// Shared URL-synced date range for date-ranged pages (funnels, behavior, +// search, CDN) (?period=&start=&end=) — the journeys grammar, so list and +// detail views are shareable and survive refresh. Defaults (period=30) stay +// out of the URL. +// --------------------------------------------------------------------------- + +export interface UrlDateRange { + period: Period + dateRange: { start: string; end: string } + setPeriod: (p: Period, customRange?: { start: string; end: string }) => void + shiftPeriod: (direction: -1 | 1) => void +} + +export function useUrlDateRange(): UrlDateRange { + const router = useRouter() + const pathname = usePathname() + const searchParams = useSearchParams() + + const rawPeriod = parsePeriod(searchParams.get('period')) + const rawStart = searchParams.get('start') + const rawEnd = searchParams.get('end') + + // * period=custom without a valid start/end pair normalizes to the default + const period: Period = + rawPeriod === 'custom' && (!isValidDateString(rawStart) || !isValidDateString(rawEnd)) + ? DEFAULT_PERIOD + : rawPeriod + + const dateRange = useMemo( + () => + period === 'custom' && rawStart && rawEnd + ? { start: rawStart, end: rawEnd } + : periodToDateRange(period), + [period, rawStart, rawEnd], + ) + + const updateUrl = useCallback( + (updates: Record) => { + const params = new URLSearchParams(searchParams.toString()) + for (const [key, value] of Object.entries(updates)) { + if (value === null || value === '') params.delete(key) + else params.set(key, value) + } + if (params.get('period') === DEFAULT_PERIOD) params.delete('period') + const qs = params.toString() + router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false }) + }, + [router, pathname, searchParams], + ) + + const setPeriod = useCallback( + (p: Period, range?: { start: string; end: string }) => { + if (p === 'custom' && range) { + updateUrl({ period: p, start: range.start, end: range.end }) + } else { + updateUrl({ period: p, start: null, end: null }) + } + }, + [updateUrl], + ) + + const shiftPeriod = useCallback( + (direction: -1 | 1) => { + const next = shiftDateRange(dateRange, direction) + if (next) setPeriod('custom', next) + }, + [dateRange, setPeriod], + ) + + return { period, dateRange, setPeriod, shiftPeriod } +} diff --git a/lib/integrations.tsx b/lib/integrations.tsx index d7dc968cf..090e01101 100644 --- a/lib/integrations.tsx +++ b/lib/integrations.tsx @@ -28,7 +28,9 @@ export interface Integration { category: IntegrationCategory /** Brand hex colour (with #) */ brandColor: string - /** Whether the icon needs `invert` (black-only logos on dark background) */ + /** Black-only brand logo, rendered as a white fill on the dark tiles. + * (Never combine `fill-current` with `invert` — currentColor is already + * light here, so inverting it produced near-black glyphs on dark tiles.) */ invertInDark?: boolean /** Official 24×24 SVG as a React node */ icon: ReactNode @@ -73,7 +75,7 @@ export const integrations: Integration[] = [ brandColor: '#000000', invertInDark: true, icon: ( - + ), @@ -116,7 +118,7 @@ export const integrations: Integration[] = [ brandColor: '#0F0F11', invertInDark: true, icon: ( - + ), @@ -159,7 +161,7 @@ export const integrations: Integration[] = [ brandColor: '#000000', invertInDark: true, icon: ( - + ), @@ -187,7 +189,7 @@ export const integrations: Integration[] = [ category: 'framework', brandColor: '#2C4F7C', icon: ( - + ), @@ -316,7 +318,7 @@ export const integrations: Integration[] = [ brandColor: '#000000', invertInDark: true, icon: ( - + ), @@ -361,7 +363,7 @@ export const integrations: Integration[] = [ brandColor: '#222222', invertInDark: true, icon: ( - + ), @@ -462,7 +464,7 @@ export const integrations: Integration[] = [ brandColor: '#15171A', invertInDark: true, icon: ( - + ), @@ -519,7 +521,7 @@ export const integrations: Integration[] = [ brandColor: '#0D0E12', invertInDark: true, icon: ( - + ), @@ -548,7 +550,7 @@ export const integrations: Integration[] = [ brandColor: '#000000', invertInDark: true, icon: ( - + ), @@ -578,7 +580,7 @@ export const integrations: Integration[] = [ category: 'ecommerce', brandColor: '#96588A', icon: ( - + ), @@ -593,7 +595,7 @@ export const integrations: Integration[] = [ brandColor: '#121118', invertInDark: true, icon: ( - + ), @@ -638,7 +640,7 @@ export const integrations: Integration[] = [ brandColor: '#000000', invertInDark: true, icon: ( - + ), @@ -723,7 +725,7 @@ export const integrations: Integration[] = [ brandColor: '#000000', invertInDark: true, icon: ( - + ), @@ -768,7 +770,7 @@ export const integrations: Integration[] = [ brandColor: '#000000', invertInDark: true, icon: ( - + ), @@ -783,7 +785,7 @@ export const integrations: Integration[] = [ brandColor: '#222222', invertInDark: true, icon: ( - + ), @@ -842,7 +844,7 @@ export const integrations: Integration[] = [ brandColor: '#000000', invertInDark: true, icon: ( - + ), @@ -857,7 +859,7 @@ export const integrations: Integration[] = [ brandColor: '#221E1F', invertInDark: true, icon: ( - + ), @@ -946,7 +948,7 @@ export const integrations: Integration[] = [ brandColor: '#000000', invertInDark: true, icon: ( - + ), @@ -961,7 +963,7 @@ export const integrations: Integration[] = [ brandColor: '#000000', invertInDark: true, icon: ( - + ), @@ -1062,7 +1064,7 @@ export const integrations: Integration[] = [ brandColor: '#000000', invertInDark: true, icon: ( - + ), @@ -1077,7 +1079,7 @@ export const integrations: Integration[] = [ brandColor: '#000000', invertInDark: true, icon: ( - + ), @@ -1124,7 +1126,7 @@ export const integrations: Integration[] = [ brandColor: '#000000', invertInDark: true, icon: ( - + ), diff --git a/lib/journeys/__tests__/chain.test.ts b/lib/journeys/__tests__/chain.test.ts new file mode 100644 index 000000000..cf89350a4 --- /dev/null +++ b/lib/journeys/__tests__/chain.test.ts @@ -0,0 +1,234 @@ +import { describe, it, expect } from 'vitest' +import { aggregateJourney } from '../aggregate' +import { + buildLinks, + buildAdjacency, + chainThrough, + chainThroughLink, + chainThroughNode, + exitCount, + nodeId, + linkKey, + pathOfNode, + spineThrough, + stepOfNode, +} from '../chain' +import type { PathTransition } from '@/lib/api/journeys' + +const t = (step_index: number, from_path: string, to_path: string, session_count: number): PathTransition => ({ + step_index, + from_path, + to_path, + session_count, +}) + +describe('node/link id helpers', () => { + it('round-trips step and path through the id', () => { + const id = nodeId(2, '/docs/getting-started') + expect(stepOfNode(id)).toBe(2) + expect(pathOfNode(id)).toBe('/docs/getting-started') + }) + + it('keeps paths containing colons intact', () => { + expect(pathOfNode(nodeId(0, '/a:b:c'))).toBe('/a:b:c') + }) +}) + +describe('buildLinks', () => { + it('aggregates duplicate hops into one link', () => { + const transitions = [t(0, '/', '/login', 3), t(0, '/', '/login', 2)] + const columns = aggregateJourney(transitions, { depth: 4, maxPagesPerStep: 20 }) + const links = buildLinks(transitions, columns) + expect(links).toEqual([{ source: '0:/', target: '1:/login', value: 5 }]) + }) + + it('rolls hidden paths into the (other) bucket', () => { + // maxPagesPerStep 1 → step 1 keeps only /a; /b and /c roll into (other) + const transitions = [ + t(0, '/', '/a', 10), + t(0, '/', '/b', 2), + t(0, '/', '/c', 1), + ] + const columns = aggregateJourney(transitions, { depth: 4, maxPagesPerStep: 1 }) + const links = buildLinks(transitions, columns) + expect(links).toContainEqual({ source: '0:/', target: '1:/a', value: 10 }) + expect(links).toContainEqual({ source: '0:/', target: '1:(other)', value: 3 }) + }) + + it('drops (other) to (other) hops and out-of-depth transitions', () => { + const transitions = [ + t(0, '/', '/a', 10), + t(0, '/x', '/y', 1), // both roll up at depth cap 1 per step + t(5, '/deep', '/deeper', 9), // beyond aggregated depth + ] + const columns = aggregateJourney(transitions, { depth: 2, maxPagesPerStep: 1 }) + const links = buildLinks(transitions, columns) + expect(links.some((l) => pathOfNode(l.source) === '(other)' && pathOfNode(l.target) === '(other)')).toBe(false) + expect(links.some((l) => stepOfNode(l.source) >= 2)).toBe(false) + }) +}) + +describe('buildAdjacency', () => { + it('indexes forward and backward neighbours', () => { + const links = [ + { source: '0:/', target: '1:/a', value: 1 }, + { source: '0:/', target: '1:/b', value: 1 }, + ] + const { fwd, bwd } = buildAdjacency(links) + expect(fwd.get('0:/')).toEqual(new Set(['1:/a', '1:/b'])) + expect(bwd.get('1:/a')).toEqual(new Set(['0:/'])) + }) +}) + +describe('chainThrough', () => { + const links = [ + { source: '0:/', target: '1:/login', value: 5 }, + { source: '0:/', target: '1:/pricing', value: 2 }, + { source: '1:/login', target: '2:/app', value: 4 }, + { source: '1:/pricing', target: '2:/checkout', value: 1 }, + ] + + it('keeps the full chain through the lens path, forward and backward', () => { + const chain = chainThrough(links, '/login') + expect(chain.nodeIds).toEqual(new Set(['0:/', '1:/login', '2:/app'])) + expect(chain.linkKeys).toEqual( + new Set([linkKey('0:/', '1:/login'), linkKey('1:/login', '2:/app')]), + ) + }) + + it('excludes sibling branches that bypass the lens', () => { + const chain = chainThrough(links, '/login') + expect(chain.nodeIds.has('1:/pricing')).toBe(false) + expect(chain.linkKeys.has(linkKey('1:/pricing', '2:/checkout'))).toBe(false) + }) + + it('seeds from every occurrence of the path across steps', () => { + const loop = [ + { source: '0:/a', target: '1:/b', value: 1 }, + { source: '1:/b', target: '2:/a', value: 1 }, + { source: '2:/a', target: '3:/c', value: 1 }, + ] + const chain = chainThrough(loop, '/a') + expect(chain.nodeIds).toEqual(new Set(['0:/a', '1:/b', '2:/a', '3:/c'])) + }) + + it('returns empty sets for a path that appears nowhere', () => { + const chain = chainThrough(links, '/nope') + expect(chain.nodeIds.size).toBe(0) + expect(chain.linkKeys.size).toBe(0) + }) +}) + +describe('chainThroughNode', () => { + // Two occurrences of /relay: at step 1 and step 3. They have DIFFERENT flows. + const links = [ + { source: '0:/', target: '1:/relay', value: 5 }, // → /relay @1 + { source: '1:/relay', target: '2:/app', value: 4 }, // /relay @1 → + { source: '2:/app', target: '3:/relay', value: 3 }, // → /relay @3 + { source: '3:/relay', target: '4:/done', value: 2 }, // /relay @3 → + { source: '0:/x', target: '1:/other', value: 9 }, // unrelated branch + ] + + it('highlights only the flow through the specific node, not sibling occurrences', () => { + const chain = chainThroughNode(links, '1:/relay') + // Only /relay @1's own chain — @1 connects to / (back) and /app→/relay@3→/done (fwd) + expect(chain.nodeIds.has('1:/relay')).toBe(true) + expect(chain.nodeIds.has('0:/')).toBe(true) + // the unrelated branch is never touched + expect(chain.nodeIds.has('1:/other')).toBe(false) + expect(chain.linkKeys.has(linkKey('0:/x', '1:/other'))).toBe(false) + }) + + it('a different occurrence of the same path yields a different (upstream-limited) chain', () => { + const atStep3 = chainThroughNode(links, '3:/relay') + // /relay @3 reaches back through /app → /relay@1 → / and forward to /done + expect(atStep3.nodeIds.has('3:/relay')).toBe(true) + expect(atStep3.nodeIds.has('4:/done')).toBe(true) + // but it does NOT seed from /relay @1 as an origin — the forward-only /done + // proves node-specific seeding (a step-1 seed would also pull /other-free branches) + expect(atStep3.nodeIds.has('1:/other')).toBe(false) + }) + + it('returns just the node when it participates in no links', () => { + const chain = chainThroughNode(links, '9:/isolated') + expect(chain.nodeIds).toEqual(new Set(['9:/isolated'])) + expect(chain.linkKeys.size).toBe(0) + }) +}) + +describe('chainThroughLink', () => { + const links = [ + { source: '0:/', target: '1:/login', value: 5 }, + { source: '0:/', target: '1:/pricing', value: 2 }, + { source: '1:/login', target: '2:/app', value: 4 }, + { source: '1:/pricing', target: '2:/checkout', value: 1 }, + ] + + it('keeps the hop plus its upstream and downstream walk', () => { + const chain = chainThroughLink(links, '0:/', '1:/login') + expect(chain.linkKeys).toEqual( + new Set([linkKey('0:/', '1:/login'), linkKey('1:/login', '2:/app')]), + ) + expect(chain.nodeIds).toEqual(new Set(['0:/', '1:/login', '2:/app'])) + }) + + it('does not leak into sibling fans of the shared source', () => { + const chain = chainThroughLink(links, '1:/login', '2:/app') + expect(chain.linkKeys.has(linkKey('0:/', '1:/pricing'))).toBe(false) + expect(chain.nodeIds.has('2:/checkout')).toBe(false) + }) +}) + +describe('spineThrough', () => { + it('follows the heaviest hop in both directions', () => { + const links = [ + { source: '0:/', target: '1:/login', value: 8 }, + { source: '0:/', target: '1:/pricing', value: 2 }, + { source: '1:/login', target: '2:/app', value: 6 }, + { source: '1:/login', target: '2:/docs', value: 1 }, + ] + expect(spineThrough(links, '/login')).toEqual(['/', '/login', '/app']) + }) + + it('stops at (other) buckets instead of including them', () => { + const links = [ + { source: '0:/', target: '1:/login', value: 5 }, + { source: '1:/login', target: '2:(other)', value: 9 }, + ] + expect(spineThrough(links, '/login')).toEqual(['/', '/login']) + }) + + it('caps the spine at maxSteps while keeping the lens inside', () => { + const links = Array.from({ length: 9 }, (_, i) => ({ + source: `${i}:/p${i}`, + target: `${i + 1}:/p${i + 1}`, + value: 10 - i, + })) + const spine = spineThrough(links, '/p4', 6) + expect(spine).toHaveLength(6) + expect(spine).toContain('/p4') + }) + + it('returns empty for a path with no flows', () => { + expect(spineThrough([], '/ghost')).toEqual([]) + }) +}) + +describe('exitCount', () => { + it('is the sessions on the row minus its onward hops', () => { + const transitions = [ + t(0, '/', '/login', 10), + t(1, '/login', '/app', 6), + ] + const columns = aggregateJourney(transitions, { depth: 4, maxPagesPerStep: 20 }) + // 10 landed on /login at step 1, 6 moved on → 4 exited + expect(exitCount(transitions, 1, '/login', columns)).toBe(4) + }) + + it('floors at zero and returns 0 for unknown rows', () => { + const transitions = [t(0, '/', '/login', 3), t(1, '/login', '/app', 5)] + const columns = aggregateJourney(transitions, { depth: 4, maxPagesPerStep: 20 }) + expect(exitCount(transitions, 1, '/login', columns)).toBe(0) + expect(exitCount(transitions, 1, '/ghost', columns)).toBe(0) + }) +}) diff --git a/lib/journeys/__tests__/sankeyLayout.test.ts b/lib/journeys/__tests__/sankeyLayout.test.ts new file mode 100644 index 000000000..97aa89c8e --- /dev/null +++ b/lib/journeys/__tests__/sankeyLayout.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from 'vitest' +import { layoutSankey, NODE_WIDTH } from '../sankeyLayout' +import type { PathTransition } from '@/lib/api/journeys' + +const t = (step_index: number, from_path: string, to_path: string, session_count: number): PathTransition => ({ + step_index, + from_path, + to_path, + session_count, +}) + +// / → login (10) + / → pricing (5); login → app (8) +const FIXTURE: PathTransition[] = [ + t(0, '/', '/login', 10), + t(0, '/', '/pricing', 5), + t(1, '/login', '/app', 8), +] + +const OPTS = { depth: 4, maxPagesPerStep: 20, width: 900 } + +describe('layoutSankey', () => { + it('returns an empty layout for no transitions or zero width', () => { + expect(layoutSankey([], OPTS).nodes).toHaveLength(0) + expect(layoutSankey(FIXTURE, { ...OPTS, width: 0 }).nodes).toHaveLength(0) + }) + + it('builds one node per visible step:path and one link per hop', () => { + const layout = layoutSankey(FIXTURE, OPTS) + expect(layout.nodes.map((n) => n.id).sort()).toEqual( + ['0:/', '1:/login', '1:/pricing', '2:/app'].sort(), + ) + expect(layout.links).toHaveLength(3) + }) + + it('justifies columns edge-to-edge across the width', () => { + const layout = layoutSankey(FIXTURE, OPTS) + const step0 = layout.nodes.find((n) => n.id === '0:/')! + const step2 = layout.nodes.find((n) => n.id === '2:/app')! + expect(step0.x).toBe(0) + expect(step2.x).toBe(900 - NODE_WIDTH) + }) + + it('scales strip heights to the busiest link and respects the minimum', () => { + const layout = layoutSankey(FIXTURE, OPTS) + const root = layout.nodes.find((n) => n.id === '0:/')! + const pricing = layout.nodes.find((n) => n.id === '1:/pricing')! + // root moves 15 sessions vs max link 10 → taller than the busiest link's 50px + expect(root.height).toBeGreaterThan(pricing.height) + for (const n of layout.nodes) expect(n.height).toBeGreaterThanOrEqual(3) + }) + + it('stacks a strip\'s outgoing links without overlap, biggest first', () => { + const layout = layoutSankey(FIXTURE, OPTS) + const out = layout.links + .filter((l) => l.source === '0:/') + .sort((a, b) => a.sourceY - b.sourceY) + expect(out).toHaveLength(2) + // biggest (value 10) sits on top; fans don't overlap + expect(out[0].value).toBe(10) + expect(out[0].sourceY + out[0].strokeWidth / 2).toBeLessThanOrEqual( + out[1].sourceY - out[1].strokeWidth / 2 + 0.001, + ) + }) + + it('anchors link x coordinates to the strip edges', () => { + const layout = layoutSankey(FIXTURE, OPTS) + const hop = layout.links.find((l) => l.key === '1:/login|2:/app')! + const login = layout.nodes.find((n) => n.id === '1:/login')! + const app = layout.nodes.find((n) => n.id === '2:/app')! + expect(hop.sourceX).toBe(login.x + NODE_WIDTH) + expect(hop.targetX).toBe(app.x) + }) + + it('reduces to the lens chain and drops sibling branches', () => { + const layout = layoutSankey(FIXTURE, { ...OPTS, lens: '/login' }) + expect(layout.nodes.map((n) => n.id).sort()).toEqual(['0:/', '1:/login', '2:/app'].sort()) + expect(layout.links.some((l) => l.target === '1:/pricing')).toBe(false) + }) + + it('returns an empty layout when the lens path has no flows', () => { + const layout = layoutSankey(FIXTURE, { ...OPTS, lens: '/ghost' }) + expect(layout.nodes).toHaveLength(0) + }) + + it('computes per-step visitors (departures at entry, arrivals after) and drop-off', () => { + const layout = layoutSankey(FIXTURE, OPTS) + expect(layout.steps.map((s) => s.visitors)).toEqual([15, 15, 8]) + expect(layout.steps[1].dropOffPercent).toBe(0) + expect(layout.steps[2].dropOffPercent).toBe(Math.round(((8 - 15) / 15) * 100)) + }) + + it('never returns a height below the minimum canvas height', () => { + const layout = layoutSankey(FIXTURE, OPTS) + expect(layout.height).toBeGreaterThanOrEqual(200) + }) +}) diff --git a/lib/journeys/chain.ts b/lib/journeys/chain.ts new file mode 100644 index 000000000..149856bcb --- /dev/null +++ b/lib/journeys/chain.ts @@ -0,0 +1,272 @@ +import type { PathTransition } from '@/lib/api/journeys' +import type { AggregatedStep } from './aggregate' + +// --------------------------------------------------------------------------- +// Chain/lens helpers shared by both journey views. Nodes are addressed as +// `step:path` ids; links aggregate gutter transitions onto the rows that +// survived the per-step (other) rollup. chainThrough ports SankeyJourney's +// BFS filter (forward + backward from every occurrence of a path) so the +// columns ribbons, the flow subgraph and the lens all agree on what "the +// chain through /login" means. +// --------------------------------------------------------------------------- + +export interface ChainLink { + /** `step:path` of the source row. */ + source: string + /** `step:path` of the target row. */ + target: string + /** Sessions that made this hop. */ + value: number +} + +export interface Chain { + nodeIds: Set + linkKeys: Set +} + +export const nodeId = (step: number, path: string) => `${step}:${path}` +export const linkKey = (source: string, target: string) => `${source}|${target}` +export const pathOfNode = (id: string) => id.slice(id.indexOf(':') + 1) +export const stepOfNode = (id: string) => parseInt(id, 10) + +/** + * Gutter links between the rows each step actually shows. Paths rolled into + * a step's (other) bucket fall back to that bucket; (other)→(other) hops are + * dropped as noise. Transitions outside the aggregated depth are ignored. + */ +export function buildLinks( + transitions: PathTransition[], + columns: AggregatedStep[], +): ChainLink[] { + const pathsByStep = new Map>() + for (const step of columns) { + pathsByStep.set(step.index, new Set(step.pages.map((p) => p.path))) + } + + const linkMap = new Map() + for (const t of transitions) { + const fromPaths = pathsByStep.get(t.step_index) + const toPaths = pathsByStep.get(t.step_index + 1) + if (!fromPaths || !toPaths) continue + const fp = fromPaths.has(t.from_path) ? t.from_path : '(other)' + const tp = toPaths.has(t.to_path) ? t.to_path : '(other)' + if (fp === '(other)' && tp === '(other)') continue + const key = linkKey(nodeId(t.step_index, fp), nodeId(t.step_index + 1, tp)) + linkMap.set(key, (linkMap.get(key) ?? 0) + t.session_count) + } + + return Array.from(linkMap.entries()).map(([key, value]) => { + const [source, target] = key.split('|') + return { source, target, value } + }) +} + +/** Forward and backward adjacency over `step:path` node ids. */ +export function buildAdjacency(links: ChainLink[]): { + fwd: Map> + bwd: Map> +} { + const fwd = new Map>() + const bwd = new Map>() + 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) + } + return { fwd, bwd } +} + +function bfs(seeds: Iterable, adj: Map>, into: Set) { + let queue = [...seeds] + while (queue.length > 0) { + const next: string[] = [] + for (const id of queue) { + for (const nb of adj.get(id) ?? []) { + if (!into.has(nb)) { + into.add(nb) + next.push(nb) + } + } + } + queue = next + } +} + +/** + * The connected chain reachable from a set of seed nodes: BFS forward and + * backward, keeping links whose both endpoints are reachable. Shared by the + * path-based lens (all occurrences) and the node-specific hover. + */ +function chainFromSeeds(links: ChainLink[], seeds: Set): Chain { + if (seeds.size === 0) return { nodeIds: new Set(), linkKeys: new Set() } + + const { fwd, bwd } = buildAdjacency(links) + const reachable = new Set(seeds) + bfs(seeds, fwd, reachable) + bfs(seeds, bwd, reachable) + + const linkKeys = new Set() + const nodeIds = new Set(seeds) + for (const l of links) { + if (reachable.has(l.source) && reachable.has(l.target)) { + linkKeys.add(linkKey(l.source, l.target)) + nodeIds.add(l.source) + nodeIds.add(l.target) + } + } + return { nodeIds, linkKeys } +} + +/** + * The connected chain through **every** occurrence of `path` (all steps) — the + * pinned lens's "trace this page across the whole journey" view (design §4.2). + * Empty sets when the path appears nowhere. + */ +export function chainThrough(links: ChainLink[], path: string): Chain { + const seeds = new Set() + for (const l of links) { + if (pathOfNode(l.source) === path) seeds.add(l.source) + if (pathOfNode(l.target) === path) seeds.add(l.target) + } + return chainFromSeeds(links, seeds) +} + +/** + * The connected chain through a **single** `step:path` node — the flow that + * actually passes through that specific row (hover semantics). Unlike + * chainThrough, sibling occurrences of the same path at other steps are NOT + * seeded, so hovering /relay at step 2 highlights only its own flow. + */ +export function chainThroughNode(links: ChainLink[], node: string): Chain { + return chainFromSeeds(links, new Set([node])) +} + +/** + * The chain through one specific hop: the hop itself plus everything + * link-walk-reachable downstream of its target and upstream of its source + * (the flow view's link-hover highlight, ported from findConnected). + */ +export function chainThroughLink(links: ChainLink[], source: string, target: string): Chain { + const outBy = new Map() + const inBy = new Map() + for (const l of links) { + if (!outBy.has(l.source)) outBy.set(l.source, []) + outBy.get(l.source)!.push(l) + if (!inBy.has(l.target)) inBy.set(l.target, []) + inBy.get(l.target)!.push(l) + } + + const linkKeys = new Set([linkKey(source, target)]) + const walk = (start: string, by: Map, next: (l: ChainLink) => string) => { + const seen = new Set([start]) + let queue = [start] + while (queue.length > 0) { + const batch: string[] = [] + for (const id of queue) { + for (const l of by.get(id) ?? []) { + linkKeys.add(linkKey(l.source, l.target)) + const n = next(l) + if (!seen.has(n)) { + seen.add(n) + batch.push(n) + } + } + } + queue = batch + } + } + walk(target, outBy, (l) => l.target) + walk(source, inBy, (l) => l.source) + + const nodeIds = new Set([source, target]) + for (const l of links) { + if (linkKeys.has(linkKey(l.source, l.target))) { + nodeIds.add(l.source) + nodeIds.add(l.target) + } + } + return { nodeIds, linkKeys } +} + +/** + * The heaviest single path through every hop around `path` — the funnel + * spine for "Create funnel from this path". Seeds at the occurrence with the + * most throughput, then greedily follows the biggest link in each direction. + * Stops at (other) buckets and caps at `maxSteps` values. + */ +export function spineThrough(links: ChainLink[], path: string, maxSteps = 6): string[] { + const { fwd, bwd } = buildAdjacency(links) + const valueByKey = new Map(links.map((l) => [linkKey(l.source, l.target), l.value])) + + const seeds: string[] = [] + for (const l of links) { + if (pathOfNode(l.source) === path && !seeds.includes(l.source)) seeds.push(l.source) + if (pathOfNode(l.target) === path && !seeds.includes(l.target)) seeds.push(l.target) + } + if (seeds.length === 0) return [] + + const throughput = (id: string) => { + let sum = 0 + for (const nb of fwd.get(id) ?? []) sum += valueByKey.get(linkKey(id, nb)) ?? 0 + for (const nb of bwd.get(id) ?? []) sum += valueByKey.get(linkKey(nb, id)) ?? 0 + return sum + } + seeds.sort((a, b) => throughput(b) - throughput(a) || stepOfNode(a) - stepOfNode(b)) + const seed = seeds[0] + + const heaviest = (id: string, dir: 'fwd' | 'bwd'): string | null => { + const neighbours = (dir === 'fwd' ? fwd : bwd).get(id) + if (!neighbours) return null + let best: string | null = null + let bestValue = -1 + for (const nb of neighbours) { + if (pathOfNode(nb) === '(other)') continue + const v = valueByKey.get(dir === 'fwd' ? linkKey(id, nb) : linkKey(nb, id)) ?? 0 + if (v > bestValue) { + best = nb + bestValue = v + } + } + return best + } + + const forward: string[] = [] + let cursor: string | null = seed + while (forward.length < maxSteps && (cursor = heaviest(cursor, 'fwd'))) { + forward.push(pathOfNode(cursor)) + } + const backward: string[] = [] + cursor = seed + while (backward.length < maxSteps && (cursor = heaviest(cursor, 'bwd'))) { + backward.unshift(pathOfNode(cursor)) + } + + const spine = [...backward, path, ...forward] + // * Trim to maxSteps keeping the lens inside the window + if (spine.length > maxSteps) { + const lensIdx = backward.length + const start = Math.max(0, Math.min(lensIdx, spine.length - maxSteps)) + return spine.slice(start, start + maxSteps) + } + return spine +} + +/** + * Sessions that were on `path` at `colIdx` and made no onward hop — the + * exit count shown on the column after the lens column. + */ +export function exitCount( + transitions: PathTransition[], + colIdx: number, + path: string, + columns: AggregatedStep[], +): number { + const col = columns[colIdx] + const page = col?.pages.find((p) => p.path === path) + if (!page) return 0 + const outbound = transitions + .filter((t) => t.step_index === colIdx && t.from_path === path) + .reduce((sum, t) => sum + t.session_count, 0) + return Math.max(0, page.sessionCount - outbound) +} diff --git a/lib/journeys/sankeyLayout.ts b/lib/journeys/sankeyLayout.ts new file mode 100644 index 000000000..861b2aba8 --- /dev/null +++ b/lib/journeys/sankeyLayout.ts @@ -0,0 +1,240 @@ +import type { PathTransition } from '@/lib/api/journeys' +import { aggregateJourney } from './aggregate' +import { + buildLinks, + chainThrough, + linkKey, + nodeId, + stepOfNode, + type ChainLink, +} from './chain' + +// --------------------------------------------------------------------------- +// Pure Sankey layout for the journeys flow view. Ports SankeyJourney's +// imperative buildData + d3 positioning into testable math: thin node strips +// scaled to a shared link scale, columns justified edge-to-edge across the +// given width, link endpoints stacked within each strip, and the ?lens= +// subgraph filter (BFS both directions via chain.ts). The component only +// renders what this returns. +// --------------------------------------------------------------------------- + +export const NODE_WIDTH = 14 +const NODE_GAP = 20 +const MIN_NODE_HEIGHT = 3 +const MAX_LINK_HEIGHT = 50 +const MIN_LINK_WIDTH = 1 +const TOP_PAD = 8 +const BOTTOM_PAD = 20 +const MIN_HEIGHT = 200 + +export interface SankeyNode { + id: string + path: string + step: number + /** Left edge of the strip. */ + x: number + /** Top edge of the strip. */ + y: number + height: number + /** Sessions represented by this strip. */ + count: number +} + +export interface SankeyLink { + key: string + source: string + target: string + value: number + sourceX: number + sourceY: number + targetX: number + targetY: number + strokeWidth: number +} + +export interface SankeyStepMeta { + index: number + /** Left edge of the step's column. */ + x: number + visitors: number + dropOffPercent: number +} + +export interface SankeyLayoutResult { + nodes: SankeyNode[] + links: SankeyLink[] + steps: SankeyStepMeta[] + width: number + height: number +} + +export interface SankeyLayoutOptions { + depth: number + maxPagesPerStep: number + width: number + lens?: string | null +} + +const EMPTY = (width: number): SankeyLayoutResult => ({ + nodes: [], + links: [], + steps: [], + width, + height: MIN_HEIGHT, +}) + +export function layoutSankey( + transitions: PathTransition[], + opts: SankeyLayoutOptions, +): SankeyLayoutResult { + const { depth, maxPagesPerStep, width, lens } = opts + if (transitions.length === 0 || width <= 0) return EMPTY(width) + + const columns = aggregateJourney(transitions, { depth, maxPagesPerStep }) + if (columns.length === 0) return EMPTY(width) + + const allLinks = buildLinks(transitions, columns) + + // * Node order: aggregated pages first (count-desc per step, (other) last), + // * then any link endpoints aggregation didn't produce. + const orderedIds: string[] = [] + const seen = new Set() + const push = (id: string) => { + if (!seen.has(id)) { + seen.add(id) + orderedIds.push(id) + } + } + for (const col of columns) { + for (const page of col.pages) push(nodeId(col.index, page.path)) + } + for (const l of allLinks) { + push(l.source) + push(l.target) + } + + // * Lens: reduce to the BFS chain subgraph. Nodes survive only as endpoints + // * of surviving links (original filter semantics) — an isolated match + // * yields an empty layout and the caller falls back to the full graph. + let links = allLinks + let ids = orderedIds + if (lens) { + const chain = chainThrough(allLinks, lens) + links = allLinks.filter((l) => chain.linkKeys.has(linkKey(l.source, l.target))) + const kept = new Set() + for (const l of links) { + kept.add(l.source) + kept.add(l.target) + } + ids = orderedIds.filter((id) => kept.has(id)) + if (ids.length === 0) return EMPTY(width) + } + + const inBy = new Map() + const outBy = new Map() + for (const l of links) { + if (!outBy.has(l.source)) outBy.set(l.source, []) + outBy.get(l.source)!.push(l) + if (!inBy.has(l.target)) inBy.set(l.target, []) + inBy.get(l.target)!.push(l) + } + const sum = (ls: ChainLink[] | undefined) => (ls ?? []).reduce((s, l) => s + l.value, 0) + + // * Shared scale: the busiest link maps to MAX_LINK_HEIGHT so strips stay + // * thin rather than growing into bars. + const maxValue = Math.max(...links.map((l) => l.value), 1) + const scale = (v: number) => (v / maxValue) * MAX_LINK_HEIGHT + + const stepIndices = Array.from(new Set(ids.map((id) => stepOfNode(id)))).sort((a, b) => a - b) + const numSteps = stepIndices.length + const xForOrdinal = (ordinal: number) => + numSteps <= 1 ? 0 : ordinal * ((width - NODE_WIDTH) / (numSteps - 1)) + const stepX = new Map() + stepIndices.forEach((step, ordinal) => stepX.set(step, xForOrdinal(ordinal))) + + const nodes: SankeyNode[] = ids.map((id) => { + const step = stepOfNode(id) + const inVal = sum(inBy.get(id)) + const outVal = sum(outBy.get(id)) + return { + id, + path: id.slice(id.indexOf(':') + 1), + step, + x: stepX.get(step) ?? 0, + y: 0, + height: Math.max(scale(Math.max(inVal, outVal)), MIN_NODE_HEIGHT), + count: step === 0 ? outVal : Math.max(inVal, outVal), + } + }) + + // * Stack strips per column from the top, in node order. + let tallest = 0 + for (const step of stepIndices) { + let cy = TOP_PAD + for (const n of nodes) { + if (n.step !== step) continue + n.y = cy + cy += n.height + NODE_GAP + } + tallest = Math.max(tallest, cy - NODE_GAP) + } + const height = Math.max(MIN_HEIGHT, tallest + BOTTOM_PAD) + + // * Link endpoints stack within their strips, biggest first — each strip + // * orders its own outgoing and incoming fans independently. + const nodeById = new Map(nodes.map((n) => [n.id, n])) + const byValueDesc = (a: ChainLink, b: ChainLink) => b.value - a.value + + const sourceYByKey = new Map() + const targetYByKey = new Map() + for (const n of nodes) { + let outY = n.y + for (const l of (outBy.get(n.id) ?? []).slice().sort(byValueDesc)) { + const lh = scale(l.value) + sourceYByKey.set(linkKey(l.source, l.target), outY + lh / 2) + outY += lh + } + let inY = n.y + for (const l of (inBy.get(n.id) ?? []).slice().sort(byValueDesc)) { + const lh = scale(l.value) + targetYByKey.set(linkKey(l.source, l.target), inY + lh / 2) + inY += lh + } + } + + const laidOut: SankeyLink[] = [] + for (const l of links) { + const src = nodeById.get(l.source) + const tgt = nodeById.get(l.target) + if (!src || !tgt) continue + const key = linkKey(l.source, l.target) + laidOut.push({ + key, + source: l.source, + target: l.target, + value: l.value, + sourceX: src.x + NODE_WIDTH, + sourceY: sourceYByKey.get(key) ?? src.y + src.height / 2, + targetX: tgt.x, + targetY: targetYByKey.get(key) ?? tgt.y + tgt.height / 2, + strokeWidth: Math.max(MIN_LINK_WIDTH, scale(l.value)), + }) + } + + // * Per-step visitors (entry column counts departures, later columns count + // * arrivals) and drop-off vs the previous column. + const steps: SankeyStepMeta[] = stepIndices.map((step) => { + const visitors = nodes + .filter((n) => n.step === step) + .reduce((s, n) => s + (step === 0 ? sum(outBy.get(n.id)) : sum(inBy.get(n.id))), 0) + return { index: step, x: stepX.get(step) ?? 0, visitors, dropOffPercent: 0 } + }) + for (let i = 1; i < steps.length; i++) { + const prev = steps[i - 1].visitors + if (prev > 0) { + steps[i].dropOffPercent = Math.round(((steps[i].visitors - prev) / prev) * 100) + } + } + + return { nodes, links: laidOut, steps, width, height } +} diff --git a/lib/plans.ts b/lib/plans.ts index a082ca5f8..d9a603b02 100644 --- a/lib/plans.ts +++ b/lib/plans.ts @@ -13,6 +13,20 @@ export const PLAN_ID_SOLO = 'solo' export const PLAN_ID_TEAM = 'team' export const PLAN_ID_BUSINESS = 'business' +/** + * Display name for a subscription plan_id — the single source of truth. + * Ad-hoc ternaries drift (the Privacy tab once recognised only 'pro' and + * labelled a Pioneer org "Free"). + * + * plan_id shapes seen in the wild: 'free' (marketing name: Hobby), legacy + * Stripe 'price_…' ids (Pro), and plain ids like 'solo' / 'team' / 'pioneer'. + */ +export function formatPlanName(planId?: string | null): string { + if (!planId || planId === 'free') return 'Hobby' + if (planId.startsWith('price_')) return 'Pro' + return planId.charAt(0).toUpperCase() + planId.slice(1) +} + /** Sites limit per plan. */ export function getSitesLimitForPlan(planId: string | null | undefined): number | null { if (!planId || planId === 'free') return 1 diff --git a/lib/swr/dashboard.ts b/lib/swr/dashboard.ts index 09296b7e9..1d4247fcd 100644 --- a/lib/swr/dashboard.ts +++ b/lib/swr/dashboard.ts @@ -18,6 +18,9 @@ import { getStats, getDailyStats, getBehavior, + getRageClicks, + getDeadClicks, + getFrustrationDaily, type RealtimePageVisitors, } from '@/lib/api/stats' import { @@ -28,7 +31,7 @@ import { } from '@/lib/api/journeys' import { getSite } from '@/lib/api/sites' import type { Site } from '@/lib/api/sites' -import { listFunnels, getFunnel, getFunnelStats, getFunnelTrends, type Funnel, type FunnelStats, type FunnelTrends } from '@/lib/api/funnels' +import { listFunnels, getFunnel, getFunnelStats, getFunnelTrends, getFunnelBreakdown, type Funnel, type FunnelStats, type FunnelTrends, type FunnelBreakdown } from '@/lib/api/funnels' import { getUptimeStatus, type UptimeStatusResponse } from '@/lib/api/uptime' import { getPageSpeedConfig, getPageSpeedLatest, getPageSpeedHistory, type PageSpeedConfig, type PageSpeedCheck } from '@/lib/api/pagespeed' import { listGoals, type Goal } from '@/lib/api/goals' @@ -44,8 +47,8 @@ import { type SessionSummary, type QuarantineFilters, } from '@/lib/api/quarantine' -import { getGSCStatus, getGSCOverview, getGSCTopQueries, getGSCTopPages, getGSCDailyTotals, getGSCNewQueries, getGSCTopCountries, getGSCTopDevices, getGSCOpportunities } from '@/lib/api/gsc' -import type { GSCStatus, GSCOverview, GSCQueryResponse, GSCPageResponse, GSCDailyTotal, GSCNewQueries, GSCCountryResponse, GSCDeviceResponse, GSCOpportunityResponse } from '@/lib/api/gsc' +import { getGSCStatus, getGSCOverview, getGSCTopQueries, getGSCTopPages, getGSCDailyTotals, getGSCNewQueries, getGSCTopCountries, getGSCTopDevices, getGSCOpportunities, getGSCQueryPages, getGSCPageQueries, getGSCQueryTrend } from '@/lib/api/gsc' +import type { GSCStatus, GSCOverview, GSCQueryResponse, GSCPageResponse, GSCDailyTotal, GSCNewQueries, GSCCountryResponse, GSCDeviceResponse, GSCOpportunityResponse, GSCQueryTrendPoint } 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' @@ -61,6 +64,8 @@ import type { DashboardReferrersData, DashboardGoalsData, BehaviorData, + FrustrationElement, + FrustrationDailyPoint, } from '@/lib/api/stats' // * SWR fetcher functions @@ -80,10 +85,10 @@ const fetchers = { campaigns: (siteId: string, start: string, end: string, limit: number) => getCampaigns(siteId, start, end, limit), behavior: (siteId: string, start: string, end: string) => getBehavior(siteId, start, end), - journeyTransitions: (siteId: string, start: string, end: string, depth?: number, minSessions?: number, entryPath?: string) => - getJourneyTransitions(siteId, start, end, { depth, minSessions, entryPath }), - journeyEntryPoints: (siteId: string, start: string, end: string) => - getJourneyEntryPoints(siteId, start, end), + journeyTransitions: (siteId: string, start: string, end: string, depth?: number, minSessions?: number, entryPath?: string, filters?: string) => + getJourneyTransitions(siteId, start, end, { depth, minSessions, entryPath, filters }), + journeyEntryPoints: (siteId: string, start: string, end: string, filters?: string) => + getJourneyEntryPoints(siteId, start, end, filters), funnels: (siteId: string) => listFunnels(siteId), uptimeStatus: (siteId: string) => getUptimeStatus(siteId), pageSpeedConfig: (siteId: string) => getPageSpeedConfig(siteId), @@ -333,32 +338,80 @@ export function useBehavior(siteId: string, start: string, end: string, period?: ...dashboardSWRConfig, refreshInterval: 60 * 1000, dedupingInterval: 10 * 1000, + keepPreviousData: true, } ) } +// * Hook for the daily rage/dead frustration series (behavior trend chart). +export function useFrustrationDaily(siteId: string, start: string, end: string) { + return useSWR<{ days: FrustrationDailyPoint[] }>( + siteId && start && end ? ['frustrationDaily', siteId, start, end] : null, + () => getFrustrationDaily(siteId, start, end), + { ...dashboardSWRConfig, keepPreviousData: true }, + ) +} + +// * Lens/dialog hooks for FILTERED frustration elements (behavior page ?page= lens +// * and the view-all dialog). `enabled` gates the fetch so the un-lensed tables +// * render the useBehavior payload with no extra request; keepPreviousData holds +// * the previous page's rows while a new lens loads. +export function useRageClicks( + siteId: string, + start: string, + end: string, + limit: number, + pagePath?: string, + enabled: boolean = true, +) { + return useSWR<{ items: FrustrationElement[]; total: number }>( + enabled && siteId && start && end ? ['rageClicks', siteId, start, end, limit, pagePath ?? ''] : null, + () => getRageClicks(siteId, start, end, limit, pagePath), + { ...dashboardSWRConfig, keepPreviousData: true }, + ) +} + +export function useDeadClicks( + siteId: string, + start: string, + end: string, + limit: number, + pagePath?: string, + enabled: boolean = true, +) { + return useSWR<{ items: FrustrationElement[]; total: number }>( + enabled && siteId && start && end ? ['deadClicks', siteId, start, end, limit, pagePath ?? ''] : null, + () => getDeadClicks(siteId, start, end, limit, pagePath), + { ...dashboardSWRConfig, keepPreviousData: true }, + ) +} + // * Hook for journey flow transitions (Sankey diagram data) -export function useJourneyTransitions(siteId: string, start: string, end: string, depth?: number, minSessions?: number, entryPath?: string) { +export function useJourneyTransitions(siteId: string, start: string, end: string, depth?: number, minSessions?: number, entryPath?: string, filters?: string) { return useSWR( - siteId && start && end ? ['journeyTransitions', siteId, start, end, depth, minSessions, entryPath] : null, - () => fetchers.journeyTransitions(siteId, start, end, depth, minSessions, entryPath), + siteId && start && end ? ['journeyTransitions', siteId, start, end, depth, minSessions, entryPath, filters] : null, + () => fetchers.journeyTransitions(siteId, start, end, depth, minSessions, entryPath, filters), { ...dashboardSWRConfig, refreshInterval: 60 * 1000, dedupingInterval: 10 * 1000, + // * Depth/entry/period changes keep the canvas rendered with the previous + // * data while the new key loads — no full-page skeleton after first load. + keepPreviousData: true, } ) } // * Hook for journey entry points (refreshes less frequently) -export function useJourneyEntryPoints(siteId: string, start: string, end: string) { +export function useJourneyEntryPoints(siteId: string, start: string, end: string, filters?: string) { return useSWR( - siteId && start && end ? ['journeyEntryPoints', siteId, start, end] : null, - () => fetchers.journeyEntryPoints(siteId, start, end), + siteId && start && end ? ['journeyEntryPoints', siteId, start, end, filters] : null, + () => fetchers.journeyEntryPoints(siteId, start, end, filters), { ...dashboardSWRConfig, refreshInterval: 5 * 60 * 1000, dedupingInterval: 30 * 1000, + keepPreviousData: true, } ) } @@ -372,6 +425,7 @@ export function useFunnels(siteId: string) { ...dashboardSWRConfig, refreshInterval: 60 * 1000, dedupingInterval: 10 * 1000, + keepPreviousData: true, } ) } @@ -417,6 +471,29 @@ export function useFunnelTrends(siteId: string, funnelId: string, startDate: str ) } +// * Hook for a funnel step's dimension breakdown (step is 0-based, per the API) +export function useFunnelBreakdown( + siteId: string, + funnelId: string, + step: number, + dimension: string, + startDate: string, + endDate: string, + filters?: string, +) { + return useSWR( + siteId && funnelId && step >= 0 && dimension && startDate && endDate + ? ['funnelBreakdown', siteId, funnelId, step, dimension, `${startDate}-${endDate}`, filters] + : null, + () => getFunnelBreakdown(siteId, funnelId, step, dimension, startDate, endDate, filters), + { + ...dashboardSWRConfig, + dedupingInterval: 30_000, + keepPreviousData: true, + } + ) +} + // * Hook for uptime status (refreshes every 30s to match original polling) export function useUptimeStatus(siteId: string) { return useSWR( @@ -488,7 +565,7 @@ export function useGSCOverview(siteId: string, start: string, end: string) { return useSWR( siteId && start && end ? ['gscOverview', siteId, start, end] : null, () => fetchers.gscOverview(siteId, start, end), - dashboardSWRConfig + { ...dashboardSWRConfig, keepPreviousData: true } ) } @@ -497,7 +574,7 @@ export function useGSCTopQueries(siteId: string, start: string, end: string, lim return useSWR( siteId && start && end ? ['gscTopQueries', siteId, start, end, limit, offset] : null, () => fetchers.gscTopQueries(siteId, start, end, limit, offset), - dashboardSWRConfig + { ...dashboardSWRConfig, keepPreviousData: true } ) } @@ -506,7 +583,7 @@ export function useGSCTopPages(siteId: string, start: string, end: string, limit return useSWR( siteId && start && end ? ['gscTopPages', siteId, start, end, limit, offset] : null, () => fetchers.gscTopPages(siteId, start, end, limit, offset), - dashboardSWRConfig + { ...dashboardSWRConfig, keepPreviousData: true } ) } @@ -515,7 +592,7 @@ 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 + { ...dashboardSWRConfig, keepPreviousData: true } ) } @@ -524,7 +601,7 @@ export function useGSCNewQueries(siteId: string, start: string, end: string) { return useSWR( siteId && start && end ? ['gscNewQueries', siteId, start, end] : null, () => fetchers.gscNewQueries(siteId, start, end), - dashboardSWRConfig + { ...dashboardSWRConfig, keepPreviousData: true } ) } @@ -534,7 +611,7 @@ export function useGSCTopCountries(siteId: string, start: string, end: string, l return useSWR( status?.connected ? [`gsc-top-countries`, siteId, start, end, limit, offset] : null, () => getGSCTopCountries(siteId, start, end, limit, offset), - { revalidateOnFocus: false } + { ...dashboardSWRConfig, keepPreviousData: true } ) } @@ -544,7 +621,7 @@ export function useGSCTopDevices(siteId: string, start: string, end: string) { return useSWR( status?.connected ? [`gsc-top-devices`, siteId, start, end] : null, () => getGSCTopDevices(siteId, start, end), - { revalidateOnFocus: false } + { ...dashboardSWRConfig, keepPreviousData: true } ) } @@ -554,7 +631,39 @@ export function useGSCOpportunities(siteId: string, start: string, end: string, return useSWR( status?.connected ? [`gsc-opportunities`, siteId, start, end, limit] : null, () => getGSCOpportunities(siteId, start, end, limit), - { revalidateOnFocus: false } + { ...dashboardSWRConfig, keepPreviousData: true } + ) +} + +// * Drill-down: the pages contributing to a query (queries-view expansion). The +// * key includes the query, so each expanded row owns its own cache entry — two +// * quick expands can't cross-render (the old shared-state race). Null key while +// * collapsed (query empty); keepPreviousData holds the prior pages across a +// * range revalidation. +export function useGSCQueryPages(siteId: string, query: string, start: string, end: string) { + return useSWR( + siteId && query && start && end ? ['gscQueryPages', siteId, query, start, end] : null, + () => getGSCQueryPages(siteId, query, start, end), + { ...dashboardSWRConfig, keepPreviousData: true }, + ) +} + +// * Drill-down: the queries contributing to a page (pages-view expansion). +export function useGSCPageQueries(siteId: string, page: string, start: string, end: string) { + return useSWR( + siteId && page && start && end ? ['gscPageQueries', siteId, page, start, end] : null, + () => getGSCPageQueries(siteId, page, start, end), + { ...dashboardSWRConfig, keepPreviousData: true }, + ) +} + +// * A single query's daily position/clicks trend (queries-view sparkline). Null +// * key until the row is expanded (query empty). +export function useGSCQueryTrend(siteId: string, query: string, start: string, end: string) { + return useSWR( + siteId && query && start && end ? ['gscQueryTrend', siteId, query, start, end] : null, + () => getGSCQueryTrend(siteId, query, start, end), + { ...dashboardSWRConfig, keepPreviousData: true }, ) } @@ -572,7 +681,7 @@ export function useBunnyOverview(siteId: string, startDate: string, endDate: str return useSWR( siteId && startDate && endDate ? ['bunnyOverview', siteId, startDate, endDate] : null, () => fetchers.bunnyOverview(siteId, startDate, endDate), - dashboardSWRConfig + { ...dashboardSWRConfig, keepPreviousData: true } ) } @@ -581,7 +690,7 @@ export function useBunnyDailyStats(siteId: string, startDate: string, endDate: s return useSWR<{ daily_stats: BunnyDailyRow[] }>( siteId && startDate && endDate ? ['bunnyDailyStats', siteId, startDate, endDate] : null, () => fetchers.bunnyDailyStats(siteId, startDate, endDate), - dashboardSWRConfig + { ...dashboardSWRConfig, keepPreviousData: true } ) } @@ -590,7 +699,7 @@ export function useBunnyTopCountries(siteId: string, startDate: string, endDate: return useSWR<{ countries: BunnyGeoRow[] }>( siteId && startDate && endDate ? ['bunnyTopCountries', siteId, startDate, endDate] : null, () => fetchers.bunnyTopCountries(siteId, startDate, endDate), - dashboardSWRConfig + { ...dashboardSWRConfig, keepPreviousData: true } ) } diff --git a/lib/utils/__tests__/bunnyDatacenter.test.ts b/lib/utils/__tests__/bunnyDatacenter.test.ts new file mode 100644 index 000000000..5866e5089 --- /dev/null +++ b/lib/utils/__tests__/bunnyDatacenter.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'vitest' +import { extractCountryCode, extractCity } from '../bunnyDatacenter' + +describe('extractCountryCode', () => { + it('returns the ISO country for non-NA regions', () => { + expect(extractCountryCode('EU: Zurich, CH')).toBe('CH') + // * DE is Germany here, not Delaware + expect(extractCountryCode('EU: Frankfurt, DE')).toBe('DE') + // * NL is the Netherlands here, not Newfoundland + expect(extractCountryCode('EU: Amsterdam, NL')).toBe('NL') + expect(extractCountryCode('AS: Singapore, SG')).toBe('SG') + expect(extractCountryCode('SA: Sao Paulo, BR')).toBe('BR') + }) + + it('maps NA state and province suffixes to their country', () => { + expect(extractCountryCode('NA: Chicago, IL')).toBe('US') + expect(extractCountryCode('NA: Ashburn, VA')).toBe('US') + expect(extractCountryCode('NA: Toronto, ON')).toBe('CA') + }) + + it('breaks the CA ambiguity inside NA by city', () => { + // * California + expect(extractCountryCode('NA: Los Angeles, CA')).toBe('US') + // * Canada-as-country suffix + expect(extractCountryCode('NA: Toronto, CA')).toBe('CA') + expect(extractCountryCode('NA: Vancouver, CA')).toBe('CA') + }) + + it('passes plain country suffixes through inside NA', () => { + expect(extractCountryCode('NA: Mexico City, MX')).toBe('MX') + }) + + it('normalizes Bunny\'s UK to ISO GB so the flag set resolves', () => { + expect(extractCountryCode('EU: London, UK')).toBe('GB') + }) + + it('returns empty for unparseable strings', () => { + expect(extractCountryCode('')).toBe('') + expect(extractCountryCode('Zurich')).toBe('') + expect(extractCountryCode('EU: Somewhere, ABC')).toBe('') + }) +}) + +describe('extractCity', () => { + it('extracts the city between region and code', () => { + expect(extractCity('EU: Zurich, CH')).toBe('Zurich') + expect(extractCity('NA: Los Angeles, CA')).toBe('Los Angeles') + }) + + it('falls back to the raw string without a region prefix', () => { + expect(extractCity('Zurich')).toBe('Zurich') + }) +}) diff --git a/lib/utils/__tests__/countryCodes.test.ts b/lib/utils/__tests__/countryCodes.test.ts new file mode 100644 index 000000000..77eeb8d1a --- /dev/null +++ b/lib/utils/__tests__/countryCodes.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from 'vitest' +import { alpha3ToAlpha2, countryName } from '../countryCodes' + +describe('countryCodes', () => { + it('alpha3ToAlpha2 maps known ISO 3166-1 codes', () => { + expect(alpha3ToAlpha2('USA')).toBe('US') + expect(alpha3ToAlpha2('GBR')).toBe('GB') + expect(alpha3ToAlpha2('DEU')).toBe('DE') + }) + + it('alpha3ToAlpha2 is case-insensitive', () => { + expect(alpha3ToAlpha2('usa')).toBe('US') + expect(alpha3ToAlpha2('gbr')).toBe('GB') + }) + + it('alpha3ToAlpha2 returns null for unknown or empty codes', () => { + expect(alpha3ToAlpha2('ZZZ')).toBeNull() + expect(alpha3ToAlpha2('')).toBeNull() + }) + + it('countryName resolves display names', () => { + expect(countryName('USA')).toMatch(/United States/) + expect(countryName('DEU')).toBe('Germany') + }) + + it('countryName falls back to the raw code when unknown', () => { + expect(countryName('ZZZ')).toBe('ZZZ') + }) +}) diff --git a/lib/utils/__tests__/pctChange.test.ts b/lib/utils/__tests__/pctChange.test.ts new file mode 100644 index 000000000..566a2f041 --- /dev/null +++ b/lib/utils/__tests__/pctChange.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from 'vitest' +import { pctChange, guardedPctChange } from '../pctChange' + +describe('pctChange', () => { + it('returns null when both are zero', () => { + expect(pctChange(0, 0)).toBeNull() + }) + + it('returns "new" when previous is zero and current is not', () => { + expect(pctChange(5, 0)).toEqual({ type: 'new' }) + }) + + it('computes rounded percentage change', () => { + expect(pctChange(150, 100)).toEqual({ type: 'pct', value: 50 }) + expect(pctChange(50, 100)).toEqual({ type: 'pct', value: -50 }) + expect(pctChange(101, 300)).toEqual({ type: 'pct', value: -66 }) + }) +}) + +describe('guardedPctChange', () => { + it('suppresses the badge below the minimum base', () => { + // 1 → 3 visitors is "↑200%" — noise, not signal + expect(guardedPctChange(3, 1, 1)).toBeNull() + expect(guardedPctChange(90, 9, 9)).toBeNull() + }) + + it('passes through at or above the minimum base', () => { + expect(guardedPctChange(20, 10, 10)).toEqual({ type: 'pct', value: 100 }) + expect(guardedPctChange(50, 100, 100)).toEqual({ type: 'pct', value: -50 }) + }) + + it('suppresses "new" badges on an insufficient base', () => { + // previous metric 0 but the whole window had < minBase visitors + expect(guardedPctChange(5, 0, 3)).toBeNull() + }) + + it('respects a custom minimum base', () => { + expect(guardedPctChange(3, 1, 5, 5)).toEqual({ type: 'pct', value: 200 }) + expect(guardedPctChange(3, 1, 4, 5)).toBeNull() + }) + + it('still returns null when both values are zero on a big base', () => { + expect(guardedPctChange(0, 0, 100)).toBeNull() + }) +}) diff --git a/lib/utils/bunnyDatacenter.ts b/lib/utils/bunnyDatacenter.ts new file mode 100644 index 000000000..e4b02006a --- /dev/null +++ b/lib/utils/bunnyDatacenter.ts @@ -0,0 +1,45 @@ +// --------------------------------------------------------------------------- +// BunnyCDN datacenter string parsing ("EU: Zurich, CH", "NA: Chicago, IL"). +// The trailing token is a US state or Canadian province ONLY on North American +// datacenters; everywhere else it is an ISO country code — interpreting "DE" +// as Delaware or "NL" as Newfoundland outside NA misflags Frankfurt and +// Amsterdam as US/CA traffic. +// --------------------------------------------------------------------------- + +const US_STATES = new Set([ + 'AL','AK','AZ','AR','CA','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', +]) + +const CA_PROVINCES = new Set(['AB','BC','MB','NB','NL','NS','NT','NU','ON','PE','QC','SK','YT']) + +// * Bunny's Canadian PoPs can carry a ", CA" country suffix, which collides +// * with California inside the NA region — the city breaks the tie. +const CANADIAN_CITIES = new Set(['TORONTO', 'VANCOUVER', 'MONTREAL', 'CALGARY', 'OTTAWA']) + +/** ISO country code for a Bunny datacenter string, '' when unparseable. */ +export function extractCountryCode(datacenter: string): string { + const parts = datacenter.split(', ') + const code = parts[parts.length - 1]?.trim().toUpperCase() + if (!code || code.length !== 2) return '' + + const region = datacenter.includes(':') ? datacenter.split(':')[0].trim().toUpperCase() : '' + if (region === 'NA') { + if (code === 'CA') { + return CANADIAN_CITIES.has(extractCity(datacenter).toUpperCase()) ? 'CA' : 'US' + } + if (US_STATES.has(code)) return 'US' + if (CA_PROVINCES.has(code)) return 'CA' + } + // * Bunny writes "UK" for London; ISO 3166 (and the flag set) use GB. + if (code === 'UK') return 'GB' + return code +} + +/** City name from a Bunny datacenter string, e.g. "EU: Zurich, CH" → "Zurich". */ +export function extractCity(datacenter: string): string { + const afterColon = datacenter.split(': ')[1] || datacenter + return afterColon.split(',')[0]?.trim() || datacenter +} diff --git a/lib/utils/countryCodes.ts b/lib/utils/countryCodes.ts new file mode 100644 index 000000000..434449f75 --- /dev/null +++ b/lib/utils/countryCodes.ts @@ -0,0 +1,24 @@ +// Country code helpers shared by the Search Console views (GSC returns alpha-3 +// codes; the flag icon set and display names key on alpha-2). Extracted from the +// dashboard SearchPerformance widget's proven logic so both surfaces agree — +// full ISO 3166-1 coverage via i18n-iso-countries, names via Intl.DisplayNames. + +import countries from 'i18n-iso-countries' + +const regionNames = new Intl.DisplayNames(['en'], { type: 'region' }) + +/** Alpha-3 → alpha-2 (e.g. "USA" → "US"), or null for unknown codes. */ +export function alpha3ToAlpha2(alpha3: string): string | null { + return countries.alpha3ToAlpha2(alpha3.toUpperCase()) ?? null +} + +/** Human country name for an alpha-3 code, falling back to the raw code. */ +export function countryName(alpha3: string): string { + const a2 = alpha3ToAlpha2(alpha3) + if (!a2) return alpha3 + try { + return regionNames.of(a2) ?? alpha3 + } catch { + return alpha3 + } +} diff --git a/lib/utils/favicon.ts b/lib/utils/favicon.ts index 8e77df8f0..851c7dd9b 100644 --- a/lib/utils/favicon.ts +++ b/lib/utils/favicon.ts @@ -1,8 +1,12 @@ /** - * Google's public favicon service base URL. - * Append `?domain=&sz=` to get a favicon. + * Same-origin favicon proxy endpoint (see app/api/favicon/route.ts). + * Append `?domain=&sz=` to get a favicon (sz ∈ 16/32/64/128). + * + * Never point this at a third-party service directly: favicons render in the + * authenticated app, so a third-party URL leaks every customer's referrer and + * page domains (plus the user's IP) to that party on each dashboard view. * * Kept in a separate module so server components can import it * without pulling in the React-dependent icon registry. */ -export const FAVICON_SERVICE_URL = 'https://www.google.com/s2/favicons' +export const FAVICON_SERVICE_URL = '/api/favicon' diff --git a/lib/utils/format.ts b/lib/utils/format.ts index b08e53c82..96d244841 100644 --- a/lib/utils/format.ts +++ b/lib/utils/format.ts @@ -50,3 +50,12 @@ export function formatDuration(seconds: number): string { if (m > 0) return `${m}m ${s}s` return `${s}s` } + +/** Coarse duration for funnel conversion times — "45s", "12m", "2h 5m". */ +export function formatConvertTime(seconds: number): string { + if (seconds < 60) return `${Math.round(seconds)}s` + if (seconds < 3600) return `${Math.round(seconds / 60)}m` + const h = Math.floor(seconds / 3600) + const m = Math.round((seconds % 3600) / 60) + return m > 0 ? `${h}h ${m}m` : `${h}h` +} diff --git a/lib/utils/icons.tsx b/lib/utils/icons.tsx index f85ce511d..e6755642f 100644 --- a/lib/utils/icons.tsx +++ b/lib/utils/icons.tsx @@ -8,7 +8,14 @@ import { Desktop, Link, CursorClick, + MapPin, + Buildings, + Broadcast, + FileText, + Tag, + FrameCorners, } from '@phosphor-icons/react' +import * as Flags from 'country-flag-icons/react/3x2' const ICON_SIZE = 20 function brandIcon(slug: string, alt: string) { @@ -302,6 +309,58 @@ export function getReferrerFavicon(referrer: string): string | null { } } +// ─── Filter value icons ─────────────────────────────────────────────────────── + +/** Country flag for an ISO code; special aggregate codes fall back to a globe. */ +export function getCountryFlagIcon(code: string): ReactNode { + if (!code || code === 'Unknown') return + const FlagComponent = (Flags as Record>)[code.toUpperCase()] + return FlagComponent ? : +} + +/** + * Icon for a filter-suggestion value, matching the dashboard panels' + * iconography (flags in Locations, brand icons in TechSpecs, favicons in + * Referrers) so the filter popover reads as the same product. + */ +export function getFilterValueIcon(dimension: string, value: string): ReactNode { + switch (dimension) { + case 'country': + return getCountryFlagIcon(value) + case 'browser': + return getBrowserIcon(value) + case 'os': + return getOSIcon(value) + case 'device': + return getDeviceIcon(value) + case 'referrer': { + const favicon = getReferrerFavicon(value) + if (favicon) return + return getReferrerIcon(value) + } + case 'channel': + return + case 'page': + return + case 'region': + return + case 'city': + return + case 'screen_resolution': + return + case 'event_name': + return + case 'utm_source': + case 'utm_medium': + case 'utm_campaign': + case 'utm_term': + case 'utm_content': + return + default: + return + } +} + /** * Merges referrer rows that share the same display name (e.g. chatgpt.com and https://chatgpt.com/...), * summing pageviews and keeping one referrer per group for icon/tooltip. Sorted by pageviews desc. diff --git a/lib/utils/pctChange.ts b/lib/utils/pctChange.ts index 5de728336..bc3b3e5d2 100644 --- a/lib/utils/pctChange.ts +++ b/lib/utils/pctChange.ts @@ -8,3 +8,18 @@ export function pctChange(current: number, previous: number): PctChangeResult { if (previous === 0) return { type: 'new' } return { type: 'pct', value: Math.round(((current - previous) / previous) * 100) } } + +/** + * pctChange, but null (no badge) when the comparison base is too small to be + * meaningful — a 1→3 visitor week is "↑200%" only in the least useful sense. + * Mirrors the dashboard KPI rule (Chart.tsx: previous-window visitors < 10). + */ +export function guardedPctChange( + current: number, + previous: number, + previousBase: number, + minBase = 10, +): PctChangeResult { + if (previousBase < minBase) return null + return pctChange(current, previous) +} diff --git a/middleware.ts b/middleware.ts index 0392db099..2924e5e68 100644 --- a/middleware.ts +++ b/middleware.ts @@ -64,8 +64,10 @@ export const config = { * Match all routes except: * - _next/static, _next/image (Next.js internals) * - favicon.ico, manifest.json, icons, images (static assets) + * - sw.js, workbox-*.js (service worker — a redirect here breaks registration + * with a SecurityError; must be reachable without auth on every page) * - api routes (handled by their own auth) */ - '/((?!_next/static|_next/image|favicon\\.ico|manifest\\.json|sitemap\\.xml|robots\\.txt|llms\\.txt|build-id\\.json|.*\\.png$|.*\\.svg$|.*\\.ico$|api/).*)', + '/((?!_next/static|_next/image|favicon\\.ico|manifest\\.json|sitemap\\.xml|robots\\.txt|llms\\.txt|build-id\\.json|sw\\.js$|workbox-.*\\.js$|.*\\.png$|.*\\.svg$|.*\\.ico$|api/).*)', ], } diff --git a/next.config.ts b/next.config.ts index e10f968c0..5f72e3210 100644 --- a/next.config.ts +++ b/next.config.ts @@ -14,9 +14,12 @@ const cspDirectives = [ // Next.js requires 'unsafe-inline' for its bootstrap scripts; 'unsafe-eval' only in dev (HMR) `script-src 'self' 'unsafe-inline' https://js.ciphera.net https://api.help.ciphera.net${process.env.NODE_ENV === 'development' ? " 'unsafe-eval'" : ''}`, "style-src 'self' 'unsafe-inline'", - "img-src 'self' data: blob: https://www.google.com https://*.gstatic.com https://ciphera.net https://captcha.ciphera.net https://*.cartocdn.com https://cdn.ciphera.net", + // * google/gstatic were only ever here for favicons — those now go through + // * the same-origin /api/favicon proxy, and CSP enforces that nothing + // * regresses to loading them from Google directly. + "img-src 'self' data: blob: https://ciphera.net https://captcha.ciphera.net https://*.cartocdn.com https://cdn.ciphera.net", "font-src 'self'", - `connect-src 'self' https://*.ciphera.net wss://*.ciphera.net https://ciphera.net https://www.google.com https://*.gstatic.com https://cdn.jsdelivr.net https://*.cartocdn.com${process.env.NODE_ENV === 'development' ? ' http://localhost:* ws://localhost:*' : ''}`, + `connect-src 'self' https://*.ciphera.net wss://*.ciphera.net https://ciphera.net https://cdn.jsdelivr.net https://*.cartocdn.com${process.env.NODE_ENV === 'development' ? ' http://localhost:* ws://localhost:*' : ''}`, "worker-src 'self' blob:", "frame-src https://api.help.ciphera.net", "object-src 'none'", @@ -61,7 +64,9 @@ const nextConfig: NextConfig = { { // * Prevent CDN/browser from serving stale HTML after deploys. // * Static assets (/_next/static/*) are content-hashed and cached separately by Next.js. - source: '/((?!_next/static|_next/image).*)', + // * /api/favicon sets its own long-lived Cache-Control (the CDN must cache + // * it, or every favicon render becomes an origin hit + upstream fetch). + source: '/((?!_next/static|_next/image|api/favicon).*)', headers: [ { key: 'Cache-Control', value: 'no-cache, must-revalidate' }, ], diff --git a/package-lock.json b/package-lock.json index a5ea008c9..573bb8d46 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "@ciphera-net/facet": "^0.2.0", "@ciphera-net/facet-tailwind": "^0.1.2", "@ciphera-net/facet-tokens": "^0.0.3", - "@ciphera-net/support-widget": "^0.9.0", + "@ciphera-net/support-widget": "^0.10.0", "@ducanh2912/next-pwa": "^10.2.9", "@phosphor-icons/react": "^2.1.10", "@radix-ui/react-dialog": "^1.1.15", @@ -1687,9 +1687,9 @@ } }, "node_modules/@ciphera-net/facet": { - "version": "0.2.0", - "resolved": "https://npm.pkg.github.com/download/@ciphera-net/facet/0.2.0/01579ada894336a7c99cacce88313bb6da8393cb", - "integrity": "sha512-kYTeOFuz29i4U4ArWBymH/ts5HD+fySQaQnmyHraUwU5h0rvVsdI+3P3UdeeKItc8iWDjO6Quaw4zybHzmoiCQ==", + "version": "0.2.2", + "resolved": "https://npm.pkg.github.com/download/@ciphera-net/facet/0.2.2/46230ea21c44f968c92c6d157ee04503237c8158", + "integrity": "sha512-vVcofISIA8b7A31dVjYBWY2o1Eg4IQ4Zii32hxaXR8QH7sXVWSxBNHrFzMxrZQ2hfdqyS0pHVsB6Z3NuFeYy8A==", "dependencies": { "@ciphera-net/facet-tokens": "0.0.3", "@phosphor-icons/react": "^2.1.10", @@ -2113,9 +2113,9 @@ "license": "MIT" }, "node_modules/@ciphera-net/support-widget": { - "version": "0.9.0", - "resolved": "https://npm.pkg.github.com/download/@ciphera-net/support-widget/0.9.0/af8b780aa8eefe3db7a58cc8a47c1cfa38118942", - "integrity": "sha512-skcF6rEBV+Y7rBWCbGaxmuZrg/+mULY4EkZZdQ7VjRSCOM+67j/5umvIHWBN9Vbz3SOEiThfG7Oa19PkQQGRrg==", + "version": "0.10.0", + "resolved": "https://npm.pkg.github.com/download/@ciphera-net/support-widget/0.10.0/b7ba2a287ee0c4b62810ab0337b3f3960c931e0e", + "integrity": "sha512-rOw8jH2gsF67opidv03+QsE03gLgOqkL+/v3NP9QtNrPBrpZZx+lkLT4mdsXOJCYF3UQx01CEgzNn9mWBIMxlg==", "dependencies": { "clsx": "^2.1.1" }, diff --git a/package.json b/package.json index 12a143038..60fd0b97f 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "@ciphera-net/facet": "^0.2.0", "@ciphera-net/facet-tailwind": "^0.1.2", "@ciphera-net/facet-tokens": "^0.0.3", - "@ciphera-net/support-widget": "^0.9.0", + "@ciphera-net/support-widget": "^0.10.0", "@ducanh2912/next-pwa": "^10.2.9", "@phosphor-icons/react": "^2.1.10", "@radix-ui/react-dialog": "^1.1.15",