178 Commits

Author SHA1 Message Date
Usman
484300c307 Merge pull request #43 from ciphera-net/staging
Release 0.15.0-alpha
2026-03-13 00:13:41 +01:00
Usman Baig
9fb19c18e8 chore: release 0.15.0-alpha 2026-03-13 00:12:13 +01:00
Usman Baig
0112004457 fix: depth default 3 max 5, min_sessions 2, 5 nodes per column, stricter labels 2026-03-12 23:58:56 +01:00
Usman Baig
063a21adeb feat: cap nodes per column, dynamic SVG height, smart labels, thinner exit node 2026-03-12 23:46:24 +01:00
Usman Baig
90de83ad6d fix: lower min_sessions from 3 to 1 for journey data 2026-03-12 23:30:12 +01:00
Usman Baig
a3fa48732a fix: correct ribbon y-offset — d3-sankey y0/y1 are center, not top 2026-03-12 23:20:33 +01:00
Usman Baig
a637d32446 revert: remove frontend same-page filter, backend fix handles this 2026-03-12 23:12:12 +01:00
Usman Baig
df394b85ef fix: filter out same-page transitions (reloads) from Sankey 2026-03-12 23:08:30 +01:00
Usman Baig
4e7c495160 fix: use SVG-level onMouseMove with data attrs for reliable hover 2026-03-12 23:04:08 +01:00
Usman Baig
9c8943d1e3 fix: rewrite hover state to single object, fix link dimming on node hover 2026-03-12 22:56:13 +01:00
Usman Baig
e7debdeb41 fix: consolidate exit nodes into single (exit) node 2026-03-12 22:52:24 +01:00
Usman Baig
3df93bb227 fix: link color from source node, fix hover dimming, labels trigger hover 2026-03-12 22:48:31 +01:00
Usman Baig
3bde3fd4e1 fix: only highlight links on node hover, not other nodes 2026-03-12 22:40:40 +01:00
Usman Baig
5cdf353233 feat: add node hover highlighting with connection dimming 2026-03-12 22:37:40 +01:00
Usman Baig
683bbce817 fix: thick node bars, multi-hue palette, higher link opacity, more padding 2026-03-12 22:31:05 +01:00
Usman Baig
828e930a69 fix: Sankey visual overhaul — lower link opacity, column color gradient, breathing room
- Links: 18% opacity default (was 60%), 45% on hover, grey for exit links
- Nodes: column-based orange gradient (bright entry → dark deep), stroke outline
- Labels: larger font, better padding, higher contrast backgrounds
- Layout: more vertical padding, wider node gap (24px)
2026-03-12 22:23:52 +01:00
Usman Baig
54daf14c6a feat: replace MUI X Charts Pro with d3-sankey custom Sankey
Remove paid MUI dependency. Use d3-sankey (MIT, ~5KB) for layout
algorithm + custom SVG rendering. Same visual quality: smooth bezier
ribbon links, proper node spacing via sankeyJustify, label backgrounds,
hover dimming, exit nodes.
2026-03-12 22:17:16 +01:00
Usman Baig
281a9f237a feat: replace custom Sankey SVG with MUI X Charts Pro Sankey 2026-03-12 22:07:20 +01:00
Usman Baig
4b10f8c1fc fix: refine Sankey visual — thinner nodes, subtle links, orange on hover 2026-03-12 21:58:50 +01:00
Usman Baig
31286c45f4 fix: use brand orange for Sankey diagram nodes and links 2026-03-12 21:51:07 +01:00
Usman Baig
908606ade2 fix: make journey empty states consistent with dashboard blocks 2026-03-12 21:49:31 +01:00
Usman Baig
4cd9544672 fix(journeys): use correct session_count property in entry point dropdown 2026-03-12 21:39:31 +01:00
Usman Baig
49aa8aae60 docs: update frontend changelog for user journeys 2026-03-12 21:37:07 +01:00
Usman Baig
b3e335ec6c feat(journeys): add skeleton and error boundary 2026-03-12 21:36:27 +01:00
Usman Baig
e7e76bb3db feat(journeys): add Journeys tab to site navigation 2026-03-12 21:35:43 +01:00
Usman Baig
dc1030036c feat(journeys): add Journeys page with controls and layout 2026-03-12 21:35:05 +01:00
Usman Baig
0fa6c4aaf4 feat(journeys): add top paths table component 2026-03-12 21:32:18 +01:00
Usman Baig
c669035718 feat(journeys): add Sankey diagram SVG component 2026-03-12 21:29:45 +01:00
Usman Baig
7336f9126e feat(journeys): add frontend API client and SWR hooks 2026-03-12 21:24:39 +01:00
Usman Baig
6964be9610 refactor: remove realtime visitors detail page
Remove the individual session journey page and make the live visitor
count a static indicator. Prepares for the new aggregated User Journeys
feature (v0.17).
2026-03-12 20:45:58 +01:00
Usman Baig
bae492e8d9 style: show only percentage badge on hover in frustration tables 2026-03-12 20:31:21 +01:00
Usman Baig
03e3f41e48 refactor: use bundled /behavior endpoint via useBehavior SWR hook
Replaces 4 separate frustration API calls with single useBehavior hook.
Removes manual fetchData, loading/error state, and refresh interval—SWR
handles caching, revalidation, and error state automatically.
2026-03-12 20:24:28 +01:00
Usman Baig
eb17e8e8d6 fix: hide scroll depth and trend chart when rate-limited
Frustration APIs and dashboard API are separate — when frustration
calls fail, Scroll Depth still rendered from cached SWR data,
creating a broken mixed state. Now tracks error state and hides
the bottom section entirely on failure.
2026-03-12 18:35:36 +01:00
Usman Baig
540c774100 fix: custom tooltip with inline fill color, dynamic subtitle
- Replace ChartTooltipContent with custom tooltip that reads fill
  directly from payload — guaranteed to show the slice color
- Subtitle adapts: shows 'current and previous period' only when
  previous period data exists, otherwise 'rage vs dead breakdown'
- Filter out zero-count slices from chart data
2026-03-12 18:29:43 +01:00
Usman Baig
3bf832af92 style: use transparent orange tones for frustration pie chart
Rage clicks: warm orange at 70% opacity
Dead clicks: darker amber at 70% opacity
Previous period: same hues at 35% opacity
2026-03-12 18:27:57 +01:00
Usman Baig
5050422a60 refactor: match frustration tables to dashboard pattern
- Remove column headers for cleaner look
- Show secondary info (avg, sessions, last seen) on hover
- Add orange percentage badge that slides in on hover
- Add empty row padding for consistent card height
2026-03-12 18:27:20 +01:00
Usman Baig
13f6f53868 fix: tooltip indicator dot not showing slice color
bg-[--color-bg] doesn't resolve in Tailwind v4 — needs var() wrapper.
Changed to bg-[var(--color-bg)] and border-[var(--color-border)].
2026-03-12 18:25:15 +01:00
Usman Baig
bf7fe87120 fix: use direct hex colors for pie chart tooltip and distinct color palette 2026-03-12 18:21:19 +01:00
Usman Baig
d4dc45e82b fix: align table headers with row data using CSS grid
- Switch FrustrationTable from flex to grid columns so headers
  and row cells share the same column widths
- Replace bar chart with pie chart for frustration trend
- Remove Card wrapper borders, footer line, and total signals text
- Change dead clicks color from yellow to darker orange
2026-03-12 18:16:12 +01:00
Usman Baig
0889079372 refactor: replace bar chart with pie chart for frustration trend 2026-03-12 18:09:34 +01:00
Usman Baig
2f01be1c67 feat: polish behavior page UI with 8 improvements
- Add column headers to rage/dead click tables
- Rich empty states with icons matching dashboard pattern
- Add frustration trend comparison chart (current vs previous period)
- Show "New" badge instead of misleading "+100%" when previous period is 0
- Click-to-copy on CSS selectors with toast feedback
- Normalize min-height to 270px for consistent card sizing
- Fix page title to include site domain (Behavior · domain | Pulse)
- Add "last seen" column with relative timestamps
2026-03-12 18:03:22 +01:00
Usman Baig
585f37f444 docs: add rage click and dead click detection to changelog 2026-03-12 17:06:36 +01:00
Usman Baig
1f64bec46d fix: correct summary card label and skip MutationObserver on html/body 2026-03-12 17:02:52 +01:00
Usman Baig
9179e058f7 refactor: move scroll depth from dashboard to behavior tab 2026-03-12 16:56:26 +01:00
Usman Baig
d5aafdc48a feat: add behavior page shell 2026-03-12 16:56:00 +01:00
Usman Baig
062d0a2b44 feat: add frustration by page breakdown component 2026-03-12 16:55:01 +01:00
Usman Baig
46084b71a6 feat: add frustration table component with view-all modal 2026-03-12 16:54:38 +01:00
Usman Baig
a00042c557 feat: add frustration summary cards component 2026-03-12 16:53:47 +01:00
Usman Baig
c17a856224 feat: add Behavior tab to site navigation 2026-03-12 16:53:12 +01:00
Usman Baig
953762075b feat: add frustration signal API types and fetch functions 2026-03-12 16:53:05 +01:00
Usman Baig
fb47716711 fix: use hasAttribute for data-no-rage and data-no-dead opt-out checks 2026-03-12 16:50:20 +01:00
Usman Baig
247a0b3460 feat: add dead click detection to tracking script 2026-03-12 16:47:53 +01:00
Usman Baig
9e6e2a2214 feat: add rage click detection to tracking script 2026-03-12 16:47:15 +01:00
Usman Baig
b05f7bbcf6 feat: add element identifier function for frustration tracking 2026-03-12 16:46:32 +01:00
Usman Baig
1417c952c6 docs: update changelog with time-of-day report scheduling 2026-03-12 16:10:07 +01:00
Usman Baig
a22333bbc2 fix: bump @ciphera-net/ui to 0.2.5 2026-03-12 15:25:05 +01:00
Usman Baig
27a9836d5a feat: add time-of-day controls to scheduled reports UI
Add send hour, day of week/month selectors to report schedule modal.
Schedule cards now show descriptive delivery times like
"Every Monday at 9:00 AM (UTC)". Timezone picker moved into modal.
2026-03-12 15:17:46 +01:00
Usman Baig
c6ec4671a4 fix: match report_schedules JSON key from backend response 2026-03-12 14:50:51 +01:00
Usman Baig
acf7b16dde docs: add scheduled reports to changelog 2026-03-12 14:44:06 +01:00
Usman Baig
31aff95552 feat: add Reports tab to site settings with schedule CRUD 2026-03-12 14:33:05 +01:00
Usman Baig
d728b49f67 feat: add report schedules API client module 2026-03-12 14:33:05 +01:00
Usman Baig
eeb46affda docs: add region name fix to changelog 2026-03-12 13:37:36 +01:00
Usman
cf5fbb6f8e Merge pull request #42 from ciphera-net/staging
Release 0.14.0-alpha
2026-03-12 13:12:03 +01:00
Usman Baig
bb9e907a50 chore: bump version to 0.14.0-alpha and finalize changelog
Move all unreleased entries into 0.14.0-alpha. Recategorize
Instagram attribution and OS parsing fixes from Improved to Fixed.
2026-03-12 13:05:40 +01:00
Usman Baig
7fe8c3818f docs: add changelog entries for Instagram attribution and OS parsing fixes 2026-03-12 12:36:13 +01:00
Usman Baig
3fc0dec9d9 fix: show branded icons for UA-inferred referrers instead of broken favicons
Plain name referrers like "Instagram" or "WhatsApp" (inferred from
User-Agent) were being passed to the favicon service as invalid
domains, returning a generic globe. Now skips favicon fetch for
any referrer without a dot, falling through to Phosphor icons.
2026-03-12 12:31:21 +01:00
Usman Baig
7bd922a012 feat: add shared link referrer icon and new social platform icons
Add branded icons for WhatsApp, Telegram, Snapchat, Pinterest, Threads.
Add link icon for new "Shared Link" referrer category. Update changelog.
2026-03-12 12:08:07 +01:00
Usman Baig
7e91e08532 feat: 2-hour bucket grid for larger square cells 2026-03-12 00:33:56 +01:00
Usman Baig
cb6c03432c fix: use CSS grid with aspect-square for square heatmap cells 2026-03-12 00:30:23 +01:00
Usman Baig
bc299fe9a0 fix: increase Peak Hours grid row height 2026-03-12 00:27:17 +01:00
Usman Baig
632530af7f feat: replace heatmap grid with CommitsGrid-style animated cells 2026-03-12 00:20:04 +01:00
Usman Baig
ffbfcf342f feat: fix cell visibility, add thermal blob mode & peak cell pulse 2026-03-12 00:13:47 +01:00
Usman Baig
602f7350b8 fix: remove row/column dim highlight on Peak Hours hover 2026-03-12 00:09:09 +01:00
Usman Baig
c15737b9c6 feat: interactive Peak Hours heatmap
- Row + column highlight on cell hover with dim effect
- Cell-anchored tooltip showing pageviews and % of week's traffic
- Best time callout: "Your busiest time is Xdays at Ypm"
- Staggered entrance animation on data load
2026-03-12 00:05:46 +01:00
Usman Baig
a189952fad feat: add Peak Hours heatmap dashboard panel 2026-03-11 23:59:22 +01:00
Usman Baig
428a6fd18d chore: bump @ciphera-net/ui to 0.2.4 2026-03-11 23:48:27 +01:00
Usman Baig
136ceff962 feat: add dividers to period selector dropdown 2026-03-11 23:47:52 +01:00
Usman Baig
eb872dbc5a docs: add missing changelog entries for block height fix and goals panel correction 2026-03-11 23:42:49 +01:00
Usman Baig
956cfbcf35 feat: animate active metric indicator with spring slide 2026-03-11 23:38:04 +01:00
Usman Baig
b5dd5e7082 feat: add This week / This month period options and fix comparison labels 2026-03-11 23:33:24 +01:00
Usman Baig
34eca64967 fix: correct off-by-one in comparison period label 2026-03-11 23:23:39 +01:00
Usman Baig
1c5ca7fa54 fix: active metric label white and slightly smaller 2026-03-11 23:15:59 +01:00
Usman Baig
275503ae8f fix: show dynamic comparison period label in stat headers 2026-03-11 23:14:35 +01:00
Usman Baig
73db65c0b2 feat: redesign chart stat headers and fix badge semantic colors 2026-03-11 23:10:16 +01:00
Usman Baig
0754cb0e4f fix: align Goals & Events and Scroll Depth block height with other dashboard blocks 2026-03-11 22:52:14 +01:00
Usman Baig
1ba6bf6a84 fix: add subtitle to scroll depth radar chart 2026-03-11 22:49:54 +01:00
Usman Baig
72011dea5c fix: enlarge scroll depth radar chart 2026-03-11 22:48:37 +01:00
Usman Baig
7431f2b78d fix: increase radar fill opacity to cover grid lines 2026-03-11 22:45:36 +01:00
Usman Baig
bf37add366 revert: restore radar chart for scroll depth (4 axes, no 0% anchor) 2026-03-11 22:42:32 +01:00
Usman Baig
ca60379e5e feat: replace radar with clean bar chart for scroll depth 2026-03-11 22:36:55 +01:00
Usman Baig
b30619e6b4 fix: add 0% baseline axis to scroll depth radar for pentagon shape 2026-03-11 22:30:19 +01:00
Usman Baig
0f5d5338f3 fix: make scroll depth block half-width and enlarge radar chart 2026-03-11 22:26:15 +01:00
Usman Baig
faa2f50d6e feat: replace scroll depth bar chart with radar chart 2026-03-11 22:22:59 +01:00
Usman Baig
55bf20c58d fix: remove rank numbers from Goals & Events panel 2026-03-11 22:19:39 +01:00
Usman Baig
2fa3540a48 feat: polish Goals & Events dashboard panel
Align visual style with Pages, Referrers, and Locations panels — flat
rows with hover state, rank numbers, muted count colors, and a
slide-in percentage on hover. Add empty slots for consistent height.
2026-03-11 22:15:59 +01:00
Usman Baig
c2d5935394 security: send X-CSRF-Token on all state-changing API requests (F-01) 2026-03-11 21:54:24 +01:00
Usman Baig
8136268988 fix: bump ciphera-ui to 0.2.3 and allow blob: in worker-src CSP
Adds blob: to worker-src so the captcha PoW web worker can run.
2026-03-11 11:53:04 +01:00
Usman Baig
15d41f5bd9 fix: bump @ciphera-net/ui to 0.2.2 (PoW difficulty fix) 2026-03-11 11:39:16 +01:00
Usman Baig
37eb49eb37 feat: action-scoped captcha tokens for share access and org settings
Captcha on shared dashboard and organization settings now passes
action-specific identifiers. Bumps @ciphera-net/ui to 0.2.1.
2026-03-11 11:30:21 +01:00
Usman Baig
3d12f35331 chore: bump @ciphera-net/ui to 0.1.4
PasswordInput now forwards the required attribute to the underlying
input element, enabling HTML5 form validation on password fields.
2026-03-10 23:55:47 +01:00
Usman Baig
205cdf314c perf: bound SWR cache, clean stale storage, cap annotations
Add LRU cache provider (200 entries) to prevent unbounded SWR memory
growth. Clean up stale PKCE localStorage keys on app init. Cap chart
annotations to 20 visible reference lines with overflow indicator.
2026-03-10 21:19:33 +01:00
Usman Baig
502f4952fc perf: lazy-load globe/map and update changelog
Globe and DottedMap now only render when the Locations section enters
the viewport via IntersectionObserver. Added changelog entries for
rate limit fallback, buffer improvements, and lazy loading.
2026-03-10 20:57:55 +01:00
Usman Baig
f10b903a80 perf: add export loading state and virtual scrolling for large lists
Export modal now shows a loading indicator and doesn't freeze the UI.
Large list modals use virtual scrolling for smooth performance.
2026-03-10 20:45:49 +01:00
Usman Baig
848bde237f docs: add faster entry/exit page stats to changelog 2026-03-10 20:25:57 +01:00
Usman Baig
835c284a6b docs: add smarter caching improvement to changelog 2026-03-10 20:13:09 +01:00
Usman Baig
beee87bd2e docs: add query timeout improvement to changelog 2026-03-10 18:45:52 +01:00
Usman Baig
bcaa5c25f8 perf: replace real-time polling with SSE streaming
Replace 5-second setInterval polling with EventSource connection to the
new /realtime/stream SSE endpoint. The server pushes visitor updates
instead of each client independently polling. Auto-reconnects on
connection drops.
2026-03-10 18:33:17 +01:00
Usman Baig
d863004d5f perf: consolidate 7 dashboard hooks into single batch request
Replace useDashboardOverview, useDashboardPages, useDashboardLocations,
useDashboardDevices, useDashboardReferrers, useDashboardPerformance, and
useDashboardGoals with a single useDashboard hook that calls the existing
/dashboard batch endpoint. This endpoint runs all queries in parallel on
the backend and caches the result in Redis (30s TTL).

Reduces dashboard requests from 12 to 6 per refresh cycle (50% reduction).
At 1,000 concurrent users: ~6,000 req/min instead of 12,000.
2026-03-10 17:55:29 +01:00
Usman Baig
00d8656ad2 Fix modal titles, hover rounding, search focus, and page filter dimension
- Remove redundant prefixes from modal titles
- Remove -mx-2 from modal rows so hover rounds evenly on both sides
- Fix page filter using wrong dimension name (path -> page)
- Bump @ciphera-net/ui to 0.1.3 (fixes search input losing focus)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 01:46:31 +01:00
Usman Baig
64a8652423 Add search bar to expanded panel modals 2026-03-10 01:34:05 +01:00
Usman Baig
a99d13309f Improve expanded modals: wider, taller, hover percentage, click-to-filter
- Widen modals from max-w-lg to max-w-2xl
- Increase max height from 60vh to 80vh
- Add hover percentage on each row (matching card behavior)
- Click any row to filter dashboard and close modal
2026-03-10 01:32:00 +01:00
Usman Baig
7aa809c8a0 Move expand icon to the right of panel titles 2026-03-10 01:25:46 +01:00
Usman Baig
ca71c1646d Move expand icon to the left of panel titles 2026-03-10 01:22:11 +01:00
Usman Baig
3587f93645 Scope 1s tick interval to Chart component to eliminate page-level re-renders
The setInterval that drives the "Live · Xs ago" display was at the page level,
forcing all 10+ dashboard components to re-render every second. Now it lives
inside Chart — the only consumer — so the rest of the dashboard is unaffected.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 01:19:34 +01:00
Usman Baig
e07fd3f0e8 Move expand icon to top-right corner of panels, make it larger
Reposition FrameCornersIcon from next to the title to the far right
of each card header (after tabs). Increase size from w-3.5 to w-4
and add hover background for better visibility and discoverability.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 01:13:03 +01:00
Usman Baig
05d13bff81 Use FrameCornersIcon for expand buttons in dashboard panels
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 01:06:56 +01:00
Usman Baig
a9f42acbf6 Use ref for lastUpdatedAt to avoid extra re-render on mount
When navigating to dashboard with cached SWR data, setLastUpdatedAt
triggered a second full render of the entire dashboard immediately
after the first. Using a ref instead avoids this — the value still
updates and Chart reads it on the next 1-second tick re-render.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 01:03:12 +01:00
Usman Baig
a60efeb6a7 Replace 'View all' buttons with expand icon in block headers
Add ArrowsOutSimpleIcon next to each panel title (Pages, Referrers,
Locations, Technology, Campaigns) that opens the full-data modal.
Remove the old text-based 'View all' button from the bottom of lists.
Update changelog with performance and UI improvements.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 01:00:09 +01:00
Usman Baig
88f02a244b Hoist DottedMap constants to module scope, static-import above-fold components
DottedMap: move createMap, stagger helpers, and base dots path to module
scope so they compute once on module load and survive unmount/remount
cycles — eliminates all recomputation when switching tabs.

Dashboard: restore static imports for the 5 above-fold components
(Chart, ContentStats, TopReferrers, Locations, TechSpecs) now that
their heavy computations are memoized. Keeps below-fold components
(PerformanceStats, GoalStats, ScrollDepth, Campaigns, etc.) dynamic.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 00:51:32 +01:00
Usman Baig
8c5b452f73 Batch 8000 SVG circles into single path element in DottedMap
Instead of rendering 8000 individual <circle> elements (each a React
node to reconcile), batch all map dots into a single <path d="...">
string. Reduces DOM nodes from ~8000 to 1 for the base map layer.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 00:47:27 +01:00
Usman Baig
5f797112ec Memoize expensive computations in Chart and Globe components
Chart: wrap chartData (Date formatting on every data point) and
metricsWithTrends in useMemo — these ran on every render.
Globe: memoize marker computation (Math.max + filter + map on every render).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 00:44:19 +01:00
Usman Baig
ae0f6b8ffa Fix dashboard and map tab lag with memoization and code splitting
Memoize createMap() in DottedMap (was regenerating 8000 SVG dots every
render) and convert 11 heavy dashboard components to next/dynamic imports
so the page shell renders instantly instead of blocking on one massive
render pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 00:39:35 +01:00
Usman Baig
4babbc7555 fix: skip skeleton when SWR has cached data + lazy-load Map and Globe 2026-03-10 00:32:07 +01:00
Usman Baig
01f6d8d065 fix: remove content crossfade animation that caused lag on heavy pages
Also configure PWA service worker to use network-first for JS chunks
to prevent ChunkLoadError after deploys.
2026-03-10 00:26:50 +01:00
Usman Baig
628749a416 feat: opacity-only page transition + sliding indicator on all sub-tabs 2026-03-10 00:18:52 +01:00
Usman Baig
b88f4d438b fix: use popLayout mode so heavy pages animate in without delay 2026-03-10 00:12:59 +01:00
Usman Baig
2776c803f1 fix: use focus-visible for all button/tab/link focus rings across app
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 00:08:09 +01:00
Usman Baig
c46d463533 fix: use focus-visible for tab nav ring so it only shows on keyboard 2026-03-09 23:45:41 +01:00
Usman Baig
6f964f38f3 feat: add sliding tab indicator and content crossfade animations
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 23:41:34 +01:00
Usman Baig
330cc134aa feat: instant tab navigation by moving SiteNav to shared layout
SiteNav now lives in the [id] layout instead of each page, so it stays
mounted during route transitions. Switching between Dashboard, Uptime,
Funnels, and Settings no longer flashes a full-page skeleton.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 23:35:06 +01:00
Usman Baig
92fae83772 chore: move site nav tabs above site header and update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 23:21:12 +01:00
Usman Baig
242c76b763 fix: reduce funnel chart size with max-w-md constraint 2026-03-09 23:12:37 +01:00
Usman Baig
9f2032fc32 Replace custom FunnelChart with 21st.dev funnel-chart component
Drops in the exact 21st.dev FunnelChart component with motion/react,
curved bezier segments, layered rings, and spring hover animations.
Removes the previous custom SVG implementation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 23:06:31 +01:00
Usman Baig
cc4f924fb8 fix: put annotations and live indicator on same row in chart footer 2026-03-09 23:03:58 +01:00
Usman Baig
5625703168 fix: move annotations and live indicator inside Card component 2026-03-09 23:00:44 +01:00
Usman Baig
7175de44af Fix metric cards grid to use md: breakpoint instead of @container queries
Tailwind 3 doesn't support container queries without a plugin.
2026-03-09 22:56:53 +01:00
Usman Baig
033d735c3a Replace dashboard BarChart with 21st.dev LineChart component
Swap the main site dashboard chart from a bar chart to a line chart
using 21st.dev's line-charts-6 component with dot grid background,
glow shadow, and animated active dots. Add Badge trend indicators
on metric cards using Phosphor icons. All existing features preserved
(annotations, comparison, export, live indicator, interval controls).

New UI primitives: line-charts-6, badge-2, card, button-1, avatar.
Added shadcn-compatible CSS variables and Tailwind color mappings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 22:53:35 +01:00
Usman Baig
5721d25291 Rewrite FunnelChart as proper SVG funnel with curved sides
Replaces crude clip-path divs with SVG bezier paths matching the 21st.dev
reference: smooth curved sides, background glow, divider lines, centered
percentage pills, and labels on both sides.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 22:20:55 +01:00
Usman Baig
536aebc086 Add FunnelChart visualization to replace BarChart on funnel detail page
Replaces the recharts BarChart with a custom funnel component using clip-path
trapezoid segments, framer-motion animations, and hover interactions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 22:12:34 +01:00
Usman Baig
8c9c711296 Make Globe drag more responsive (damping 3000→800) 2026-03-09 21:56:38 +01:00
Usman Baig
652c93cbd0 Update changelog with Globe, DottedMap, theme toggle, and chart restyle 2026-03-09 16:22:41 +01:00
Usman Baig
2d7e13b098 Rewrite Globe with pure refs, remove framer-motion dependency
- Remove useMotionValue/useSpring which caused effect re-runs
  and globe destroy/recreate cycles (source of glitches)
- All state tracked via refs (phi, drag offset, pointer position)
- Effect only re-runs on theme change, not on every spring tick
- Direct delta tracking for drag instead of spring physics
- Simpler, more stable WebGL lifecycle
2026-03-09 16:18:24 +01:00
Usman Baig
58c151e2b0 Fix Globe glitches: stop resizing buffer every frame
- Set canvas size once on mount instead of every render frame
- Use actual devicePixelRatio (capped at 2) instead of hardcoded 2
- Remove redundant width*2 doubling (was 4x with devicePixelRatio:2)
- Increase spring damping 50→60, reduce stiffness 80→60 for smoother drag
2026-03-09 16:15:38 +01:00
Usman Baig
1a75b44c68 Move Globe up to top of container to fill space 2026-03-09 16:10:19 +01:00
Usman Baig
9629a5788c Add very slow auto-rotation to Globe (pauses on drag) 2026-03-09 16:07:52 +01:00
Usman Baig
464a361094 Center Globe horizontally and move up to show more surface 2026-03-09 16:05:44 +01:00
Usman Baig
12ae1a9175 Zoom in Globe and slow drag speed
- Position globe lower with overflow-hidden for cropped zoomed look
- Globe at 140% width pushed down 30% so only top portion visible
- Add radial gradient overlay at bottom for depth
- Increase movement damping 1400→3000 for slower drag
2026-03-09 16:02:47 +01:00
Usman Baig
3268a70baa Fix Globe: reduce mapBrightness to fix glitches, brighten base color
mapBrightness 6→2 fixes overblown dot artifacts, baseColor 0.3→0.5
makes the sphere visible against the dark card background
2026-03-09 15:57:33 +01:00
Usman Baig
9dba2cf2e2 Fix Globe: remove auto-spin, brighten dark mode, reduce jitter 2026-03-09 15:51:01 +01:00
Usman Baig
efd647d856 Add interactive 3D Globe tab to Locations using cobe WebGL
- Magic UI Globe component with auto-rotation and drag interaction
- Dark/light mode reactive (base color, glow, brightness)
- Country markers from visitor data using existing centroids
- Brand orange (#FD5E0F) marker color matching DottedMap

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 15:46:29 +01:00
Usman Baig
df2f38eb83 Scale DottedMap SVG to fill full card height 2026-03-09 15:34:23 +01:00
Usman Baig
c065853800 Make map landmass dots more prominent in both themes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 15:05:28 +01:00
Usman Baig
f58154f18d Bump @ciphera-net/ui to ^0.1.2 2026-03-09 14:43:35 +01:00
Usman Baig
31416f0eb2 Polish DottedMap: glow effect, tooltips, better fill
- SVG filter for orange glow behind markers
- Hover tooltips showing country name + pageview count
- Reduced viewBox height (75→68) to fill card better
- Bumped mapSamples to 8000 for crisper landmass
- Centered map vertically with flexbox wrapper

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 14:23:48 +01:00
Usman Baig
6ccc26ab48 Replace WorldMap with Magic UI DottedMap for visitor locations
- New DottedMap component using svg-dotted-map with country centroid markers
- Marker size scales by pageview proportion (brand orange)
- Static country-centroids.ts lookup (~200 ISO codes)
- Remove react-simple-maps, i18n-iso-countries, world-atlas CDN dependency
2026-03-09 14:17:35 +01:00
Usman Baig
cbf48318ce Update package-lock.json for @ciphera-net/ui ^0.1.1 2026-03-09 13:59:07 +01:00
Usman Baig
874ff61a46 Bump @ciphera-net/ui to ^0.1.1 2026-03-09 13:58:30 +01:00
Usman Baig
0dfd0ccb3c Fix ChartContainer CSS to work without ShadCN theme, match ShadCN bar chart exactly
- ChartContainer: replace stroke-border/fill-muted (ShadCN tokens we
  don't have) with var(--chart-grid) so recharts CSS overrides actually work
- Dashboard chart: remove YAxis (ShadCN interactive bar has none)
- Remove unused formatAxisValue, formatAxisDuration, tick calculations
- Exact ShadCN structure: CartesianGrid vertical={false}, XAxis with
  tickMargin/minTickGap, single Bar with fill var(--color-{metric})

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 13:45:13 +01:00
Usman Baig
56225bb1ad Match ShadCN interactive bar chart style
- Remove cursor={false} to enable hover highlight
- Remove rounded bar corners for flat ShadCN look
- Remove explicit stroke/color on grid and axes (inherit from CSS)
- Use var(--color-{metric}) for bar fill
- Reduce chart height to 250px (ShadCN default)
- Simplify bar props to match ShadCN minimal pattern

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 13:37:00 +01:00
Usman Baig
ad747b1772 Switch main dashboard chart from AreaChart to BarChart
ShadCN-style bar chart with rounded corners, solid fill,
clean grid, and translucent comparison bars.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 13:31:07 +01:00
Usman Baig
3f81cb0e48 feat: adopt ShadCN chart primitives
Add ChartContainer, ChartConfig, ChartTooltip, ChartTooltipContent
primitives ported from ShadCN's chart pattern. Refactor all 3 chart
locations (dashboard, funnels, uptime) to use CSS variable-driven
theming instead of duplicated CHART_COLORS_LIGHT/DARK objects.

- Add --chart-1 through --chart-5, --chart-grid, --chart-axis CSS vars
- Remove duplicated color objects from 3 files (-223 lines)
- Add accessibilityLayer to all charts
- Rounded bar corners on funnel chart
- Tooltips use Tailwind dark classes instead of imperative style props

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 13:24:29 +01:00
Usman Baig
86c11dc16f chore: bump @ciphera-net/ui to ^0.1.0
ShadCN-quality restyle of shared UI components.
2026-03-09 13:02:06 +01:00
Usman Baig
5fc6f183db feat: annotation UX improvements
- Custom calendar (DatePicker) instead of native date input
- Custom dropdown (Select) instead of native select
- EU date format (DD/MM/YYYY) in tooltips and form
- Right-click context menu on chart to add annotations
- Optional time field (HH:MM) for precise timestamps
- Escape key to dismiss, loading state on save
- Bump @ciphera-net/ui to 0.0.95
2026-03-09 04:17:58 +01:00
Usman Baig
4d99334bcf feat: add chart annotations
Inline annotation markers on the dashboard chart with create/edit/delete UI.
Color-coded categories: deploy, campaign, incident, other.
2026-03-09 03:44:05 +01:00
Usman Baig
3002c4f58c docs: add hide unknown locations to changelog 2026-03-09 02:37:28 +01:00
Usman Baig
a05e2e94b8 feat: add hide unknown locations toggle in site settings
Adds toggle in Data & Privacy > Filtering section to exclude entries
where geographic data could not be resolved from location stats.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 02:26:15 +01:00
Usman Baig
7ff5be7c4e fix: pre-render Phosphor icons in capabilities array
The /features page mixed inline SVG JSX with Phosphor component
references in the capabilities array. The typeof check couldn't
distinguish them, causing a prerender crash:
"Objects are not valid as a React child (found: object with keys
{$$typeof, render, displayName})".

Pre-rendering Share2Icon and GlobeIcon as JSX makes all entries
consistent and eliminates the conditional entirely.
2026-03-09 02:00:32 +01:00
Usman Baig
f516c59d32 chore: regenerate package-lock.json for @ciphera-net/ui 0.0.94
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:43:24 +01:00
Usman Baig
b6199e8a3a chore: bump @ciphera-net/ui to ^0.0.94
Picks up the Phosphor icon migration in ciphera-ui.
2026-03-09 01:39:40 +01:00
Usman Baig
7f9ad0e977 refactor: switch icons from react-icons to Phosphor
Replace react-icons and @radix-ui/react-icons with @phosphor-icons/react
for a consistent icon style across all dashboard panels.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 00:23:31 +01:00
Usman Baig
397a5afef9 fix: capitalize technology labels in dashboard 2026-03-09 00:07:12 +01:00
Usman Baig
6f1956b740 fix: chart no longer shows tomorrow's date on 7/30-day views
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 23:40:49 +01:00
Usman
831fd86f67 Merge pull request #41 from ciphera-net/staging
fix: add Cache-Control no-cache for HTML pages to prevent stale CDN content
2026-03-07 22:04:22 +01:00
Usman Baig
2f5bcf479a fix: add Cache-Control no-cache for HTML pages to prevent stale CDN content
Bunny CDN was caching HTML pages, so after deploys the browser kept
loading old JS bundles with expired Server Action hashes. This header
tells the CDN to always revalidate with the origin. Static assets
(/_next/static/*) are excluded since they are content-hashed.
2026-03-07 20:12:11 +01:00
Usman Baig
ad806e0427 fix: remove reload-based stale build recovery to stop login loop
window.location.reload() causes infinite loops when the CDN keeps
serving cached assets. Instead, silently treat Server Action failures
as no-session — the OAuth flow uses full navigations (window.location.href)
which naturally fetch fresh content from the server on return.
2026-03-07 20:02:58 +01:00
Usman Baig
6338d1dfe7 fix: prevent infinite reload loop on stale build recovery
Use sessionStorage guard so the hard reload only fires once. If the
reload doesn't fix it (CDN still serving stale JS), fall through
gracefully instead of looping forever.
2026-03-07 19:55:16 +01:00
Usman Baig
d2dfe62993 fix: recover gracefully from stale Server Action hashes after deployment
Wrap all Server Action calls (getSessionAction, exchangeAuthCode,
logoutAction) in try-catch so a cached browser bundle with old action
IDs triggers a hard reload instead of an infinite loading spinner.
2026-03-07 19:37:41 +01:00
Usman Baig
cc268c320e feat: replace ghost buttons with underline tab bar for site navigation
Dashboard, Uptime, Funnels, and Settings now use a consistent
underline tab bar with orange active indicator, matching the
existing panel tab design language.
2026-03-07 19:10:23 +01:00
84 changed files with 10106 additions and 2305 deletions

View File

@@ -4,7 +4,99 @@ All notable changes to Pulse (frontend and product) are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and Pulse uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) with a **0.x.y** version scheme while in initial development. The leading `0` indicates that the public API and behaviour may change until we release **1.0.0**.
## [Unreleased]
## [0.15.0-alpha] - 2026-03-13
### Added
- **User Journeys tab.** A new "Journeys" tab on your site dashboard visualizes how visitors navigate through your site. A Sankey flow diagram shows the most common paths users take — from landing page through to exit — so you can see where traffic flows and where it drops off. Filter by entry page, adjust the depth (2-10 steps), and click any page in the diagram to drill into paths through it. Below the diagram, a "Top Paths" table ranks the most common full navigation sequences with session counts and average duration.
### Removed
- **Realtime visitors detail page.** The page that showed individual active visitors and their page-by-page session journey has been removed. The live visitor count on your dashboard still works — it just no longer links to a separate page.
### Added
- **Rage click detection.** Pulse now detects when visitors rapidly click the same element 3 or more times — a strong signal of UI frustration. Rage clicks are tracked automatically (no setup required) and surfaced in the new Behavior tab with the element, page, click count, and number of affected sessions.
- **Dead click detection.** Clicks on buttons, links, and other interactive elements that produce no visible result (no navigation, no DOM change, no network request) are now detected and reported. This helps you find broken buttons, disabled links, and unresponsive UI elements your visitors are struggling with.
- **Behavior tab.** A new tab in your site dashboard — alongside Dashboard, Uptime, and Funnels — dedicated to user behavior signals. Houses rage clicks, dead clicks, a by-page frustration breakdown, and scroll depth (moved from the main dashboard for a cleaner layout).
- **Frustration summary cards.** The Behavior tab opens with three at-a-glance cards: total rage clicks, total dead clicks, and total frustration signals with the most affected page — each with a percentage change compared to the previous period.
- **Scheduled Reports.** You can now get your analytics delivered automatically — set up daily, weekly, or monthly reports sent straight to your email, Slack, Discord, or any webhook. Each report includes your key stats (visitors, pageviews, bounce rate), top pages, and traffic sources, all in a clean branded format. Set them up in your site settings under the new "Reports" tab, and hit "Test" to preview before going live. You can create up to 10 schedules per site.
- **Time-of-day report scheduling.** Choose when your reports arrive — pick the hour, day of week (for weekly), or day of month (for monthly). Schedule cards show a human-readable description like "Every Monday at 9:00 AM (UTC)."
### Changed
- **Scroll depth moved to Behavior tab.** The scroll depth radar chart has been relocated from the main dashboard to the new Behavior tab, where it fits more naturally alongside other user behavior metrics.
### Fixed
- **Region names now display correctly.** Some regions were showing as cryptic codes like "14" (Poland), "KKC" (Thailand), or "IDF" (France) instead of their actual names. The Locations panel now shows proper region names like "Masovian", "Khon Kaen", and "Île-de-France."
## [0.14.0-alpha] - 2026-03-12
### Improved
- **Smarter referrer attribution.** Traffic that arrives without a referrer on a deep page (like a blog post) is now shown as "Shared Link" instead of "Direct." Real direct traffic — visitors who land on your homepage — still shows as "Direct." This gives you a much clearer picture of where your traffic actually comes from, since most unattributed deep-page visits are people clicking links shared in messaging apps or AI chatbots that strip the referrer header.
- **More in-app browsers detected.** Pulse now recognises visits from WhatsApp, Telegram, Snapchat, Pinterest, Reddit, and Threads in-app browsers and attributes them correctly instead of lumping them into "Direct."
- **Dashboard blocks are now consistent in height.** The Goals & Events and Scroll Depth panels now match the height of every other block on the dashboard.
- **Cleaner period picker.** The date range dropdown now has visual separators between the rolling windows (Today, Last 7 days, Last 30 days), the calendar periods (This week, This month), and Custom — so it's easy to tell them apart at a glance.
- **New date range options.** The period selector now includes "This week" (Monday to today) and "This month" (1st to today) alongside the existing rolling windows. Your selection is remembered between sessions.
- **Smarter comparison labels.** The "vs …" label under each stat now matches the period you're viewing — "vs yesterday" for today, "vs last week" for this week, "vs last month" for this month, and "vs previous N days" for rolling windows.
- **Refreshed stat headers.** The Unique Visitors, Total Pageviews, Bounce Rate, and Visit Duration stats at the top of the chart have a new look — uppercase labels, the percentage change shown inline next to the number, and an orange underline on whichever metric you're currently graphing.
- **Consistent green and red colors.** The up/down percentage indicators now use the same green and red as the rest of the app, instead of slightly different shades.
- **Scroll Depth is now a radar chart.** The Scroll Depth panel has been redesigned from a bar chart into a radar chart. The four scroll milestones (25%, 50%, 75%, 100%) are plotted as axes, with the filled shape showing how far visitors are getting through your pages at a glance.
- **Polished Goals & Events panel.** The Goals & Events block on your dashboard got a visual refresh to match the style of the Pages, Referrers, and Locations panels. Counts are shown in a consistent style, and hovering any row reveals what percentage of total events that action accounts for — sliding in smoothly from the right.
- **Smarter bot protection.** The security checks on shared dashboard access and organization settings now use action-specific tokens tied to each page. A token earned on one page can't be reused on another, making it harder for automated tools to bypass the captcha.
- **More resilient under Redis outages.** If the caching layer goes down temporarily, Pulse now continues enforcing rate limits using an in-memory fallback instead of letting all traffic through unchecked. This prevents one infrastructure hiccup from snowballing into a bigger problem.
- **Better handling of traffic bursts.** The system can now absorb 5x larger spikes of incoming events before applying backpressure. When events are dropped during extreme bursts, the system now tracks and logs exactly how many — so we can detect and respond to sustained overload before it affects your data.
- **Faster map and globe loading.** The interactive 3D globe and dotted map in the Locations panel now only load when you scroll down to them, instead of rendering immediately on page load. This makes the initial dashboard load faster and saves battery on mobile devices.
- **Real-time updates work across all servers.** If Pulse runs on multiple servers behind a load balancer, real-time visitor updates now stay in sync no matter which server you're connected to. Previously, you might miss live visitor changes if your connection landed on a different server than the one fetching data.
- **Lighter memory usage in long sessions.** If you manage many sites and keep Pulse open for hours, the app now automatically clears out old cached data for sites you're no longer viewing. This keeps the tab responsive and prevents it from slowly using more and more memory over time.
- **Cleaner login storage.** Temporary data left behind by abandoned sign-in attempts is now cleaned up automatically when the app loads. This prevents clutter from building up in your browser's storage over time.
- **Tidier annotation display.** If you've added a lot of annotations to your chart, only the 20 most recent are shown as lines on the chart to keep it readable. A "+N more" label lets you know there are additional annotations.
- **Even faster dashboard loading.** Your dashboard now fetches all its data — pages, locations, devices, referrers, performance, and goals — in a single request instead of seven separate ones. This means the entire dashboard appears at once rather than sections loading one by one, and puts much less strain on the server when many people are viewing their analytics at the same time.
- **Smoother real-time updates.** The real-time visitors page now streams updates instantly from the server instead of checking for new data every few seconds. Visitors appear and disappear in real-time with no delay, and the page uses far fewer server resources — especially when many people are watching their live traffic at the same time.
- **More reliable under heavy load.** Database queries now have automatic time limits so a single slow query can never lock up the system. If your dashboard or stats take too long to load, the request is gracefully cancelled instead of hanging forever — keeping everything responsive even during traffic spikes.
- **Smarter caching for dashboard data.** Your dashboard stats are now cached for longer and shared more efficiently between requests. When the cache refreshes, only one request does the work while others wait for the result — so your dashboard loads consistently fast even when lots of people are viewing their analytics at the same time.
- **Faster filtered views.** When you filter your dashboard by country, browser, page, or any other dimension, the results are now cached so repeat views load instantly. If multiple people apply the same filter, only one lookup runs and the result is shared — making filtered views much snappier under heavy use.
- **Faster entry and exit page stats.** The queries that figure out which pages visitors land on and leave from have been rewritten to be much more efficient. Instead of sorting through every single event, they now look up just the first and last page per visit — so your Entry Pages and Exit Pages panels load noticeably faster, especially on high-traffic sites.
- **Faster goal stats.** The Goals panel on your dashboard now loads faster, especially for sites with many custom events. Goal names are now looked up in a single step instead of one at a time.
- **Fairer performance under heavy traffic.** One busy site can no longer slow down dashboards for everyone else. Each site now gets its own dedicated share of server resources, so your analytics stay fast and responsive even when other sites on the platform are experiencing traffic spikes.
- **Smoother exports.** Exporting your data to PDF, Excel, or CSV no longer freezes the page. You'll see a clear "Exporting..." indicator while your file is being prepared, and the rest of the dashboard stays fully interactive.
- **Smoother "View All" popups.** Opening the expanded view for Pages, Locations, Technology, Referrers, or Campaigns now scrolls smoothly even with hundreds of items. Only the rows you can see are rendered, so the popup opens instantly on any device.
- **Faster daily stats processing.** Behind the scenes, the system that calculates your daily visitor stats now automatically scales up when there are more sites to process — so your dashboard numbers stay accurate and up to date even as the platform grows.
- **More reliable background processing.** When multiple servers are running, long-running background tasks like daily stats calculations no longer risk being interrupted or duplicated. The system now keeps its coordination lock active for as long as the task is running.
### Added
- **Peak Hours heatmap.** A new panel on your dashboard shows a 7×24 grid of when your visitors are most active — every day of the week against every hour of the day. Cells glow brighter in brand orange the busier that hour is. Hover any cell to see the exact pageview count. No other indie analytics tool surfaces this on the main dashboard.
- **Interactive 3D Globe.** The Locations panel now has a "Globe" tab showing your visitor locations on a beautiful, interactive 3D globe. Drag to rotate, and orange markers highlight where your visitors are — sized by how much traffic each country sends. The globe slowly auto-rotates and adapts to light and dark mode.
- **Dotted world map.** The "Map" tab in Locations now uses a sleek dotted map style instead of the old filled map. Country markers glow in brand orange and show a tooltip with the country name and pageview count when you hover.
- **Hide unknown locations.** New toggle in Site Settings under Data & Privacy to hide "Unknown" entries from your Locations panel. When geographic data can't be determined for a visitor, it normally shows as "Unknown" in countries, cities, and regions. Turn this on to keep your location stats clean and only show resolved locations.
- **Chart annotations.** Mark events on your dashboard timeline — like deploys, campaigns, or incidents — so you always know why traffic changed. Click the + button on the chart to add a note on any date. Annotations appear as colored markers on the chart: blue for deploys, green for campaigns, red for incidents. Hover to see the details. Team owners and admins can add, edit, and delete annotations; everyone else (including public dashboard viewers) can see them.
### Improved
- **Beautiful funnel visualization.** Funnel reports now show a smooth, animated funnel shape instead of a plain bar chart. Each step flows into the next with curved segments, hover effects, and labels showing visitor counts and conversion percentages at a glance.
- **Tidier dashboard layout.** The tab navigation (Dashboard, Uptime, Funnels, Settings) now sits above your site name and controls, keeping the tabs front and center.
- **Instant tab switching.** Clicking between Dashboard, Uptime, Funnels, and Settings now feels instant — the tab bar stays in place while the page content loads below it, instead of the whole screen flashing with a loading skeleton.
- **Smooth tab animations.** Switching tabs now plays a sliding indicator animation on the active tab and a subtle crossfade on the page content, making navigation feel polished and responsive.
- **Cleaner focus styles.** Buttons, tabs, and links no longer show an orange outline when you click them — the focus ring now only appears when navigating with the keyboard, keeping the interface clean.
- **Faster dashboard loading.** Switching to the Dashboard and Map tabs is now instant — no more brief lag or delay when navigating between sections.
- **Expand icon for data panels.** Pages, Referrers, Locations, Technology, and Campaigns panels now show a small expand icon next to the title when there's more data to see, replacing the old "View all" button at the bottom.
- **Better expanded views.** When you expand a data panel, the popup is now wider and taller so you can see more at once. Each row shows a percentage on hover, clicking a row filters your dashboard, and there's a search bar at the top to quickly find what you're looking for.
- **Smoother theme switching.** Toggling between light and dark mode now plays a satisfying circular reveal animation that expands from the toggle button, instead of everything just flipping instantly.
- **Cleaner site navigation.** Dashboard, Uptime, Funnels, and Settings now use an underline tab bar instead of floating buttons. The active section is highlighted with an orange underline, making it easy to see where you are and switch between views.
- **Consistent icon style.** All dashboard icons now use a single, unified icon set for a cleaner look across Technology, Locations, Campaigns, and Referrers panels.
### Fixed
- **Correct Instagram attribution.** Visits from Instagram's in-app browser were showing as "Facebook" because Instagram routes shared links through Facebook's URL redirector. Pulse now checks the User-Agent to detect the real source app.
- **Android and iOS now show up in OS stats.** A bug in the User-Agent parsing order meant Android was always classified as "Linux" (because Android UAs contain "Linux") and iOS as "macOS" (because iPhone UAs contain "like Mac OS X"). Both are now detected correctly.
- **Charts no longer show tomorrow's date.** The visitor chart on 7-day and 30-day views could display the next day with zero traffic, making it look like a sudden drop. The chart now ends on today.
- **Capitalized technology labels.** Device types, browsers, and OS names in the Technology panel now display with a capital first letter (e.g. "Desktop" instead of "desktop").
- **Login no longer gets stuck after updates.** If you happened to have Pulse open when a new version was deployed, logging back in could get stuck on a loading screen. The app now automatically refreshes itself to pick up the latest version.
- **City and region data is now accurate.** Location data was incorrectly showing the CDN server's location (e.g. Paris, Villeurbanne) instead of the visitor's actual city. Fixed by reading the correct visitor IP header from Bunny CDN.
- **"Reset Data" now clears everything.** Previously, resetting a site's data in Settings only removed pageviews and daily stats. Uptime check history, uptime daily stats, and cached dashboard data were left behind. All collected data is now properly cleared when you reset, while your site configuration, goals, funnels, and uptime monitors are kept.
## [0.13.0-alpha] - 2026-03-07

View File

@@ -22,7 +22,14 @@ function AuthCallbackContent() {
const codeVerifier = localStorage.getItem('oauth_code_verifier')
const redirectUri = typeof window !== 'undefined' ? window.location.origin + '/auth/callback' : ''
if (!code) return
const result = await exchangeAuthCode(code, codeVerifier, redirectUri)
let result: Awaited<ReturnType<typeof exchangeAuthCode>>
try {
result = await exchangeAuthCode(code, codeVerifier, redirectUri)
} catch {
// * Stale build or network error — show error so user can retry via full navigation
setError('Something went wrong. Please try logging in again.')
return
}
if (result.success && result.user) {
// * Fetch full profile (including display_name) before navigating so header shows correct name on first paint
try {

View File

@@ -58,7 +58,7 @@ function FAQItem({ faq, index }: { faq: typeof faqs[0]; index: number }) {
>
<button
onClick={() => setIsOpen(!isOpen)}
className="w-full py-6 flex items-center justify-between text-left hover:text-brand-orange transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-offset-2"
className="w-full py-6 flex items-center justify-between text-left hover:text-brand-orange transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:ring-offset-2"
>
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white pr-4">
{faq.question}

View File

@@ -83,12 +83,12 @@ const capabilities = [
description: 'Automatically parse UTM parameters. Built-in link builder for campaigns, sources, and mediums.',
},
{
icon: Share2Icon,
icon: <Share2Icon className="w-5 h-5" />,
title: 'Shared Dashboards',
description: 'Generate a public link to share analytics with clients or teammates — no login required.',
},
{
icon: GlobeIcon,
icon: <GlobeIcon className="w-5 h-5" />,
title: 'Geographic Insights',
description: 'Country, region, and city-level breakdowns. IPs are never stored — derived at request time only.',
},
@@ -190,7 +190,7 @@ export default function FeaturesPage() {
className="flex gap-4"
>
<div className="w-10 h-10 rounded-lg bg-brand-orange/10 flex items-center justify-center shrink-0 text-brand-orange mt-0.5">
{typeof cap.icon === 'object' ? cap.icon : <cap.icon className="w-5 h-5" />}
{cap.icon}
</div>
<div>
<h3 className="font-bold text-neutral-900 dark:text-white mb-1">

View File

@@ -285,7 +285,7 @@ export default function IntegrationsPage() {
>
<Link
href={`/integrations/${integration.id}`}
className="group relative p-6 bg-white/50 dark:bg-neutral-900/50 backdrop-blur-sm border border-neutral-200 dark:border-neutral-800 rounded-2xl hover:border-brand-orange/50 dark:hover:border-brand-orange/50 transition-all duration-300 hover:-translate-y-1 hover:shadow-xl block h-full focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-offset-2"
className="group relative p-6 bg-white/50 dark:bg-neutral-900/50 backdrop-blur-sm border border-neutral-200 dark:border-neutral-800 rounded-2xl hover:border-brand-orange/50 dark:hover:border-brand-orange/50 transition-all duration-300 hover:-translate-y-1 hover:shadow-xl block h-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:ring-offset-2"
>
<div className="flex items-start justify-between mb-6">
<div className="p-3 bg-neutral-100 dark:bg-neutral-800 rounded-xl group-hover:scale-110 transition-transform duration-300">
@@ -336,7 +336,7 @@ export default function IntegrationsPage() {
</p>
<a
href="mailto:support@ciphera.net"
className="inline-flex items-center gap-2 px-5 py-2.5 bg-brand-orange text-white font-medium rounded-lg hover:bg-brand-orange/90 transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-offset-2"
className="inline-flex items-center gap-2 px-5 py-2.5 bg-brand-orange text-white font-medium rounded-lg hover:bg-brand-orange/90 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:ring-offset-2"
>
Request Integration
</a>
@@ -361,7 +361,7 @@ export default function IntegrationsPage() {
</p>
<a
href="mailto:support@ciphera.net"
className="text-sm font-medium text-brand-orange hover:underline focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded"
className="text-sm font-medium text-brand-orange hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:rounded"
>
Request Integration
</a>

View File

@@ -1,5 +1,6 @@
import { ThemeProviders, Toaster } from '@ciphera-net/ui'
import { AuthProvider } from '@/lib/auth/context'
import SWRProvider from '@/components/SWRProvider'
import type { Metadata, Viewport } from 'next'
import { Plus_Jakarta_Sans } from 'next/font/google'
import LayoutContent from './layout-content'
@@ -46,12 +47,14 @@ export default function RootLayout({
return (
<html lang="en" className={plusJakartaSans.variable} suppressHydrationWarning>
<body className="antialiased min-h-screen flex flex-col bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-50">
<ThemeProviders>
<AuthProvider>
<LayoutContent>{children}</LayoutContent>
<Toaster />
</AuthProvider>
</ThemeProviders>
<SWRProvider>
<ThemeProviders>
<AuthProvider>
<LayoutContent>{children}</LayoutContent>
<Toaster />
</AuthProvider>
</ThemeProviders>
</SWRProvider>
</body>
</html>
)

View File

@@ -475,11 +475,11 @@ export default function HomePage() {
)}
<div className="mt-2 flex gap-2">
{subscription.has_payment_method ? (
<Link href="/org-settings?tab=billing" className="text-sm font-medium text-brand-orange hover:underline focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded">
<Link href="/org-settings?tab=billing" className="text-sm font-medium text-brand-orange hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:rounded">
Manage billing
</Link>
) : (
<Link href="/pricing" className="text-sm font-medium text-brand-orange hover:underline focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded">
<Link href="/pricing" className="text-sm font-medium text-brand-orange hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:rounded">
Upgrade
</Link>
)}

View File

@@ -238,6 +238,7 @@ export default function PublicDashboardPage() {
setCaptchaToken(token || '')
}}
apiUrl={process.env.NEXT_PUBLIC_CAPTCHA_API_URL}
action="share-access"
/>
</div>
<Button

View File

@@ -0,0 +1,20 @@
'use client'
import SiteNav from '@/components/dashboard/SiteNav'
export default function SiteLayoutShell({
siteId,
children,
}: {
siteId: string
children: React.ReactNode
}) {
return (
<>
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pt-8">
<SiteNav siteId={siteId} />
</div>
{children}
</>
)
}

View File

@@ -0,0 +1,168 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { useParams } from 'next/navigation'
import { getDateRange, formatDate } from '@ciphera-net/ui'
import { Select, DatePicker } from '@ciphera-net/ui'
import dynamic from 'next/dynamic'
import { getRageClicks, getDeadClicks } from '@/lib/api/stats'
import FrustrationSummaryCards from '@/components/behavior/FrustrationSummaryCards'
import FrustrationTable from '@/components/behavior/FrustrationTable'
import FrustrationByPageTable from '@/components/behavior/FrustrationByPageTable'
import FrustrationTrend from '@/components/behavior/FrustrationTrend'
import { useDashboard, useBehavior } from '@/lib/swr/dashboard'
const ScrollDepth = dynamic(() => import('@/components/dashboard/ScrollDepth'))
function getThisWeekRange(): { start: string; end: string } {
const today = new Date()
const dayOfWeek = today.getDay()
const monday = new Date(today)
monday.setDate(today.getDate() - (dayOfWeek === 0 ? 6 : dayOfWeek - 1))
return { start: formatDate(monday), end: formatDate(today) }
}
function getThisMonthRange(): { start: string; end: string } {
const today = new Date()
const firstOfMonth = new Date(today.getFullYear(), today.getMonth(), 1)
return { start: formatDate(firstOfMonth), end: formatDate(today) }
}
export default function BehaviorPage() {
const params = useParams()
const siteId = params.id as string
const [period, setPeriod] = useState('30')
const [dateRange, setDateRange] = useState(() => getDateRange(30))
const [isDatePickerOpen, setIsDatePickerOpen] = useState(false)
// Single request for all frustration data
const { data: behavior, isLoading: loading, error: behaviorError } = useBehavior(siteId, dateRange.start, dateRange.end)
// Fetch dashboard data for scroll depth (goal_counts + stats)
const { data: dashboard } = useDashboard(siteId, dateRange.start, dateRange.end)
useEffect(() => {
const domain = dashboard?.site?.domain
document.title = domain ? `Behavior · ${domain} | Pulse` : 'Behavior | Pulse'
}, [dashboard?.site?.domain])
// On-demand fetchers for modal "view all"
const fetchAllRage = useCallback(
() => getRageClicks(siteId, dateRange.start, dateRange.end, 100),
[siteId, dateRange.start, dateRange.end]
)
const fetchAllDead = useCallback(
() => getDeadClicks(siteId, dateRange.start, dateRange.end, 100),
[siteId, dateRange.start, dateRange.end]
)
const summary = behavior?.summary ?? null
const rageClicks = behavior?.rage_clicks ?? { items: [], total: 0 }
const deadClicks = behavior?.dead_clicks ?? { items: [], total: 0 }
const byPage = behavior?.by_page ?? []
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
{/* Header */}
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-1">
Behavior
</h1>
<p className="text-sm text-neutral-500 dark:text-neutral-400">
Frustration signals and user engagement patterns
</p>
</div>
<Select
variant="input"
className="min-w-[140px]"
value={period}
onChange={(value) => {
if (value === 'today') {
const today = formatDate(new Date())
setDateRange({ start: today, end: today })
setPeriod('today')
} else if (value === '7') {
setDateRange(getDateRange(7))
setPeriod('7')
} else if (value === 'week') {
setDateRange(getThisWeekRange())
setPeriod('week')
} else if (value === '30') {
setDateRange(getDateRange(30))
setPeriod('30')
} else if (value === 'month') {
setDateRange(getThisMonthRange())
setPeriod('month')
} else if (value === 'custom') {
setIsDatePickerOpen(true)
}
}}
options={[
{ value: 'today', label: 'Today' },
{ value: '7', label: 'Last 7 days' },
{ value: '30', label: 'Last 30 days' },
{ value: 'divider-1', label: '', divider: true },
{ value: 'week', label: 'This week' },
{ value: 'month', label: 'This month' },
{ value: 'divider-2', label: '', divider: true },
{ value: 'custom', label: 'Custom' },
]}
/>
</div>
{/* Summary cards */}
<FrustrationSummaryCards data={summary} loading={loading} />
{/* Rage clicks + Dead clicks side by side */}
<div className="grid gap-6 lg:grid-cols-2 mb-8">
<FrustrationTable
title="Rage Clicks"
description="Elements users clicked repeatedly in frustration"
items={rageClicks.items}
total={rageClicks.total}
totalSignals={summary?.rage_clicks ?? 0}
showAvgClicks
loading={loading}
fetchAll={fetchAllRage}
/>
<FrustrationTable
title="Dead Clicks"
description="Elements users clicked that produced no response"
items={deadClicks.items}
total={deadClicks.total}
totalSignals={summary?.dead_clicks ?? 0}
loading={loading}
fetchAll={fetchAllDead}
/>
</div>
{/* By page breakdown */}
<FrustrationByPageTable pages={byPage} loading={loading} />
{/* Scroll depth + Frustration trend — hide when data failed to load */}
{!behaviorError && (
<div className="grid gap-6 lg:grid-cols-2 mb-8">
<ScrollDepth
goalCounts={dashboard?.goal_counts ?? []}
totalPageviews={dashboard?.stats?.pageviews ?? 0}
/>
<FrustrationTrend summary={summary} loading={loading} />
</div>
)}
<DatePicker
isOpen={isDatePickerOpen}
onClose={() => setIsDatePickerOpen(false)}
onApply={(range) => {
setDateRange(range)
setPeriod('custom')
setIsDatePickerOpen(false)
}}
initialRange={dateRange}
/>
</div>
)
}

View File

@@ -1,40 +1,15 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { ApiError } from '@/lib/api/client'
import { getFunnel, getFunnelStats, deleteFunnel, type Funnel, type FunnelStats } from '@/lib/api/funnels'
import { toast, Select, DatePicker, ChevronLeftIcon, ArrowRightIcon, TrashIcon, useTheme, Button } from '@ciphera-net/ui'
import { toast, Select, DatePicker, ChevronLeftIcon, ArrowRightIcon, TrashIcon, Button } from '@ciphera-net/ui'
import { FunnelDetailSkeleton, useMinimumLoading } from '@/components/skeletons'
import Link from 'next/link'
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Cell
} from 'recharts'
import { FunnelChart } from '@/components/ui/funnel-chart'
import { getDateRange } from '@ciphera-net/ui'
const CHART_COLORS_LIGHT = {
border: 'var(--color-neutral-200)',
axis: 'var(--color-neutral-400)',
tooltipBg: '#ffffff',
tooltipBorder: 'var(--color-neutral-200)',
}
const CHART_COLORS_DARK = {
border: 'var(--color-neutral-700)',
axis: 'var(--color-neutral-500)',
tooltipBg: 'var(--color-neutral-800)',
tooltipBorder: 'var(--color-neutral-700)',
}
const BRAND_ORANGE = 'var(--color-brand-orange)'
export default function FunnelReportPage() {
const params = useParams()
const router = useRouter()
@@ -74,12 +49,6 @@ export default function FunnelReportPage() {
loadData()
}, [loadData])
const { resolvedTheme } = useTheme()
const chartColors = useMemo(
() => (resolvedTheme === 'dark' ? CHART_COLORS_DARK : CHART_COLORS_LIGHT),
[resolvedTheme]
)
const handleDelete = async () => {
if (!confirm('Are you sure you want to delete this funnel?')) return
@@ -100,7 +69,7 @@ export default function FunnelReportPage() {
if (loadError === 'not_found' || (!funnel && !stats && !loadError)) {
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
<p className="text-neutral-600 dark:text-neutral-400">Funnel not found</p>
</div>
)
@@ -108,7 +77,7 @@ export default function FunnelReportPage() {
if (loadError === 'forbidden') {
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
<p className="text-neutral-600 dark:text-neutral-400">Access denied</p>
<Link href={`/sites/${siteId}/funnels`}>
<Button variant="primary" className="mt-4">
@@ -121,7 +90,7 @@ export default function FunnelReportPage() {
if (loadError === 'error') {
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
<p className="text-neutral-600 dark:text-neutral-400 mb-4">Unable to load funnel</p>
<Button type="button" onClick={() => loadData()} variant="primary">
Try again
@@ -132,21 +101,19 @@ export default function FunnelReportPage() {
if (!funnel || !stats) {
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
<p className="text-neutral-600 dark:text-neutral-400">Funnel not found</p>
</div>
)
}
const chartData = stats.steps.map(s => ({
name: s.step.name,
visitors: s.visitors,
dropoff: s.dropoff,
conversion: s.conversion
label: s.step.name,
value: s.visitors,
}))
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
<div className="mb-8">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
@@ -204,64 +171,13 @@ export default function FunnelReportPage() {
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white mb-6">
Funnel Visualization
</h3>
<div className="h-[400px] w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={chartColors.border} />
<XAxis
dataKey="name"
stroke={chartColors.axis}
fontSize={12}
tickLine={false}
axisLine={false}
/>
<YAxis
stroke={chartColors.axis}
fontSize={12}
tickLine={false}
axisLine={false}
/>
<Tooltip
cursor={{ fill: 'transparent' }}
content={({ active, payload, label }) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
return (
<div
className="p-3 rounded-xl shadow-lg border transition-shadow duration-300"
style={{
backgroundColor: chartColors.tooltipBg,
borderColor: chartColors.tooltipBorder,
}}
>
<p className="font-medium text-neutral-900 dark:text-white mb-1">{label}</p>
<p className="text-brand-orange font-bold text-lg">
{data.visitors.toLocaleString()} visitors
</p>
{data.dropoff > 0 && (
<p className="text-red-500 text-sm">
{Math.round(data.dropoff)}% drop-off
</p>
)}
{data.conversion > 0 && (
<p className="text-green-500 text-sm">
{Math.round(data.conversion)}% conversion (overall)
</p>
)}
</div>
);
}
return null;
}}
/>
<Bar dataKey="visitors" radius={[4, 4, 0, 0]} barSize={60}>
{chartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={BRAND_ORANGE} fillOpacity={Math.max(0.1, 1 - index * 0.15)} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
<FunnelChart
data={chartData}
orientation="vertical"
color="var(--chart-1)"
layers={3}
className="mx-auto max-w-md"
/>
</div>
{/* Detailed Stats Table */}

View File

@@ -91,7 +91,7 @@ export default function CreateFunnelPage() {
}
return (
<div className="w-full max-w-3xl mx-auto px-4 sm:px-6 py-8">
<div className="w-full max-w-3xl mx-auto px-4 sm:px-6 pb-8">
<div className="mb-8">
<Link
href={`/sites/${siteId}/funnels`}

View File

@@ -51,15 +51,9 @@ export default function FunnelsPage() {
}
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
<div className="mb-8">
<div className="flex items-center gap-4 mb-6">
<Link
href={`/sites/${siteId}`}
className="p-2 -ml-2 text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white rounded-xl hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
>
<ChevronLeftIcon className="w-5 h-5" />
</Link>
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white">
Funnels
@@ -68,14 +62,12 @@ export default function FunnelsPage() {
Track user journeys and identify drop-off points
</p>
</div>
<div className="ml-auto">
<Link href={`/sites/${siteId}/funnels/new`}>
<Button variant="primary" className="inline-flex items-center gap-2">
<PlusIcon className="w-4 h-4" />
<span>Create Funnel</span>
</Button>
</Link>
</div>
<Link href={`/sites/${siteId}/funnels/new`}>
<Button variant="primary" className="inline-flex items-center gap-2">
<PlusIcon className="w-4 h-4" />
<span>Create Funnel</span>
</Button>
</Link>
</div>
{funnels.length === 0 ? (

View File

@@ -0,0 +1,13 @@
'use client'
import ErrorDisplay from '@/components/ErrorDisplay'
export default function JourneysError({ reset }: { error: Error; reset: () => void }) {
return (
<ErrorDisplay
title="Journeys failed to load"
message="We couldn't load the journey data. This might be a temporary issue — try again."
onRetry={reset}
/>
)
}

View File

@@ -0,0 +1,9 @@
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Journeys | Pulse',
}
export default function JourneysLayout({ children }: { children: React.ReactNode }) {
return children
}

View File

@@ -0,0 +1,179 @@
'use client'
import { useEffect, useState } from 'react'
import { useParams } from 'next/navigation'
import { getDateRange, formatDate } from '@ciphera-net/ui'
import { Select, DatePicker } from '@ciphera-net/ui'
import SankeyDiagram from '@/components/journeys/SankeyDiagram'
import TopPathsTable from '@/components/journeys/TopPathsTable'
import { SkeletonCard } from '@/components/skeletons'
import {
useDashboard,
useJourneyTransitions,
useJourneyTopPaths,
useJourneyEntryPoints,
} from '@/lib/swr/dashboard'
function getThisWeekRange(): { start: string; end: string } {
const today = new Date()
const dayOfWeek = today.getDay()
const monday = new Date(today)
monday.setDate(today.getDate() - (dayOfWeek === 0 ? 6 : dayOfWeek - 1))
return { start: formatDate(monday), end: formatDate(today) }
}
function getThisMonthRange(): { start: string; end: string } {
const today = new Date()
const firstOfMonth = new Date(today.getFullYear(), today.getMonth(), 1)
return { start: formatDate(firstOfMonth), end: formatDate(today) }
}
export default function JourneysPage() {
const params = useParams()
const siteId = params.id as string
const [period, setPeriod] = useState('30')
const [dateRange, setDateRange] = useState(() => getDateRange(30))
const [isDatePickerOpen, setIsDatePickerOpen] = useState(false)
const [depth, setDepth] = useState(3)
const [entryPath, setEntryPath] = useState('')
const { data: transitionsData, isLoading: transitionsLoading } = useJourneyTransitions(
siteId, dateRange.start, dateRange.end, depth, 2, entryPath || undefined
)
const { data: topPaths, isLoading: topPathsLoading } = useJourneyTopPaths(
siteId, dateRange.start, dateRange.end, 20, 2, entryPath || undefined
)
const { data: entryPoints } = useJourneyEntryPoints(siteId, dateRange.start, dateRange.end)
const { data: dashboard } = useDashboard(siteId, dateRange.start, dateRange.end)
useEffect(() => {
const domain = dashboard?.site?.domain
document.title = domain ? `Journeys \u00b7 ${domain} | Pulse` : 'Journeys | Pulse'
}, [dashboard?.site?.domain])
const entryPointOptions = [
{ value: '', label: 'All entry points' },
...(entryPoints ?? []).map((ep) => ({
value: ep.path,
label: `${ep.path} (${ep.session_count.toLocaleString()})`,
})),
]
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
{/* Header */}
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-1">
Journeys
</h1>
<p className="text-sm text-neutral-500 dark:text-neutral-400">
How visitors navigate through your site
</p>
</div>
<Select
variant="input"
className="min-w-[140px]"
value={period}
onChange={(value) => {
if (value === 'today') {
const today = formatDate(new Date())
setDateRange({ start: today, end: today })
setPeriod('today')
} else if (value === '7') {
setDateRange(getDateRange(7))
setPeriod('7')
} else if (value === 'week') {
setDateRange(getThisWeekRange())
setPeriod('week')
} else if (value === '30') {
setDateRange(getDateRange(30))
setPeriod('30')
} else if (value === 'month') {
setDateRange(getThisMonthRange())
setPeriod('month')
} else if (value === 'custom') {
setIsDatePickerOpen(true)
}
}}
options={[
{ value: 'today', label: 'Today' },
{ value: '7', label: 'Last 7 days' },
{ value: '30', label: 'Last 30 days' },
{ value: 'divider-1', label: '', divider: true },
{ value: 'week', label: 'This week' },
{ value: 'month', label: 'This month' },
{ value: 'divider-2', label: '', divider: true },
{ value: 'custom', label: 'Custom' },
]}
/>
</div>
{/* Controls */}
<div className="flex flex-wrap items-center gap-4 mb-6">
<div className="flex items-center gap-3">
<label className="text-sm text-neutral-500 dark:text-neutral-400">Depth</label>
<input
type="range"
min={2}
max={5}
step={1}
value={depth}
onChange={(e) => setDepth(Number(e.target.value))}
className="w-32 accent-brand-orange"
/>
<span className="text-sm font-medium text-neutral-900 dark:text-white w-4">{depth}</span>
</div>
<Select
variant="input"
className="min-w-[180px]"
value={entryPath}
onChange={(value) => setEntryPath(value)}
options={entryPointOptions}
/>
{(depth !== 3 || entryPath) && (
<button
onClick={() => { setDepth(3); setEntryPath('') }}
className="text-sm text-neutral-500 hover:text-neutral-900 dark:hover:text-white transition-colors"
>
Reset
</button>
)}
</div>
{/* Sankey Diagram */}
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 mb-6">
{transitionsLoading ? (
<div className="h-[400px] flex items-center justify-center">
<SkeletonCard className="w-full h-full" />
</div>
) : (
<SankeyDiagram
transitions={transitionsData?.transitions ?? []}
totalSessions={transitionsData?.total_sessions ?? 0}
depth={depth}
onNodeClick={(path) => setEntryPath(path)}
/>
)}
</div>
{/* Top Paths */}
<TopPathsTable paths={topPaths ?? []} loading={topPathsLoading} />
{/* Date Picker Modal */}
<DatePicker
isOpen={isDatePickerOpen}
onClose={() => setIsDatePickerOpen(false)}
onApply={(range) => {
setDateRange(range)
setPeriod('custom')
setIsDatePickerOpen(false)
}}
initialRange={dateRange}
/>
</div>
)
}

View File

@@ -1,4 +1,5 @@
import type { Metadata } from 'next'
import SiteLayoutShell from './SiteLayoutShell'
export const metadata: Metadata = {
title: 'Dashboard | Pulse',
@@ -6,10 +7,13 @@ export const metadata: Metadata = {
robots: { index: false, follow: false },
}
export default function SiteLayout({
export default async function SiteLayout({
children,
params,
}: {
children: React.ReactNode
params: Promise<{ id: string }>
}) {
return children
const { id } = await params
return <SiteLayoutShell siteId={id}>{children}</SiteLayoutShell>
}

View File

@@ -1,10 +1,9 @@
'use client'
import { useAuth } from '@/lib/auth/context'
import { logger } from '@/lib/utils/logger'
import { useCallback, useEffect, useState, useMemo } from 'react'
import { useCallback, useEffect, useRef, useState, useMemo } from 'react'
import { useParams, useRouter, useSearchParams } from 'next/navigation'
import { motion } from 'framer-motion'
import {
getPerformanceByPage,
getTopPages,
@@ -19,38 +18,36 @@ import {
type Stats,
type DailyStat,
} from '@/lib/api/stats'
import { getDateRange } from '@ciphera-net/ui'
import { getDateRange, formatDate } from '@ciphera-net/ui'
import { toast } from '@ciphera-net/ui'
import { Button } from '@ciphera-net/ui'
import { Select, DatePicker, DownloadIcon } from '@ciphera-net/ui'
import dynamic from 'next/dynamic'
import { DashboardSkeleton, useMinimumLoading } from '@/components/skeletons'
import ExportModal from '@/components/dashboard/ExportModal'
import FilterBar from '@/components/dashboard/FilterBar'
import AddFilterDropdown, { type FilterSuggestion, type FilterSuggestions } from '@/components/dashboard/AddFilterDropdown'
import Chart from '@/components/dashboard/Chart'
import ContentStats from '@/components/dashboard/ContentStats'
import TopReferrers from '@/components/dashboard/TopReferrers'
import Locations from '@/components/dashboard/Locations'
import TechSpecs from '@/components/dashboard/TechSpecs'
import Chart from '@/components/dashboard/Chart'
import PerformanceStats from '@/components/dashboard/PerformanceStats'
import GoalStats from '@/components/dashboard/GoalStats'
import ScrollDepth from '@/components/dashboard/ScrollDepth'
import Campaigns from '@/components/dashboard/Campaigns'
import FilterBar from '@/components/dashboard/FilterBar'
import AddFilterDropdown, { type FilterSuggestion, type FilterSuggestions } from '@/components/dashboard/AddFilterDropdown'
import EventProperties from '@/components/dashboard/EventProperties'
const PerformanceStats = dynamic(() => import('@/components/dashboard/PerformanceStats'))
const GoalStats = dynamic(() => import('@/components/dashboard/GoalStats'))
const Campaigns = dynamic(() => import('@/components/dashboard/Campaigns'))
const PeakHours = dynamic(() => import('@/components/dashboard/PeakHours'))
const EventProperties = dynamic(() => import('@/components/dashboard/EventProperties'))
const ExportModal = dynamic(() => import('@/components/dashboard/ExportModal'))
import { type DimensionFilter, serializeFilters, parseFiltersFromURL } from '@/lib/filters'
import {
useDashboardOverview,
useDashboardPages,
useDashboardLocations,
useDashboardDevices,
useDashboardReferrers,
useDashboardPerformance,
useDashboardGoals,
useDashboard,
useRealtime,
useStats,
useDailyStats,
useCampaigns,
useAnnotations,
} from '@/lib/swr/dashboard'
import { createAnnotation, updateAnnotation, deleteAnnotation, type AnnotationCategory } from '@/lib/api/annotations'
function loadSavedSettings(): {
type?: string
@@ -67,26 +64,47 @@ function loadSavedSettings(): {
}
}
function getThisWeekRange(): { start: string; end: string } {
const today = new Date()
const dayOfWeek = today.getDay()
const monday = new Date(today)
monday.setDate(today.getDate() - (dayOfWeek === 0 ? 6 : dayOfWeek - 1))
return { start: formatDate(monday), end: formatDate(today) }
}
function getThisMonthRange(): { start: string; end: string } {
const today = new Date()
const firstOfMonth = new Date(today.getFullYear(), today.getMonth(), 1)
return { start: formatDate(firstOfMonth), end: formatDate(today) }
}
function getInitialDateRange(): { start: string; end: string } {
const settings = loadSavedSettings()
if (settings?.type === 'today') {
const today = new Date().toISOString().split('T')[0]
const today = formatDate(new Date())
return { start: today, end: today }
}
if (settings?.type === '7') return getDateRange(7)
if (settings?.type === 'week') return getThisWeekRange()
if (settings?.type === 'month') return getThisMonthRange()
if (settings?.type === 'custom' && settings.dateRange) return settings.dateRange
return getDateRange(30)
}
function getInitialPeriod(): string {
return loadSavedSettings()?.type || '30'
}
export default function SiteDashboardPage() {
const { user } = useAuth()
const canEdit = user?.role === 'owner' || user?.role === 'admin'
const params = useParams()
const router = useRouter()
const siteId = params.id as string
// UI state - initialized from localStorage synchronously to avoid double-fetch
const [period, setPeriod] = useState(getInitialPeriod)
const [dateRange, setDateRange] = useState(getInitialDateRange)
const [todayInterval, setTodayInterval] = useState<'minute' | 'hour'>(
() => loadSavedSettings()?.todayInterval || 'hour'
@@ -96,8 +114,7 @@ export default function SiteDashboardPage() {
)
const [isDatePickerOpen, setIsDatePickerOpen] = useState(false)
const [isExportModalOpen, setIsExportModalOpen] = useState(false)
const [lastUpdatedAt, setLastUpdatedAt] = useState<number | null>(null)
const [, setTick] = useState(0)
const lastUpdatedAtRef = useRef<number | null>(null)
// Dimension filters state
const searchParams = useSearchParams()
@@ -218,39 +235,53 @@ export default function SiteDashboardPage() {
return { start: prevStart.toISOString().split('T')[0], end: prevEnd.toISOString().split('T')[0] }
}, [dateRange])
// SWR hooks - replace manual useState + useEffect + setInterval polling
// Each hook handles its own refresh interval, deduplication, and error retry
// Filters are included in cache keys so changing filters auto-refetches
const { data: overview, isLoading: overviewLoading, error: overviewError } = useDashboardOverview(siteId, dateRange.start, dateRange.end, interval, filtersParam || undefined)
const { data: pages } = useDashboardPages(siteId, dateRange.start, dateRange.end, filtersParam || undefined)
const { data: locations } = useDashboardLocations(siteId, dateRange.start, dateRange.end, filtersParam || undefined)
const { data: devicesData } = useDashboardDevices(siteId, dateRange.start, dateRange.end, filtersParam || undefined)
const { data: referrers } = useDashboardReferrers(siteId, dateRange.start, dateRange.end, filtersParam || undefined)
const { data: performanceData } = useDashboardPerformance(siteId, dateRange.start, dateRange.end, filtersParam || undefined)
const { data: goalsData } = useDashboardGoals(siteId, dateRange.start, dateRange.end, filtersParam || undefined)
// Single dashboard request replaces 7 focused hooks (overview, pages, locations,
// devices, referrers, performance, goals). The backend runs all queries in parallel
// and caches the result in Redis, reducing requests from 12 to 6 per refresh cycle.
const { data: dashboard, isLoading: dashboardLoading, error: dashboardError } = useDashboard(siteId, dateRange.start, dateRange.end, interval, filtersParam || undefined)
const { data: realtimeData } = useRealtime(siteId)
const { data: prevStats } = useStats(siteId, prevRange.start, prevRange.end)
const { data: prevDailyStats } = useDailyStats(siteId, prevRange.start, prevRange.end, interval)
const { data: campaigns } = useCampaigns(siteId, dateRange.start, dateRange.end)
const { data: annotations, mutate: mutateAnnotations } = useAnnotations(siteId, dateRange.start, dateRange.end)
// Derive typed values from SWR data
const site = overview?.site ?? null
const stats: Stats = overview?.stats ?? { pageviews: 0, visitors: 0, bounce_rate: 0, avg_duration: 0 }
const realtime = realtimeData?.visitors ?? overview?.realtime_visitors ?? 0
const dailyStats: DailyStat[] = overview?.daily_stats ?? []
// Annotation mutation handlers
const handleCreateAnnotation = async (data: { date: string; time?: string; text: string; category: string }) => {
await createAnnotation(siteId, { ...data, category: data.category as AnnotationCategory })
mutateAnnotations()
toast.success('Annotation added')
}
const handleUpdateAnnotation = async (id: string, data: { date: string; time?: string; text: string; category: string }) => {
await updateAnnotation(siteId, id, { ...data, category: data.category as AnnotationCategory })
mutateAnnotations()
toast.success('Annotation updated')
}
const handleDeleteAnnotation = async (id: string) => {
await deleteAnnotation(siteId, id)
mutateAnnotations()
toast.success('Annotation deleted')
}
// Derive typed values from single dashboard response
const site = dashboard?.site ?? null
const stats: Stats = dashboard?.stats ?? { pageviews: 0, visitors: 0, bounce_rate: 0, avg_duration: 0 }
const realtime = realtimeData?.visitors ?? dashboard?.realtime_visitors ?? 0
const dailyStats: DailyStat[] = dashboard?.daily_stats ?? []
// Build filter suggestions from current dashboard data
const filterSuggestions = useMemo<FilterSuggestions>(() => {
const s: FilterSuggestions = {}
// Pages
const topPages = pages?.top_pages ?? []
const topPages = dashboard?.top_pages ?? []
if (topPages.length > 0) {
s.page = topPages.map(p => ({ value: p.path, label: p.path, count: p.pageviews }))
}
// Referrers
const refs = referrers?.top_referrers ?? []
const refs = dashboard?.top_referrers ?? []
if (refs.length > 0) {
s.referrer = refs.filter(r => r.referrer && r.referrer !== '').map(r => ({
value: r.referrer,
@@ -260,7 +291,7 @@ export default function SiteDashboardPage() {
}
// Countries
const ctrs = locations?.countries ?? []
const ctrs = dashboard?.countries ?? []
if (ctrs.length > 0) {
const regionNames = (() => { try { return new Intl.DisplayNames(['en'], { type: 'region' }) } catch { return null } })()
s.country = ctrs.filter(c => c.country && c.country !== 'Unknown').map(c => ({
@@ -271,7 +302,7 @@ export default function SiteDashboardPage() {
}
// Regions
const regs = locations?.regions ?? []
const regs = dashboard?.regions ?? []
if (regs.length > 0) {
s.region = regs.filter(r => r.region && r.region !== 'Unknown').map(r => ({
value: r.region,
@@ -281,7 +312,7 @@ export default function SiteDashboardPage() {
}
// Cities
const cts = locations?.cities ?? []
const cts = dashboard?.cities ?? []
if (cts.length > 0) {
s.city = cts.filter(c => c.city && c.city !== 'Unknown').map(c => ({
value: c.city,
@@ -291,7 +322,7 @@ export default function SiteDashboardPage() {
}
// Browsers
const brs = devicesData?.browsers ?? []
const brs = dashboard?.browsers ?? []
if (brs.length > 0) {
s.browser = brs.filter(b => b.browser && b.browser !== 'Unknown').map(b => ({
value: b.browser,
@@ -301,7 +332,7 @@ export default function SiteDashboardPage() {
}
// OS
const oses = devicesData?.os ?? []
const oses = dashboard?.os ?? []
if (oses.length > 0) {
s.os = oses.filter(o => o.os && o.os !== 'Unknown').map(o => ({
value: o.os,
@@ -311,7 +342,7 @@ export default function SiteDashboardPage() {
}
// Devices
const devs = devicesData?.devices ?? []
const devs = dashboard?.devices ?? []
if (devs.length > 0) {
s.device = devs.filter(d => d.device && d.device !== 'Unknown').map(d => ({
value: d.device,
@@ -337,25 +368,19 @@ export default function SiteDashboardPage() {
}
return s
}, [pages, referrers, locations, devicesData, campaigns])
}, [dashboard, campaigns])
// Show error toast on fetch failure
useEffect(() => {
if (overviewError) {
if (dashboardError) {
toast.error('Failed to load dashboard analytics')
}
}, [overviewError])
}, [dashboardError])
// Track when data was last updated (for "Live · Xs ago" display)
useEffect(() => {
if (overview) setLastUpdatedAt(Date.now())
}, [overview])
// Tick every 1s so "Live · Xs ago" counts in real time
useEffect(() => {
const timer = setInterval(() => setTick((t) => t + 1), 1000)
return () => clearInterval(timer)
}, [])
if (dashboard) lastUpdatedAtRef.current = Date.now()
}, [dashboard])
// Save settings to localStorage
const saveSettings = (type: string, newDateRange?: { start: string; end: string }) => {
@@ -376,7 +401,7 @@ export default function SiteDashboardPage() {
// Save intervals when they change
useEffect(() => {
let type = 'custom'
const today = new Date().toISOString().split('T')[0]
const today = formatDate(new Date())
if (dateRange.start === today && dateRange.end === today) type = 'today'
else if (dateRange.start === getDateRange(7).start) type = '7'
else if (dateRange.start === getDateRange(30).start) type = '30'
@@ -395,7 +420,9 @@ export default function SiteDashboardPage() {
if (site?.domain) document.title = `${site.domain} | Pulse`
}, [site?.domain])
const showSkeleton = useMinimumLoading(overviewLoading)
// Skip the minimum-loading skeleton when SWR already has cached data
// (prevents the 300ms flash when navigating back to the dashboard)
const showSkeleton = useMinimumLoading(dashboardLoading && !dashboard)
if (showSkeleton) {
return <DashboardSkeleton />
@@ -403,19 +430,14 @@ export default function SiteDashboardPage() {
if (!site) {
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
<p className="text-neutral-600 dark:text-neutral-400">Site not found</p>
</div>
)
}
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2 }}
className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8"
>
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-4">
@@ -429,9 +451,8 @@ export default function SiteDashboardPage() {
</div>
{/* Realtime Indicator */}
<button
onClick={() => router.push(`/sites/${siteId}/realtime`)}
className="flex items-center gap-2 px-3 py-1 bg-green-500/10 rounded-full border border-green-500/20 hover:bg-green-500/20 transition-colors cursor-pointer"
<div
className="flex items-center gap-2 px-3 py-1 bg-green-500/10 rounded-full border border-green-500/20"
>
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-500 opacity-75"></span>
@@ -440,7 +461,7 @@ export default function SiteDashboardPage() {
<span className="text-sm font-medium text-green-700 dark:text-green-400">
{realtime} current visitors
</span>
</button>
</div>
</div>
<div className="flex items-center gap-2">
@@ -456,33 +477,35 @@ export default function SiteDashboardPage() {
<Select
variant="input"
className="min-w-[140px]"
value={
dateRange.start === new Date().toISOString().split('T')[0] && dateRange.end === new Date().toISOString().split('T')[0]
? 'today'
: dateRange.start === getDateRange(7).start
? '7'
: dateRange.start === getDateRange(30).start
? '30'
: 'custom'
}
value={period}
onChange={(value) => {
if (value === '7') {
const range = getDateRange(7)
setDateRange(range)
saveSettings('7', range)
}
else if (value === '30') {
const range = getDateRange(30)
setDateRange(range)
saveSettings('30', range)
}
else if (value === 'today') {
const today = new Date().toISOString().split('T')[0]
if (value === 'today') {
const today = formatDate(new Date())
const range = { start: today, end: today }
setDateRange(range)
setPeriod('today')
saveSettings('today', range)
}
else if (value === 'custom') {
} else if (value === '7') {
const range = getDateRange(7)
setDateRange(range)
setPeriod('7')
saveSettings('7', range)
} else if (value === 'week') {
const range = getThisWeekRange()
setDateRange(range)
setPeriod('week')
saveSettings('week', range)
} else if (value === '30') {
const range = getDateRange(30)
setDateRange(range)
setPeriod('30')
saveSettings('30', range)
} else if (value === 'month') {
const range = getThisMonthRange()
setDateRange(range)
setPeriod('month')
saveSettings('month', range)
} else if (value === 'custom') {
setIsDatePickerOpen(true)
}
}}
@@ -490,39 +513,14 @@ export default function SiteDashboardPage() {
{ value: 'today', label: 'Today' },
{ value: '7', label: 'Last 7 days' },
{ value: '30', label: 'Last 30 days' },
{ value: 'divider-1', label: '', divider: true },
{ value: 'week', label: 'This week' },
{ value: 'month', label: 'This month' },
{ value: 'divider-2', label: '', divider: true },
{ value: 'custom', label: 'Custom' },
]}
/>
</div>
<div
className="h-6 w-px bg-neutral-200 dark:bg-neutral-700 flex-shrink-0"
aria-hidden
/>
<div className="flex items-center gap-1">
<Button
onClick={() => router.push(`/sites/${siteId}/uptime`)}
variant="ghost"
className="text-sm"
>
Uptime
</Button>
<Button
onClick={() => router.push(`/sites/${siteId}/funnels`)}
variant="ghost"
className="text-sm"
>
Funnels
</Button>
{canEdit && (
<Button
onClick={() => router.push(`/sites/${siteId}/settings`)}
variant="ghost"
className="text-sm"
>
Settings
</Button>
)}
</div>
</div>
</div>
</div>
@@ -542,11 +540,17 @@ export default function SiteDashboardPage() {
prevStats={prevStats}
interval={dateRange.start === dateRange.end ? todayInterval : multiDayInterval}
dateRange={dateRange}
period={period}
todayInterval={todayInterval}
setTodayInterval={setTodayInterval}
multiDayInterval={multiDayInterval}
setMultiDayInterval={setMultiDayInterval}
lastUpdatedAt={lastUpdatedAt}
lastUpdatedAt={lastUpdatedAtRef.current}
annotations={annotations}
canManageAnnotations={true}
onCreateAnnotation={handleCreateAnnotation}
onUpdateAnnotation={handleUpdateAnnotation}
onDeleteAnnotation={handleDeleteAnnotation}
/>
</div>
@@ -554,8 +558,8 @@ export default function SiteDashboardPage() {
{site.enable_performance_insights && (
<div className="mb-8">
<PerformanceStats
stats={performanceData?.performance ?? { lcp: 0, cls: 0, inp: 0 }}
performanceByPage={performanceData?.performance_by_page ?? null}
stats={dashboard?.performance ?? { lcp: 0, cls: 0, inp: 0 }}
performanceByPage={dashboard?.performance_by_page ?? null}
siteId={siteId}
startDate={dateRange.start}
endDate={dateRange.end}
@@ -566,9 +570,9 @@ export default function SiteDashboardPage() {
<div className="grid gap-6 lg:grid-cols-2 mb-8">
<ContentStats
topPages={pages?.top_pages ?? []}
entryPages={pages?.entry_pages ?? []}
exitPages={pages?.exit_pages ?? []}
topPages={dashboard?.top_pages ?? []}
entryPages={dashboard?.entry_pages ?? []}
exitPages={dashboard?.exit_pages ?? []}
domain={site.domain}
collectPagePaths={site.collect_page_paths ?? true}
siteId={siteId}
@@ -576,7 +580,7 @@ export default function SiteDashboardPage() {
onFilter={handleAddFilter}
/>
<TopReferrers
referrers={referrers?.top_referrers ?? []}
referrers={dashboard?.top_referrers ?? []}
collectReferrers={site.collect_referrers ?? true}
siteId={siteId}
dateRange={dateRange}
@@ -586,19 +590,19 @@ export default function SiteDashboardPage() {
<div className="grid gap-6 lg:grid-cols-2 mb-8">
<Locations
countries={locations?.countries ?? []}
cities={locations?.cities ?? []}
regions={locations?.regions ?? []}
countries={dashboard?.countries ?? []}
cities={dashboard?.cities ?? []}
regions={dashboard?.regions ?? []}
geoDataLevel={site.collect_geo_data || 'full'}
siteId={siteId}
dateRange={dateRange}
onFilter={handleAddFilter}
/>
<TechSpecs
browsers={devicesData?.browsers ?? []}
os={devicesData?.os ?? []}
devices={devicesData?.devices ?? []}
screenResolutions={devicesData?.screen_resolutions ?? []}
browsers={dashboard?.browsers ?? []}
os={dashboard?.os ?? []}
devices={dashboard?.devices ?? []}
screenResolutions={dashboard?.screen_resolutions ?? []}
collectDeviceInfo={site.collect_device_info ?? true}
collectScreenResolution={site.collect_screen_resolution ?? true}
siteId={siteId}
@@ -609,14 +613,14 @@ export default function SiteDashboardPage() {
<div className="grid gap-6 lg:grid-cols-2 mb-8">
<Campaigns siteId={siteId} dateRange={dateRange} filters={filtersParam || undefined} onFilter={handleAddFilter} />
<GoalStats
goalCounts={(goalsData?.goal_counts ?? []).filter(g => !/^scroll_\d+$/.test(g.event_name))}
onSelectEvent={setSelectedEvent}
/>
<PeakHours siteId={siteId} dateRange={dateRange} />
</div>
<div className="mb-8">
<ScrollDepth goalCounts={goalsData?.goal_counts ?? []} totalPageviews={stats.pageviews} />
<GoalStats
goalCounts={(dashboard?.goal_counts ?? []).filter(g => !/^scroll_\d+$/.test(g.event_name))}
onSelectEvent={setSelectedEvent}
/>
</div>
{/* Event Properties Breakdown */}
@@ -636,6 +640,7 @@ export default function SiteDashboardPage() {
onClose={() => setIsDatePickerOpen(false)}
onApply={(range) => {
setDateRange(range)
setPeriod('custom')
saveSettings('custom', range)
setIsDatePickerOpen(false)
}}
@@ -647,10 +652,10 @@ export default function SiteDashboardPage() {
onClose={() => setIsExportModalOpen(false)}
data={dailyStats}
stats={stats}
topPages={pages?.top_pages}
topReferrers={referrers?.top_referrers}
topPages={dashboard?.top_pages}
topReferrers={dashboard?.top_referrers}
campaigns={campaigns}
/>
</motion.div>
</div>
)
}

View File

@@ -1,13 +0,0 @@
'use client'
import ErrorDisplay from '@/components/ErrorDisplay'
export default function RealtimeError({ reset }: { error: Error; reset: () => void }) {
return (
<ErrorDisplay
title="Realtime view failed to load"
message="We couldn't connect to the realtime data stream. Please try again."
onRetry={reset}
/>
)
}

View File

@@ -1,15 +0,0 @@
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Realtime | Pulse',
description: 'See who is on your site right now.',
robots: { index: false, follow: false },
}
export default function RealtimeLayout({
children,
}: {
children: React.ReactNode
}) {
return children
}

View File

@@ -1,256 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { getSite, type Site } from '@/lib/api/sites'
import { getRealtimeVisitors, getSessionDetails, type Visitor, type SessionEvent } from '@/lib/api/realtime'
import { toast } from '@ciphera-net/ui'
import { getAuthErrorMessage } from '@ciphera-net/ui'
import { UserIcon } from '@ciphera-net/ui'
import { RealtimeSkeleton, SessionEventsSkeleton, useMinimumLoading } from '@/components/skeletons'
import { motion, AnimatePresence } from 'framer-motion'
function formatTimeAgo(dateString: string) {
const date = new Date(dateString)
const now = new Date()
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000)
if (diffInSeconds < 60) return 'just now'
if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)}m ago`
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)}h ago`
return `${Math.floor(diffInSeconds / 86400)}d ago`
}
export default function RealtimePage() {
const params = useParams()
const router = useRouter()
const siteId = params.id as string
const [site, setSite] = useState<Site | null>(null)
const [visitors, setVisitors] = useState<Visitor[]>([])
const [selectedVisitor, setSelectedVisitor] = useState<Visitor | null>(null)
const [sessionEvents, setSessionEvents] = useState<SessionEvent[]>([])
const [loading, setLoading] = useState(true)
const [loadingEvents, setLoadingEvents] = useState(false)
// Load site info and initial visitors
useEffect(() => {
const init = async () => {
try {
const [siteData, visitorsData] = await Promise.all([
getSite(siteId),
getRealtimeVisitors(siteId)
])
setSite(siteData)
setVisitors(visitorsData || [])
// Select first visitor if available
if (visitorsData && visitorsData.length > 0) {
handleSelectVisitor(visitorsData[0])
}
} catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || 'Failed to load realtime visitors')
} finally {
setLoading(false)
}
}
init()
}, [siteId])
// Poll for updates
useEffect(() => {
const interval = setInterval(async () => {
try {
const data = await getRealtimeVisitors(siteId)
setVisitors(data || [])
// Update selected visitor reference if they are still in the list
if (selectedVisitor) {
const updatedVisitor = data?.find(v => v.session_id === selectedVisitor.session_id)
if (updatedVisitor) {
// Don't overwrite the selectedVisitor state directly to avoid flickering details
// But we could update "last seen" indicators if we wanted
}
}
} catch (e) {
// Silent fail
}
}, 5000)
return () => clearInterval(interval)
}, [siteId, selectedVisitor])
const handleSelectVisitor = async (visitor: Visitor) => {
setSelectedVisitor(visitor)
setLoadingEvents(true)
try {
const events = await getSessionDetails(siteId, visitor.session_id)
setSessionEvents(events || [])
} catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || 'Failed to load session events')
} finally {
setLoadingEvents(false)
}
}
useEffect(() => {
if (site?.domain) document.title = `Realtime · ${site.domain} | Pulse`
}, [site?.domain])
const showSkeleton = useMinimumLoading(loading)
if (showSkeleton) return <RealtimeSkeleton />
if (!site) return <div className="p-8">Site not found</div>
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8 h-[calc(100vh-64px)] flex flex-col">
<div className="mb-6 flex items-center justify-between">
<div>
<div className="flex items-center gap-2 mb-1">
<button onClick={() => router.push(`/sites/${siteId}`)} className="text-sm text-neutral-500 hover:text-neutral-900 dark:hover:text-white transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded">
&larr; Back to Dashboard
</button>
</div>
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white flex items-center gap-3">
Realtime Visitors
<span className="relative flex h-3 w-3">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-3 w-3 bg-green-500"></span>
</span>
<span className="text-lg font-normal text-neutral-500" aria-live="polite" aria-atomic="true">
{visitors.length} active now
</span>
</h1>
</div>
</div>
<div className="flex flex-col md:flex-row flex-1 gap-6 min-h-0">
{/* Visitors List */}
<div className="w-full md:w-1/3 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden flex flex-col bg-white dark:bg-neutral-900">
<div className="p-4 border-b border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-800/50">
<h2 className="text-xl font-semibold text-neutral-900 dark:text-white">Active Sessions</h2>
</div>
<div className="overflow-y-auto flex-1">
{visitors.length === 0 ? (
<div className="p-8 flex flex-col items-center justify-center text-center gap-3">
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-3">
<UserIcon className="w-6 h-6 text-neutral-500 dark:text-neutral-400" />
</div>
<p className="text-sm font-medium text-neutral-900 dark:text-white">
No active visitors right now
</p>
<p className="text-xs text-neutral-500 dark:text-neutral-400">
New visitors will appear here in real-time
</p>
</div>
) : (
<div className="divide-y divide-neutral-100 dark:divide-neutral-800">
<AnimatePresence mode="popLayout">
{visitors.map((visitor) => (
<motion.button
key={visitor.session_id}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -10 }}
transition={{ duration: 0.2 }}
onClick={() => handleSelectVisitor(visitor)}
className={`w-full text-left p-4 hover:bg-neutral-50 dark:hover:bg-neutral-800/50 transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-inset ${
selectedVisitor?.session_id === visitor.session_id ? 'bg-neutral-50 dark:bg-neutral-800/50 ring-1 ring-inset ring-neutral-200 dark:ring-neutral-700' : ''
}`}
>
<div className="flex justify-between items-start mb-1">
<div className="font-medium text-neutral-900 dark:text-white truncate pr-2">
{visitor.country ? `${getFlagEmoji(visitor.country)} ${visitor.city || 'Unknown City'}` : 'Unknown Location'}
</div>
<span className="text-xs text-neutral-500 whitespace-nowrap">
{formatTimeAgo(visitor.last_seen)}
</span>
</div>
<div className="text-sm text-neutral-600 dark:text-neutral-400 truncate mb-1" title={visitor.current_path}>
{visitor.current_path}
</div>
<div className="flex items-center gap-2 text-xs text-neutral-400">
<span>{visitor.device_type}</span>
<span></span>
<span>{visitor.browser}</span>
<span></span>
<span>{visitor.os}</span>
<span className="ml-auto bg-neutral-100 dark:bg-neutral-800 px-1.5 py-0.5 rounded text-neutral-600 dark:text-neutral-400">
{visitor.pageviews} views
</span>
</div>
</motion.button>
))}
</AnimatePresence>
</div>
)}
</div>
</div>
{/* Session Details */}
<div className="flex-1 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden flex flex-col bg-white dark:bg-neutral-900">
<div className="p-4 border-b border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-800/50 flex justify-between items-center">
<h2 className="text-xl font-semibold text-neutral-900 dark:text-white">
{selectedVisitor ? 'Session Journey' : 'Select a visitor'}
</h2>
{selectedVisitor && (
<span className="text-xs font-mono text-neutral-400">
ID: {selectedVisitor.session_id.substring(0, 8)}...
</span>
)}
</div>
<div className="flex-1 overflow-y-auto p-6">
{!selectedVisitor ? (
<div className="h-full flex items-center justify-center text-neutral-500">
Select a visitor on the left to see their activity.
</div>
) : loadingEvents ? (
<SessionEventsSkeleton />
) : (
<div className="relative pl-6 border-l-2 border-neutral-100 dark:border-neutral-800 space-y-8">
{sessionEvents.map((event, idx) => (
<div key={event.id} className="relative">
<span className={`absolute -left-[29px] top-1 h-3 w-3 rounded-full border-2 border-white dark:border-neutral-900 ${
idx === 0 ? 'bg-green-500 ring-4 ring-green-100 dark:ring-green-900/30' : 'bg-neutral-300 dark:bg-neutral-700'
}`}></span>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-neutral-900 dark:text-white">
Visited {event.path}
</span>
<span className="text-xs text-neutral-500">
{new Date(event.timestamp).toLocaleTimeString()}
</span>
</div>
{event.referrer && (
<div className="text-xs text-neutral-500">
Referrer: <span className="text-neutral-700 dark:text-neutral-300">{event.referrer}</span>
</div>
)}
</div>
</div>
))}
<div className="relative">
<span className="absolute -left-[29px] top-1 h-3 w-3 rounded-full border-2 border-white dark:border-neutral-900 bg-neutral-300 dark:bg-neutral-700"></span>
<div className="text-sm text-neutral-500">
Session started {formatTimeAgo(sessionEvents[sessionEvents.length - 1]?.timestamp || new Date().toISOString())}
</div>
</div>
</div>
)}
</div>
</div>
</div>
</div>
)
}
function getFlagEmoji(countryCode: string) {
if (!countryCode || countryCode.length !== 2) return '🌍'
const codePoints = countryCode
.toUpperCase()
.split('')
.map(char => 127397 + char.charCodeAt(0))
return String.fromCodePoint(...codePoints)
}

View File

@@ -4,6 +4,7 @@ import { useEffect, useState, useRef } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { getSite, updateSite, resetSiteData, deleteSite, type Site, type GeoDataLevel } from '@/lib/api/sites'
import { listGoals, createGoal, updateGoal, deleteGoal, type Goal } from '@/lib/api/goals'
import { listReportSchedules, createReportSchedule, updateReportSchedule, deleteReportSchedule, testReportSchedule, type ReportSchedule, type CreateReportScheduleRequest, type EmailConfig, type WebhookConfig } from '@/lib/api/report-schedules'
import { toast } from '@ciphera-net/ui'
import { getAuthErrorMessage } from '@ciphera-net/ui'
import { SettingsFormSkeleton, GoalsListSkeleton, useMinimumLoading } from '@/components/skeletons'
@@ -25,6 +26,7 @@ import {
AlertTriangleIcon,
ZapIcon,
} from '@ciphera-net/ui'
import { PaperPlaneTilt, Envelope, WebhooksLogo, SpinnerGap, Trash, PencilSimple, Play } from '@phosphor-icons/react'
const TIMEZONES = [
'UTC',
@@ -54,7 +56,7 @@ export default function SiteSettingsPage() {
const [site, setSite] = useState<Site | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [activeTab, setActiveTab] = useState<'general' | 'visibility' | 'data' | 'goals'>('general')
const [activeTab, setActiveTab] = useState<'general' | 'visibility' | 'data' | 'goals' | 'reports'>('general')
const [formData, setFormData] = useState({
name: '',
@@ -72,6 +74,8 @@ export default function SiteSettingsPage() {
enable_performance_insights: false,
// Bot and noise filtering
filter_bots: true,
// Hide unknown locations
hide_unknown_locations: false,
// Data retention (6 = free-tier max; safe default)
data_retention_months: 6
})
@@ -89,6 +93,24 @@ export default function SiteSettingsPage() {
const [goalSaving, setGoalSaving] = useState(false)
const initialFormRef = useRef<string>('')
// Report schedules state
const [reportSchedules, setReportSchedules] = useState<ReportSchedule[]>([])
const [reportLoading, setReportLoading] = useState(false)
const [reportModalOpen, setReportModalOpen] = useState(false)
const [editingSchedule, setEditingSchedule] = useState<ReportSchedule | null>(null)
const [reportSaving, setReportSaving] = useState(false)
const [reportTesting, setReportTesting] = useState<string | null>(null)
const [reportForm, setReportForm] = useState({
channel: 'email' as string,
recipients: '',
webhookUrl: '',
frequency: 'weekly' as string,
reportType: 'summary' as string,
timezone: '',
sendHour: 9,
sendDay: 1,
})
useEffect(() => {
loadSite()
loadSubscription()
@@ -100,6 +122,12 @@ export default function SiteSettingsPage() {
}
}, [activeTab, siteId])
useEffect(() => {
if (activeTab === 'reports' && siteId) {
loadReportSchedules()
}
}, [activeTab, siteId])
const loadSubscription = async () => {
try {
setSubscriptionLoadFailed(false)
@@ -145,6 +173,8 @@ export default function SiteSettingsPage() {
enable_performance_insights: data.enable_performance_insights ?? false,
// Bot and noise filtering (default to true)
filter_bots: data.filter_bots ?? true,
// Hide unknown locations (default to false)
hide_unknown_locations: data.hide_unknown_locations ?? false,
// Data retention (default 6 = free-tier max; avoids flash-then-clamp for existing sites)
data_retention_months: data.data_retention_months ?? 6
})
@@ -160,6 +190,7 @@ export default function SiteSettingsPage() {
collect_screen_resolution: data.collect_screen_resolution ?? true,
enable_performance_insights: data.enable_performance_insights ?? false,
filter_bots: data.filter_bots ?? true,
hide_unknown_locations: data.hide_unknown_locations ?? false,
data_retention_months: data.data_retention_months ?? 6
})
if (data.has_password) {
@@ -186,6 +217,184 @@ export default function SiteSettingsPage() {
}
}
const loadReportSchedules = async () => {
try {
setReportLoading(true)
const data = await listReportSchedules(siteId)
setReportSchedules(data)
} catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || 'Failed to load report schedules')
} finally {
setReportLoading(false)
}
}
const resetReportForm = () => {
setReportForm({
channel: 'email',
recipients: '',
webhookUrl: '',
frequency: 'weekly',
reportType: 'summary',
timezone: site?.timezone || '',
sendHour: 9,
sendDay: 1,
})
}
const openEditSchedule = (schedule: ReportSchedule) => {
setEditingSchedule(schedule)
const isEmail = schedule.channel === 'email'
setReportForm({
channel: schedule.channel,
recipients: isEmail ? (schedule.channel_config as EmailConfig).recipients.join(', ') : '',
webhookUrl: !isEmail ? (schedule.channel_config as WebhookConfig).url : '',
frequency: schedule.frequency,
reportType: schedule.report_type,
timezone: schedule.timezone || site?.timezone || '',
sendHour: schedule.send_hour ?? 9,
sendDay: schedule.send_day ?? (schedule.frequency === 'monthly' ? 1 : 0),
})
setReportModalOpen(true)
}
const handleReportSubmit = async (e: React.FormEvent) => {
e.preventDefault()
let channelConfig: EmailConfig | WebhookConfig
if (reportForm.channel === 'email') {
const recipients = reportForm.recipients.split(',').map(r => r.trim()).filter(r => r.length > 0)
if (recipients.length === 0) {
toast.error('At least one recipient email is required')
return
}
channelConfig = { recipients }
} else {
if (!reportForm.webhookUrl.trim()) {
toast.error('Webhook URL is required')
return
}
channelConfig = { url: reportForm.webhookUrl.trim() }
}
const payload: CreateReportScheduleRequest = {
channel: reportForm.channel,
channel_config: channelConfig,
frequency: reportForm.frequency,
timezone: reportForm.timezone || undefined,
report_type: reportForm.reportType,
send_hour: reportForm.sendHour,
...(reportForm.frequency !== 'daily' ? { send_day: reportForm.sendDay } : {}),
}
setReportSaving(true)
try {
if (editingSchedule) {
await updateReportSchedule(siteId, editingSchedule.id, payload)
toast.success('Report schedule updated')
} else {
await createReportSchedule(siteId, payload)
toast.success('Report schedule created')
}
setReportModalOpen(false)
loadReportSchedules()
} catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || 'Failed to save report schedule')
} finally {
setReportSaving(false)
}
}
const handleReportDelete = async (schedule: ReportSchedule) => {
if (!confirm('Delete this report schedule?')) return
try {
await deleteReportSchedule(siteId, schedule.id)
toast.success('Report schedule deleted')
loadReportSchedules()
} catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || 'Failed to delete report schedule')
}
}
const handleReportToggle = async (schedule: ReportSchedule) => {
try {
await updateReportSchedule(siteId, schedule.id, { enabled: !schedule.enabled })
toast.success(schedule.enabled ? 'Report paused' : 'Report enabled')
loadReportSchedules()
} catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || 'Failed to update report schedule')
}
}
const handleReportTest = async (schedule: ReportSchedule) => {
setReportTesting(schedule.id)
try {
await testReportSchedule(siteId, schedule.id)
toast.success('Test report sent successfully')
} catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || 'Failed to send test report')
} finally {
setReportTesting(null)
}
}
const getChannelLabel = (channel: string) => {
switch (channel) {
case 'email': return 'Email'
case 'slack': return 'Slack'
case 'discord': return 'Discord'
case 'webhook': return 'Webhook'
default: return channel
}
}
const getFrequencyLabel = (frequency: string) => {
switch (frequency) {
case 'daily': return 'Daily'
case 'weekly': return 'Weekly'
case 'monthly': return 'Monthly'
default: return frequency
}
}
const WEEKDAY_NAMES = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
const formatHour = (hour: number) => {
if (hour === 0) return '12:00 AM'
if (hour === 12) return '12:00 PM'
return hour < 12 ? `${hour}:00 AM` : `${hour - 12}:00 PM`
}
const getScheduleDescription = (schedule: ReportSchedule) => {
const hour = formatHour(schedule.send_hour ?? 9)
const tz = schedule.timezone || 'UTC'
switch (schedule.frequency) {
case 'daily':
return `Every day at ${hour} (${tz})`
case 'weekly': {
const day = WEEKDAY_NAMES[schedule.send_day ?? 0] || 'Monday'
return `Every ${day} at ${hour} (${tz})`
}
case 'monthly': {
const d = schedule.send_day ?? 1
const suffix = d === 1 ? 'st' : d === 2 ? 'nd' : d === 3 ? 'rd' : 'th'
return `${d}${suffix} of each month at ${hour} (${tz})`
}
default:
return schedule.frequency
}
}
const getReportTypeLabel = (type: string) => {
switch (type) {
case 'summary': return 'Summary'
case 'pages': return 'Pages'
case 'sources': return 'Sources'
case 'goals': return 'Goals'
default: return type
}
}
const openAddGoal = () => {
setEditingGoal(null)
setGoalForm({ name: '', event_name: '' })
@@ -276,6 +485,8 @@ export default function SiteSettingsPage() {
enable_performance_insights: formData.enable_performance_insights,
// Bot and noise filtering
filter_bots: formData.filter_bots,
// Hide unknown locations
hide_unknown_locations: formData.hide_unknown_locations,
// Data retention
data_retention_months: formData.data_retention_months
})
@@ -292,6 +503,7 @@ export default function SiteSettingsPage() {
collect_screen_resolution: formData.collect_screen_resolution,
enable_performance_insights: formData.enable_performance_insights,
filter_bots: formData.filter_bots,
hide_unknown_locations: formData.hide_unknown_locations,
data_retention_months: formData.data_retention_months
})
loadSite()
@@ -359,6 +571,7 @@ export default function SiteSettingsPage() {
collect_screen_resolution: formData.collect_screen_resolution,
enable_performance_insights: formData.enable_performance_insights,
filter_bots: formData.filter_bots,
hide_unknown_locations: formData.hide_unknown_locations,
data_retention_months: formData.data_retention_months
}) !== initialFormRef.current
@@ -372,7 +585,7 @@ export default function SiteSettingsPage() {
if (showSkeleton) {
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
<div className="space-y-8">
<div>
<div className="h-8 w-40 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800 mb-2" />
@@ -380,7 +593,7 @@ export default function SiteSettingsPage() {
</div>
<div className="flex flex-col md:flex-row gap-8">
<nav className="w-full md:w-64 flex-shrink-0 space-y-1">
{Array.from({ length: 4 }).map((_, i) => (
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-12 animate-pulse rounded-xl bg-neutral-100 dark:bg-neutral-800" />
))}
</nav>
@@ -395,14 +608,15 @@ export default function SiteSettingsPage() {
if (!site) {
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
<p className="text-neutral-600 dark:text-neutral-400">Site not found</p>
</div>
)
}
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
<div className="space-y-8">
<div>
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white">Site Settings</h1>
@@ -418,7 +632,7 @@ export default function SiteSettingsPage() {
onClick={() => setActiveTab('general')}
role="tab"
aria-selected={activeTab === 'general'}
className={`w-full flex items-center gap-3 px-4 py-3 text-sm font-medium rounded-xl transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-offset-2 ${
className={`w-full flex items-center gap-3 px-4 py-3 text-sm font-medium rounded-xl transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:ring-offset-2 ${
activeTab === 'general'
? 'bg-brand-orange/10 text-brand-orange'
: 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800'
@@ -431,7 +645,7 @@ export default function SiteSettingsPage() {
onClick={() => setActiveTab('visibility')}
role="tab"
aria-selected={activeTab === 'visibility'}
className={`w-full flex items-center gap-3 px-4 py-3 text-sm font-medium rounded-xl transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-offset-2 ${
className={`w-full flex items-center gap-3 px-4 py-3 text-sm font-medium rounded-xl transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:ring-offset-2 ${
activeTab === 'visibility'
? 'bg-brand-orange/10 text-brand-orange'
: 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800'
@@ -444,7 +658,7 @@ export default function SiteSettingsPage() {
onClick={() => setActiveTab('data')}
role="tab"
aria-selected={activeTab === 'data'}
className={`w-full flex items-center gap-3 px-4 py-3 text-sm font-medium rounded-xl transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-offset-2 ${
className={`w-full flex items-center gap-3 px-4 py-3 text-sm font-medium rounded-xl transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:ring-offset-2 ${
activeTab === 'data'
? 'bg-brand-orange/10 text-brand-orange'
: 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800'
@@ -457,7 +671,7 @@ export default function SiteSettingsPage() {
onClick={() => setActiveTab('goals')}
role="tab"
aria-selected={activeTab === 'goals'}
className={`w-full flex items-center gap-3 px-4 py-3 text-sm font-medium rounded-xl transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-offset-2 ${
className={`w-full flex items-center gap-3 px-4 py-3 text-sm font-medium rounded-xl transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:ring-offset-2 ${
activeTab === 'goals'
? 'bg-brand-orange/10 text-brand-orange'
: 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800'
@@ -466,6 +680,19 @@ export default function SiteSettingsPage() {
<ZapIcon className="w-5 h-5" />
Goals & Events
</button>
<button
onClick={() => setActiveTab('reports')}
role="tab"
aria-selected={activeTab === 'reports'}
className={`w-full flex items-center gap-3 px-4 py-3 text-sm font-medium rounded-xl transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:ring-offset-2 ${
activeTab === 'reports'
? 'bg-brand-orange/10 text-brand-orange'
: 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800'
}`}
>
<PaperPlaneTilt className="w-5 h-5" />
Reports
</button>
</nav>
{/* Content Area */}
@@ -558,7 +785,7 @@ export default function SiteSettingsPage() {
<button
type="button"
onClick={() => setShowVerificationModal(true)}
className="flex items-center gap-2 px-4 py-2 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 text-neutral-700 dark:text-neutral-300 rounded-xl hover:bg-neutral-50 dark:hover:bg-neutral-700 transition-all text-sm font-medium focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-offset-2"
className="flex items-center gap-2 px-4 py-2 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 text-neutral-700 dark:text-neutral-300 rounded-xl hover:bg-neutral-50 dark:hover:bg-neutral-700 transition-all text-sm font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:ring-offset-2"
>
<ZapIcon className="w-4 h-4" />
Verify Installation
@@ -593,7 +820,7 @@ export default function SiteSettingsPage() {
</div>
<button
onClick={handleResetData}
className="px-4 py-2 bg-white dark:bg-neutral-900 border border-red-200 dark:border-red-900 text-red-600 dark:text-red-400 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors text-sm font-medium focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
className="px-4 py-2 bg-white dark:bg-neutral-900 border border-red-200 dark:border-red-900 text-red-600 dark:text-red-400 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors text-sm font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2"
>
Reset Data
</button>
@@ -606,7 +833,7 @@ export default function SiteSettingsPage() {
</div>
<button
onClick={handleDeleteSite}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2"
>
Delete Site
</button>
@@ -672,7 +899,7 @@ export default function SiteSettingsPage() {
<button
type="button"
onClick={copyLink}
className="px-4 py-2 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 text-neutral-900 dark:text-white rounded-xl font-medium hover:bg-neutral-50 dark:hover:bg-neutral-700 transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-offset-2"
className="px-4 py-2 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 text-neutral-900 dark:text-white rounded-xl font-medium hover:bg-neutral-50 dark:hover:bg-neutral-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:ring-offset-2"
>
{linkCopied ? 'Copied!' : 'Copy Link'}
</button>
@@ -882,6 +1109,25 @@ export default function SiteSettingsPage() {
</label>
</div>
</div>
<div className="p-6 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800">
<div className="flex items-center justify-between">
<div>
<h4 className="font-medium text-neutral-900 dark:text-white">Hide unknown locations</h4>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-0.5">
Exclude entries where geographic data could not be resolved from location stats
</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={formData.hide_unknown_locations}
onChange={(e) => setFormData({ ...formData, hide_unknown_locations: e.target.checked })}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-neutral-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-brand-orange/20 dark:peer-focus:ring-brand-orange/20 rounded-full peer dark:bg-neutral-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-neutral-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-neutral-600 peer-checked:bg-brand-orange"></div>
</label>
</div>
</div>
</div>
{/* Performance Insights Toggle */}
@@ -1095,6 +1341,135 @@ export default function SiteSettingsPage() {
)}
</div>
)}
{activeTab === 'reports' && (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-1">Scheduled Reports</h2>
<p className="text-sm text-neutral-500 dark:text-neutral-400">Automatically deliver analytics reports via email or webhooks.</p>
</div>
{canEdit && (
<Button onClick={() => { setEditingSchedule(null); resetReportForm(); setReportModalOpen(true) }}>
Add Report
</Button>
)}
</div>
{reportLoading ? (
<div className="space-y-2">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-20 animate-pulse rounded-xl bg-neutral-100 dark:bg-neutral-800" />
))}
</div>
) : reportSchedules.length === 0 ? (
<div className="p-6 rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-900/50 text-center text-neutral-500 dark:text-neutral-400 text-sm">
No scheduled reports yet. Add a report to automatically receive analytics summaries.
</div>
) : (
<div className="space-y-3">
{reportSchedules.map((schedule) => (
<div
key={schedule.id}
className={`rounded-xl border p-4 transition-colors ${
schedule.enabled
? 'border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900/50'
: 'border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-900/30 opacity-60'
}`}
>
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3 min-w-0">
<div className="p-2 bg-neutral-100 dark:bg-neutral-800 rounded-lg mt-0.5">
{schedule.channel === 'email' ? (
<Envelope className="w-5 h-5 text-neutral-600 dark:text-neutral-400" />
) : (
<WebhooksLogo className="w-5 h-5 text-neutral-600 dark:text-neutral-400" />
)}
</div>
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-neutral-900 dark:text-white">
{getChannelLabel(schedule.channel)}
</span>
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-brand-orange/10 text-brand-orange">
{getFrequencyLabel(schedule.frequency)}
</span>
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400">
{getReportTypeLabel(schedule.report_type)}
</span>
</div>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-1 truncate">
{schedule.channel === 'email'
? (schedule.channel_config as EmailConfig).recipients.join(', ')
: (schedule.channel_config as WebhookConfig).url}
</p>
<p className="text-xs text-neutral-400 dark:text-neutral-500 mt-1">
{getScheduleDescription(schedule)}
</p>
<div className="flex items-center gap-3 mt-1 text-xs text-neutral-400 dark:text-neutral-500">
<span>
Last sent: {schedule.last_sent_at
? new Date(schedule.last_sent_at).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' })
: 'Never'}
</span>
</div>
{schedule.last_error && (
<p className="text-xs text-red-500 dark:text-red-400 mt-1">
Error: {schedule.last_error}
</p>
)}
</div>
</div>
{canEdit && (
<div className="flex items-center gap-2 flex-shrink-0">
<button
type="button"
onClick={() => handleReportTest(schedule)}
disabled={reportTesting === schedule.id}
className="p-2 text-neutral-500 hover:text-brand-orange hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg transition-colors disabled:opacity-50"
title="Send test report"
>
{reportTesting === schedule.id ? (
<SpinnerGap className="w-4 h-4 animate-spin" />
) : (
<Play className="w-4 h-4" />
)}
</button>
<button
type="button"
onClick={() => openEditSchedule(schedule)}
className="p-2 text-neutral-500 hover:text-brand-orange hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg transition-colors"
title="Edit schedule"
>
<PencilSimple className="w-4 h-4" />
</button>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={schedule.enabled}
onChange={() => handleReportToggle(schedule)}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-neutral-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-brand-orange/20 dark:peer-focus:ring-brand-orange/20 rounded-full peer dark:bg-neutral-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-neutral-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:border-neutral-600 peer-checked:bg-brand-orange"></div>
</label>
<button
type="button"
onClick={() => handleReportDelete(schedule)}
className="p-2 text-neutral-500 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
title="Delete schedule"
>
<Trash className="w-4 h-4" />
</button>
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
)}
</motion.div>
</div>
</div>
@@ -1148,6 +1523,165 @@ export default function SiteSettingsPage() {
</form>
</Modal>
<Modal
isOpen={reportModalOpen}
onClose={() => setReportModalOpen(false)}
title={editingSchedule ? 'Edit report schedule' : 'Add report schedule'}
>
<form onSubmit={handleReportSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">Channel</label>
<div className="grid grid-cols-2 gap-2">
{(['email', 'slack', 'discord', 'webhook'] as const).map((ch) => (
<button
key={ch}
type="button"
onClick={() => setReportForm({ ...reportForm, channel: ch })}
className={`flex items-center gap-2 px-3 py-2 rounded-lg border text-sm font-medium transition-colors ${
reportForm.channel === ch
? 'border-brand-orange bg-brand-orange/10 text-brand-orange'
: 'border-neutral-200 dark:border-neutral-700 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-50 dark:hover:bg-neutral-800'
}`}
>
{ch === 'email' ? <Envelope className="w-4 h-4" /> : <WebhooksLogo className="w-4 h-4" />}
{getChannelLabel(ch)}
</button>
))}
</div>
</div>
{reportForm.channel === 'email' ? (
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">Recipients</label>
<input
type="text"
value={reportForm.recipients}
onChange={(e) => setReportForm({ ...reportForm, recipients: e.target.value })}
placeholder="email1@example.com, email2@example.com"
className="w-full px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-lg bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white"
required
/>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">Comma-separated email addresses.</p>
</div>
) : (
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{reportForm.channel === 'slack' ? 'Slack Webhook URL' : reportForm.channel === 'discord' ? 'Discord Webhook URL' : 'Webhook URL'}
</label>
<input
type="url"
value={reportForm.webhookUrl}
onChange={(e) => setReportForm({ ...reportForm, webhookUrl: e.target.value })}
placeholder="https://hooks.example.com/..."
className="w-full px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-lg bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white"
required
/>
</div>
)}
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">Frequency</label>
<Select
value={reportForm.frequency}
onChange={(v) => setReportForm({ ...reportForm, frequency: v })}
options={[
{ value: 'daily', label: 'Daily' },
{ value: 'weekly', label: 'Weekly' },
{ value: 'monthly', label: 'Monthly' },
]}
variant="input"
fullWidth
align="left"
/>
</div>
{reportForm.frequency === 'weekly' && (
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">Day of week</label>
<Select
value={String(reportForm.sendDay)}
onChange={(v) => setReportForm({ ...reportForm, sendDay: parseInt(v) })}
options={WEEKDAY_NAMES.map((name, i) => ({ value: String(i), label: name }))}
variant="input"
fullWidth
align="left"
/>
</div>
)}
{reportForm.frequency === 'monthly' && (
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">Day of month</label>
<Select
value={String(reportForm.sendDay)}
onChange={(v) => setReportForm({ ...reportForm, sendDay: parseInt(v) })}
options={Array.from({ length: 28 }, (_, i) => {
const d = i + 1
const suffix = d === 1 ? 'st' : d === 2 ? 'nd' : d === 3 ? 'rd' : 'th'
return { value: String(d), label: `${d}${suffix}` }
})}
variant="input"
fullWidth
align="left"
/>
</div>
)}
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">Time</label>
<Select
value={String(reportForm.sendHour)}
onChange={(v) => setReportForm({ ...reportForm, sendHour: parseInt(v) })}
options={Array.from({ length: 24 }, (_, i) => ({
value: String(i),
label: formatHour(i),
}))}
variant="input"
fullWidth
align="left"
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">Timezone</label>
<Select
value={reportForm.timezone || 'UTC'}
onChange={(v) => setReportForm({ ...reportForm, timezone: v })}
options={TIMEZONES.map((tz) => ({ value: tz, label: tz }))}
variant="input"
fullWidth
align="left"
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">Report Type</label>
<Select
value={reportForm.reportType}
onChange={(v) => setReportForm({ ...reportForm, reportType: v })}
options={[
{ value: 'summary', label: 'Summary' },
{ value: 'pages', label: 'Pages' },
{ value: 'sources', label: 'Sources' },
{ value: 'goals', label: 'Goals' },
]}
variant="input"
fullWidth
align="left"
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="secondary" onClick={() => setReportModalOpen(false)}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={reportSaving}>
{reportSaving ? 'Saving...' : editingSchedule ? 'Update' : 'Create'}
</Button>
</div>
</form>
</Modal>
<VerificationModal
isOpen={showVerificationModal}
onClose={() => setShowVerificationModal(false)}

View File

@@ -28,28 +28,15 @@ import {
XAxis,
YAxis,
CartesianGrid,
Tooltip as RechartsTooltip,
ResponsiveContainer,
} from 'recharts'
import type { TooltipProps } from 'recharts'
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@/components/charts'
// * Chart theme colors (consistent with main Pulse chart)
const CHART_COLORS_LIGHT = {
border: 'var(--color-neutral-200)',
text: 'var(--color-neutral-900)',
textMuted: 'var(--color-neutral-500)',
axis: 'var(--color-neutral-400)',
tooltipBg: '#ffffff',
tooltipBorder: 'var(--color-neutral-200)',
}
const CHART_COLORS_DARK = {
border: 'var(--color-neutral-700)',
text: 'var(--color-neutral-50)',
textMuted: 'var(--color-neutral-400)',
axis: 'var(--color-neutral-500)',
tooltipBg: 'var(--color-neutral-800)',
tooltipBorder: 'var(--color-neutral-700)',
}
const responseTimeChartConfig = {
ms: {
label: 'Response Time',
color: 'var(--chart-1)',
},
} satisfies ChartConfig
// * Status color mapping
function getStatusColor(status: string): string {
@@ -284,9 +271,6 @@ function UptimeStatusBar({
// * Component: Response time chart (Recharts area chart)
function ResponseTimeChart({ checks }: { checks: UptimeCheck[] }) {
const { resolvedTheme } = useTheme()
const colors = resolvedTheme === 'dark' ? CHART_COLORS_DARK : CHART_COLORS_LIGHT
// * Prepare data in chronological order (oldest first)
const data = [...checks]
.reverse()
@@ -302,71 +286,58 @@ function ResponseTimeChart({ checks }: { checks: UptimeCheck[] }) {
if (data.length < 2) return null
const CustomTooltip = ({ active, payload, label }: TooltipProps<number, string>) => {
if (!active || !payload?.length) return null
return (
<div
className="rounded-xl px-3 py-2 text-xs shadow-lg border transition-shadow duration-300"
style={{
background: colors.tooltipBg,
borderColor: colors.tooltipBorder,
color: colors.text,
}}
>
<div className="font-medium mb-0.5">{label}</div>
<div style={{ color: 'var(--color-brand-orange)' }} className="font-semibold">
{payload[0].value}ms
</div>
</div>
)
}
return (
<div className="mt-4">
<h4 className="text-xs font-semibold text-neutral-500 dark:text-neutral-400 uppercase tracking-wider mb-3">
Response Time
</h4>
<div className="h-40">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data} margin={{ top: 5, right: 5, left: -20, bottom: 0 }}>
<defs>
<linearGradient id="responseTimeGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="var(--color-brand-orange)" stopOpacity={0.3} />
<stop offset="100%" stopColor="var(--color-brand-orange)" stopOpacity={0.02} />
</linearGradient>
</defs>
<CartesianGrid
strokeDasharray="3 3"
stroke={colors.border}
strokeOpacity={0.5}
vertical={false}
/>
<XAxis
dataKey="time"
tick={{ fontSize: 10, fill: colors.axis }}
tickLine={false}
axisLine={false}
interval="preserveStartEnd"
/>
<YAxis
tick={{ fontSize: 10, fill: colors.axis }}
tickLine={false}
axisLine={false}
tickFormatter={(v: number) => `${v}ms`}
/>
<RechartsTooltip content={<CustomTooltip />} />
<Area
type="monotone"
dataKey="ms"
stroke="var(--color-brand-orange)"
strokeWidth={2}
fill="url(#responseTimeGradient)"
dot={false}
activeDot={{ r: 4, fill: 'var(--color-brand-orange)', strokeWidth: 0 }}
/>
</AreaChart>
</ResponsiveContainer>
</div>
<ChartContainer config={responseTimeChartConfig} className="h-40">
<AreaChart accessibilityLayer data={data} margin={{ top: 5, right: 5, left: -20, bottom: 0 }}>
<defs>
<linearGradient id="responseTimeGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="var(--color-ms)" stopOpacity={0.3} />
<stop offset="100%" stopColor="var(--color-ms)" stopOpacity={0.02} />
</linearGradient>
</defs>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--chart-grid)"
strokeOpacity={0.5}
vertical={false}
/>
<XAxis
dataKey="time"
tick={{ fontSize: 10, fill: 'var(--chart-axis)' }}
tickLine={false}
axisLine={false}
interval="preserveStartEnd"
/>
<YAxis
tick={{ fontSize: 10, fill: 'var(--chart-axis)' }}
tickLine={false}
axisLine={false}
tickFormatter={(v: number) => `${v}ms`}
/>
<ChartTooltip
content={
<ChartTooltipContent
className="text-xs"
labelKey="time"
formatter={(value) => <span className="font-semibold">{value}ms</span>}
/>
}
/>
<Area
type="monotone"
dataKey="ms"
stroke="var(--color-ms)"
strokeWidth={2}
fill="url(#responseTimeGradient)"
dot={false}
activeDot={{ r: 4, fill: 'var(--color-ms)', strokeWidth: 0 }}
/>
</AreaChart>
</ChartContainer>
</div>
)
}
@@ -717,27 +688,13 @@ export default function UptimePage() {
const overallStatus = uptimeData?.status ?? 'operational'
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2 }}
className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8"
>
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
{/* Header */}
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<div className="flex items-center gap-2 mb-1">
<button
onClick={() => router.push(`/sites/${siteId}`)}
className="text-sm text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200 transition-colors"
>
{site.name}
</button>
<span className="text-neutral-300 dark:text-neutral-600">/</span>
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white">
Uptime
</h1>
</div>
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-1">
Uptime
</h1>
<p className="text-sm text-neutral-500 dark:text-neutral-400">
Monitor your endpoints and track availability over time
</p>
@@ -851,7 +808,7 @@ export default function UptimePage() {
siteDomain={site.domain}
/>
</Modal>
</motion.div>
</div>
)
}

View File

@@ -133,7 +133,7 @@ export default function NewSitePage() {
<button
type="button"
onClick={() => setShowVerificationModal(true)}
className="flex items-center gap-2 px-4 py-2 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 text-neutral-700 dark:text-neutral-300 rounded-xl hover:bg-neutral-50 dark:hover:bg-neutral-700 transition-all text-sm font-medium focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-offset-2"
className="flex items-center gap-2 px-4 py-2 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 text-neutral-700 dark:text-neutral-300 rounded-xl hover:bg-neutral-50 dark:hover:bg-neutral-700 transition-all text-sm font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:ring-offset-2"
>
<span className="text-brand-orange">Verify installation</span>
</button>

View File

@@ -475,7 +475,7 @@ function WelcomeContent() {
<button
type="button"
onClick={() => setStep(1)}
className="flex items-center gap-2 text-sm text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300 mb-6 focus:outline-none focus:ring-2 focus:ring-brand-orange rounded"
className="flex items-center gap-2 text-sm text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300 mb-6 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange rounded"
aria-label="Back to welcome"
>
<ArrowLeftIcon className="h-4 w-4" />
@@ -546,7 +546,7 @@ function WelcomeContent() {
<button
type="button"
onClick={() => setStep(2)}
className="flex items-center gap-2 text-sm text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300 mb-6 focus:outline-none focus:ring-2 focus:ring-brand-orange rounded"
className="flex items-center gap-2 text-sm text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300 mb-6 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange rounded"
aria-label="Back to organization"
>
<ArrowLeftIcon className="h-4 w-4" />
@@ -604,14 +604,14 @@ function WelcomeContent() {
<button
type="button"
onClick={() => router.push('/pricing')}
className="text-sm text-brand-orange hover:underline focus:outline-none focus:ring-2 focus:ring-brand-orange rounded"
className="text-sm text-brand-orange hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange rounded"
>
Choose a different plan
</button>
</p>
) : (
<p className="mt-4 text-center">
<Link href="/pricing" className="text-sm text-brand-orange hover:underline focus:outline-none focus:ring-2 focus:ring-brand-orange rounded">
<Link href="/pricing" className="text-sm text-brand-orange hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange rounded">
View pricing
</Link>
</p>
@@ -631,7 +631,7 @@ function WelcomeContent() {
<button
type="button"
onClick={() => setStep(3)}
className="flex items-center gap-2 text-sm text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300 mb-6 focus:outline-none focus:ring-2 focus:ring-brand-orange rounded"
className="flex items-center gap-2 text-sm text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300 mb-6 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange rounded"
aria-label="Back to plan"
>
<ArrowLeftIcon className="h-4 w-4" />
@@ -750,7 +750,7 @@ function WelcomeContent() {
<button
type="button"
onClick={() => setShowVerificationModal(true)}
className="flex items-center gap-2 px-4 py-2 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 text-neutral-700 dark:text-neutral-300 rounded-xl hover:bg-neutral-50 dark:hover:bg-neutral-700 transition-all text-sm font-medium focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-offset-2"
className="flex items-center gap-2 px-4 py-2 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 text-neutral-700 dark:text-neutral-300 rounded-xl hover:bg-neutral-50 dark:hover:bg-neutral-700 transition-all text-sm font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:ring-offset-2"
>
<span className="text-brand-orange">Verify installation</span>
</button>

View File

@@ -52,16 +52,16 @@ export function Footer({ LinkComponent = Link, appName = 'Pulse', isAuthenticate
© 2024-{year} Ciphera. All rights reserved.
</div>
<div className="flex gap-6 text-sm font-medium text-neutral-600 dark:text-neutral-300">
<Component href="/about" className="hover:text-brand-orange transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded">
<Component href="/about" className="hover:text-brand-orange transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus:rounded">
Why {appName}
</Component>
<Component href="/changelog" className="hover:text-brand-orange transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded">
<Component href="/changelog" className="hover:text-brand-orange transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus:rounded">
Changelog
</Component>
<Component href="/pricing" className="hover:text-brand-orange transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded">
<Component href="/pricing" className="hover:text-brand-orange transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus:rounded">
Pricing
</Component>
<Component href="/faq" className="hover:text-brand-orange transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded">
<Component href="/faq" className="hover:text-brand-orange transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus:rounded">
FAQ
</Component>
</div>
@@ -106,7 +106,7 @@ export function Footer({ LinkComponent = Link, appName = 'Pulse', isAuthenticate
href="https://github.com/ciphera-net"
target="_blank"
rel="noopener noreferrer"
className="w-9 h-9 rounded-lg bg-neutral-100 dark:bg-neutral-800 flex items-center justify-center text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange"
className="w-9 h-9 rounded-lg bg-neutral-100 dark:bg-neutral-800 flex items-center justify-center text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange"
aria-label="GitHub"
>
<GithubIcon className="w-5 h-5" />
@@ -115,7 +115,7 @@ export function Footer({ LinkComponent = Link, appName = 'Pulse', isAuthenticate
href="https://x.com/cipheranet"
target="_blank"
rel="noopener noreferrer"
className="w-9 h-9 rounded-lg bg-neutral-100 dark:bg-neutral-800 flex items-center justify-center text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange"
className="w-9 h-9 rounded-lg bg-neutral-100 dark:bg-neutral-800 flex items-center justify-center text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange"
aria-label="X (Twitter)"
>
<TwitterIcon className="w-5 h-5" />
@@ -134,14 +134,14 @@ export function Footer({ LinkComponent = Link, appName = 'Pulse', isAuthenticate
href={link.href}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded"
className="text-sm text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus:rounded"
>
{link.name}
</a>
) : (
<Component
href={link.href}
className="text-sm text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded"
className="text-sm text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus:rounded"
>
{link.name}
</Component>
@@ -162,14 +162,14 @@ export function Footer({ LinkComponent = Link, appName = 'Pulse', isAuthenticate
href={link.href}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded"
className="text-sm text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus:rounded"
>
{link.name}
</a>
) : (
<Component
href={link.href}
className="text-sm text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded"
className="text-sm text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus:rounded"
>
{link.name}
</Component>
@@ -190,14 +190,14 @@ export function Footer({ LinkComponent = Link, appName = 'Pulse', isAuthenticate
href={link.href}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded"
className="text-sm text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus:rounded"
>
{link.name}
</a>
) : (
<Component
href={link.href}
className="text-sm text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded"
className="text-sm text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus:rounded"
>
{link.name}
</Component>

View File

@@ -1,13 +1,13 @@
'use client';
import { FiWifiOff } from 'react-icons/fi';
import { WifiSlash } from '@phosphor-icons/react';
export function OfflineBanner({ isOnline }: { isOnline: boolean }) {
if (isOnline) return null;
return (
<div className="fixed top-0 left-0 right-0 z-[100] rounded-b-xl bg-yellow-500/15 dark:bg-yellow-500/25 border-b border-yellow-500/30 dark:border-yellow-500/40 text-yellow-700 dark:text-yellow-300 px-4 sm:px-8 py-2.5 text-sm flex items-center justify-center gap-2 font-medium shadow-md transition-shadow duration-300">
<FiWifiOff className="w-4 h-4 shrink-0" />
<WifiSlash className="w-4 h-4 shrink-0" />
<span>You are currently offline. Changes may not be saved.</span>
</div>
);

View File

@@ -267,7 +267,7 @@ export default function PricingSection() {
onClick={() => setIsYearly(false)}
role="radio"
aria-checked={!isYearly}
className={`min-w-[88px] px-4 py-2 rounded-lg text-sm font-medium transition-all focus:outline-none focus:ring-2 focus:ring-brand-orange ${
className={`min-w-[88px] px-4 py-2 rounded-lg text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange ${
!isYearly
? 'bg-white dark:bg-neutral-700 text-neutral-900 dark:text-white shadow-sm'
: 'text-neutral-500 hover:text-neutral-900 dark:hover:text-white'
@@ -279,7 +279,7 @@ export default function PricingSection() {
onClick={() => setIsYearly(true)}
role="radio"
aria-checked={isYearly}
className={`min-w-[88px] px-4 py-2 rounded-lg text-sm font-medium transition-all focus:outline-none focus:ring-2 focus:ring-brand-orange ${
className={`min-w-[88px] px-4 py-2 rounded-lg text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange ${
isYearly
? 'bg-white dark:bg-neutral-700 text-neutral-900 dark:text-white shadow-sm'
: 'text-neutral-500 hover:text-neutral-900 dark:hover:text-white'

View File

@@ -0,0 +1,12 @@
'use client'
import { SWRConfig } from 'swr'
import { boundedCacheProvider } from '@/lib/swr/cache-provider'
export default function SWRProvider({ children }: { children: React.ReactNode }) {
return (
<SWRConfig value={{ provider: boundedCacheProvider }}>
{children}
</SWRConfig>
)
}

View File

@@ -0,0 +1,114 @@
'use client'
import { formatNumber } from '@ciphera-net/ui'
import { Files } from '@phosphor-icons/react'
import type { FrustrationByPage } from '@/lib/api/stats'
interface FrustrationByPageTableProps {
pages: FrustrationByPage[]
loading: boolean
}
function SkeletonRows() {
return (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="animate-pulse flex items-center justify-between h-9 px-2">
<div className="h-4 w-40 bg-neutral-200 dark:bg-neutral-700 rounded" />
<div className="flex gap-6">
<div className="h-4 w-10 bg-neutral-200 dark:bg-neutral-700 rounded" />
<div className="h-4 w-10 bg-neutral-200 dark:bg-neutral-700 rounded" />
<div className="h-4 w-10 bg-neutral-200 dark:bg-neutral-700 rounded" />
<div className="h-4 w-10 bg-neutral-200 dark:bg-neutral-700 rounded" />
</div>
</div>
))}
</div>
)
}
export default function FrustrationByPageTable({ pages, loading }: FrustrationByPageTableProps) {
const hasData = pages.length > 0
const maxTotal = Math.max(...pages.map(p => p.total), 1)
return (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 mb-8">
<div className="flex items-center justify-between mb-1">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
Frustration by Page
</h3>
</div>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-4">
Pages with the most frustration signals
</p>
{loading ? (
<SkeletonRows />
) : hasData ? (
<div>
{/* Header */}
<div className="flex items-center justify-between px-2 -mx-2 mb-2 text-xs font-medium text-neutral-400 dark:text-neutral-500 uppercase tracking-wider">
<span>Page</span>
<div className="flex items-center gap-6">
<span className="w-12 text-right">Rage</span>
<span className="w-12 text-right">Dead</span>
<span className="w-12 text-right">Total</span>
<span className="w-16 text-right">Elements</span>
</div>
</div>
{/* Rows */}
<div className="space-y-0.5">
{pages.map((page) => {
const barWidth = (page.total / maxTotal) * 100
return (
<div
key={page.page_path}
className="relative flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors"
>
{/* Background bar */}
<div
className="absolute inset-y-0 left-0 bg-brand-orange/5 dark:bg-brand-orange/10 rounded-lg transition-all"
style={{ width: `${barWidth}%` }}
/>
<span
className="relative text-sm text-neutral-900 dark:text-white truncate max-w-[300px]"
title={page.page_path}
>
{page.page_path}
</span>
<div className="relative flex items-center gap-6">
<span className="w-12 text-right text-sm tabular-nums text-neutral-600 dark:text-neutral-400">
{formatNumber(page.rage_clicks)}
</span>
<span className="w-12 text-right text-sm tabular-nums text-neutral-600 dark:text-neutral-400">
{formatNumber(page.dead_clicks)}
</span>
<span className="w-12 text-right text-sm font-semibold tabular-nums text-neutral-900 dark:text-white">
{formatNumber(page.total)}
</span>
<span className="w-16 text-right text-sm tabular-nums text-neutral-600 dark:text-neutral-400">
{page.unique_elements}
</span>
</div>
</div>
)
})}
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center text-center px-6 py-8 gap-4 min-h-[200px]">
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
<Files className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
</div>
<h4 className="font-semibold text-neutral-900 dark:text-white">
No frustration signals detected
</h4>
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-md">
Page-level frustration data will appear here once rage clicks or dead clicks are detected on your site.
</p>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,124 @@
'use client'
import type { FrustrationSummary } from '@/lib/api/stats'
interface FrustrationSummaryCardsProps {
data: FrustrationSummary | null
loading: boolean
}
function pctChange(current: number, previous: number): { type: 'pct'; value: number } | { type: 'new' } | null {
if (previous === 0 && current === 0) return null
if (previous === 0) return { type: 'new' }
return { type: 'pct', value: Math.round(((current - previous) / previous) * 100) }
}
function ChangeIndicator({ change }: { change: ReturnType<typeof pctChange> }) {
if (change === null) return null
if (change.type === 'new') {
return (
<span className="text-xs font-medium bg-brand-orange/10 text-brand-orange px-1.5 py-0.5 rounded">
New
</span>
)
}
const isUp = change.value > 0
const isDown = change.value < 0
return (
<span
className={`text-xs font-medium ${
isUp
? 'text-red-600 dark:text-red-400'
: isDown
? 'text-green-600 dark:text-green-400'
: 'text-neutral-500 dark:text-neutral-400'
}`}
>
{isUp ? '+' : ''}{change.value}%
</span>
)
}
function SkeletonCard() {
return (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
<div className="animate-pulse space-y-3">
<div className="h-4 w-24 bg-neutral-200 dark:bg-neutral-700 rounded" />
<div className="h-8 w-16 bg-neutral-200 dark:bg-neutral-700 rounded" />
<div className="h-3 w-32 bg-neutral-200 dark:bg-neutral-700 rounded" />
</div>
</div>
)
}
export default function FrustrationSummaryCards({ data, loading }: FrustrationSummaryCardsProps) {
if (loading || !data) {
return (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-8">
<SkeletonCard />
<SkeletonCard />
<SkeletonCard />
</div>
)
}
const rageChange = pctChange(data.rage_clicks, data.prev_rage_clicks)
const deadChange = pctChange(data.dead_clicks, data.prev_dead_clicks)
const topPage = data.rage_top_page || data.dead_top_page
const totalSignals = data.rage_clicks + data.dead_clicks
return (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-8">
{/* Rage Clicks */}
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
<p className="text-sm font-medium text-neutral-500 dark:text-neutral-400 mb-1">
Rage Clicks
</p>
<div className="flex items-baseline gap-2">
<span className="text-2xl font-bold text-neutral-900 dark:text-white tabular-nums">
{data.rage_clicks.toLocaleString()}
</span>
<ChangeIndicator change={rageChange} />
</div>
<p className="text-xs text-neutral-400 dark:text-neutral-500 mt-1">
{data.rage_unique_elements} unique elements
</p>
</div>
{/* Dead Clicks */}
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
<p className="text-sm font-medium text-neutral-500 dark:text-neutral-400 mb-1">
Dead Clicks
</p>
<div className="flex items-baseline gap-2">
<span className="text-2xl font-bold text-neutral-900 dark:text-white tabular-nums">
{data.dead_clicks.toLocaleString()}
</span>
<ChangeIndicator change={deadChange} />
</div>
<p className="text-xs text-neutral-400 dark:text-neutral-500 mt-1">
{data.dead_unique_elements} unique elements
</p>
</div>
{/* Total Frustration Signals */}
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
<p className="text-sm font-medium text-neutral-500 dark:text-neutral-400 mb-1">
Total Signals
</p>
<span className="text-2xl font-bold text-neutral-900 dark:text-white tabular-nums">
{totalSignals.toLocaleString()}
</span>
{topPage ? (
<p className="text-xs text-neutral-400 dark:text-neutral-500 mt-1">
Top page: {topPage}
</p>
) : (
<p className="text-xs text-neutral-400 dark:text-neutral-500 mt-1">
No data in this period
</p>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,220 @@
'use client'
import { useState, useEffect } from 'react'
import { formatNumber, Modal } from '@ciphera-net/ui'
import { FrameCornersIcon, Copy, Check, CursorClick } from '@phosphor-icons/react'
import { toast } from '@ciphera-net/ui'
import type { FrustrationElement } from '@/lib/api/stats'
import { ListSkeleton } from '@/components/skeletons'
const DISPLAY_LIMIT = 7
interface FrustrationTableProps {
title: string
description: string
items: FrustrationElement[]
total: number
totalSignals: number
showAvgClicks?: boolean
loading: boolean
fetchAll?: () => Promise<{ items: FrustrationElement[]; total: number }>
}
function SkeletonRows() {
return (
<div className="space-y-2">
{Array.from({ length: DISPLAY_LIMIT }).map((_, i) => (
<div key={i} className="animate-pulse flex items-center justify-between h-9 px-2">
<div className="flex items-center gap-3 flex-1">
<div className="h-4 w-32 bg-neutral-200 dark:bg-neutral-700 rounded" />
<div className="h-3 w-20 bg-neutral-200 dark:bg-neutral-700 rounded" />
</div>
<div className="h-4 w-10 bg-neutral-200 dark:bg-neutral-700 rounded" />
</div>
))}
</div>
)
}
function SelectorCell({ selector }: { selector: string }) {
const [copied, setCopied] = useState(false)
const handleCopy = (e: React.MouseEvent) => {
e.stopPropagation()
navigator.clipboard.writeText(selector)
setCopied(true)
toast.success('Selector copied')
setTimeout(() => setCopied(false), 2000)
}
return (
<button
onClick={handleCopy}
className="flex items-center gap-1 min-w-0 group/copy cursor-pointer"
title={selector}
>
<span className="text-sm font-mono text-neutral-900 dark:text-white truncate">
{selector}
</span>
<span className="opacity-0 group-hover/copy:opacity-100 transition-opacity shrink-0">
{copied ? (
<Check className="w-3 h-3 text-green-500" />
) : (
<Copy className="w-3 h-3 text-neutral-400" />
)}
</span>
</button>
)
}
function Row({
item,
showAvgClicks,
totalSignals,
}: {
item: FrustrationElement
showAvgClicks?: boolean
totalSignals: number
}) {
const pct = totalSignals > 0 ? `${Math.round((item.count / totalSignals) * 100)}%` : ''
return (
<div className="flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<SelectorCell selector={item.selector} />
<span
className="text-xs text-neutral-400 dark:text-neutral-500 truncate shrink-0"
title={item.page_path}
>
{item.page_path}
</span>
</div>
</div>
<div className="flex items-center gap-2 ml-4 shrink-0">
{/* Percentage badge: slides in on hover */}
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200 tabular-nums">
{pct}
</span>
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400 tabular-nums">
{formatNumber(item.count)}
</span>
</div>
</div>
)
}
export default function FrustrationTable({
title,
description,
items,
total,
totalSignals,
showAvgClicks,
loading,
fetchAll,
}: FrustrationTableProps) {
const [isModalOpen, setIsModalOpen] = useState(false)
const [fullData, setFullData] = useState<FrustrationElement[]>([])
const [isLoadingFull, setIsLoadingFull] = useState(false)
const hasData = items.length > 0
const showViewAll = hasData && total > items.length
const emptySlots = Math.max(0, DISPLAY_LIMIT - items.length)
useEffect(() => {
if (isModalOpen && fetchAll) {
const load = async () => {
setIsLoadingFull(true)
try {
const result = await fetchAll()
setFullData(result.items)
} catch {
// silent
} finally {
setIsLoadingFull(false)
}
}
load()
} else {
setFullData([])
}
}, [isModalOpen, fetchAll])
return (
<>
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
{title}
</h3>
{showViewAll && (
<button
onClick={() => setIsModalOpen(true)}
className="p-1.5 text-neutral-400 dark:text-neutral-500 hover:text-brand-orange dark:hover:text-brand-orange hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-all cursor-pointer rounded-lg"
aria-label={`View all ${title.toLowerCase()}`}
>
<FrameCornersIcon className="w-4 h-4" weight="bold" />
</button>
)}
</div>
</div>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-4">
{description}
</p>
<div className="flex-1 min-h-[270px]">
{loading ? (
<SkeletonRows />
) : hasData ? (
<>
{items.map((item, i) => (
<Row key={`${item.selector}-${item.page_path}-${i}`} item={item} showAvgClicks={showAvgClicks} totalSignals={totalSignals} />
))}
{Array.from({ length: emptySlots }).map((_, i) => (
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
))}
</>
) : (
<div className="h-full flex flex-col items-center justify-center text-center px-6 py-8 gap-4">
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
<CursorClick className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
</div>
<h4 className="font-semibold text-neutral-900 dark:text-white">
No {title.toLowerCase()} detected
</h4>
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-md">
{description}. Data will appear here once frustration signals are detected on your site.
</p>
</div>
)}
</div>
</div>
<Modal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
title={title}
className="max-w-2xl"
>
<div className="max-h-[80vh] overflow-y-auto">
{isLoadingFull ? (
<div className="py-4">
<ListSkeleton rows={10} />
</div>
) : fullData.length > 0 ? (
<div className="space-y-0.5">
{fullData.map((item, i) => (
<Row key={`${item.selector}-${item.page_path}-${i}`} item={item} showAvgClicks={showAvgClicks} totalSignals={totalSignals} />
))}
</div>
) : (
<p className="text-sm text-neutral-500 dark:text-neutral-400 py-8 text-center">
No data available
</p>
)}
</div>
</Modal>
</>
)
}

View File

@@ -0,0 +1,166 @@
'use client'
import { TrendUp } from '@phosphor-icons/react'
import { Pie, PieChart, Tooltip } from 'recharts'
import {
ChartContainer,
type ChartConfig,
} from '@/components/charts'
import type { FrustrationSummary } from '@/lib/api/stats'
interface FrustrationTrendProps {
summary: FrustrationSummary | null
loading: boolean
}
function SkeletonCard() {
return (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
<div className="animate-pulse space-y-3 mb-4">
<div className="h-5 w-36 bg-neutral-200 dark:bg-neutral-700 rounded" />
<div className="h-4 w-48 bg-neutral-200 dark:bg-neutral-700 rounded" />
</div>
<div className="flex-1 min-h-[270px] animate-pulse flex items-center justify-center">
<div className="w-[200px] h-[200px] rounded-full bg-neutral-200 dark:bg-neutral-700" />
</div>
</div>
)
}
const LABELS: Record<string, string> = {
rage_clicks: 'Rage Clicks',
dead_clicks: 'Dead Clicks',
prev_rage_clicks: 'Prev Rage Clicks',
prev_dead_clicks: 'Prev Dead Clicks',
}
const COLORS = {
rage_clicks: 'rgba(253, 94, 15, 0.7)',
dead_clicks: 'rgba(180, 83, 9, 0.7)',
prev_rage_clicks: 'rgba(253, 94, 15, 0.35)',
prev_dead_clicks: 'rgba(180, 83, 9, 0.35)',
} as const
const chartConfig = {
count: { label: 'Count' },
rage_clicks: { label: 'Rage Clicks', color: COLORS.rage_clicks },
dead_clicks: { label: 'Dead Clicks', color: COLORS.dead_clicks },
prev_rage_clicks: { label: 'Prev Rage Clicks', color: COLORS.prev_rage_clicks },
prev_dead_clicks: { label: 'Prev Dead Clicks', color: COLORS.prev_dead_clicks },
} satisfies ChartConfig
function CustomTooltip({ active, payload }: { active?: boolean; payload?: Array<{ payload: { type: string; count: number; fill: string } }> }) {
if (!active || !payload?.length) return null
const item = payload[0].payload
return (
<div className="flex items-center gap-2 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-2.5 py-1.5 text-xs shadow-xl">
<div
className="h-2.5 w-2.5 shrink-0 rounded-full"
style={{ backgroundColor: item.fill }}
/>
<span className="text-neutral-500 dark:text-neutral-400">
{LABELS[item.type] ?? item.type}
</span>
<span className="font-mono font-medium tabular-nums text-neutral-900 dark:text-neutral-50">
{item.count.toLocaleString()}
</span>
</div>
)
}
export default function FrustrationTrend({ summary, loading }: FrustrationTrendProps) {
if (loading || !summary) return <SkeletonCard />
const hasData = summary.rage_clicks > 0 || summary.dead_clicks > 0 ||
summary.prev_rage_clicks > 0 || summary.prev_dead_clicks > 0
const totalCurrent = summary.rage_clicks + summary.dead_clicks
const totalPrevious = summary.prev_rage_clicks + summary.prev_dead_clicks
const totalChange = totalPrevious > 0
? Math.round(((totalCurrent - totalPrevious) / totalPrevious) * 100)
: null
const hasPrevious = totalPrevious > 0
const chartData = [
{ type: 'rage_clicks', count: summary.rage_clicks, fill: COLORS.rage_clicks },
{ type: 'dead_clicks', count: summary.dead_clicks, fill: COLORS.dead_clicks },
{ type: 'prev_rage_clicks', count: summary.prev_rage_clicks, fill: COLORS.prev_rage_clicks },
{ type: 'prev_dead_clicks', count: summary.prev_dead_clicks, fill: COLORS.prev_dead_clicks },
].filter(d => d.count > 0)
if (!hasData) {
return (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
<div className="flex items-center justify-between mb-1">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
Frustration Trend
</h3>
</div>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-4">
Rage vs. dead click breakdown
</p>
<div className="flex-1 min-h-[270px] flex flex-col items-center justify-center text-center px-6 py-8 gap-4">
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
<TrendUp className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
</div>
<h4 className="font-semibold text-neutral-900 dark:text-white">
No trend data yet
</h4>
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-md">
Frustration trend data will appear here once rage clicks or dead clicks are detected on your site.
</p>
</div>
</div>
)
}
return (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
<div className="flex items-center justify-between mb-1">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
Frustration Trend
</h3>
</div>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-4">
{hasPrevious
? 'Rage and dead clicks split across current and previous period'
: 'Rage vs. dead click breakdown'}
</p>
<div className="flex-1">
<ChartContainer
config={chartConfig}
className="mx-auto aspect-square max-h-[250px]"
>
<PieChart>
<Tooltip
cursor={false}
content={<CustomTooltip />}
/>
<Pie
data={chartData}
dataKey="count"
nameKey="type"
stroke="0"
/>
</PieChart>
</ChartContainer>
</div>
<div className="flex items-center justify-center gap-2 text-sm font-medium pt-2">
{totalChange !== null ? (
<>
{totalChange > 0 ? 'Up' : totalChange < 0 ? 'Down' : 'No change'} by {Math.abs(totalChange)}% vs previous period <TrendUp className="h-4 w-4" />
</>
) : totalCurrent > 0 ? (
<>
{totalCurrent.toLocaleString()} new signals this period <TrendUp className="h-4 w-4" />
</>
) : (
'No frustration signals detected'
)}
</div>
</div>
)
}

323
components/charts/chart.tsx Normal file
View File

@@ -0,0 +1,323 @@
'use client'
import * as React from 'react'
import { Tooltip, Legend, ResponsiveContainer } from 'recharts'
import { cn } from '@ciphera-net/ui'
// ─── ChartConfig ────────────────────────────────────────────────────
export type ChartConfig = Record<
string,
{
label?: React.ReactNode
icon?: React.ComponentType
color?: string
theme?: { light: string; dark: string }
}
>
// ─── ChartContext ───────────────────────────────────────────────────
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error('useChart must be used within a <ChartContainer />')
}
return context
}
// ─── ChartContainer ────────────────────────────────────────────────
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<'div'> & {
config: ChartConfig
children: React.ComponentProps<typeof ResponsiveContainer>['children']
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`
// Build CSS variables from config
const colorVars = React.useMemo(() => {
const vars: Record<string, string> = {}
for (const [key, value] of Object.entries(config)) {
if (value.color) {
vars[`--color-${key}`] = value.color
}
}
return vars
}, [config])
return (
<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"[&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-[var(--chart-grid)]",
"[&_.recharts-curve.recharts-tooltip-cursor]:stroke-[var(--chart-grid)]",
"[&_.recharts-rectangle.recharts-tooltip-cursor]:fill-[var(--chart-grid)]",
"[&_.recharts-reference-line_[stroke='#ccc']]:stroke-[var(--chart-grid)]",
'[&_.recharts-sector]:outline-none',
'[&_.recharts-surface]:outline-none',
className,
)}
style={colorVars as React.CSSProperties}
{...props}
>
<ResponsiveContainer width="100%" height="100%">
{children}
</ResponsiveContainer>
</div>
</ChartContext.Provider>
)
})
ChartContainer.displayName = 'ChartContainer'
// ─── ChartTooltip ──────────────────────────────────────────────────
const ChartTooltip = Tooltip
// ─── ChartTooltipContent ───────────────────────────────────────────
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof Tooltip> &
React.ComponentProps<'div'> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: 'line' | 'dot' | 'dashed'
nameKey?: string
labelKey?: string
labelFormatter?: (value: string, payload: Record<string, unknown>[]) => React.ReactNode
}
>(
(
{
active,
payload,
className,
indicator = 'dot',
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelKey,
nameKey,
},
ref,
) => {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) return null
const item = payload[0]
const key = `${labelKey || item?.dataKey || item?.name || 'value'}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === 'string'
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) {
return labelFormatter(
value as string,
payload as Record<string, unknown>[],
)
}
return value
}, [label, labelFormatter, payload, hideLabel, config, labelKey])
if (!active || !payload?.length) return null
const nestLabel = payload.length === 1 && indicator !== 'dot'
return (
<div
ref={ref}
className={cn(
'grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-2.5 py-1.5 text-xs shadow-xl',
className,
)}
>
{!nestLabel ? tooltipLabel ? (
<div className="font-medium text-neutral-900 dark:text-neutral-50">
{tooltipLabel}
</div>
) : null : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || 'value'}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = item.fill || item.color
return (
<div
key={item.dataKey || index}
className={cn(
'flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground',
indicator === 'dot' && 'items-center',
)}
>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
'shrink-0 rounded-[2px] border-[var(--color-border)] bg-[var(--color-bg)]',
indicator === 'dot' && 'h-2.5 w-2.5 rounded-full',
indicator === 'line' && 'w-1',
indicator === 'dashed' &&
'w-0 border-[1.5px] border-dashed bg-transparent',
nestLabel && indicator === 'dashed'
? 'my-0.5'
: 'my-0.5',
)}
style={
{
'--color-bg': indicatorColor,
'--color-border': indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
'flex flex-1 justify-between leading-none',
nestLabel ? 'items-end' : 'items-center',
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value != null && (
<span className="font-mono font-medium tabular-nums text-neutral-900 dark:text-neutral-50">
{typeof item.value === 'number'
? item.value.toLocaleString()
: item.value}
</span>
)}
</div>
</div>
)
})}
</div>
</div>
)
},
)
ChartTooltipContent.displayName = 'ChartTooltipContent'
// ─── ChartLegend ───────────────────────────────────────────────────
const ChartLegend = Legend
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<'div'> &
Pick<React.ComponentProps<typeof Legend>, 'payload' | 'verticalAlign'> & {
hideIcon?: boolean
nameKey?: string
}
>(
(
{ className, hideIcon = false, payload, verticalAlign = 'bottom', nameKey },
ref,
) => {
const { config } = useChart()
if (!payload?.length) return null
return (
<div
ref={ref}
className={cn(
'flex items-center justify-center gap-4',
verticalAlign === 'top' ? 'pb-3' : 'pt-3',
className,
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || 'value'}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className="flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{ backgroundColor: item.color }}
/>
)}
<span className="text-xs text-muted-foreground">
{itemConfig?.label}
</span>
</div>
)
})}
</div>
)
},
)
ChartLegendContent.displayName = 'ChartLegendContent'
// ─── Helpers ───────────────────────────────────────────────────────
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string,
) {
if (typeof payload !== 'object' || payload === null) return undefined
const payloadPayload =
'payload' in payload &&
typeof (payload as Record<string, unknown>).payload === 'object' &&
(payload as Record<string, unknown>).payload !== null
? ((payload as Record<string, unknown>).payload as Record<string, unknown>)
: undefined
let configLabelKey = key
if (
key in config
) {
configLabelKey = key
} else if (payloadPayload) {
const payloadKey = Object.keys(payloadPayload).find(
(k) => payloadPayload[k] === key && k in config,
)
if (payloadKey) configLabelKey = payloadKey
}
return configLabelKey in config ? config[configLabelKey] : config[key]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartContext,
useChart,
}

View File

@@ -0,0 +1,8 @@
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
type ChartConfig,
} from './chart'

View File

@@ -7,9 +7,10 @@ import Image from 'next/image'
import { formatNumber } from '@ciphera-net/ui'
import { Modal, ArrowRightIcon } from '@ciphera-net/ui'
import { ListSkeleton } from '@/components/skeletons'
import VirtualList from './VirtualList'
import { getCampaigns, CampaignStat } from '@/lib/api/stats'
import { getReferrerFavicon, getReferrerIcon, getReferrerDisplayName } from '@/lib/utils/icons'
import { FaBullhorn } from 'react-icons/fa'
import { Megaphone, FrameCornersIcon } from '@phosphor-icons/react'
import UtmBuilder from '@/components/tools/UtmBuilder'
import { type DimensionFilter } from '@/lib/filters'
@@ -26,6 +27,7 @@ export default function Campaigns({ siteId, dateRange, filters, onFilter }: Camp
const [data, setData] = useState<CampaignStat[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isModalOpen, setIsModalOpen] = useState(false)
const [modalSearch, setModalSearch] = useState('')
const [isBuilderOpen, setIsBuilderOpen] = useState(false)
const [fullData, setFullData] = useState<CampaignStat[]>([])
const [isLoadingFull, setIsLoadingFull] = useState(false)
@@ -124,9 +126,20 @@ export default function Campaigns({ siteId, dateRange, filters, onFilter }: Camp
<>
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
Campaigns
</h3>
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
Campaigns
</h3>
{showViewAll && (
<button
onClick={() => setIsModalOpen(true)}
className="p-1.5 text-neutral-400 dark:text-neutral-500 hover:text-brand-orange dark:hover:text-brand-orange hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-all cursor-pointer rounded-lg"
aria-label="View all campaigns"
>
<FrameCornersIcon className="w-4 h-4" weight="bold" />
</button>
)}
</div>
<button
onClick={() => setIsBuilderOpen(true)}
className="text-xs font-medium text-neutral-400 dark:text-neutral-500 hover:text-brand-orange dark:hover:text-brand-orange transition-colors cursor-pointer"
@@ -171,26 +184,14 @@ export default function Campaigns({ siteId, dateRange, filters, onFilter }: Camp
</div>
)
})}
{showViewAll ? (
<button
onClick={() => setIsModalOpen(true)}
className="flex items-center justify-center gap-1.5 h-9 w-full text-xs font-medium text-neutral-400 dark:text-neutral-500 hover:text-brand-orange dark:hover:text-brand-orange transition-colors cursor-pointer rounded-lg px-2 -mx-2"
>
View all
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</button>
) : (
Array.from({ length: emptySlots }).map((_, i) => (
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
))
)}
{Array.from({ length: emptySlots }).map((_, i) => (
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
))}
</>
) : (
<div className="h-full flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
<FaBullhorn className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
<Megaphone className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
</div>
<h4 className="font-semibold text-neutral-900 dark:text-white">
Track your marketing campaigns
@@ -200,7 +201,7 @@ export default function Campaigns({ siteId, dateRange, filters, onFilter }: Camp
</p>
<Link
href="/installation"
className="inline-flex items-center gap-2 text-sm font-medium text-brand-orange hover:text-brand-orange/90 hover:underline focus:outline-none focus:ring-2 focus:ring-brand-orange/20 rounded"
className="inline-flex items-center gap-2 text-sm font-medium text-brand-orange hover:text-brand-orange/90 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange/20 rounded"
>
Learn more
<ArrowRightIcon className="w-4 h-4" />
@@ -212,56 +213,80 @@ export default function Campaigns({ siteId, dateRange, filters, onFilter }: Camp
<Modal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
title="All Campaigns"
onClose={() => { setIsModalOpen(false); setModalSearch('') }}
title="Campaigns"
className="max-w-2xl"
>
<div className="space-y-1 max-h-[60vh] overflow-y-auto pr-2">
<div>
<input
type="text"
value={modalSearch}
onChange={(e) => setModalSearch(e.target.value)}
placeholder="Search campaigns..."
className="w-full px-3 py-2 mb-3 text-sm bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg text-neutral-900 dark:text-white placeholder-neutral-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-brand-orange/50"
/>
</div>
<div className="max-h-[80vh]">
{isLoadingFull ? (
<div className="py-4">
<ListSkeleton rows={10} />
</div>
) : (
<>
<div className="flex items-center justify-end mb-2">
<button
onClick={handleExportCampaigns}
className="text-xs font-medium text-neutral-400 hover:text-brand-orange transition-colors cursor-pointer"
>
Export CSV
</button>
</div>
{sortedFullData.map((item) => {
return (
<div
key={`${item.source}|${item.medium}|${item.campaign}`}
className="flex items-center justify-between py-2 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors"
) : (() => {
const filteredCampaigns = !modalSearch ? sortedFullData : sortedFullData.filter(item => {
const search = modalSearch.toLowerCase()
return item.source.toLowerCase().includes(search) || (item.medium || '').toLowerCase().includes(search) || (item.campaign || '').toLowerCase().includes(search)
})
const modalTotal = filteredCampaigns.reduce((sum, item) => sum + item.visitors, 0)
return (
<>
<div className="flex items-center justify-end mb-2">
<button
onClick={handleExportCampaigns}
className="text-xs font-medium text-neutral-400 hover:text-brand-orange transition-colors cursor-pointer"
>
<div className="flex-1 flex items-center gap-3 min-w-0">
{renderSourceIcon(item.source)}
<div className="min-w-0">
<div className="text-neutral-900 dark:text-white font-medium truncate text-sm" title={item.source}>
{getReferrerDisplayName(item.source)}
</div>
<div className="flex items-center gap-1.5 text-[11px] text-neutral-400 dark:text-neutral-500">
<span>{item.medium || '—'}</span>
<span>·</span>
<span className="truncate">{item.campaign || '—'}</span>
Export CSV
</button>
</div>
<VirtualList
items={filteredCampaigns}
estimateSize={36}
className="max-h-[80vh] overflow-y-auto pr-2"
renderItem={(item) => (
<div
key={`${item.source}|${item.medium}|${item.campaign}`}
onClick={() => { if (onFilter) { onFilter({ dimension: 'utm_source', operator: 'is', values: [item.source] }); setIsModalOpen(false) } }}
className={`flex items-center justify-between py-2 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 transition-colors${onFilter ? ' cursor-pointer' : ''}`}
>
<div className="flex-1 flex items-center gap-3 min-w-0">
{renderSourceIcon(item.source)}
<div className="min-w-0">
<div className="text-neutral-900 dark:text-white font-medium truncate text-sm" title={item.source}>
{getReferrerDisplayName(item.source)}
</div>
<div className="flex items-center gap-1.5 text-[11px] text-neutral-400 dark:text-neutral-500">
<span>{item.medium || '—'}</span>
<span>·</span>
<span className="truncate">{item.campaign || '—'}</span>
</div>
</div>
</div>
<div className="flex items-center gap-4 ml-4 text-sm">
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
{modalTotal > 0 ? `${Math.round((item.visitors / modalTotal) * 100)}%` : ''}
</span>
<span className="font-semibold text-neutral-900 dark:text-white">
{formatNumber(item.visitors)}
</span>
<span className="text-neutral-400 dark:text-neutral-500 w-16 text-right">
{formatNumber(item.pageviews)} pv
</span>
</div>
</div>
<div className="flex items-center gap-4 ml-4 text-sm">
<span className="font-semibold text-neutral-900 dark:text-white">
{formatNumber(item.visitors)}
</span>
<span className="text-neutral-400 dark:text-neutral-500 w-16 text-right">
{formatNumber(item.pageviews)} pv
</span>
</div>
</div>
)
})}
</>
)}
)}
/>
</>
)
})()}
</div>
</Modal>

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,15 @@
'use client'
import { useState, useEffect } from 'react'
import { motion } from 'framer-motion'
import { logger } from '@/lib/utils/logger'
import { formatNumber } from '@ciphera-net/ui'
import { useTabListKeyboard } from '@/lib/hooks/useTabListKeyboard'
import { TopPage, getTopPages, getEntryPages, getExitPages } from '@/lib/api/stats'
import { FrameCornersIcon } from '@phosphor-icons/react'
import { Modal, ArrowUpRightIcon, LayoutDashboardIcon } from '@ciphera-net/ui'
import { ListSkeleton } from '@/components/skeletons'
import VirtualList from './VirtualList'
import { type DimensionFilter } from '@/lib/filters'
interface ContentStatsProps {
@@ -28,6 +31,7 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain,
const [activeTab, setActiveTab] = useState<Tab>('top_pages')
const handleTabKeyDown = useTabListKeyboard()
const [isModalOpen, setIsModalOpen] = useState(false)
const [modalSearch, setModalSearch] = useState('')
const [fullData, setFullData] = useState<TopPage[]>([])
const [isLoadingFull, setIsLoadingFull] = useState(false)
@@ -96,9 +100,20 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain,
<>
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
Pages
</h3>
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
Pages
</h3>
{showViewAll && (
<button
onClick={() => setIsModalOpen(true)}
className="p-1.5 text-neutral-400 dark:text-neutral-500 hover:text-brand-orange dark:hover:text-brand-orange hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-all cursor-pointer rounded-lg"
aria-label="View all pages"
>
<FrameCornersIcon className="w-4 h-4" weight="bold" />
</button>
)}
</div>
<div className="flex gap-1" role="tablist" aria-label="Pages view tabs" onKeyDown={handleTabKeyDown}>
{(['top_pages', 'entry_pages', 'exit_pages'] as Tab[]).map((tab) => (
<button
@@ -106,13 +121,20 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain,
onClick={() => setActiveTab(tab)}
role="tab"
aria-selected={activeTab === tab}
className={`px-2.5 py-1 text-xs font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange rounded cursor-pointer border-b-2 ${
className={`relative px-2.5 py-1 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange rounded cursor-pointer ${
activeTab === tab
? 'border-brand-orange text-neutral-900 dark:text-white'
: 'border-transparent text-neutral-400 dark:text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300'
? 'text-neutral-900 dark:text-white'
: 'text-neutral-400 dark:text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300'
}`}
>
{getTabLabel(tab)}
{activeTab === tab && (
<motion.div
layoutId="contentStatsTab"
className="absolute inset-x-0 -bottom-px h-0.5 bg-brand-orange"
transition={{ type: 'spring', stiffness: 500, damping: 35 }}
/>
)}
</button>
))}
</div>
@@ -153,21 +175,9 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain,
</div>
</div>
))}
{showViewAll ? (
<button
onClick={() => setIsModalOpen(true)}
className="flex items-center justify-center gap-1.5 h-9 w-full text-xs font-medium text-neutral-400 dark:text-neutral-500 hover:text-brand-orange dark:hover:text-brand-orange transition-colors cursor-pointer rounded-lg px-2 -mx-2"
>
View all
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</button>
) : (
Array.from({ length: emptySlots }).map((_, i) => (
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
))
)}
{Array.from({ length: emptySlots }).map((_, i) => (
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
))}
</>
) : (
<div className="h-full flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
@@ -187,34 +197,57 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain,
<Modal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
title={`Pages - ${getTabLabel(activeTab)}`}
onClose={() => { setIsModalOpen(false); setModalSearch('') }}
title={getTabLabel(activeTab)}
className="max-w-2xl"
>
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-2">
<div>
<input
type="text"
value={modalSearch}
onChange={(e) => setModalSearch(e.target.value)}
placeholder="Search pages..."
className="w-full px-3 py-2 mb-3 text-sm bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg text-neutral-900 dark:text-white placeholder-neutral-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-brand-orange/50"
/>
</div>
<div className="max-h-[80vh]">
{isLoadingFull ? (
<div className="py-4">
<ListSkeleton rows={10} />
</div>
) : (
(fullData.length > 0 ? fullData : data).map((page) => (
<div key={page.path} className="flex items-center justify-between py-2 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors">
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center">
<a
href={`https://${domain.replace(/^https?:\/\//, '')}${page.path}`}
target="_blank"
rel="noopener noreferrer"
className="hover:underline flex items-center"
>
{page.path}
<ArrowUpRightIcon className="w-3 h-3 ml-2 text-neutral-400 opacity-0 group-hover:opacity-100 transition-opacity" />
</a>
</div>
<div className="text-sm font-semibold text-neutral-600 dark:text-neutral-400 ml-4">
{formatNumber(page.pageviews)}
</div>
</div>
))
)}
) : (() => {
const modalData = (fullData.length > 0 ? fullData : data).filter(p => !modalSearch || p.path.toLowerCase().includes(modalSearch.toLowerCase()))
const modalTotal = modalData.reduce((sum, p) => sum + p.pageviews, 0)
return (
<VirtualList
items={modalData}
estimateSize={36}
className="max-h-[80vh] overflow-y-auto pr-2"
renderItem={(page) => {
const canFilter = onFilter && page.path
return (
<div
key={page.path}
onClick={() => { if (canFilter) { onFilter({ dimension: 'page', operator: 'is', values: [page.path] }); setIsModalOpen(false) } }}
className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 transition-colors${canFilter ? ' cursor-pointer' : ''}`}
>
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center">
<span className="truncate">{page.path}</span>
</div>
<div className="flex items-center gap-2 ml-4">
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
{modalTotal > 0 ? `${Math.round((page.pageviews / modalTotal) * 100)}%` : ''}
</span>
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
{formatNumber(page.pageviews)}
</span>
</div>
</div>
)
}}
/>
)
})()}
</div>
</Modal>
</>

View File

@@ -0,0 +1,160 @@
'use client'
import { useMemo, useState } from 'react'
import { createMap } from 'svg-dotted-map'
import { cn, formatNumber } from '@ciphera-net/ui'
import { countryCentroids } from '@/lib/country-centroids'
// ─── Module-level constants ────────────────────────────────────────
// Computed once when the module loads, survives component unmount/remount.
const MAP_WIDTH = 150
const MAP_HEIGHT = 68
const DOT_RADIUS = 0.25
const { points: MAP_POINTS, addMarkers } = createMap({ width: MAP_WIDTH, height: MAP_HEIGHT, mapSamples: 8000 })
// Pre-compute stagger helpers (row offsets for hex-grid pattern)
const _stagger = (() => {
const sorted = [...MAP_POINTS].sort((a, b) => a.y - b.y || a.x - b.x)
const rowMap = new Map<number, number>()
let step = 0
let prevY = Number.NaN
let prevXInRow = Number.NaN
for (const p of sorted) {
if (p.y !== prevY) {
prevY = p.y
prevXInRow = Number.NaN
if (!rowMap.has(p.y)) rowMap.set(p.y, rowMap.size)
}
if (!Number.isNaN(prevXInRow)) {
const delta = p.x - prevXInRow
if (delta > 0) step = step === 0 ? delta : Math.min(step, delta)
}
prevXInRow = p.x
}
return { xStep: step || 1, yToRowIndex: rowMap }
})()
// Pre-compute the base map dots as a single SVG path string (~8000 circles → 1 path)
const BASE_DOTS_PATH = (() => {
const r = DOT_RADIUS
const d = r * 2
const parts: string[] = []
for (const point of MAP_POINTS) {
const rowIndex = _stagger.yToRowIndex.get(point.y) ?? 0
const offsetX = rowIndex % 2 === 1 ? _stagger.xStep / 2 : 0
const cx = point.x + offsetX
const cy = point.y
parts.push(`M${cx - r},${cy}a${r},${r} 0 1,0 ${d},0a${r},${r} 0 1,0 ${-d},0`)
}
return parts.join('')
})()
// ─── Component ─────────────────────────────────────────────────────
interface DottedMapProps {
data: Array<{ country: string; pageviews: number }>
className?: string
}
function getCountryName(code: string): string {
try {
const regionNames = new Intl.DisplayNames(['en'], { type: 'region' })
return regionNames.of(code) || code
} catch {
return code
}
}
export default function DottedMap({ data, className }: DottedMapProps) {
const [tooltip, setTooltip] = useState<{ x: number; y: number; country: string; pageviews: number } | null>(null)
const markerData = useMemo(() => {
if (!data.length) return []
const max = Math.max(...data.map((d) => d.pageviews))
if (max === 0) return []
return data
.filter((d) => d.country && d.country !== 'Unknown' && countryCentroids[d.country])
.map((d) => ({
lat: countryCentroids[d.country].lat,
lng: countryCentroids[d.country].lng,
size: 0.4 + (d.pageviews / max) * 0.8,
country: d.country,
pageviews: d.pageviews,
}))
}, [data])
const processedMarkers = useMemo(
() => addMarkers(markerData.map((d) => ({ lat: d.lat, lng: d.lng, size: d.size }))),
[markerData],
)
return (
<div className="relative w-full h-full flex items-center justify-center">
<svg
viewBox={`0 0 ${MAP_WIDTH} ${MAP_HEIGHT}`}
className={cn('text-neutral-400 dark:text-neutral-500', className)}
style={{ width: '100%', height: '100%' }}
>
<defs>
<filter id="marker-glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur in="SourceGraphic" stdDeviation="0.8" result="blur" />
<feColorMatrix in="blur" type="matrix" values="1 0 0 0 0 0 0.4 0 0 0 0 0 0 0 0 0 0 0 0.6 0" />
<feMerge>
<feMergeNode />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
<path
d={BASE_DOTS_PATH}
fill="currentColor"
/>
{processedMarkers.map((marker, index) => {
const rowIndex = _stagger.yToRowIndex.get(marker.y) ?? 0
const offsetX = rowIndex % 2 === 1 ? _stagger.xStep / 2 : 0
const info = markerData[index]
return (
<circle
cx={marker.x + offsetX}
cy={marker.y}
r={marker.size ?? DOT_RADIUS}
fill="#FD5E0F"
filter="url(#marker-glow)"
className="cursor-pointer"
key={`marker-${marker.x}-${marker.y}-${index}`}
onMouseEnter={(e) => {
if (info) {
const rect = (e.target as SVGCircleElement).closest('svg')!.getBoundingClientRect()
const svgX = marker.x + offsetX
const svgY = marker.y
setTooltip({
x: rect.left + (svgX / MAP_WIDTH) * rect.width,
y: rect.top + (svgY / MAP_HEIGHT) * rect.height,
country: info.country,
pageviews: info.pageviews,
})
}
}}
onMouseLeave={() => setTooltip(null)}
/>
)
})}
</svg>
{tooltip && (
<div
className="fixed z-50 px-2.5 py-1.5 text-xs font-medium text-white bg-neutral-900 dark:bg-neutral-800 border border-neutral-700 rounded-lg shadow-lg pointer-events-none -translate-x-1/2 -translate-y-full -mt-2"
style={{ left: tooltip.x, top: tooltip.y }}
>
<span>{getCountryName(tooltip.country)}</span>
<span className="ml-1.5 text-brand-orange font-bold">{formatNumber(tooltip.pageviews)}</span>
</div>
)}
</div>
)
}

View File

@@ -1,6 +1,6 @@
'use client'
import { useState } from 'react'
import { useState, useCallback } from 'react'
import { Modal, Button, Checkbox, Input, Select } from '@ciphera-net/ui'
import * as XLSX from 'xlsx'
import jsPDF from 'jspdf'
@@ -49,6 +49,7 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to
const [format, setFormat] = useState<ExportFormat>('csv')
const [filename, setFilename] = useState(`pulse_export_${new Date().toISOString().split('T')[0]}`)
const [includeHeader, setIncludeHeader] = useState(true)
const [isExporting, setIsExporting] = useState(false)
const [selectedFields, setSelectedFields] = useState<Record<keyof DailyStat, boolean>>({
date: true,
pageviews: true,
@@ -61,300 +62,312 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to
setSelectedFields((prev) => ({ ...prev, [field]: checked }))
}
const handleExport = async () => {
// Filter fields
const fields = (Object.keys(selectedFields) as Array<keyof DailyStat>).filter((k) => selectedFields[k])
// Prepare data
const exportData = data.map((item) => {
const filteredItem: Record<string, string | number> = {}
fields.forEach((field) => {
filteredItem[field] = item[field]
})
return filteredItem
})
const handleExport = () => {
setIsExporting(true)
// Let the browser paint the loading state before starting heavy work
requestAnimationFrame(() => {
setTimeout(async () => {
try {
// Filter fields
const fields = (Object.keys(selectedFields) as Array<keyof DailyStat>).filter((k) => selectedFields[k])
let content = ''
let mimeType = ''
let extension = ''
// Prepare data
const exportData = data.map((item) => {
const filteredItem: Record<string, string | number> = {}
fields.forEach((field) => {
filteredItem[field] = item[field]
})
return filteredItem
})
if (format === 'csv') {
const header = fields.join(',')
const rows = exportData.map((row) =>
fields.map((field) => {
const val = row[field]
if (field === 'date' && typeof val === 'string') {
return new Date(val).toISOString()
let content = ''
let mimeType = ''
let extension = ''
if (format === 'csv') {
const header = fields.join(',')
const rows = exportData.map((row) =>
fields.map((field) => {
const val = row[field]
if (field === 'date' && typeof val === 'string') {
return new Date(val).toISOString()
}
return val
}).join(',')
)
content = (includeHeader ? header + '\n' : '') + rows.join('\n')
mimeType = 'text/csv;charset=utf-8;'
extension = 'csv'
} else if (format === 'xlsx') {
const ws = XLSX.utils.json_to_sheet(exportData)
const wb = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(wb, ws, 'Data')
if (campaigns && campaigns.length > 0) {
const campaignsSheet = XLSX.utils.json_to_sheet(
campaigns.map(c => ({
Source: getReferrerDisplayName(c.source),
Medium: c.medium || '—',
Campaign: c.campaign || '—',
Visitors: c.visitors,
Pageviews: c.pageviews,
}))
)
XLSX.utils.book_append_sheet(wb, campaignsSheet, 'Campaigns')
}
return val
}).join(',')
)
content = (includeHeader ? header + '\n' : '') + rows.join('\n')
mimeType = 'text/csv;charset=utf-8;'
extension = 'csv'
} else if (format === 'xlsx') {
const ws = XLSX.utils.json_to_sheet(exportData)
const wb = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(wb, ws, 'Data')
if (campaigns && campaigns.length > 0) {
const campaignsSheet = XLSX.utils.json_to_sheet(
campaigns.map(c => ({
Source: getReferrerDisplayName(c.source),
Medium: c.medium || '—',
Campaign: c.campaign || '—',
Visitors: c.visitors,
Pageviews: c.pageviews,
}))
)
XLSX.utils.book_append_sheet(wb, campaignsSheet, 'Campaigns')
}
const wbout = XLSX.write(wb, { bookType: 'xlsx', type: 'array' })
const blob = new Blob([wbout], { type: 'application/octet-stream' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.setAttribute('href', url)
link.setAttribute('download', `${filename || 'export'}.${extension || 'xlsx'}`)
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
onClose()
return
} else if (format === 'pdf') {
const doc = new jsPDF()
// Header Section
try {
// Logo
const logoData = await loadImage('/pulse_icon_no_margins.png')
doc.addImage(logoData, 'PNG', 14, 12, 12, 12) // x, y, w, h
// Title
doc.setFontSize(22)
doc.setTextColor(249, 115, 22) // Brand Orange #F97316
doc.text('Pulse', 32, 20)
doc.setFontSize(12)
doc.setTextColor(100, 100, 100)
doc.text('Analytics Export', 32, 25)
} catch (e) {
// Fallback if logo fails
doc.setFontSize(22)
doc.setTextColor(249, 115, 22)
doc.text('Pulse Analytics', 14, 20)
}
const wbout = XLSX.write(wb, { bookType: 'xlsx', type: 'array' })
const blob = new Blob([wbout], { type: 'application/octet-stream' })
// Metadata (Top Right)
doc.setFontSize(9)
doc.setTextColor(150, 150, 150)
const generatedDate = new Date().toLocaleDateString()
const dateRange = data.length > 0
? `${new Date(data[0].date).toLocaleDateString()} - ${new Date(data[data.length - 1].date).toLocaleDateString()}`
: generatedDate
const pageWidth = doc.internal.pageSize.width
doc.text(`Generated: ${generatedDate}`, pageWidth - 14, 18, { align: 'right' })
doc.text(`Range: ${dateRange}`, pageWidth - 14, 23, { align: 'right' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.setAttribute('href', url)
link.setAttribute('download', `${filename || 'export'}.${extension || 'xlsx'}`)
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
onClose()
return
} else if (format === 'pdf') {
const doc = new jsPDF()
let startY = 35
// Header Section
try {
// Logo
const logoData = await loadImage('/pulse_icon_no_margins.png')
doc.addImage(logoData, 'PNG', 14, 12, 12, 12) // x, y, w, h
// Summary Section
if (stats) {
const summaryY = 35
const cardWidth = (pageWidth - 28 - 15) / 4 // 4 cards with 5mm gap
const cardHeight = 20
const drawCard = (x: number, label: string, value: string) => {
doc.setFillColor(255, 247, 237) // Very light orange
doc.setDrawColor(254, 215, 170) // Light orange border
doc.roundedRect(x, summaryY, cardWidth, cardHeight, 2, 2, 'FD')
doc.setFontSize(8)
// Title
doc.setFontSize(22)
doc.setTextColor(249, 115, 22) // Brand Orange #F97316
doc.text('Pulse', 32, 20)
doc.setFontSize(12)
doc.setTextColor(100, 100, 100)
doc.text('Analytics Export', 32, 25)
} catch (e) {
// Fallback if logo fails
doc.setFontSize(22)
doc.setTextColor(249, 115, 22)
doc.text('Pulse Analytics', 14, 20)
}
// Metadata (Top Right)
doc.setFontSize(9)
doc.setTextColor(150, 150, 150)
doc.text(label, x + 3, summaryY + 6)
doc.setFontSize(12)
doc.setTextColor(23, 23, 23) // Neutral 900
doc.setFont('helvetica', 'bold')
doc.text(value, x + 3, summaryY + 14)
doc.setFont('helvetica', 'normal')
}
const generatedDate = new Date().toLocaleDateString()
const dateRange = data.length > 0
? `${new Date(data[0].date).toLocaleDateString()} - ${new Date(data[data.length - 1].date).toLocaleDateString()}`
: generatedDate
drawCard(14, 'Unique Visitors', formatNumber(stats.visitors))
drawCard(14 + cardWidth + 5, 'Total Pageviews', formatNumber(stats.pageviews))
drawCard(14 + (cardWidth + 5) * 2, 'Bounce Rate', `${Math.round(stats.bounce_rate)}%`)
drawCard(14 + (cardWidth + 5) * 3, 'Avg Duration', formatDuration(stats.avg_duration))
startY = 65 // Move table down
}
const pageWidth = doc.internal.pageSize.width
doc.text(`Generated: ${generatedDate}`, pageWidth - 14, 18, { align: 'right' })
doc.text(`Range: ${dateRange}`, pageWidth - 14, 23, { align: 'right' })
// Check if data is hourly (same date for multiple rows)
const isHourly = data.length > 1 && data[0].date.split('T')[0] === data[1].date.split('T')[0]
let startY = 35
const tableData = exportData.map(row =>
fields.map(field => {
const val = row[field]
if (field === 'date' && typeof val === 'string') {
const date = new Date(val)
return isHourly
? date.toLocaleString('en-US', { month: 'numeric', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' })
: date.toLocaleDateString()
// Summary Section
if (stats) {
const summaryY = 35
const cardWidth = (pageWidth - 28 - 15) / 4 // 4 cards with 5mm gap
const cardHeight = 20
const drawCard = (x: number, label: string, value: string) => {
doc.setFillColor(255, 247, 237) // Very light orange
doc.setDrawColor(254, 215, 170) // Light orange border
doc.roundedRect(x, summaryY, cardWidth, cardHeight, 2, 2, 'FD')
doc.setFontSize(8)
doc.setTextColor(150, 150, 150)
doc.text(label, x + 3, summaryY + 6)
doc.setFontSize(12)
doc.setTextColor(23, 23, 23) // Neutral 900
doc.setFont('helvetica', 'bold')
doc.text(value, x + 3, summaryY + 14)
doc.setFont('helvetica', 'normal')
}
drawCard(14, 'Unique Visitors', formatNumber(stats.visitors))
drawCard(14 + cardWidth + 5, 'Total Pageviews', formatNumber(stats.pageviews))
drawCard(14 + (cardWidth + 5) * 2, 'Bounce Rate', `${Math.round(stats.bounce_rate)}%`)
drawCard(14 + (cardWidth + 5) * 3, 'Avg Duration', formatDuration(stats.avg_duration))
startY = 65 // Move table down
}
// Check if data is hourly (same date for multiple rows)
const isHourly = data.length > 1 && data[0].date.split('T')[0] === data[1].date.split('T')[0]
const tableData = exportData.map(row =>
fields.map(field => {
const val = row[field]
if (field === 'date' && typeof val === 'string') {
const date = new Date(val)
return isHourly
? date.toLocaleString('en-US', { month: 'numeric', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' })
: date.toLocaleDateString()
}
if (typeof val === 'number') {
if (field === 'bounce_rate') return `${Math.round(val)}%`
if (field === 'avg_duration') return formatDuration(val)
if (field === 'pageviews' || field === 'visitors') return formatNumber(val)
}
return val ?? ''
})
)
autoTable(doc, {
startY: startY,
head: [fields.map(f => f.charAt(0).toUpperCase() + f.slice(1).replace('_', ' '))],
body: tableData as (string | number)[][],
styles: {
font: 'helvetica',
fontSize: 9,
cellPadding: 4,
lineColor: [229, 231, 235], // Neutral 200
lineWidth: 0.1,
},
headStyles: {
fillColor: [249, 115, 22], // Brand Orange
textColor: [255, 255, 255],
fontStyle: 'bold',
halign: 'left'
},
columnStyles: {
0: { halign: 'left' }, // Date
1: { halign: 'right' }, // Pageviews
2: { halign: 'right' }, // Visitors
3: { halign: 'right' }, // Bounce Rate
4: { halign: 'right' }, // Avg Duration
},
alternateRowStyles: {
fillColor: [255, 250, 245], // Very very light orange
},
didDrawPage: (data) => {
// Footer
const pageSize = doc.internal.pageSize
const pageHeight = pageSize.height ? pageSize.height : pageSize.getHeight()
doc.setFontSize(8)
doc.setTextColor(150, 150, 150)
doc.text('Powered by Ciphera', 14, pageHeight - 10)
const str = 'Page ' + doc.getNumberOfPages()
doc.text(str, pageSize.width - 14, pageHeight - 10, { align: 'right' })
}
})
let finalY = doc.lastAutoTable.finalY + 10
// Top Pages Table
if (topPages && topPages.length > 0) {
// Check if we need a new page
if (finalY + 40 > doc.internal.pageSize.height) {
doc.addPage()
finalY = 20
}
doc.setFontSize(14)
doc.setTextColor(23, 23, 23)
doc.text('Top Pages', 14, finalY)
finalY += 5
const pagesData = topPages.slice(0, 10).map(p => [p.path, formatNumber(p.pageviews)])
autoTable(doc, {
startY: finalY,
head: [['Path', 'Pageviews']],
body: pagesData,
styles: { font: 'helvetica', fontSize: 9, cellPadding: 3 },
headStyles: { fillColor: [249, 115, 22], textColor: [255, 255, 255], fontStyle: 'bold' },
columnStyles: { 1: { halign: 'right' } },
alternateRowStyles: { fillColor: [255, 250, 245] },
})
finalY = doc.lastAutoTable.finalY + 10
}
// Top Referrers Table
if (topReferrers && topReferrers.length > 0) {
// Check if we need a new page
if (finalY + 40 > doc.internal.pageSize.height) {
doc.addPage()
finalY = 20
}
doc.setFontSize(14)
doc.setTextColor(23, 23, 23)
doc.text('Top Referrers', 14, finalY)
finalY += 5
const mergedReferrers = mergeReferrersByDisplayName(topReferrers)
const referrersData = mergedReferrers.slice(0, 10).map(r => [getReferrerDisplayName(r.referrer), formatNumber(r.pageviews)])
autoTable(doc, {
startY: finalY,
head: [['Referrer', 'Pageviews']],
body: referrersData,
styles: { font: 'helvetica', fontSize: 9, cellPadding: 3 },
headStyles: { fillColor: [249, 115, 22], textColor: [255, 255, 255], fontStyle: 'bold' },
columnStyles: { 1: { halign: 'right' } },
alternateRowStyles: { fillColor: [255, 250, 245] },
})
finalY = doc.lastAutoTable.finalY + 10
}
// Campaigns Table
if (campaigns && campaigns.length > 0) {
if (finalY + 40 > doc.internal.pageSize.height) {
doc.addPage()
finalY = 20
}
doc.setFontSize(14)
doc.setTextColor(23, 23, 23)
doc.text('Campaigns', 14, finalY)
finalY += 5
const campaignsData = campaigns.slice(0, 10).map(c => [
getReferrerDisplayName(c.source),
c.medium || '—',
c.campaign || '—',
formatNumber(c.visitors),
formatNumber(c.pageviews),
])
autoTable(doc, {
startY: finalY,
head: [['Source', 'Medium', 'Campaign', 'Visitors', 'Pageviews']],
body: campaignsData,
styles: { font: 'helvetica', fontSize: 9, cellPadding: 3 },
headStyles: { fillColor: [249, 115, 22], textColor: [255, 255, 255], fontStyle: 'bold' },
columnStyles: { 3: { halign: 'right' }, 4: { halign: 'right' } },
alternateRowStyles: { fillColor: [255, 250, 245] },
})
}
doc.save(`${filename || 'export'}.pdf`)
onClose()
return
} else {
content = JSON.stringify(exportData, null, 2)
mimeType = 'application/json;charset=utf-8;'
extension = 'json'
}
if (typeof val === 'number') {
if (field === 'bounce_rate') return `${Math.round(val)}%`
if (field === 'avg_duration') return formatDuration(val)
if (field === 'pageviews' || field === 'visitors') return formatNumber(val)
}
return val ?? ''
})
)
autoTable(doc, {
startY: startY,
head: [fields.map(f => f.charAt(0).toUpperCase() + f.slice(1).replace('_', ' '))],
body: tableData as (string | number)[][],
styles: {
font: 'helvetica',
fontSize: 9,
cellPadding: 4,
lineColor: [229, 231, 235], // Neutral 200
lineWidth: 0.1,
},
headStyles: {
fillColor: [249, 115, 22], // Brand Orange
textColor: [255, 255, 255],
fontStyle: 'bold',
halign: 'left'
},
columnStyles: {
0: { halign: 'left' }, // Date
1: { halign: 'right' }, // Pageviews
2: { halign: 'right' }, // Visitors
3: { halign: 'right' }, // Bounce Rate
4: { halign: 'right' }, // Avg Duration
},
alternateRowStyles: {
fillColor: [255, 250, 245], // Very very light orange
},
didDrawPage: (data) => {
// Footer
const pageSize = doc.internal.pageSize
const pageHeight = pageSize.height ? pageSize.height : pageSize.getHeight()
doc.setFontSize(8)
doc.setTextColor(150, 150, 150)
doc.text('Powered by Ciphera', 14, pageHeight - 10)
const str = 'Page ' + doc.getNumberOfPages()
doc.text(str, pageSize.width - 14, pageHeight - 10, { align: 'right' })
const blob = new Blob([content], { type: mimeType })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.setAttribute('href', url)
link.setAttribute('download', `${filename || 'export'}.${extension}`)
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
onClose()
} catch (e) {
console.error('Export failed:', e)
} finally {
setIsExporting(false)
}
})
let finalY = doc.lastAutoTable.finalY + 10
// Top Pages Table
if (topPages && topPages.length > 0) {
// Check if we need a new page
if (finalY + 40 > doc.internal.pageSize.height) {
doc.addPage()
finalY = 20
}
doc.setFontSize(14)
doc.setTextColor(23, 23, 23)
doc.text('Top Pages', 14, finalY)
finalY += 5
const pagesData = topPages.slice(0, 10).map(p => [p.path, formatNumber(p.pageviews)])
autoTable(doc, {
startY: finalY,
head: [['Path', 'Pageviews']],
body: pagesData,
styles: { font: 'helvetica', fontSize: 9, cellPadding: 3 },
headStyles: { fillColor: [249, 115, 22], textColor: [255, 255, 255], fontStyle: 'bold' },
columnStyles: { 1: { halign: 'right' } },
alternateRowStyles: { fillColor: [255, 250, 245] },
})
finalY = doc.lastAutoTable.finalY + 10
}
// Top Referrers Table
if (topReferrers && topReferrers.length > 0) {
// Check if we need a new page
if (finalY + 40 > doc.internal.pageSize.height) {
doc.addPage()
finalY = 20
}
doc.setFontSize(14)
doc.setTextColor(23, 23, 23)
doc.text('Top Referrers', 14, finalY)
finalY += 5
const mergedReferrers = mergeReferrersByDisplayName(topReferrers)
const referrersData = mergedReferrers.slice(0, 10).map(r => [getReferrerDisplayName(r.referrer), formatNumber(r.pageviews)])
autoTable(doc, {
startY: finalY,
head: [['Referrer', 'Pageviews']],
body: referrersData,
styles: { font: 'helvetica', fontSize: 9, cellPadding: 3 },
headStyles: { fillColor: [249, 115, 22], textColor: [255, 255, 255], fontStyle: 'bold' },
columnStyles: { 1: { halign: 'right' } },
alternateRowStyles: { fillColor: [255, 250, 245] },
})
finalY = doc.lastAutoTable.finalY + 10
}
// Campaigns Table
if (campaigns && campaigns.length > 0) {
if (finalY + 40 > doc.internal.pageSize.height) {
doc.addPage()
finalY = 20
}
doc.setFontSize(14)
doc.setTextColor(23, 23, 23)
doc.text('Campaigns', 14, finalY)
finalY += 5
const campaignsData = campaigns.slice(0, 10).map(c => [
getReferrerDisplayName(c.source),
c.medium || '—',
c.campaign || '—',
formatNumber(c.visitors),
formatNumber(c.pageviews),
])
autoTable(doc, {
startY: finalY,
head: [['Source', 'Medium', 'Campaign', 'Visitors', 'Pageviews']],
body: campaignsData,
styles: { font: 'helvetica', fontSize: 9, cellPadding: 3 },
headStyles: { fillColor: [249, 115, 22], textColor: [255, 255, 255], fontStyle: 'bold' },
columnStyles: { 3: { halign: 'right' }, 4: { halign: 'right' } },
alternateRowStyles: { fillColor: [255, 250, 245] },
})
}
doc.save(`${filename || 'export'}.pdf`)
onClose()
return
} else {
content = JSON.stringify(exportData, null, 2)
mimeType = 'application/json;charset=utf-8;'
extension = 'json'
}
const blob = new Blob([content], { type: mimeType })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.setAttribute('href', url)
link.setAttribute('download', `${filename || 'export'}.${extension}`)
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
onClose()
}, 0)
})
}
return (
@@ -440,11 +453,11 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to
{/* Actions */}
<div className="flex justify-end gap-3 pt-4">
<Button variant="secondary" onClick={onClose}>
<Button variant="secondary" onClick={onClose} disabled={isExporting}>
Cancel
</Button>
<Button variant="primary" onClick={handleExport}>
Export Data
<Button variant="primary" onClick={handleExport} disabled={isExporting}>
{isExporting ? 'Exporting...' : 'Export Data'}
</Button>
</div>
</div>

View File

@@ -0,0 +1,113 @@
'use client'
import { useEffect, useMemo, useRef } from 'react'
import createGlobe from 'cobe'
import { useTheme } from '@ciphera-net/ui'
import { countryCentroids } from '@/lib/country-centroids'
interface GlobeProps {
data: Array<{ country: string; pageviews: number }>
className?: string
}
export default function Globe({ data, className }: GlobeProps) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const phiRef = useRef(0)
const dragRef = useRef(0)
const pointerRef = useRef<number | null>(null)
const { resolvedTheme } = useTheme()
const isDarkRef = useRef(resolvedTheme === 'dark')
const markersRef = useRef<Array<{ location: [number, number]; size: number }>>([])
// Update refs without causing effect re-runs
isDarkRef.current = resolvedTheme === 'dark'
// Compute markers into ref (memoized to avoid recalculating on every render)
const markers = useMemo(() => {
const max = data.length ? Math.max(...data.map((d) => d.pageviews)) : 0
return max > 0
? data
.filter((d) => d.country && d.country !== 'Unknown' && countryCentroids[d.country])
.map((d) => ({
location: [countryCentroids[d.country].lat, countryCentroids[d.country].lng] as [number, number],
size: 0.03 + (d.pageviews / max) * 0.12,
}))
: []
}, [data])
markersRef.current = markers
useEffect(() => {
if (!canvasRef.current) return
const size = canvasRef.current.offsetWidth
const pixelRatio = Math.min(window.devicePixelRatio, 2)
const isDark = isDarkRef.current
const globe = createGlobe(canvasRef.current, {
width: size * pixelRatio,
height: size * pixelRatio,
devicePixelRatio: pixelRatio,
phi: phiRef.current,
theta: 0.3,
dark: isDark ? 1 : 0,
diffuse: isDark ? 2 : 0.4,
mapSamples: 16000,
mapBrightness: isDark ? 2 : 1.2,
baseColor: isDark ? [0.5, 0.5, 0.5] : [1, 1, 1],
markerColor: [253 / 255, 94 / 255, 15 / 255],
glowColor: isDark ? [0.15, 0.15, 0.15] : [1, 1, 1],
markers: markersRef.current,
onRender: (state) => {
if (!pointerRef.current) phiRef.current += 0.002
state.phi = phiRef.current + dragRef.current
},
})
setTimeout(() => {
if (canvasRef.current) canvasRef.current.style.opacity = '1'
}, 0)
return () => { globe.destroy() }
// Only recreate on theme change
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [resolvedTheme])
return (
<div className={`relative w-full h-full overflow-hidden ${className ?? ''}`}>
<div className="absolute left-1/2 -translate-x-1/2 top-0 aspect-square w-[130%]">
<canvas
className="size-full opacity-0 transition-opacity duration-500"
style={{ contain: 'layout paint size' }}
ref={canvasRef}
onPointerDown={(e) => {
pointerRef.current = e.clientX
canvasRef.current!.style.cursor = 'grabbing'
}}
onPointerUp={() => {
pointerRef.current = null
canvasRef.current!.style.cursor = 'grab'
}}
onPointerOut={() => {
pointerRef.current = null
if (canvasRef.current) canvasRef.current.style.cursor = 'grab'
}}
onMouseMove={(e) => {
if (pointerRef.current !== null) {
const delta = e.clientX - pointerRef.current
dragRef.current += delta / 800
pointerRef.current = e.clientX
}
}}
onTouchMove={(e) => {
if (pointerRef.current !== null && e.touches[0]) {
const delta = e.touches[0].clientX - pointerRef.current
dragRef.current += delta / 800
pointerRef.current = e.touches[0].clientX
}
}}
/>
</div>
<div className="pointer-events-none absolute inset-0 h-full bg-[radial-gradient(circle_at_50%_200%,rgba(0,0,0,0.2),rgba(255,255,255,0))]" />
</div>
)
}

View File

@@ -15,6 +15,8 @@ const LIMIT = 10
export default function GoalStats({ goalCounts, onSelectEvent }: GoalStatsProps) {
const list = (goalCounts || []).slice(0, LIMIT)
const hasData = list.length > 0
const total = list.reduce((sum, r) => sum + r.count, 0)
const emptySlots = Math.max(0, 6 - list.length)
return (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
@@ -25,24 +27,34 @@ export default function GoalStats({ goalCounts, onSelectEvent }: GoalStatsProps)
</div>
{hasData ? (
<div className="space-y-2 flex-1 min-h-[200px]">
<div className="flex-1 min-h-[270px]">
{list.map((row) => (
<div
key={row.event_name}
onClick={() => onSelectEvent?.(row.event_name)}
className={`flex items-center justify-between py-2 px-3 rounded-lg bg-neutral-50 dark:bg-neutral-800/50 hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors${onSelectEvent ? ' cursor-pointer' : ''}`}
className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors${onSelectEvent ? ' cursor-pointer' : ''}`}
>
<span className="text-sm font-medium text-neutral-900 dark:text-white truncate">
{row.display_name ?? row.event_name.replace(/_/g, ' ')}
</span>
<span className="text-sm font-semibold text-brand-orange tabular-nums">
{formatNumber(row.count)}
</span>
<div className="flex items-center flex-1 min-w-0">
<span className="text-sm font-medium text-neutral-900 dark:text-white truncate">
{row.display_name ?? row.event_name.replace(/_/g, ' ')}
</span>
</div>
<div className="flex items-center gap-2 ml-4">
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
{total > 0 ? `${Math.round((row.count / total) * 100)}%` : ''}
</span>
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400 tabular-nums">
{formatNumber(row.count)}
</span>
</div>
</div>
))}
{Array.from({ length: emptySlots }).map((_, i) => (
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
))}
</div>
) : (
<div className="flex-1 min-h-[200px] flex flex-col items-center justify-center text-center px-6 py-8 gap-4">
<div className="flex-1 min-h-[270px] flex flex-col items-center justify-center text-center px-6 py-8 gap-4">
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
<BookOpenIcon className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
</div>
@@ -54,7 +66,7 @@ export default function GoalStats({ goalCounts, onSelectEvent }: GoalStatsProps)
</p>
<Link
href="/installation"
className="inline-flex items-center gap-2 text-sm font-medium text-brand-orange hover:text-brand-orange/90 hover:underline focus:outline-none focus:ring-2 focus:ring-brand-orange/20 rounded"
className="inline-flex items-center gap-2 text-sm font-medium text-brand-orange hover:text-brand-orange/90 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange/20 rounded"
>
Read documentation
<ArrowRightIcon className="w-4 h-4" />

View File

@@ -1,16 +1,20 @@
'use client'
import { useState, useEffect } from 'react'
import { useState, useEffect, useRef } from 'react'
import dynamic from 'next/dynamic'
import { motion } from 'framer-motion'
import { logger } from '@/lib/utils/logger'
import { formatNumber } from '@ciphera-net/ui'
import { useTabListKeyboard } from '@/lib/hooks/useTabListKeyboard'
import * as Flags from 'country-flag-icons/react/3x2'
import iso3166 from 'iso-3166-2'
import WorldMap from './WorldMap'
const DottedMap = dynamic(() => import('./DottedMap'), { ssr: false })
const Globe = dynamic(() => import('./Globe'), { ssr: false })
import { Modal, GlobeIcon } from '@ciphera-net/ui'
import { ListSkeleton } from '@/components/skeletons'
import { SiTorproject } from 'react-icons/si'
import { FaUserSecret, FaSatellite } from 'react-icons/fa'
import VirtualList from './VirtualList'
import { ShieldCheck, Detective, Broadcast, FrameCornersIcon } from '@phosphor-icons/react'
import { getCountries, getCities, getRegions } from '@/lib/api/stats'
import { type DimensionFilter } from '@/lib/filters'
@@ -24,7 +28,7 @@ interface LocationProps {
onFilter?: (filter: DimensionFilter) => void
}
type Tab = 'map' | 'countries' | 'regions' | 'cities'
type Tab = 'map' | 'globe' | 'countries' | 'regions' | 'cities'
const LIMIT = 7
@@ -34,10 +38,25 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
const [activeTab, setActiveTab] = useState<Tab>('map')
const handleTabKeyDown = useTabListKeyboard()
const [isModalOpen, setIsModalOpen] = useState(false)
const [modalSearch, setModalSearch] = useState('')
type LocationItem = { country?: string; city?: string; region?: string; pageviews: number }
const [fullData, setFullData] = useState<LocationItem[]>([])
const [isLoadingFull, setIsLoadingFull] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const [inView, setInView] = useState(false)
useEffect(() => {
const el = containerRef.current
if (!el) return
const observer = new IntersectionObserver(
([entry]) => { if (entry.isIntersecting) setInView(true) },
{ rootMargin: '200px' }
)
observer.observe(el)
return () => observer.disconnect()
}, [])
useEffect(() => {
if (isModalOpen) {
const fetchData = async () => {
@@ -69,11 +88,11 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
switch (countryCode) {
case 'T1':
return <SiTorproject className="w-5 h-5 text-purple-600 dark:text-purple-400" />
return <ShieldCheck className="w-5 h-5 text-purple-600 dark:text-purple-400" />
case 'A1':
return <FaUserSecret className="w-5 h-5 text-neutral-600 dark:text-neutral-400" />
return <Detective className="w-5 h-5 text-neutral-600 dark:text-neutral-400" />
case 'A2':
return <FaSatellite className="w-5 h-5 text-blue-500 dark:text-blue-400" />
return <Broadcast className="w-5 h-5 text-blue-500 dark:text-blue-400" />
case 'O1':
case 'EU':
case 'AP':
@@ -174,15 +193,16 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
})
}
const rawData = activeTab === 'map' ? [] : getData()
const isVisualTab = activeTab === 'map' || activeTab === 'globe'
const rawData = isVisualTab ? [] : getData()
const data = filterUnknown(rawData)
const totalPageviews = data.reduce((sum, item) => sum + item.pageviews, 0)
const hasData = activeTab === 'map'
const hasData = isVisualTab
? (countries && filterUnknown(countries).length > 0)
: (data && data.length > 0)
const displayedData = (activeTab !== 'map' && hasData) ? data.slice(0, LIMIT) : []
const displayedData = (!isVisualTab && hasData) ? data.slice(0, LIMIT) : []
const emptySlots = Math.max(0, LIMIT - displayedData.length)
const showViewAll = activeTab !== 'map' && hasData && data.length > LIMIT
const showViewAll = !isVisualTab && hasData && data.length > LIMIT
const getDisabledMessage = () => {
if (geoDataLevel === 'none') {
@@ -196,25 +216,43 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
return (
<>
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
<div ref={containerRef} className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
Locations
</h3>
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
Locations
</h3>
{showViewAll && (
<button
onClick={() => setIsModalOpen(true)}
className="p-1.5 text-neutral-400 dark:text-neutral-500 hover:text-brand-orange dark:hover:text-brand-orange hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-all cursor-pointer rounded-lg"
aria-label="View all locations"
>
<FrameCornersIcon className="w-4 h-4" weight="bold" />
</button>
)}
</div>
<div className="flex gap-1" role="tablist" aria-label="Location view tabs" onKeyDown={handleTabKeyDown}>
{(['map', 'countries', 'regions', 'cities'] as Tab[]).map((tab) => (
{(['map', 'globe', 'countries', 'regions', 'cities'] as Tab[]).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
role="tab"
aria-selected={activeTab === tab}
className={`px-2.5 py-1 text-xs font-medium transition-colors capitalize focus:outline-none focus:ring-2 focus:ring-brand-orange rounded cursor-pointer border-b-2 ${
className={`relative px-2.5 py-1 text-xs font-medium transition-colors capitalize focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange rounded cursor-pointer ${
activeTab === tab
? 'border-brand-orange text-neutral-900 dark:text-white'
: 'border-transparent text-neutral-400 dark:text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300'
? 'text-neutral-900 dark:text-white'
: 'text-neutral-400 dark:text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300'
}`}
>
{tab}
{activeTab === tab && (
<motion.div
layoutId="locationsTab"
className="absolute inset-x-0 -bottom-px h-0.5 bg-brand-orange"
transition={{ type: 'spring', stiffness: 500, damping: 35 }}
/>
)}
</button>
))}
</div>
@@ -225,8 +263,12 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
<div className="h-full flex flex-col items-center justify-center text-center px-4">
<p className="text-neutral-500 dark:text-neutral-400 text-sm">{getDisabledMessage()}</p>
</div>
) : activeTab === 'map' ? (
hasData ? <WorldMap data={filterUnknown(countries) as { country: string; pageviews: number }[]} /> : (
) : isVisualTab ? (
hasData ? (
activeTab === 'globe'
? (inView ? <Globe data={filterUnknown(countries) as { country: string; pageviews: number }[]} /> : null)
: (inView ? <DottedMap data={filterUnknown(countries) as { country: string; pageviews: number }[]} /> : null)
) : (
<div className="h-full flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
<GlobeIcon className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
@@ -271,21 +313,9 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
</div>
)
})}
{showViewAll ? (
<button
onClick={() => setIsModalOpen(true)}
className="flex items-center justify-center gap-1.5 h-9 w-full text-xs font-medium text-neutral-400 dark:text-neutral-500 hover:text-brand-orange dark:hover:text-brand-orange transition-colors cursor-pointer rounded-lg px-2 -mx-2"
>
View all
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</button>
) : (
Array.from({ length: emptySlots }).map((_, i) => (
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
))
)}
{Array.from({ length: emptySlots }).map((_, i) => (
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
))}
</>
) : (
<div className="h-full flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
@@ -306,31 +336,69 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
<Modal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
title={`Locations - ${activeTab.charAt(0).toUpperCase() + activeTab.slice(1)}`}
onClose={() => { setIsModalOpen(false); setModalSearch('') }}
title={activeTab.charAt(0).toUpperCase() + activeTab.slice(1)}
className="max-w-2xl"
>
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-2">
<div>
<input
type="text"
value={modalSearch}
onChange={(e) => setModalSearch(e.target.value)}
placeholder="Search locations..."
className="w-full px-3 py-2 mb-3 text-sm bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg text-neutral-900 dark:text-white placeholder-neutral-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-brand-orange/50"
/>
</div>
<div className="max-h-[80vh]">
{isLoadingFull ? (
<div className="py-4">
<ListSkeleton rows={10} />
</div>
) : (
(fullData.length > 0 ? fullData : data).map((item) => (
<div key={`${item.country ?? ''}-${item.region ?? ''}-${item.city ?? ''}`} className="flex items-center justify-between py-2 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors">
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
<span className="shrink-0">{getFlagComponent(item.country ?? '')}</span>
<span className="truncate">
{activeTab === 'countries' ? getCountryName(item.country ?? '') :
activeTab === 'regions' ? getRegionName(item.region ?? '', item.country ?? '') :
getCityName(item.city ?? '')}
</span>
</div>
<div className="text-sm font-semibold text-neutral-600 dark:text-neutral-400 ml-4">
{formatNumber(item.pageviews)}
</div>
</div>
))
)}
) : (() => {
const rawModalData = fullData.length > 0 ? fullData : data
const search = modalSearch.toLowerCase()
const modalData = !modalSearch ? rawModalData : rawModalData.filter(item => {
const label = activeTab === 'countries' ? getCountryName(item.country ?? '') : activeTab === 'regions' ? getRegionName(item.region ?? '', item.country ?? '') : getCityName(item.city ?? '')
return label.toLowerCase().includes(search)
})
const modalTotal = modalData.reduce((sum, item) => sum + item.pageviews, 0)
return (
<VirtualList
items={modalData}
estimateSize={36}
className="max-h-[80vh] overflow-y-auto pr-2"
renderItem={(item) => {
const dim = TAB_TO_DIMENSION[activeTab]
const filterValue = activeTab === 'countries' ? item.country : activeTab === 'regions' ? item.region : item.city
const canFilter = onFilter && dim && filterValue
return (
<div
key={`${item.country ?? ''}-${item.region ?? ''}-${item.city ?? ''}`}
onClick={() => { if (canFilter) { onFilter({ dimension: dim, operator: 'is', values: [filterValue!] }); setIsModalOpen(false) } }}
className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 transition-colors${canFilter ? ' cursor-pointer' : ''}`}
>
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
<span className="shrink-0">{getFlagComponent(item.country ?? '')}</span>
<span className="truncate">
{activeTab === 'countries' ? getCountryName(item.country ?? '') :
activeTab === 'regions' ? getRegionName(item.region ?? '', item.country ?? '') :
getCityName(item.city ?? '')}
</span>
</div>
<div className="flex items-center gap-2 ml-4">
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
{modalTotal > 0 ? `${Math.round((item.pageviews / modalTotal) * 100)}%` : ''}
</span>
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
{formatNumber(item.pageviews)}
</span>
</div>
</div>
)
}}
/>
)
})()}
</div>
</Modal>
</>

View File

@@ -0,0 +1,271 @@
'use client'
import { useState, useEffect, useMemo, useRef, type CSSProperties } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { logger } from '@/lib/utils/logger'
import { getDailyStats } from '@/lib/api/stats'
import type { DailyStat } from '@/lib/api/stats'
interface PeakHoursProps {
siteId: string
dateRange: { start: string, end: string }
}
const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
const BUCKETS = 12 // 2-hour buckets
// Label at bucket index 0=12am, 3=6am, 6=12pm, 9=6pm
const BUCKET_LABELS: Record<number, string> = { 0: '12am', 3: '6am', 6: '12pm', 9: '6pm' }
const HIGHLIGHT_COLORS = [
'rgba(253,94,15,0.18)',
'rgba(253,94,15,0.38)',
'rgba(253,94,15,0.62)',
'#FD5E0F',
]
function formatBucket(bucket: number): string {
const hour = bucket * 2
if (hour === 0) return '12am2am'
if (hour === 12) return '12pm2pm'
return hour < 12 ? `${hour}am${hour + 2}am` : `${hour - 12}pm${hour - 10}pm`
}
function formatHour(hour: number): string {
if (hour === 0) return '12am'
if (hour === 12) return '12pm'
return hour < 12 ? `${hour}am` : `${hour - 12}pm`
}
function getHighlightColor(value: number, max: number): string {
if (value === 0) return HIGHLIGHT_COLORS[0]
const ratio = value / max
if (ratio < 0.25) return HIGHLIGHT_COLORS[1]
if (ratio < 0.6) return HIGHLIGHT_COLORS[2]
return HIGHLIGHT_COLORS[3]
}
export default function PeakHours({ siteId, dateRange }: PeakHoursProps) {
const [data, setData] = useState<DailyStat[]>([])
const [isLoading, setIsLoading] = useState(true)
const [animKey, setAnimKey] = useState(0)
const [hovered, setHovered] = useState<{ day: number; bucket: number } | null>(null)
const [tooltipPos, setTooltipPos] = useState<{ x: number; y: number } | null>(null)
const gridRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const fetchData = async () => {
setIsLoading(true)
try {
const result = await getDailyStats(siteId, dateRange.start, dateRange.end, 'hour')
setData(result)
setAnimKey(k => k + 1)
} catch (e) {
logger.error(e)
} finally {
setIsLoading(false)
}
}
fetchData()
}, [siteId, dateRange])
const { grid, max, dayTotals, bucketTotals, weekTotal } = useMemo(() => {
// grid[day][bucket] — aggregate 2-hour buckets
const grid: number[][] = Array.from({ length: 7 }, () => Array(BUCKETS).fill(0))
for (const d of data) {
const date = new Date(d.date)
const day = date.getDay()
const hour = date.getHours()
const adjustedDay = day === 0 ? 6 : day - 1
const bucket = Math.floor(hour / 2)
grid[adjustedDay][bucket] += d.pageviews
}
const max = Math.max(...grid.flat(), 1)
const dayTotals = grid.map(buckets => buckets.reduce((a, b) => a + b, 0))
const bucketTotals = Array.from({ length: BUCKETS }, (_, b) => grid.reduce((a, row) => a + row[b], 0))
const weekTotal = dayTotals.reduce((a, b) => a + b, 0)
return { grid, max, dayTotals, bucketTotals, weekTotal }
}, [data])
const hasData = data.some(d => d.pageviews > 0)
const bestTime = useMemo(() => {
if (!hasData) return null
let bestDay = 0, bestBucket = 0, bestVal = 0
for (let d = 0; d < 7; d++) {
for (let b = 0; b < BUCKETS; b++) {
if (grid[d][b] > bestVal) {
bestVal = grid[d][b]
bestDay = d
bestBucket = b
}
}
}
return { day: bestDay, bucket: bestBucket }
}, [grid, hasData])
const tooltipData = useMemo(() => {
if (!hovered) return null
const { day, bucket } = hovered
const value = grid[day][bucket]
const pct = weekTotal > 0 ? Math.round((value / weekTotal) * 100) : 0
return { value, dayTotal: dayTotals[day], bucketTotal: bucketTotals[bucket], pct }
}, [hovered, grid, dayTotals, bucketTotals, weekTotal])
const handleCellMouseEnter = (
e: React.MouseEvent<HTMLDivElement>,
dayIdx: number,
bucket: number
) => {
setHovered({ day: dayIdx, bucket })
if (gridRef.current) {
const gridRect = gridRef.current.getBoundingClientRect()
const cellRect = (e.currentTarget as HTMLDivElement).getBoundingClientRect()
setTooltipPos({
x: cellRect.left - gridRect.left + cellRect.width / 2,
y: cellRect.top - gridRect.top,
})
}
}
return (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
<div className="flex items-center justify-between mb-1">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">Peak Hours</h3>
</div>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-5">
When your visitors are most active
</p>
{isLoading ? (
<div className="flex-1 min-h-[270px] flex flex-col justify-center gap-1.5">
{Array.from({ length: 7 }).map((_, i) => (
<div key={i} className="flex items-center gap-1">
<div className="w-7 h-3 rounded bg-neutral-200 dark:bg-neutral-800 animate-pulse" />
<div className="flex-1 h-5 rounded bg-neutral-200 dark:bg-neutral-800 animate-pulse" />
</div>
))}
</div>
) : hasData ? (
<>
<div className="flex-1 min-h-[270px] flex flex-col justify-center gap-[5px] relative" ref={gridRef}>
{grid.map((buckets, dayIdx) => (
<div key={dayIdx} className="flex items-center gap-1.5">
<span className="text-[11px] text-neutral-400 dark:text-neutral-500 w-7 flex-shrink-0 text-right leading-none">
{DAYS[dayIdx]}
</span>
<div
className="flex-1"
style={{ display: 'grid', gridTemplateColumns: `repeat(${BUCKETS}, 1fr)`, gap: '5px' }}
>
{buckets.map((value, bucket) => {
const isHoveredCell = hovered?.day === dayIdx && hovered?.bucket === bucket
const isBestCell = bestTime?.day === dayIdx && bestTime?.bucket === bucket
const isActive = value > 0
const highlightColor = getHighlightColor(value, max)
return (
<div
key={`${animKey}-${dayIdx}-${bucket}`}
className={[
'aspect-square w-full rounded-[4px] border cursor-default transition-transform duration-100',
'border-neutral-200 dark:border-neutral-800',
isActive ? 'animate-cell-highlight' : '',
isHoveredCell ? 'scale-110 z-10 relative' : '',
isBestCell && !isHoveredCell ? 'ring-1 ring-brand-orange/40' : '',
].join(' ')}
style={{
animationDelay: isActive
? `${((dayIdx * BUCKETS + bucket) * 0.008).toFixed(3)}s`
: undefined,
'--highlight': highlightColor,
} as CSSProperties}
onMouseEnter={(e) => handleCellMouseEnter(e, dayIdx, bucket)}
onMouseLeave={() => { setHovered(null); setTooltipPos(null) }}
/>
)
})}
</div>
</div>
))}
{/* Hour axis labels */}
<div className="flex items-center gap-1.5 mt-1">
<span className="w-7 flex-shrink-0" />
<div className="flex-1 relative h-3">
{Object.entries(BUCKET_LABELS).map(([b, label]) => (
<span
key={b}
className="absolute text-[10px] text-neutral-400 dark:text-neutral-600 -translate-x-1/2"
style={{ left: `${(Number(b) / BUCKETS) * 100}%` }}
>
{label}
</span>
))}
<span
className="absolute text-[10px] text-neutral-400 dark:text-neutral-600 -translate-x-full"
style={{ left: '100%' }}
>
12am
</span>
</div>
</div>
{/* Cell-anchored tooltip */}
<AnimatePresence>
{hovered && tooltipData && tooltipPos && (
<motion.div
key="tooltip"
initial={{ opacity: 0, y: 4, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 4, scale: 0.95 }}
transition={{ duration: 0.12 }}
className="absolute pointer-events-none z-20"
style={{
left: tooltipPos.x,
top: tooltipPos.y - 8,
transform: 'translate(-50%, -100%)',
}}
>
<div className="bg-neutral-900 dark:bg-neutral-800 border border-neutral-700 text-white text-xs px-3 py-2 rounded-lg shadow-xl whitespace-nowrap">
<div className="font-semibold mb-1">
{DAYS[hovered.day]} {formatBucket(hovered.bucket)}
</div>
<div className="flex flex-col gap-0.5 text-neutral-300">
<span>{tooltipData.value.toLocaleString()} pageviews</span>
<span>{tooltipData.pct}% of week&apos;s traffic</span>
</div>
</div>
<div
className="absolute left-1/2 -translate-x-1/2 bottom-0 translate-y-full w-0 h-0"
style={{ borderLeft: '5px solid transparent', borderRight: '5px solid transparent', borderTop: '5px solid #404040' }}
/>
</motion.div>
)}
</AnimatePresence>
</div>
{/* Best time callout */}
{bestTime && (
<motion.p
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.6 }}
className="mt-4 text-xs text-neutral-500 dark:text-neutral-400 text-center"
>
Your busiest time is{' '}
<span className="text-brand-orange font-medium">
{DAYS[bestTime.day]}s at {formatHour(bestTime.bucket * 2)}
</span>
</motion.p>
)}
</>
) : (
<div className="flex-1 min-h-[270px] flex flex-col items-center justify-center text-center gap-3">
<p className="text-sm text-neutral-500 dark:text-neutral-400">
No data available for this period
</p>
</div>
)}
</div>
)
}

View File

@@ -114,7 +114,7 @@ export default function PerformanceStats({ stats, performanceByPage, siteId, sta
<button
type="button"
onClick={() => setMainExpanded((o) => !o)}
className="flex w-full items-center justify-between gap-4 text-left rounded cursor-pointer hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-neutral-400 dark:focus:ring-neutral-500 focus:ring-offset-2 dark:focus:ring-offset-neutral-900"
className="flex w-full items-center justify-between gap-4 text-left rounded cursor-pointer hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-400 dark:focus-visible:ring-neutral-500 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-neutral-900"
aria-expanded={mainExpanded}
>
<div className="flex items-center gap-2 min-w-0">
@@ -170,7 +170,7 @@ export default function PerformanceStats({ stats, performanceByPage, siteId, sta
<button
type="button"
onClick={() => setWorstPagesOpen((o) => !o)}
className="flex items-center gap-2 text-left rounded cursor-pointer hover:opacity-80 focus:outline-none focus:ring-2 focus:ring-neutral-400 dark:focus:ring-neutral-500 focus:ring-offset-2 dark:focus:ring-offset-neutral-900"
className="flex items-center gap-2 text-left rounded cursor-pointer hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-400 dark:focus-visible:ring-neutral-500 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-neutral-900"
aria-expanded={worstPagesOpen}
>
<ChevronDownIcon

View File

@@ -1,19 +1,11 @@
'use client'
import { useRouter } from 'next/navigation'
interface RealtimeVisitorsProps {
count: number
siteId?: string
}
export default function RealtimeVisitors({ count, siteId }: RealtimeVisitorsProps) {
const router = useRouter()
export default function RealtimeVisitors({ count }: RealtimeVisitorsProps) {
return (
<div
onClick={() => siteId && router.push(`/sites/${siteId}/realtime`)}
className={`bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 ${siteId ? 'cursor-pointer hover:border-neutral-300 dark:hover:border-neutral-700 transition-colors' : ''}`}
<div
className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6"
>
<div className="flex items-center justify-between mb-2">
<div className="text-sm text-neutral-600 dark:text-neutral-400">

View File

@@ -1,6 +1,6 @@
'use client'
import { formatNumber } from '@ciphera-net/ui'
import { PolarAngleAxis, PolarGrid, Radar, RadarChart, Tooltip } from 'recharts'
import { BarChartIcon } from '@ciphera-net/ui'
import type { GoalCountStat } from '@/lib/api/stats'
@@ -22,48 +22,57 @@ export default function ScrollDepth({ goalCounts, totalPageviews }: ScrollDepthP
const hasData = scrollCounts.size > 0 && totalPageviews > 0
const chartData = THRESHOLDS.map((threshold) => ({
label: `${threshold}%`,
value: totalPageviews > 0 ? Math.round(((scrollCounts.get(threshold) ?? 0) / totalPageviews) * 100) : 0,
}))
return (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center justify-between mb-1">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
Scroll Depth
</h3>
</div>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-4">
% of visitors who scrolled this far
</p>
{hasData ? (
<div className="space-y-3 flex-1 min-h-[200px]">
{THRESHOLDS.map((threshold) => {
const count = scrollCounts.get(threshold) ?? 0
const pct = totalPageviews > 0 ? Math.round((count / totalPageviews) * 100) : 0
const barWidth = Math.max(pct, 2)
return (
<div key={threshold} className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span className="font-medium text-neutral-900 dark:text-white">
{threshold}%
</span>
<div className="flex items-center gap-2">
<span className="text-neutral-500 dark:text-neutral-400 tabular-nums">
{formatNumber(count)}
</span>
<span className="font-semibold text-brand-orange tabular-nums w-12 text-right">
{pct}%
</span>
</div>
</div>
<div className="h-2 rounded-full bg-neutral-100 dark:bg-neutral-800 overflow-hidden">
<div
className="h-full rounded-full bg-brand-orange transition-all duration-500"
style={{ width: `${barWidth}%` }}
/>
</div>
</div>
)
})}
<div className="flex-1 min-h-[270px] flex items-center justify-center">
<RadarChart
width={320}
height={260}
data={chartData}
margin={{ top: 16, right: 32, bottom: 16, left: 32 }}
>
<PolarGrid stroke="#404040" />
<PolarAngleAxis
dataKey="label"
tick={{ fill: '#a3a3a3', fontSize: 12, fontWeight: 500 }}
/>
<Tooltip
cursor={false}
contentStyle={{
backgroundColor: '#171717',
border: '1px solid #404040',
borderRadius: 8,
fontSize: 12,
color: '#fff',
}}
formatter={(value: number) => [`${value}%`, 'Reached']}
/>
<Radar
dataKey="value"
stroke="#FD5E0F"
fill="#FD5E0F"
fillOpacity={0.6}
dot={{ r: 4, fill: '#FD5E0F', fillOpacity: 1, strokeWidth: 0 }}
/>
</RadarChart>
</div>
) : (
<div className="flex-1 min-h-[200px] flex flex-col items-center justify-center text-center px-6 py-8 gap-4">
<div className="flex-1 min-h-[270px] flex flex-col items-center justify-center text-center px-6 py-8 gap-4">
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
<BarChartIcon className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
</div>

View File

@@ -0,0 +1,64 @@
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { motion } from 'framer-motion'
import { useTabListKeyboard } from '@/lib/hooks/useTabListKeyboard'
import { useAuth } from '@/lib/auth/context'
interface SiteNavProps {
siteId: string
}
export default function SiteNav({ siteId }: SiteNavProps) {
const pathname = usePathname()
const handleTabKeyDown = useTabListKeyboard()
const { user } = useAuth()
const canEdit = user?.role === 'owner' || user?.role === 'admin'
const tabs = [
{ label: 'Dashboard', href: `/sites/${siteId}` },
{ label: 'Journeys', href: `/sites/${siteId}/journeys` },
{ label: 'Funnels', href: `/sites/${siteId}/funnels` },
{ label: 'Behavior', href: `/sites/${siteId}/behavior` },
{ label: 'Uptime', href: `/sites/${siteId}/uptime` },
...(canEdit ? [{ label: 'Settings', href: `/sites/${siteId}/settings` }] : []),
]
const isActive = (href: string) => {
if (href === `/sites/${siteId}`) {
return pathname === href
}
return pathname.startsWith(href)
}
return (
<div className="border-b border-neutral-200 dark:border-neutral-800 mb-6">
<nav className="flex gap-1" role="tablist" aria-label="Site navigation" onKeyDown={handleTabKeyDown}>
{tabs.map((tab) => (
<Link
key={tab.href}
href={tab.href}
role="tab"
aria-selected={isActive(tab.href)}
tabIndex={isActive(tab.href) ? 0 : -1}
className={`relative px-3 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange rounded-t cursor-pointer -mb-px ${
isActive(tab.href)
? 'text-neutral-900 dark:text-white'
: 'text-neutral-400 dark:text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300'
}`}
>
{tab.label}
{isActive(tab.href) && (
<motion.div
layoutId="activeTab"
className="absolute inset-x-0 -bottom-px h-0.5 bg-brand-orange"
transition={{ type: 'spring', stiffness: 500, damping: 35 }}
/>
)}
</Link>
))}
</nav>
</div>
)
}

View File

@@ -1,13 +1,15 @@
'use client'
import { useState, useEffect } from 'react'
import { motion } from 'framer-motion'
import { logger } from '@/lib/utils/logger'
import { formatNumber } from '@ciphera-net/ui'
import { useTabListKeyboard } from '@/lib/hooks/useTabListKeyboard'
import { getBrowserIcon, getOSIcon, getDeviceIcon } from '@/lib/utils/icons'
import { MdMonitor } from 'react-icons/md'
import { Monitor, FrameCornersIcon } from '@phosphor-icons/react'
import { Modal, GridIcon } from '@ciphera-net/ui'
import { ListSkeleton } from '@/components/skeletons'
import VirtualList from './VirtualList'
import { getBrowsers, getOS, getDevices, getScreenResolutions } from '@/lib/api/stats'
import { type DimensionFilter } from '@/lib/filters'
@@ -25,6 +27,11 @@ interface TechSpecsProps {
type Tab = 'browsers' | 'os' | 'devices' | 'screens'
function capitalize(s: string): string {
if (!s) return s
return s.charAt(0).toUpperCase() + s.slice(1)
}
const LIMIT = 7
const TAB_TO_DIMENSION: Record<string, string> = { browsers: 'browser', os: 'os', devices: 'device' }
@@ -33,6 +40,7 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
const [activeTab, setActiveTab] = useState<Tab>('browsers')
const handleTabKeyDown = useTabListKeyboard()
const [isModalOpen, setIsModalOpen] = useState(false)
const [modalSearch, setModalSearch] = useState('')
type TechItem = { name: string; pageviews: number; icon: React.ReactNode }
const [fullData, setFullData] = useState<TechItem[]>([])
const [isLoadingFull, setIsLoadingFull] = useState(false)
@@ -59,7 +67,7 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
data = res.map(d => ({ name: d.device, pageviews: d.pageviews, icon: getDeviceIcon(d.device) }))
} else if (activeTab === 'screens') {
const res = await getScreenResolutions(siteId, dateRange.start, dateRange.end, 100)
data = res.map(s => ({ name: s.screen_resolution, pageviews: s.pageviews, icon: <MdMonitor className="text-neutral-500" /> }))
data = res.map(s => ({ name: s.screen_resolution, pageviews: s.pageviews, icon: <Monitor className="text-neutral-500" /> }))
}
setFullData(filterUnknown(data))
} catch (e) {
@@ -83,7 +91,7 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
case 'devices':
return devices.map(d => ({ name: d.device, pageviews: d.pageviews, icon: getDeviceIcon(d.device) }))
case 'screens':
return screenResolutions.map(s => ({ name: s.screen_resolution, pageviews: s.pageviews, icon: <MdMonitor className="text-neutral-500" /> }))
return screenResolutions.map(s => ({ name: s.screen_resolution, pageviews: s.pageviews, icon: <Monitor className="text-neutral-500" /> }))
default:
return []
}
@@ -122,9 +130,20 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
<>
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
Technology
</h3>
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
Technology
</h3>
{showViewAll && (
<button
onClick={() => setIsModalOpen(true)}
className="p-1.5 text-neutral-400 dark:text-neutral-500 hover:text-brand-orange dark:hover:text-brand-orange hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-all cursor-pointer rounded-lg"
aria-label="View all technology"
>
<FrameCornersIcon className="w-4 h-4" weight="bold" />
</button>
)}
</div>
<div className="flex gap-1" role="tablist" aria-label="Technology view tabs" onKeyDown={handleTabKeyDown}>
{(['browsers', 'os', 'devices', 'screens'] as Tab[]).map((tab) => (
<button
@@ -132,13 +151,20 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
onClick={() => setActiveTab(tab)}
role="tab"
aria-selected={activeTab === tab}
className={`px-2.5 py-1 text-xs font-medium transition-colors capitalize focus:outline-none focus:ring-2 focus:ring-brand-orange rounded cursor-pointer border-b-2 ${
className={`relative px-2.5 py-1 text-xs font-medium transition-colors capitalize focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange rounded cursor-pointer ${
activeTab === tab
? 'border-brand-orange text-neutral-900 dark:text-white'
: 'border-transparent text-neutral-400 dark:text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300'
? 'text-neutral-900 dark:text-white'
: 'text-neutral-400 dark:text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300'
}`}
>
{tab}
{activeTab === tab && (
<motion.div
layoutId="techSpecsTab"
className="absolute inset-x-0 -bottom-px h-0.5 bg-brand-orange"
transition={{ type: 'spring', stiffness: 500, damping: 35 }}
/>
)}
</button>
))}
</div>
@@ -162,7 +188,7 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
>
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
{item.icon && <span className="text-lg">{item.icon}</span>}
<span className="truncate">{item.name}</span>
<span className="truncate">{capitalize(item.name)}</span>
</div>
<div className="flex items-center gap-2 ml-4">
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
@@ -175,21 +201,9 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
</div>
)
})}
{showViewAll ? (
<button
onClick={() => setIsModalOpen(true)}
className="flex items-center justify-center gap-1.5 h-9 w-full text-xs font-medium text-neutral-400 dark:text-neutral-500 hover:text-brand-orange dark:hover:text-brand-orange transition-colors cursor-pointer rounded-lg px-2 -mx-2"
>
View all
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</button>
) : (
Array.from({ length: emptySlots }).map((_, i) => (
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
))
)}
{Array.from({ length: emptySlots }).map((_, i) => (
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
))}
</>
) : (
<div className="h-full flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
@@ -209,27 +223,59 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
<Modal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
title={`Technology - ${activeTab.charAt(0).toUpperCase() + activeTab.slice(1)}`}
onClose={() => { setIsModalOpen(false); setModalSearch('') }}
title={activeTab.charAt(0).toUpperCase() + activeTab.slice(1)}
className="max-w-2xl"
>
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-2">
<div>
<input
type="text"
value={modalSearch}
onChange={(e) => setModalSearch(e.target.value)}
placeholder="Search technology..."
className="w-full px-3 py-2 mb-3 text-sm bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg text-neutral-900 dark:text-white placeholder-neutral-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-brand-orange/50"
/>
</div>
<div className="max-h-[80vh]">
{isLoadingFull ? (
<div className="py-4">
<ListSkeleton rows={10} />
</div>
) : (
(fullData.length > 0 ? fullData : data).map((item) => (
<div key={item.name} className="flex items-center justify-between py-2 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors">
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
{item.icon && <span className="text-lg">{item.icon}</span>}
<span className="truncate">{item.name === 'Unknown' ? 'Unknown' : item.name}</span>
</div>
<div className="text-sm font-semibold text-neutral-600 dark:text-neutral-400 ml-4">
{formatNumber(item.pageviews)}
</div>
</div>
))
)}
) : (() => {
const modalData = (fullData.length > 0 ? fullData : data).filter(item => !modalSearch || item.name.toLowerCase().includes(modalSearch.toLowerCase()))
const modalTotal = modalData.reduce((sum, item) => sum + item.pageviews, 0)
const dim = TAB_TO_DIMENSION[activeTab]
return (
<VirtualList
items={modalData}
estimateSize={36}
className="max-h-[80vh] overflow-y-auto pr-2"
renderItem={(item) => {
const canFilter = onFilter && dim
return (
<div
key={item.name}
onClick={() => { if (canFilter) { onFilter({ dimension: dim, operator: 'is', values: [item.name] }); setIsModalOpen(false) } }}
className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 transition-colors${canFilter ? ' cursor-pointer' : ''}`}
>
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
{item.icon && <span className="text-lg">{item.icon}</span>}
<span className="truncate">{capitalize(item.name)}</span>
</div>
<div className="flex items-center gap-2 ml-4">
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
{modalTotal > 0 ? `${Math.round((item.pageviews / modalTotal) * 100)}%` : ''}
</span>
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
{formatNumber(item.pageviews)}
</span>
</div>
</div>
)
}}
/>
)
})()}
</div>
</Modal>
</>

View File

@@ -5,8 +5,10 @@ import { logger } from '@/lib/utils/logger'
import Image from 'next/image'
import { formatNumber } from '@ciphera-net/ui'
import { getReferrerDisplayName, getReferrerFavicon, getReferrerIcon, mergeReferrersByDisplayName } from '@/lib/utils/icons'
import { FrameCornersIcon } from '@phosphor-icons/react'
import { Modal, GlobeIcon } from '@ciphera-net/ui'
import { ListSkeleton } from '@/components/skeletons'
import VirtualList from './VirtualList'
import { getTopReferrers, TopReferrer } from '@/lib/api/stats'
import { type DimensionFilter } from '@/lib/filters'
@@ -22,6 +24,7 @@ const LIMIT = 7
export default function TopReferrers({ referrers, collectReferrers = true, siteId, dateRange, onFilter }: TopReferrersProps) {
const [isModalOpen, setIsModalOpen] = useState(false)
const [modalSearch, setModalSearch] = useState('')
const [fullData, setFullData] = useState<TopReferrer[]>([])
const [isLoadingFull, setIsLoadingFull] = useState(false)
const [faviconFailed, setFaviconFailed] = useState<Set<string>>(new Set())
@@ -85,9 +88,20 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
<>
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
Referrers
</h3>
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
Referrers
</h3>
{showViewAll && (
<button
onClick={() => setIsModalOpen(true)}
className="p-1.5 text-neutral-400 dark:text-neutral-500 hover:text-brand-orange dark:hover:text-brand-orange hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-all cursor-pointer rounded-lg"
aria-label="View all referrers"
>
<FrameCornersIcon className="w-4 h-4" weight="bold" />
</button>
)}
</div>
</div>
<div className="space-y-2 flex-1 min-h-[270px]">
@@ -117,21 +131,9 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
</div>
</div>
))}
{showViewAll ? (
<button
onClick={() => setIsModalOpen(true)}
className="flex items-center justify-center gap-1.5 h-9 w-full text-xs font-medium text-neutral-400 dark:text-neutral-500 hover:text-brand-orange dark:hover:text-brand-orange transition-colors cursor-pointer rounded-lg px-2 -mx-2"
>
View all
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</button>
) : (
Array.from({ length: emptySlots }).map((_, i) => (
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
))
)}
{Array.from({ length: emptySlots }).map((_, i) => (
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
))}
</>
) : (
<div className="h-full flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
@@ -151,27 +153,55 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
<Modal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onClose={() => { setIsModalOpen(false); setModalSearch('') }}
title="Referrers"
className="max-w-2xl"
>
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-2">
<div>
<input
type="text"
value={modalSearch}
onChange={(e) => setModalSearch(e.target.value)}
placeholder="Search referrers..."
className="w-full px-3 py-2 mb-3 text-sm bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg text-neutral-900 dark:text-white placeholder-neutral-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-brand-orange/50"
/>
</div>
<div className="max-h-[80vh]">
{isLoadingFull ? (
<div className="py-4">
<ListSkeleton rows={10} />
</div>
) : (
mergeReferrersByDisplayName(fullData.length > 0 ? fullData : filteredReferrers).map((ref) => (
<div key={ref.referrer} className="flex items-center justify-between py-2 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors">
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
{renderReferrerIcon(ref.referrer)}
<span className="truncate" title={ref.referrer}>{getReferrerDisplayName(ref.referrer)}</span>
</div>
<div className="text-sm font-semibold text-neutral-600 dark:text-neutral-400 ml-4">
{formatNumber(ref.pageviews)}
</div>
</div>
))
)}
) : (() => {
const modalData = mergeReferrersByDisplayName(fullData.length > 0 ? fullData : filteredReferrers).filter(r => !modalSearch || getReferrerDisplayName(r.referrer).toLowerCase().includes(modalSearch.toLowerCase()))
const modalTotal = modalData.reduce((sum, r) => sum + r.pageviews, 0)
return (
<VirtualList
items={modalData}
estimateSize={36}
className="max-h-[80vh] overflow-y-auto pr-2"
renderItem={(ref) => (
<div
key={ref.referrer}
onClick={() => { if (onFilter) { onFilter({ dimension: 'referrer', operator: 'is', values: [ref.referrer] }); setIsModalOpen(false) } }}
className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 transition-colors${onFilter ? ' cursor-pointer' : ''}`}
>
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
{renderReferrerIcon(ref.referrer)}
<span className="truncate" title={ref.referrer}>{getReferrerDisplayName(ref.referrer)}</span>
</div>
<div className="flex items-center gap-2 ml-4">
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
{modalTotal > 0 ? `${Math.round((ref.pageviews / modalTotal) * 100)}%` : ''}
</span>
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
{formatNumber(ref.pageviews)}
</span>
</div>
</div>
)}
/>
)
})()}
</div>
</Modal>
</>

View File

@@ -0,0 +1,53 @@
'use client'
import { useRef } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
interface VirtualListProps<T> {
items: T[]
estimateSize: number
className?: string
renderItem: (item: T, index: number) => React.ReactNode
}
export default function VirtualList<T>({ items, estimateSize, className, renderItem }: VirtualListProps<T>) {
const parentRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => estimateSize,
overscan: 10,
})
// For small lists (< 50 items), render directly without virtualization
if (items.length < 50) {
return (
<div className={className}>
{items.map((item, index) => renderItem(item, index))}
</div>
)
}
return (
<div ref={parentRef} className={className} style={{ overflow: 'auto' }}>
<div style={{ height: `${virtualizer.getTotalSize()}px`, width: '100%', position: 'relative' }}>
{virtualizer.getVirtualItems().map((virtualRow) => (
<div
key={virtualRow.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
{renderItem(items[virtualRow.index], virtualRow.index)}
</div>
))}
</div>
</div>
)
}

View File

@@ -1,110 +0,0 @@
'use client'
import React, { memo, useMemo, useState } from 'react'
import { ComposableMap, Geographies, Geography } from 'react-simple-maps'
import countries from 'i18n-iso-countries'
import enLocale from 'i18n-iso-countries/langs/en.json'
import { useTheme } from '@ciphera-net/ui'
countries.registerLocale(enLocale)
const geoUrl = "https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json"
interface WorldMapProps {
data: Array<{ country: string; pageviews: number }>
}
const WorldMap = ({ data }: WorldMapProps) => {
const { resolvedTheme } = useTheme()
const [tooltipContent, setTooltipContent] = useState<{ content: string; x: number; y: number } | null>(null)
const processedData = useMemo(() => {
const map = new Map<string, number>()
let max = 0
data.forEach(item => {
if (item.country === 'Unknown') return
// API returns 2 letter code. Convert to numeric (3 digits string)
const numericCode = countries.alpha2ToNumeric(item.country)
if (numericCode) {
map.set(numericCode, item.pageviews)
if (item.pageviews > max) max = item.pageviews
}
})
return { map, max }
}, [data])
// Plausible-like colors based on provided SVG snippet
const isDark = resolvedTheme === 'dark'
const defaultFill = isDark ? "var(--color-neutral-800)" : "var(--color-neutral-100)"
const defaultStroke = isDark ? "var(--color-neutral-900)" : "#ffffff"
const brandOrange = "var(--color-brand-orange)"
return (
<div className="relative w-full">
<ComposableMap
width={800}
height={400}
projectionConfig={{ rotate: [-10, 0, 0], scale: 170, center: [0, 10] }}
className="w-full h-auto"
>
<Geographies geography={geoUrl}>
{({ geographies }) =>
geographies
.filter(geo => geo.id !== "010") // Remove Antarctica
.map((geo) => {
const id = String(geo.id).padStart(3, '0')
const count = processedData.map.get(id) || 0
const fillColor = count > 0 ? brandOrange : defaultFill
return (
<Geography
key={geo.rsmKey}
geography={geo}
fill={fillColor}
stroke={defaultStroke}
strokeWidth={0.5}
style={{
default: { outline: "none", transition: "all 250ms" },
hover: {
fill: fillColor,
stroke: brandOrange,
strokeWidth: 2,
outline: "none",
cursor: 'pointer',
zIndex: 100 // Bring border to front
},
pressed: { outline: "none" },
}}
onMouseEnter={(evt) => {
const { name } = geo.properties
setTooltipContent({
content: `${name}: ${count} visitors`,
x: evt.clientX,
y: evt.clientY
})
}}
onMouseLeave={() => {
setTooltipContent(null)
}}
onMouseMove={(evt) => {
setTooltipContent(prev => prev ? { ...prev, x: evt.clientX, y: evt.clientY } : null)
}}
/>
)
})
}
</Geographies>
</ComposableMap>
{tooltipContent && (
<div
className="fixed z-50 px-2 py-1 text-xs font-medium text-white bg-black/80 backdrop-blur-sm rounded shadow pointer-events-none transform -translate-x-1/2 -translate-y-full -mt-2.5"
style={{ left: tooltipContent.x, top: tooltipContent.y }}
>
{tooltipContent.content}
</div>
)}
</div>
)
}
export default memo(WorldMap)

View File

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

View File

@@ -0,0 +1,87 @@
'use client'
import type { TopPath } from '@/lib/api/journeys'
import { TableSkeleton } from '@/components/skeletons'
import { Path } from '@phosphor-icons/react'
interface TopPathsTableProps {
paths: TopPath[]
loading: boolean
}
function formatDuration(seconds: number): string {
if (seconds <= 0) return '0s'
const m = Math.floor(seconds / 60)
const s = Math.round(seconds % 60)
if (m === 0) return `${s}s`
return `${m}m ${s}s`
}
export default function TopPathsTable({ paths, loading }: TopPathsTableProps) {
const hasData = paths.length > 0
return (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
<div className="flex items-center justify-between mb-1">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
Top Paths
</h3>
</div>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-4">
Most common navigation paths across sessions
</p>
{loading ? (
<TableSkeleton rows={7} cols={4} />
) : hasData ? (
<div>
{/* Header */}
<div className="flex items-center px-2 -mx-2 mb-2 text-xs font-medium text-neutral-400 dark:text-neutral-500 uppercase tracking-wider">
<span className="w-8 text-right shrink-0">#</span>
<span className="flex-1 ml-3">Path</span>
<span className="w-20 text-right shrink-0">Sessions</span>
<span className="w-16 text-right shrink-0">Dur.</span>
</div>
{/* Rows */}
<div className="space-y-0.5">
{paths.map((path, i) => (
<div
key={i}
className="flex items-center h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800/50 rounded-lg px-2 -mx-2 transition-colors"
>
<span className="w-8 text-right shrink-0 text-sm tabular-nums text-neutral-400">
{i + 1}
</span>
<span
className="flex-1 ml-3 text-sm text-neutral-900 dark:text-white truncate"
title={path.page_sequence.join(' → ')}
>
{path.page_sequence.join(' → ')}
</span>
<span className="w-20 text-right shrink-0 text-sm tabular-nums text-neutral-600 dark:text-neutral-400">
{path.session_count.toLocaleString()}
</span>
<span className="w-16 text-right shrink-0 text-sm tabular-nums text-neutral-600 dark:text-neutral-400">
{formatDuration(path.avg_duration)}
</span>
</div>
))}
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
<Path className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
</div>
<h4 className="font-semibold text-neutral-900 dark:text-white">
No path data yet
</h4>
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs">
Common navigation paths will appear here as visitors browse your site.
</p>
</div>
)}
</div>
)
}

View File

@@ -515,7 +515,7 @@ export default function OrganizationSettings() {
onClick={() => handleTabChange('general')}
role="tab"
aria-selected={activeTab === 'general'}
className={`w-full flex items-center gap-3 px-4 py-3 text-sm font-medium rounded-xl transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-offset-2 ${
className={`w-full flex items-center gap-3 px-4 py-3 text-sm font-medium rounded-xl transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:ring-offset-2 ${
activeTab === 'general'
? 'bg-brand-orange/10 text-brand-orange'
: 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800'
@@ -528,7 +528,7 @@ export default function OrganizationSettings() {
onClick={() => handleTabChange('members')}
role="tab"
aria-selected={activeTab === 'members'}
className={`w-full flex items-center gap-3 px-4 py-3 text-sm font-medium rounded-xl transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-offset-2 ${
className={`w-full flex items-center gap-3 px-4 py-3 text-sm font-medium rounded-xl transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:ring-offset-2 ${
activeTab === 'members'
? 'bg-brand-orange/10 text-brand-orange'
: 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800'
@@ -541,7 +541,7 @@ export default function OrganizationSettings() {
onClick={() => handleTabChange('billing')}
role="tab"
aria-selected={activeTab === 'billing'}
className={`w-full flex items-center gap-3 px-4 py-3 text-sm font-medium rounded-xl transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-offset-2 ${
className={`w-full flex items-center gap-3 px-4 py-3 text-sm font-medium rounded-xl transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:ring-offset-2 ${
activeTab === 'billing'
? 'bg-brand-orange/10 text-brand-orange'
: 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800'
@@ -555,7 +555,7 @@ export default function OrganizationSettings() {
onClick={() => handleTabChange('notifications')}
role="tab"
aria-selected={activeTab === 'notifications'}
className={`w-full flex items-center gap-3 px-4 py-3 text-sm font-medium rounded-xl transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-offset-2 ${
className={`w-full flex items-center gap-3 px-4 py-3 text-sm font-medium rounded-xl transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:ring-offset-2 ${
activeTab === 'notifications'
? 'bg-brand-orange/10 text-brand-orange'
: 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800'
@@ -569,7 +569,7 @@ export default function OrganizationSettings() {
onClick={() => handleTabChange('audit')}
role="tab"
aria-selected={activeTab === 'audit'}
className={`w-full flex items-center gap-3 px-4 py-3 text-sm font-medium rounded-xl transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-offset-2 ${
className={`w-full flex items-center gap-3 px-4 py-3 text-sm font-medium rounded-xl transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:ring-offset-2 ${
activeTab === 'audit'
? 'bg-brand-orange/10 text-brand-orange'
: 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800'
@@ -733,6 +733,7 @@ export default function OrganizationSettings() {
setCaptchaToken(token || '')
}}
apiUrl={process.env.NEXT_PUBLIC_CAPTCHA_API_URL}
action="org-settings"
/>
</div>
</div>
@@ -1026,7 +1027,7 @@ export default function OrganizationSettings() {
type="button"
onClick={handleManageSubscription}
disabled={isRedirectingToPortal}
className="inline-flex items-center gap-2 text-sm text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white transition-colors disabled:opacity-50 focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded"
className="inline-flex items-center gap-2 text-sm text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:rounded"
>
<ExternalLinkIcon className="w-4 h-4" />
Payment method & invoices
@@ -1036,7 +1037,7 @@ export default function OrganizationSettings() {
<button
type="button"
onClick={() => setShowCancelPrompt(true)}
className="inline-flex items-center gap-2 rounded-xl border border-neutral-200 dark:border-neutral-700 px-3.5 py-1.5 text-sm text-neutral-600 dark:text-neutral-400 hover:border-red-300 hover:text-red-600 dark:hover:border-red-800 dark:hover:text-red-400 transition-colors focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
className="inline-flex items-center gap-2 rounded-xl border border-neutral-200 dark:border-neutral-700 px-3.5 py-1.5 text-sm text-neutral-600 dark:text-neutral-400 hover:border-red-300 hover:text-red-600 dark:hover:border-red-800 dark:hover:text-red-400 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2"
>
Cancel subscription
</button>
@@ -1077,14 +1078,14 @@ export default function OrganizationSettings() {
</span>
{invoice.invoice_pdf && (
<a href={invoice.invoice_pdf} target="_blank" rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-2.5 py-1.5 text-xs font-medium text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange" title="Download PDF">
className="inline-flex items-center gap-2 px-2.5 py-1.5 text-xs font-medium text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange" title="Download PDF">
<DownloadIcon className="w-3.5 h-3.5" />
Download PDF
</a>
)}
{invoice.hosted_invoice_url && (
<a href={invoice.hosted_invoice_url} target="_blank" rel="noopener noreferrer"
className={`inline-flex items-center gap-2 px-2.5 py-1.5 text-xs font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange ${
className={`inline-flex items-center gap-2 px-2.5 py-1.5 text-xs font-medium rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange ${
invoice.status === 'open'
? 'bg-brand-orange text-white hover:bg-brand-orange-hover'
: 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800'
@@ -1153,7 +1154,7 @@ export default function OrganizationSettings() {
.finally(() => setIsSavingNotificationSettings(false))
}}
disabled={isSavingNotificationSettings}
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed ${
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed ${
notificationSettings[cat.id] !== false ? 'bg-brand-orange' : 'bg-neutral-200 dark:bg-neutral-700'
}`}
>
@@ -1461,7 +1462,7 @@ export default function OrganizationSettings() {
<button
type="button"
onClick={() => setShowChangePlanModal(false)}
className="text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-400 focus:outline-none focus:ring-2 focus:ring-brand-orange rounded-lg p-1"
className="text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange rounded-lg p-1"
disabled={isChangingPlan}
aria-label="Close dialog"
>
@@ -1487,7 +1488,7 @@ export default function OrganizationSettings() {
key={plan.id}
type="button"
onClick={() => setChangePlanId(plan.id)}
className={`relative p-3 rounded-xl border text-left transition-all focus:outline-none focus:ring-2 focus:ring-brand-orange ${
className={`relative p-3 rounded-xl border text-left transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange ${
isSelected
? 'border-brand-orange bg-brand-orange/5 dark:bg-brand-orange/10'
: 'border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600'
@@ -1527,14 +1528,14 @@ export default function OrganizationSettings() {
<button
type="button"
onClick={() => setChangePlanYearly(false)}
className={`flex-1 py-2 text-sm font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange ${!changePlanYearly ? 'bg-brand-orange text-white' : 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800'}`}
className={`flex-1 py-2 text-sm font-medium rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange ${!changePlanYearly ? 'bg-brand-orange text-white' : 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800'}`}
>
Monthly
</button>
<button
type="button"
onClick={() => setChangePlanYearly(true)}
className={`flex-1 py-2 text-sm font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange ${changePlanYearly ? 'bg-brand-orange text-white' : 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800'}`}
className={`flex-1 py-2 text-sm font-medium rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange ${changePlanYearly ? 'bg-brand-orange text-white' : 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800'}`}
>
Yearly
</button>

View File

@@ -99,7 +99,7 @@ function SiteCard({ site, stats, statsLoading, onDelete, canDelete }: SiteCardPr
<button
type="button"
onClick={() => onDelete(site.id)}
className="flex items-center justify-center rounded-lg border border-neutral-200 px-3 hover:bg-neutral-50 dark:border-neutral-700 dark:hover:bg-neutral-800 text-neutral-500 hover:text-red-600 dark:hover:text-red-400 transition-colors focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
className="flex items-center justify-center rounded-lg border border-neutral-200 px-3 hover:bg-neutral-50 dark:border-neutral-700 dark:hover:bg-neutral-800 text-neutral-500 hover:text-red-600 dark:hover:text-red-400 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2"
title="Delete Site"
>
<SettingsIcon className="h-4 w-4" />

View File

@@ -122,7 +122,7 @@ export function ChartSkeleton() {
export function DashboardSkeleton() {
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
{/* Header */}
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
@@ -166,74 +166,31 @@ export function DashboardSkeleton() {
)
}
// ─── Realtime page skeleton ─────────────────────────────────
// ─── Journeys page skeleton ─────────────────────────────────
export function RealtimeSkeleton() {
export function JourneysSkeleton() {
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8 h-[calc(100vh-64px)] flex flex-col">
<div className="mb-6">
<SkeletonLine className="h-4 w-32 mb-2" />
<SkeletonLine className="h-8 w-64" />
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
{/* Header */}
<div className="mb-8 flex items-center justify-between">
<div>
<SkeletonLine className="h-8 w-32 mb-2" />
<SkeletonLine className="h-4 w-64" />
</div>
<SkeletonLine className="h-9 w-36 rounded-lg" />
</div>
<div className="flex flex-col md:flex-row flex-1 gap-6 min-h-0">
{/* Visitors list */}
<div className="w-full md:w-1/3 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden flex flex-col bg-white dark:bg-neutral-900">
<div className="p-4 border-b border-neutral-200 dark:border-neutral-800">
<SkeletonLine className="h-6 w-32" />
</div>
<div className="p-2 space-y-1">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="p-4 space-y-2">
<div className="flex justify-between">
<SkeletonLine className="h-4 w-32" />
<SkeletonLine className="h-4 w-16" />
</div>
<SkeletonLine className="h-3 w-48" />
<div className="flex gap-2">
<SkeletonLine className="h-3 w-16" />
<SkeletonLine className="h-3 w-16" />
<SkeletonLine className="h-3 w-16" />
</div>
</div>
))}
</div>
</div>
{/* Session details */}
<div className="flex-1 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden bg-white dark:bg-neutral-900">
<div className="p-4 border-b border-neutral-200 dark:border-neutral-800">
<SkeletonLine className="h-6 w-40" />
</div>
<div className="p-6 space-y-6">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex gap-4 pl-6">
<SkeletonCircle className="h-3 w-3 shrink-0 mt-1" />
<div className="space-y-1 flex-1">
<SkeletonLine className="h-4 w-48" />
<SkeletonLine className="h-3 w-32" />
</div>
</div>
))}
</div>
</div>
{/* Controls */}
<div className="flex items-center gap-4 mb-6">
<SkeletonLine className="h-9 w-48 rounded-lg" />
<SkeletonLine className="h-9 w-48 rounded-lg" />
</div>
{/* Sankey area */}
<SkeletonCard className="h-[400px] mb-6" />
{/* Top paths table */}
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
<SkeletonLine className="h-6 w-24 mb-4" />
<TableSkeleton rows={5} cols={4} />
</div>
</div>
)
}
// ─── Session events skeleton (for loading events panel) ──────
export function SessionEventsSkeleton() {
return (
<div className="relative pl-6 border-l-2 border-neutral-100 dark:border-neutral-800 space-y-8">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="relative">
<span className={`absolute -left-[29px] top-1 h-3 w-3 rounded-full ${SK}`} />
<div className="space-y-1">
<SkeletonLine className="h-4 w-48" />
<SkeletonLine className="h-3 w-32" />
</div>
</div>
))}
</div>
)
}
@@ -242,7 +199,7 @@ export function SessionEventsSkeleton() {
export function UptimeSkeleton() {
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
<div className="mb-8">
<SkeletonLine className="h-4 w-32 mb-2" />
<SkeletonLine className="h-8 w-24 mb-1" />
@@ -295,7 +252,7 @@ export function ChecksSkeleton() {
export function FunnelsListSkeleton() {
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
<div className="mb-8">
<div className="flex items-center gap-4 mb-6">
<SkeletonLine className="h-10 w-10 rounded-xl" />
@@ -329,7 +286,7 @@ export function FunnelsListSkeleton() {
export function FunnelDetailSkeleton() {
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
<div className="mb-8">
<SkeletonLine className="h-4 w-32 mb-2" />
<SkeletonLine className="h-8 w-48 mb-1" />

View File

@@ -2,7 +2,7 @@
import { useState, useEffect } from 'react'
import { logger } from '@/lib/utils/logger'
import { CopyIcon, CheckIcon } from '@radix-ui/react-icons'
import { Copy, Check } from '@phosphor-icons/react'
import { listSites, Site } from '@/lib/api/sites'
import { Select, Input, Button } from '@ciphera-net/ui'
@@ -205,7 +205,7 @@ export default function UtmBuilder({ initialSiteId }: UtmBuilderProps) {
className="ml-4 shrink-0 h-9 w-9 p-0 rounded-lg"
title="Copy to clipboard"
>
{copied ? <CheckIcon className="w-4 h-4 text-green-500" /> : <CopyIcon className="w-4 h-4" />}
{copied ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
</Button>
</div>
)}

67
components/ui/avatar.tsx Normal file
View File

@@ -0,0 +1,67 @@
'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-600 dark:bg-zinc-300',
busy: 'bg-yellow-600',
away: 'bg-blue-600',
},
},
defaultVariants: {
variant: 'online',
},
});
function Avatar({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root data-slot="avatar" className={cn('relative flex shrink-0 size-10', className)} {...props} />
);
}
function AvatarImage({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<div className={cn('relative overflow-hidden rounded-full', className)}>
<AvatarPrimitive.Image data-slot="avatar-image" className={cn('aspect-square h-full w-full')} {...props} />
</div>
);
}
function AvatarFallback({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
'flex h-full w-full items-center justify-center rounded-full border border-border bg-accent text-accent-foreground text-xs',
className,
)}
{...props}
/>
);
}
function AvatarIndicator({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
data-slot="avatar-indicator"
className={cn('absolute flex size-6 items-center justify-center', className)}
{...props}
/>
);
}
function AvatarStatus({
className,
variant,
...props
}: React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof avatarStatusVariants>) {
return <div data-slot="avatar-status" className={cn(avatarStatusVariants({ variant }), className)} {...props} />;
}
export { Avatar, AvatarFallback, AvatarImage, AvatarIndicator, AvatarStatus, avatarStatusVariants };

230
components/ui/badge-2.tsx Normal file
View File

@@ -0,0 +1,230 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
import { cva, type VariantProps } from 'class-variance-authority';
import { Slot as SlotPrimitive } from 'radix-ui';
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {
asChild?: boolean;
dotClassName?: string;
disabled?: boolean;
}
export interface BadgeButtonProps
extends React.ButtonHTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeButtonVariants> {
asChild?: boolean;
}
export type BadgeDotProps = React.HTMLAttributes<HTMLSpanElement>;
const badgeVariants = cva(
'inline-flex items-center justify-center border border-transparent font-medium focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 [&_svg]:-ms-px [&_svg]:shrink-0',
{
variants: {
variant: {
primary: 'bg-primary text-primary-foreground',
secondary: 'bg-secondary text-secondary-foreground',
success:
'bg-[var(--color-success-accent,#22c55e)] text-[var(--color-success-foreground,#ffffff)]',
warning:
'bg-[var(--color-warning-accent,#eab308)] text-[var(--color-warning-foreground,#ffffff)]',
info: 'bg-[var(--color-info-accent,#8b5cf6)] text-[var(--color-info-foreground,#ffffff)]',
outline: 'bg-transparent border border-border text-secondary-foreground',
destructive: 'bg-destructive text-destructive-foreground',
},
appearance: {
default: '',
light: '',
outline: '',
ghost: 'border-transparent bg-transparent',
},
disabled: {
true: 'opacity-50 pointer-events-none',
},
size: {
lg: 'rounded-md px-[0.5rem] h-7 min-w-7 gap-1.5 text-xs [&_svg]:size-3.5',
md: 'rounded-md px-[0.45rem] h-6 min-w-6 gap-1.5 text-xs [&_svg]:size-3.5 ',
sm: 'rounded-sm px-[0.325rem] h-5 min-w-5 gap-1 text-[0.6875rem] leading-[0.75rem] [&_svg]:size-3',
xs: 'rounded-sm px-[0.25rem] h-4 min-w-4 gap-1 text-[0.625rem] leading-[0.5rem] [&_svg]:size-3',
},
shape: {
default: '',
circle: 'rounded-full',
},
},
compoundVariants: [
/* Light */
{
variant: 'primary',
appearance: 'light',
className:
'text-blue-700 bg-blue-50 dark:bg-blue-950 dark:text-blue-600',
},
{
variant: 'secondary',
appearance: 'light',
className: 'bg-secondary dark:bg-secondary/50 text-secondary-foreground',
},
{
variant: 'success',
appearance: 'light',
className:
'text-green-800 bg-green-100 dark:bg-green-950 dark:text-green-600',
},
{
variant: 'warning',
appearance: 'light',
className:
'text-yellow-700 bg-yellow-100 dark:bg-yellow-950 dark:text-yellow-600',
},
{
variant: 'info',
appearance: 'light',
className:
'text-violet-700 bg-violet-100 dark:bg-violet-950 dark:text-violet-400',
},
{
variant: 'destructive',
appearance: 'light',
className:
'text-red-700 bg-red-50 dark:bg-red-950 dark:text-red-600',
},
/* Outline */
{
variant: 'primary',
appearance: 'outline',
className:
'text-blue-700 border-blue-100 bg-blue-50 dark:bg-blue-950 dark:border-blue-900 dark:text-blue-600',
},
{
variant: 'success',
appearance: 'outline',
className:
'text-[#10B981] border-[#10B981]/20 bg-[#10B981]/10',
},
{
variant: 'warning',
appearance: 'outline',
className:
'text-yellow-700 border-yellow-200 bg-yellow-50 dark:bg-yellow-950 dark:border-yellow-900 dark:text-yellow-600',
},
{
variant: 'info',
appearance: 'outline',
className:
'text-violet-700 border-violet-100 bg-violet-50 dark:bg-violet-950 dark:border-violet-900 dark:text-violet-400',
},
{
variant: 'destructive',
appearance: 'outline',
className:
'text-[#EF4444] border-[#EF4444]/20 bg-[#EF4444]/10',
},
/* Ghost */
{
variant: 'primary',
appearance: 'ghost',
className: 'text-primary',
},
{
variant: 'secondary',
appearance: 'ghost',
className: 'text-secondary-foreground',
},
{
variant: 'success',
appearance: 'ghost',
className: 'text-green-500',
},
{
variant: 'warning',
appearance: 'ghost',
className: 'text-yellow-500',
},
{
variant: 'info',
appearance: 'ghost',
className: 'text-violet-500',
},
{
variant: 'destructive',
appearance: 'ghost',
className: 'text-destructive',
},
{ size: 'lg', appearance: 'ghost', className: 'px-0' },
{ size: 'md', appearance: 'ghost', className: 'px-0' },
{ size: 'sm', appearance: 'ghost', className: 'px-0' },
{ size: 'xs', appearance: 'ghost', className: 'px-0' },
],
defaultVariants: {
variant: 'primary',
appearance: 'default',
size: 'md',
},
},
);
const badgeButtonVariants = cva(
'cursor-pointer transition-all inline-flex items-center justify-center leading-none size-3.5 [&>svg]:opacity-100! [&>svg]:size-3.5 p-0 rounded-md -me-0.5 opacity-60 hover:opacity-100',
{
variants: {
variant: {
default: '',
},
},
defaultVariants: {
variant: 'default',
},
},
);
function Badge({
className,
variant,
size,
appearance,
shape,
asChild = false,
disabled,
...props
}: React.ComponentProps<'span'> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? SlotPrimitive.Slot : 'span';
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant, size, appearance, shape, disabled }), className)}
{...props}
/>
);
}
function BadgeButton({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<'button'> & VariantProps<typeof badgeButtonVariants> & { asChild?: boolean }) {
const Comp = asChild ? SlotPrimitive.Slot : 'span';
return (
<Comp
data-slot="badge-button"
className={cn(badgeButtonVariants({ variant, className }))}
role="button"
{...props}
/>
);
}
function BadgeDot({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot="badge-dot"
className={cn('size-1.5 rounded-full bg-[currentColor] opacity-75', className)}
{...props}
/>
);
}
export { Badge, BadgeButton, BadgeDot, badgeVariants };

412
components/ui/button-1.tsx Normal file
View File

@@ -0,0 +1,412 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { CaretDown } from '@phosphor-icons/react';
import { Slot as SlotPrimitive } from 'radix-ui';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'cursor-pointer group whitespace-nowrap focus-visible:outline-hidden inline-flex items-center justify-center has-data-[arrow=true]:justify-between whitespace-nowrap text-sm font-medium ring-offset-background transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-60 [&_svg]:shrink-0',
{
variants: {
variant: {
primary: 'bg-primary text-primary-foreground hover:bg-primary/90 data-[state=open]:bg-primary/90',
mono: 'bg-zinc-950 text-white dark:bg-zinc-300 dark:text-black hover:bg-zinc-950/90 dark:hover:bg-zinc-300/90 data-[state=open]:bg-zinc-950/90 dark:data-[state=open]:bg-zinc-300/90',
destructive:
'bg-destructive text-destructive-foreground hover:bg-destructive/90 data-[state=open]:bg-destructive/90',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/90 data-[state=open]:bg-secondary/90',
outline: 'bg-background text-accent-foreground border border-input hover:bg-accent data-[state=open]:bg-accent',
dashed:
'text-accent-foreground border border-input border-dashed bg-background hover:bg-accent hover:text-accent-foreground data-[state=open]:text-accent-foreground',
ghost:
'text-accent-foreground hover:bg-accent hover:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground',
dim: 'text-muted-foreground hover:text-foreground data-[state=open]:text-foreground',
foreground: '',
inverse: '',
},
appearance: {
default: '',
ghost: '',
},
underline: {
solid: '',
dashed: '',
},
underlined: {
solid: '',
dashed: '',
},
size: {
lg: 'h-10 rounded-md px-4 text-sm gap-1.5 [&_svg:not([class*=size-])]:size-4',
md: 'h-8.5 rounded-md px-3 gap-1.5 text-[0.8125rem] leading-[--text-sm--line-height] [&_svg:not([class*=size-])]:size-4',
sm: 'h-7 rounded-md px-2.5 gap-1.25 text-xs [&_svg:not([class*=size-])]:size-3.5',
icon: 'size-8.5 rounded-md [&_svg:not([class*=size-])]:size-4 shrink-0',
},
autoHeight: {
true: '',
false: '',
},
shape: {
default: '',
circle: 'rounded-full',
},
mode: {
default: 'focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
icon: 'focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
link: 'text-primary h-auto p-0 bg-transparent rounded-none hover:bg-transparent data-[state=open]:bg-transparent',
input: `
justify-start font-normal hover:bg-background [&_svg]:transition-colors [&_svg]:hover:text-foreground data-[state=open]:bg-background
focus-visible:border-ring focus-visible:outline-hidden focus-visible:ring-[3px] focus-visible:ring-ring/30
[[data-state=open]>&]:border-ring [[data-state=open]>&]:outline-hidden [[data-state=open]>&]:ring-[3px]
[[data-state=open]>&]:ring-ring/30
aria-invalid:border-destructive/60 aria-invalid:ring-destructive/10 dark:aria-invalid:border-destructive dark:aria-invalid:ring-destructive/20
in-data-[invalid=true]:border-destructive/60 in-data-[invalid=true]:ring-destructive/10 dark:in-data-[invalid=true]:border-destructive dark:in-data-[invalid=true]:ring-destructive/20
`,
},
placeholder: {
true: 'text-muted-foreground',
false: '',
},
},
compoundVariants: [
// Icons opacity for default mode
{
variant: 'ghost',
mode: 'default',
className: '[&_svg:not([role=img]):not([class*=text-]):not([class*=opacity-])]:opacity-60',
},
{
variant: 'outline',
mode: 'default',
className: '[&_svg:not([role=img]):not([class*=text-]):not([class*=opacity-])]:opacity-60',
},
{
variant: 'dashed',
mode: 'default',
className: '[&_svg:not([role=img]):not([class*=text-]):not([class*=opacity-])]:opacity-60',
},
{
variant: 'secondary',
mode: 'default',
className: '[&_svg:not([role=img]):not([class*=text-]):not([class*=opacity-])]:opacity-60',
},
// Icons opacity for default mode
{
variant: 'outline',
mode: 'input',
className: '[&_svg:not([role=img]):not([class*=text-]):not([class*=opacity-])]:opacity-60',
},
{
variant: 'outline',
mode: 'icon',
className: '[&_svg:not([role=img]):not([class*=text-]):not([class*=opacity-])]:opacity-60',
},
// Auto height
{
size: 'md',
autoHeight: true,
className: 'h-auto min-h-8.5',
},
{
size: 'sm',
autoHeight: true,
className: 'h-auto min-h-7',
},
{
size: 'lg',
autoHeight: true,
className: 'h-auto min-h-10',
},
// Shadow support
{
variant: 'primary',
mode: 'default',
appearance: 'default',
className: 'shadow-xs shadow-black/5',
},
{
variant: 'mono',
mode: 'default',
appearance: 'default',
className: 'shadow-xs shadow-black/5',
},
{
variant: 'secondary',
mode: 'default',
appearance: 'default',
className: 'shadow-xs shadow-black/5',
},
{
variant: 'outline',
mode: 'default',
appearance: 'default',
className: 'shadow-xs shadow-black/5',
},
{
variant: 'dashed',
mode: 'default',
appearance: 'default',
className: 'shadow-xs shadow-black/5',
},
{
variant: 'destructive',
mode: 'default',
appearance: 'default',
className: 'shadow-xs shadow-black/5',
},
// Shadow support
{
variant: 'primary',
mode: 'icon',
appearance: 'default',
className: 'shadow-xs shadow-black/5',
},
{
variant: 'mono',
mode: 'icon',
appearance: 'default',
className: 'shadow-xs shadow-black/5',
},
{
variant: 'secondary',
mode: 'icon',
appearance: 'default',
className: 'shadow-xs shadow-black/5',
},
{
variant: 'outline',
mode: 'icon',
appearance: 'default',
className: 'shadow-xs shadow-black/5',
},
{
variant: 'dashed',
mode: 'icon',
appearance: 'default',
className: 'shadow-xs shadow-black/5',
},
{
variant: 'destructive',
mode: 'icon',
appearance: 'default',
className: 'shadow-xs shadow-black/5',
},
// Link
{
variant: 'primary',
mode: 'link',
underline: 'solid',
className:
'font-medium text-primary hover:text-primary/90 [&_svg:not([role=img]):not([class*=text-])]:opacity-60 hover:underline hover:underline-offset-4 hover:decoration-solid',
},
{
variant: 'primary',
mode: 'link',
underline: 'dashed',
className:
'font-medium text-primary hover:text-primary/90 [&_svg:not([role=img]):not([class*=text-])]:opacity-60 hover:underline hover:underline-offset-4 hover:decoration-dashed decoration-1',
},
{
variant: 'primary',
mode: 'link',
underlined: 'solid',
className:
'font-medium text-primary hover:text-primary/90 [&_svg:not([role=img]):not([class*=text-])]:opacity-60 underline underline-offset-4 decoration-solid',
},
{
variant: 'primary',
mode: 'link',
underlined: 'dashed',
className:
'font-medium text-primary hover:text-primary/90 [&_svg]:opacity-60 underline underline-offset-4 decoration-dashed decoration-1',
},
{
variant: 'inverse',
mode: 'link',
underline: 'solid',
className:
'font-medium text-inherit [&_svg:not([role=img]):not([class*=text-])]:opacity-60 hover:underline hover:underline-offset-4 hover:decoration-solid',
},
{
variant: 'inverse',
mode: 'link',
underline: 'dashed',
className:
'font-medium text-inherit [&_svg:not([role=img]):not([class*=text-])]:opacity-60 hover:underline hover:underline-offset-4 hover:decoration-dashed decoration-1',
},
{
variant: 'inverse',
mode: 'link',
underlined: 'solid',
className:
'font-medium text-inherit [&_svg:not([role=img]):not([class*=text-])]:opacity-60 underline underline-offset-4 decoration-solid',
},
{
variant: 'inverse',
mode: 'link',
underlined: 'dashed',
className:
'font-medium text-inherit [&_svg:not([role=img]):not([class*=text-])]:opacity-60 underline underline-offset-4 decoration-dashed decoration-1',
},
{
variant: 'foreground',
mode: 'link',
underline: 'solid',
className:
'font-medium text-foreground [&_svg:not([role=img]):not([class*=text-])]:opacity-60 hover:underline hover:underline-offset-4 hover:decoration-solid',
},
{
variant: 'foreground',
mode: 'link',
underline: 'dashed',
className:
'font-medium text-foreground [&_svg:not([role=img]):not([class*=text-])]:opacity-60 hover:underline hover:underline-offset-4 hover:decoration-dashed decoration-1',
},
{
variant: 'foreground',
mode: 'link',
underlined: 'solid',
className:
'font-medium text-foreground [&_svg:not([role=img]):not([class*=text-])]:opacity-60 underline underline-offset-4 decoration-solid',
},
{
variant: 'foreground',
mode: 'link',
underlined: 'dashed',
className:
'font-medium text-foreground [&_svg:not([role=img]):not([class*=text-])]:opacity-60 underline underline-offset-4 decoration-dashed decoration-1',
},
// Ghost
{
variant: 'primary',
appearance: 'ghost',
className: 'bg-transparent text-primary/90 hover:bg-primary/5 data-[state=open]:bg-primary/5',
},
{
variant: 'destructive',
appearance: 'ghost',
className: 'bg-transparent text-destructive/90 hover:bg-destructive/5 data-[state=open]:bg-destructive/5',
},
{
variant: 'ghost',
mode: 'icon',
className: 'text-muted-foreground',
},
// Size
{
size: 'sm',
mode: 'icon',
className: 'w-7 h-7 p-0 [&_svg:not([class*=size-])]:size-3.5',
},
{
size: 'md',
mode: 'icon',
className: 'w-8.5 h-8.5 p-0 [&_svg:not([class*=size-])]:size-4',
},
{
size: 'icon',
className: 'w-8.5 h-8.5 p-0 [&_svg:not([class*=size-])]:size-4',
},
{
size: 'lg',
mode: 'icon',
className: 'w-10 h-10 p-0 [&_svg:not([class*=size-])]:size-4',
},
// Input mode
{
mode: 'input',
placeholder: true,
variant: 'outline',
className: 'font-normal text-muted-foreground',
},
{
mode: 'input',
variant: 'outline',
size: 'sm',
className: 'gap-1.25',
},
{
mode: 'input',
variant: 'outline',
size: 'md',
className: 'gap-1.5',
},
{
mode: 'input',
variant: 'outline',
size: 'lg',
className: 'gap-1.5',
},
],
defaultVariants: {
variant: 'primary',
mode: 'default',
size: 'md',
shape: 'default',
appearance: 'default',
},
},
);
function Button({
className,
selected,
variant,
shape,
appearance,
mode,
size,
autoHeight,
underlined,
underline,
asChild = false,
placeholder = false,
...props
}: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
selected?: boolean;
asChild?: boolean;
}) {
const Comp = asChild ? SlotPrimitive.Slot : 'button';
return (
<Comp
data-slot="button"
className={cn(
buttonVariants({
variant,
size,
shape,
appearance,
mode,
autoHeight,
placeholder,
underlined,
underline,
className,
}),
asChild && props.disabled && 'pointer-events-none opacity-50',
)}
{...(selected && { 'data-state': 'open' })}
{...props}
/>
);
}
interface ButtonArrowProps extends React.SVGProps<SVGSVGElement> {
icon?: React.ComponentType<{ className?: string }>;
}
function ButtonArrow({ icon: Icon = CaretDown, className, ...props }: ButtonArrowProps) {
return <Icon data-slot="button-arrow" className={cn('ms-auto -me-1', className)} {...(props as Record<string, unknown>)} />;
}
export { Button, ButtonArrow, buttonVariants };

147
components/ui/card.tsx Normal file
View File

@@ -0,0 +1,147 @@
'use client';
import * as React from 'react';
import { cn } from '@/lib/utils';
import { cva, type VariantProps } from 'class-variance-authority';
// Define CardContext
type CardContextType = {
variant: 'default' | 'accent';
};
const CardContext = React.createContext<CardContextType>({
variant: 'default', // Default value
});
// Hook to use CardContext
const useCardContext = () => {
const context = React.useContext(CardContext);
if (!context) {
throw new Error('useCardContext must be used within a Card component');
}
return context;
};
// Variants
const cardVariants = cva('flex flex-col items-stretch text-card-foreground rounded-xl', {
variants: {
variant: {
default: 'bg-card border border-border shadow-xs black/5',
accent: 'bg-muted shadow-xs p-1',
},
},
defaultVariants: {
variant: 'default',
},
});
const cardHeaderVariants = cva('flex items-center justify-between flex-wrap px-5 min-h-14 gap-2.5', {
variants: {
variant: {
default: 'border-b border-border',
accent: '',
},
},
defaultVariants: {
variant: 'default',
},
});
const cardContentVariants = cva('grow p-5', {
variants: {
variant: {
default: '',
accent: 'bg-card rounded-t-xl [&:last-child]:rounded-b-xl',
},
},
defaultVariants: {
variant: 'default',
},
});
const cardTableVariants = cva('grid grow', {
variants: {
variant: {
default: '',
accent: 'bg-card rounded-xl',
},
},
defaultVariants: {
variant: 'default',
},
});
const cardFooterVariants = cva('flex items-center px-5 min-h-14', {
variants: {
variant: {
default: 'border-t border-border',
accent: 'bg-card rounded-b-xl mt-[2px]',
},
},
defaultVariants: {
variant: 'default',
},
});
// Card Component
function Card({
className,
variant = 'default',
...props
}: React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof cardVariants>) {
return (
<CardContext.Provider value={{ variant: variant || 'default' }}>
<div data-slot="card" className={cn(cardVariants({ variant }), className)} {...props} />
</CardContext.Provider>
);
}
// CardHeader Component
function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
const { variant } = useCardContext();
return <div data-slot="card-header" className={cn(cardHeaderVariants({ variant }), className)} {...props} />;
}
// CardContent Component
function CardContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
const { variant } = useCardContext();
return <div data-slot="card-content" className={cn(cardContentVariants({ variant }), className)} {...props} />;
}
// CardTable Component
function CardTable({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
const { variant } = useCardContext();
return <div data-slot="card-table" className={cn(cardTableVariants({ variant }), className)} {...props} />;
}
// CardFooter Component
function CardFooter({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
const { variant } = useCardContext();
return <div data-slot="card-footer" className={cn(cardFooterVariants({ variant }), className)} {...props} />;
}
// Other Components
function CardHeading({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div data-slot="card-heading" className={cn('space-y-1', className)} {...props} />;
}
function CardToolbar({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div data-slot="card-toolbar" className={cn('flex items-center gap-2.5', className)} {...props} />;
}
function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
return (
<h3
data-slot="card-title"
className={cn('text-base font-semibold leading-none tracking-tight', className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div data-slot="card-description" className={cn('text-sm text-muted-foreground', className)} {...props} />;
}
// Exports
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardHeading, CardTable, CardTitle, CardToolbar };

View File

@@ -0,0 +1,935 @@
"use client";
import { motion, useSpring, useTransform } from "motion/react";
import {
type CSSProperties,
type ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
// ─── Utils ───────────────────────────────────────────────────────────────────
function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
// ─── PatternLines ────────────────────────────────────────────────────────────
export interface PatternLinesProps {
id: string;
width?: number;
height?: number;
stroke?: string;
strokeWidth?: number;
orientation?: ("diagonal" | "horizontal" | "vertical")[];
background?: string;
}
export function PatternLines({
id,
width = 6,
height = 6,
stroke = "var(--chart-line-primary)",
strokeWidth = 1,
orientation = ["diagonal"],
background,
}: PatternLinesProps) {
const paths: string[] = [];
for (const o of orientation) {
if (o === "diagonal") {
paths.push(`M0,${height}l${width},${-height}`);
paths.push(`M${-width / 4},${height / 4}l${width / 2},${-height / 2}`);
paths.push(
`M${(3 * width) / 4},${height + height / 4}l${width / 2},${-height / 2}`
);
} else if (o === "horizontal") {
paths.push(`M0,${height / 2}l${width},0`);
} else if (o === "vertical") {
paths.push(`M${width / 2},0l0,${height}`);
}
}
return (
<pattern
id={id}
width={width}
height={height}
patternUnits="userSpaceOnUse"
>
{background && (
<rect width={width} height={height} fill={background} />
)}
<path
d={paths.join(" ")}
fill="none"
stroke={stroke}
strokeWidth={strokeWidth}
strokeLinecap="square"
/>
</pattern>
);
}
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.44 * layerScale;
const h1 = normEnd * H * 0.44 * 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);
useEffect(() => {
scaleY.set(hovered ? extraScale : 1);
}, [hovered, scaleY, extraScale]);
return (
<motion.path
d={d}
fill={fill ?? color}
opacity={opacity}
style={{ scaleY, transformOrigin: "center center" }}
/>
);
}
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);
useEffect(() => {
scaleX.set(hovered ? extraScale : 1);
}, [hovered, scaleX, extraScale]);
return (
<motion.path
d={d}
fill={fill ?? color}
opacity={opacity}
style={{ scaleX, transformOrigin: "center center" }}
/>
);
}
// ─── 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 (
<motion.div
className="pointer-events-none relative shrink-0 overflow-visible"
style={{
width: segW,
height: fullH,
zIndex: hovered ? 10 : 1,
opacity: dimOpacity,
}}
>
<motion.div
className="absolute inset-0 overflow-visible"
style={{
scaleX: entranceScaleX,
scaleY: entranceScaleY,
transformOrigin: "left center",
}}
>
<svg
aria-hidden="true"
className="absolute inset-0 h-full w-full overflow-visible"
preserveAspectRatio="none"
role="presentation"
viewBox={`0 0 ${segW} ${fullH}`}
>
<defs>
{gradientStops && (
<linearGradient id={gradientId} x1="0" x2="1" y1="0" y2="0">
{gradientStops.map((stop) => (
<stop
key={`${stop.offset}-${stop.color}`}
offset={
typeof stop.offset === "number"
? `${stop.offset * 100}%`
: stop.offset
}
stopColor={stop.color}
/>
))}
</linearGradient>
)}
{renderPattern?.(patternId, color)}
</defs>
{rings.map((r, i) => {
const isInnermost = i === rings.length - 1;
let ringFill: string | undefined;
if (isInnermost && renderPattern) {
ringFill = `url(#${patternId})`;
} else if (isInnermost && gradientStops) {
ringFill = `url(#${gradientId})`;
}
return (
<HRing
color={color}
d={r.d}
fill={ringFill}
hovered={hovered}
key={`h-ring-${r.opacity.toFixed(2)}`}
opacity={r.opacity}
ringIndex={i}
totalRings={layers}
/>
);
})}
</svg>
</motion.div>
</motion.div>
);
}
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 (
<motion.div
className="pointer-events-none relative shrink-0 overflow-visible"
style={{
width: fullW,
height: segH,
zIndex: hovered ? 10 : 1,
opacity: dimOpacity,
}}
>
<motion.div
className="absolute inset-0 overflow-visible"
style={{
scaleY: entranceScaleY,
scaleX: entranceScaleX,
transformOrigin: "center top",
}}
>
<svg
aria-hidden="true"
className="absolute inset-0 h-full w-full overflow-visible"
preserveAspectRatio="none"
role="presentation"
viewBox={`0 0 ${fullW} ${segH}`}
>
<defs>
{gradientStops && (
<linearGradient id={gradientId} x1="0" x2="0" y1="0" y2="1">
{gradientStops.map((stop) => (
<stop
key={`${stop.offset}-${stop.color}`}
offset={
typeof stop.offset === "number"
? `${stop.offset * 100}%`
: stop.offset
}
stopColor={stop.color}
/>
))}
</linearGradient>
)}
{renderPattern?.(patternId, color)}
</defs>
{rings.map((r, i) => {
const isInnermost = i === rings.length - 1;
let ringFill: string | undefined;
if (isInnermost && renderPattern) {
ringFill = `url(#${patternId})`;
} else if (isInnermost && gradientStops) {
ringFill = `url(#${gradientId})`;
}
return (
<VRing
color={color}
d={r.d}
fill={ringFill}
hovered={hovered}
key={`v-ring-${r.opacity.toFixed(2)}`}
opacity={r.opacity}
ringIndex={i}
totalRings={layers}
/>
);
})}
</svg>
</motion.div>
</motion.div>
);
}
// ─── 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 && (
<span className="whitespace-nowrap font-semibold text-foreground text-sm">
{display}
</span>
);
const pctEl = showPercentage && (
<span className="rounded-full bg-foreground px-3 py-1 font-bold text-background text-xs shadow-sm">
{formatPercentage(pct)}
</span>
);
const labelEl = showLabels && (
<span className="whitespace-nowrap font-medium text-muted-foreground text-xs">
{stage.label}
</span>
);
if (layout === "spread") {
return (
<motion.div
animate={{ opacity: 1 }}
className={cn(
"absolute inset-0 flex",
isHorizontal ? "flex-col items-center" : "flex-row items-center"
)}
initial={{ opacity: 0 }}
transition={{
delay: index * staggerDelay + 0.25,
duration: 0.35,
ease: "easeOut",
}}
>
{isHorizontal ? (
<>
<div className="flex h-[16%] items-end justify-center pb-1">
{valueEl}
</div>
<div className="flex flex-1 items-center justify-center">
{pctEl}
</div>
<div className="flex h-[16%] items-start justify-center pt-1">
{labelEl}
</div>
</>
) : (
<>
<div className="flex w-[16%] items-center justify-end pr-2">
{valueEl}
</div>
<div className="flex flex-1 items-center justify-center">
{pctEl}
</div>
<div className="flex w-[16%] items-center justify-start pl-2">
{labelEl}
</div>
</>
)}
</motion.div>
);
}
// 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 (
<motion.div
animate={{ opacity: 1 }}
className={cn(
"absolute inset-0 flex",
isHorizontal
? cn("flex-col items-center", justifyMap[align])
: cn("flex-row items-center", justifyMap[align])
)}
initial={{ opacity: 0 }}
style={{
padding: isHorizontal ? "8% 0" : "0 8%",
}}
transition={{
delay: index * staggerDelay + 0.25,
duration: 0.35,
ease: "easeOut",
}}
>
<div
className={cn(
"flex gap-1.5",
isVerticalStack
? cn("flex-col", itemsMap[isHorizontal ? "center" : align])
: cn("flex-row", itemsMap.center)
)}
>
{valueEl}
{pctEl}
{labelEl}
</div>
</motion.div>
);
}
// ─── 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<HTMLDivElement>(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 (
<div
className={cn("relative w-full select-none overflow-visible", className)}
ref={ref}
style={{
aspectRatio: horiz ? "2.2 / 1" : "1 / 1.8",
...style,
}}
>
{W > 0 && H > 0 && (
<>
{/* Grid background bands */}
{gridEnabled && (
<svg
aria-hidden="true"
className="pointer-events-none absolute inset-0 h-full w-full"
preserveAspectRatio="none"
role="presentation"
viewBox={`0 0 ${W} ${H}`}
>
{showBands &&
data.map((stage, i) => {
if (i % 2 !== 0) return null;
if (horiz) {
const x = (segW + gap) * i;
return (
<rect
fill={bandColor}
height={H}
key={`band-${stage.label}`}
width={segW}
x={x}
y={0}
/>
);
}
const y = (segH + gap) * i;
return (
<rect
fill={bandColor}
height={segH}
key={`band-${stage.label}`}
width={W}
x={0}
y={y}
/>
);
})}
</svg>
)}
{/* Segments */}
<div
className={cn(
"absolute inset-0 flex overflow-visible",
horiz ? "flex-row" : "flex-col"
)}
style={{ gap }}
>
{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 ? (
<HSegment
color={segColor}
dimmed={hoveredIndex !== null && hoveredIndex !== i}
fullH={H}
gradientStops={stage.gradient}
hovered={hoveredIndex === i}
index={i}
key={stage.label}
layers={layers}
normEnd={normEnd}
normStart={normStart}
renderPattern={renderPattern}
segW={segW}
staggerDelay={staggerDelay}
straight={edges === "straight"}
/>
) : (
<VSegment
color={segColor}
dimmed={hoveredIndex !== null && hoveredIndex !== i}
fullW={W}
gradientStops={stage.gradient}
hovered={hoveredIndex === i}
index={i}
key={stage.label}
layers={layers}
normEnd={normEnd}
normStart={normStart}
renderPattern={renderPattern}
segH={segH}
staggerDelay={staggerDelay}
straight={edges === "straight"}
/>
);
})}
</div>
{/* Grid lines */}
{gridEnabled && showGridLines && (
<svg
aria-hidden="true"
className="pointer-events-none absolute inset-0 h-full w-full"
preserveAspectRatio="none"
role="presentation"
viewBox={`0 0 ${W} ${H}`}
>
{Array.from({ length: n - 1 }, (_, i) => {
const idx = i + 1;
if (horiz) {
const x = segW * idx + gap * i + gap / 2;
return (
<line
key={`grid-${idx}`}
stroke={gridLineColor}
strokeOpacity={gridLineOpacity}
strokeWidth={gridLineWidth}
x1={x}
x2={x}
y1={0}
y2={H}
/>
);
}
const y = segH * idx + gap * i + gap / 2;
return (
<line
key={`grid-${idx}`}
stroke={gridLineColor}
strokeOpacity={gridLineOpacity}
strokeWidth={gridLineWidth}
x1={0}
x2={W}
y1={y}
y2={y}
/>
);
})}
</svg>
)}
{/* 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 (
<motion.div
animate={{ opacity: isDimmed ? 0.4 : 1 }}
className="absolute cursor-pointer"
key={`lbl-${stage.label}`}
onMouseEnter={() => setHoveredIndex(i)}
onMouseLeave={() => setHoveredIndex(null)}
style={{ ...posStyle, zIndex: 20 }}
transition={{ type: "spring", stiffness: 300, damping: 24 }}
>
<SegmentLabel
align={labelAlign}
formatPercentage={formatPercentage}
formatValue={formatValue}
index={i}
isHorizontal={horiz}
layout={labelLayout}
orientation={labelOrientation}
pct={pct}
showLabels={showLabels}
showPercentage={showPercentage}
showValues={showValues}
stage={stage}
staggerDelay={staggerDelay}
/>
</motion.div>
);
})}
</>
)}
</div>
);
}
FunnelChart.displayName = "FunnelChart";
export default FunnelChart;

View File

@@ -0,0 +1,290 @@
'use client';
import * as React from 'react';
import { cn } from '@/lib/utils';
import * as RechartsPrimitive from 'recharts';
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: '', dark: '.dark' } as const;
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode;
icon?: React.ComponentType;
} & ({ color?: string; theme?: never } | { color?: never; theme: Record<keyof typeof THEMES, string> });
};
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error('useChart must be used within a <ChartContainer />');
}
return context;
}
function ChartContainer({
id,
className,
children,
config,
...props
}: React.ComponentProps<'div'> & {
config: ChartConfig;
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>['children'];
}) {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`;
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className,
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(([, config]) => config.theme || config.color);
if (!colorConfig.length) {
return null;
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join('\n')}
}
`,
)
.join('\n'),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
function ChartTooltipContent({
active,
payload,
className,
indicator = 'dot',
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<'div'> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: 'line' | 'dot' | 'dashed';
nameKey?: string;
labelKey?: string;
}) {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey || item?.dataKey || item?.name || 'value'}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === 'string' ? config[label as keyof typeof config]?.label || label : itemConfig?.label;
if (labelFormatter) {
return <div className={cn('font-medium', labelClassName)}>{labelFormatter(value, payload)}</div>;
}
if (!value) {
return null;
}
return <div className={cn('font-medium', labelClassName)}>{value}</div>;
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== 'dot';
return (
<div
className={cn(
'border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl',
className,
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || 'value'}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
return (
<div
key={item.dataKey}
className={cn(
'[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5',
indicator === 'dot' && 'items-center',
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn('shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]', {
'h-2.5 w-2.5': indicator === 'dot',
'w-1': indicator === 'line',
'w-0 border-[1.5px] border-dashed bg-transparent': indicator === 'dashed',
'my-0.5': nestLabel && indicator === 'dashed',
})}
style={
{
'--color-bg': indicatorColor,
'--color-border': indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn('flex flex-1 justify-between leading-none', nestLabel ? 'items-end' : 'items-center')}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">{itemConfig?.label || item.name}</span>
</div>
{item.value && (
<span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
}
const ChartLegend = RechartsPrimitive.Legend;
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = 'bottom',
nameKey,
}: React.ComponentProps<'div'> &
Pick<RechartsPrimitive.LegendProps, 'payload' | 'verticalAlign'> & {
hideIcon?: boolean;
nameKey?: string;
}) {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div className={cn('flex items-center justify-center gap-4', verticalAlign === 'top' ? 'pb-3' : 'pt-3', className)}>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || 'value'}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={item.value}
className={cn('[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3')}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
}
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
if (typeof payload !== 'object' || payload === null) {
return undefined;
}
const payloadPayload =
'payload' in payload && typeof payload.payload === 'object' && payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (key in payload && typeof payload[key as keyof typeof payload] === 'string') {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === 'string'
) {
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
}
return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config];
}
export { ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, ChartLegendContent, ChartStyle };

58
lib/api/annotations.ts Normal file
View File

@@ -0,0 +1,58 @@
import apiRequest from './client'
export type AnnotationCategory = 'deploy' | 'campaign' | 'incident' | 'other'
export interface Annotation {
id: string
site_id: string
date: string
time?: string | null
text: string
category: AnnotationCategory
created_by: string
created_at: string
updated_at: string
}
export interface CreateAnnotationRequest {
date: string
time?: string
text: string
category?: AnnotationCategory
}
export interface UpdateAnnotationRequest {
date: string
time?: string
text: string
category: AnnotationCategory
}
export async function listAnnotations(siteId: string, startDate?: string, endDate?: string): Promise<Annotation[]> {
const params = new URLSearchParams()
if (startDate) params.set('start_date', startDate)
if (endDate) params.set('end_date', endDate)
const qs = params.toString()
const res = await apiRequest<{ annotations: Annotation[] }>(`/sites/${siteId}/annotations${qs ? `?${qs}` : ''}`)
return res?.annotations ?? []
}
export async function createAnnotation(siteId: string, data: CreateAnnotationRequest): Promise<Annotation> {
return apiRequest<Annotation>(`/sites/${siteId}/annotations`, {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function updateAnnotation(siteId: string, annotationId: string, data: UpdateAnnotationRequest): Promise<Annotation> {
return apiRequest<Annotation>(`/sites/${siteId}/annotations/${annotationId}`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
export async function deleteAnnotation(siteId: string, annotationId: string): Promise<void> {
await apiRequest(`/sites/${siteId}/annotations/${annotationId}`, {
method: 'DELETE',
})
}

View File

@@ -202,9 +202,9 @@ async function apiRequest<T>(
// * We rely on HttpOnly cookies, so no manual Authorization header injection.
// * We MUST set credentials: 'include' for the browser to send cookies cross-origin (or same-site).
// * Add CSRF token for state-changing requests to Auth API
// * Auth API uses Double Submit Cookie pattern for CSRF protection
if (isAuthRequest && isStateChangingMethod(method)) {
// * Add CSRF token for all state-changing requests (Pulse API and Auth API).
// * Both backends enforce the double-submit cookie pattern server-side.
if (isStateChangingMethod(method)) {
const csrfToken = getCSRFToken()
if (csrfToken) {
headers['X-CSRF-Token'] = csrfToken

93
lib/api/journeys.ts Normal file
View File

@@ -0,0 +1,93 @@
import apiRequest from './client'
// ─── Types ──────────────────────────────────────────────────────────
export interface PathTransition {
from_path: string
to_path: string
step_index: number
session_count: number
}
export interface TransitionsResponse {
transitions: PathTransition[]
total_sessions: number
}
export interface TopPath {
page_sequence: string[]
session_count: number
avg_duration: number
}
export interface EntryPoint {
path: string
session_count: number
}
// ─── Helpers ────────────────────────────────────────────────────────
function buildQuery(opts: {
startDate?: string
endDate?: string
depth?: number
limit?: number
min_sessions?: number
entry_path?: string
}): string {
const params = new URLSearchParams()
if (opts.startDate) params.append('start_date', opts.startDate)
if (opts.endDate) params.append('end_date', opts.endDate)
if (opts.depth != null) params.append('depth', opts.depth.toString())
if (opts.limit != null) params.append('limit', opts.limit.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)
const query = params.toString()
return query ? `?${query}` : ''
}
// ─── API Functions ──────────────────────────────────────────────────
export function getJourneyTransitions(
siteId: string,
startDate?: string,
endDate?: string,
opts?: { depth?: number; minSessions?: number; entryPath?: string }
): Promise<TransitionsResponse> {
return apiRequest<TransitionsResponse>(
`/sites/${siteId}/journeys/transitions${buildQuery({
startDate,
endDate,
depth: opts?.depth,
min_sessions: opts?.minSessions,
entry_path: opts?.entryPath,
})}`
).then(r => r ?? { transitions: [], total_sessions: 0 })
}
export function getJourneyTopPaths(
siteId: string,
startDate?: string,
endDate?: string,
opts?: { limit?: number; minSessions?: number; entryPath?: string }
): Promise<TopPath[]> {
return apiRequest<{ paths: TopPath[] }>(
`/sites/${siteId}/journeys/top-paths${buildQuery({
startDate,
endDate,
limit: opts?.limit,
min_sessions: opts?.minSessions,
entry_path: opts?.entryPath,
})}`
).then(r => r?.paths ?? [])
}
export function getJourneyEntryPoints(
siteId: string,
startDate?: string,
endDate?: string
): Promise<EntryPoint[]> {
return apiRequest<{ entry_points: EntryPoint[] }>(
`/sites/${siteId}/journeys/entry-points${buildQuery({ startDate, endDate })}`
).then(r => r?.entry_points ?? [])
}

View File

@@ -1,42 +0,0 @@
import apiRequest from './client'
export interface Visitor {
session_id: string
first_seen: string
last_seen: string
pageviews: number
current_path: string
browser: string
os: string
device_type: string
country: string
city: string
}
export interface SessionEvent {
id: string
site_id: string
session_id: string
path: string
referrer: string | null
user_agent: string
country: string | null
city: string | null
region: string | null
device_type: string
screen_resolution: string | null
browser: string | null
os: string | null
timestamp: string
created_at: string
}
export async function getRealtimeVisitors(siteId: string): Promise<Visitor[]> {
const data = await apiRequest<{ visitors: Visitor[] }>(`/sites/${siteId}/realtime/visitors`)
return data.visitors
}
export async function getSessionDetails(siteId: string, sessionId: string): Promise<SessionEvent[]> {
const data = await apiRequest<{ events: SessionEvent[] }>(`/sites/${siteId}/sessions/${sessionId}`)
return data.events
}

View File

@@ -0,0 +1,80 @@
import apiRequest from './client'
export interface ReportSchedule {
id: string
site_id: string
organization_id: string
channel: 'email' | 'slack' | 'discord' | 'webhook'
channel_config: EmailConfig | WebhookConfig
frequency: 'daily' | 'weekly' | 'monthly'
timezone: string
enabled: boolean
report_type: 'summary' | 'pages' | 'sources' | 'goals'
send_hour: number
send_day: number | null
next_send_at: string | null
last_sent_at: string | null
last_error: string | null
created_at: string
updated_at: string
}
export interface EmailConfig {
recipients: string[]
}
export interface WebhookConfig {
url: string
}
export interface CreateReportScheduleRequest {
channel: string
channel_config: EmailConfig | WebhookConfig
frequency: string
timezone?: string
report_type?: string
send_hour?: number
send_day?: number
}
export interface UpdateReportScheduleRequest {
channel?: string
channel_config?: EmailConfig | WebhookConfig
frequency?: string
timezone?: string
report_type?: string
enabled?: boolean
send_hour?: number
send_day?: number
}
export async function listReportSchedules(siteId: string): Promise<ReportSchedule[]> {
const res = await apiRequest<{ report_schedules: ReportSchedule[] }>(`/sites/${siteId}/report-schedules`)
return res?.report_schedules ?? []
}
export async function createReportSchedule(siteId: string, data: CreateReportScheduleRequest): Promise<ReportSchedule> {
return apiRequest<ReportSchedule>(`/sites/${siteId}/report-schedules`, {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function updateReportSchedule(siteId: string, scheduleId: string, data: UpdateReportScheduleRequest): Promise<ReportSchedule> {
return apiRequest<ReportSchedule>(`/sites/${siteId}/report-schedules/${scheduleId}`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
export async function deleteReportSchedule(siteId: string, scheduleId: string): Promise<void> {
await apiRequest(`/sites/${siteId}/report-schedules/${scheduleId}`, {
method: 'DELETE',
})
}
export async function testReportSchedule(siteId: string, scheduleId: string): Promise<void> {
await apiRequest(`/sites/${siteId}/report-schedules/${scheduleId}/test`, {
method: 'POST',
})
}

View File

@@ -21,6 +21,8 @@ export interface Site {
enable_performance_insights?: boolean
// Bot and noise filtering
filter_bots?: boolean
// Hide unknown locations from stats
hide_unknown_locations?: boolean
// Data retention (months); 0 = keep forever
data_retention_months?: number
created_at: string
@@ -49,6 +51,8 @@ export interface UpdateSiteRequest {
enable_performance_insights?: boolean
// Bot and noise filtering
filter_bots?: boolean
// Hide unknown locations from stats
hide_unknown_locations?: boolean
// Data retention (months); 0 = keep forever
data_retention_months?: number
}

View File

@@ -103,6 +103,34 @@ export interface AuthParams {
captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string }
}
export interface FrustrationSummary {
rage_clicks: number
rage_unique_elements: number
rage_top_page: string
dead_clicks: number
dead_unique_elements: number
dead_top_page: string
prev_rage_clicks: number
prev_dead_clicks: number
}
export interface FrustrationElement {
selector: string
page_path: string
count: number
avg_click_count?: number
sessions: number
last_seen: string
}
export interface FrustrationByPage {
page_path: string
rage_clicks: number
dead_clicks: number
total: number
unique_elements: number
}
// ─── Helpers ────────────────────────────────────────────────────────
function appendAuthParams(params: URLSearchParams, auth?: AuthParams) {
@@ -245,8 +273,8 @@ export interface DashboardData {
goal_counts?: GoalCountStat[]
}
export function getDashboard(siteId: string, startDate?: string, endDate?: string, limit = 10, interval?: string): Promise<DashboardData> {
return apiRequest<DashboardData>(`/sites/${siteId}/dashboard${buildQuery({ startDate, endDate, limit, interval })}`)
export function getDashboard(siteId: string, startDate?: string, endDate?: string, limit = 10, interval?: string, filters?: string): Promise<DashboardData> {
return apiRequest<DashboardData>(`/sites/${siteId}/dashboard${buildQuery({ startDate, endDate, limit, interval, filters })}`)
}
export function getPublicDashboard(
@@ -402,3 +430,48 @@ export function getEventPropertyValues(siteId: string, eventName: string, propNa
return apiRequest<{ values: EventPropertyValue[] }>(`/sites/${siteId}/goals/${encodeURIComponent(eventName)}/properties/${encodeURIComponent(propName)}${buildQuery({ startDate, endDate, limit })}`)
.then(r => r?.values || [])
}
// ─── Frustration Signals ────────────────────────────────────────────
export interface BehaviorData {
summary: FrustrationSummary
rage_clicks: { items: FrustrationElement[]; total: number }
dead_clicks: { items: FrustrationElement[]; total: number }
by_page: FrustrationByPage[]
}
const emptyBehavior: BehaviorData = {
summary: { rage_clicks: 0, rage_unique_elements: 0, rage_top_page: '', dead_clicks: 0, dead_unique_elements: 0, dead_top_page: '', prev_rage_clicks: 0, prev_dead_clicks: 0 },
rage_clicks: { items: [], total: 0 },
dead_clicks: { items: [], total: 0 },
by_page: [],
}
export function getBehavior(siteId: string, startDate?: string, endDate?: string, limit = 7): Promise<BehaviorData> {
return apiRequest<BehaviorData>(`/sites/${siteId}/behavior${buildQuery({ startDate, endDate, limit })}`)
.then(r => r ?? emptyBehavior)
}
export function getFrustrationSummary(siteId: string, startDate?: string, endDate?: string): Promise<FrustrationSummary> {
return apiRequest<FrustrationSummary>(`/sites/${siteId}/frustration/summary${buildQuery({ startDate, endDate })}`)
.then(r => r ?? { rage_clicks: 0, rage_unique_elements: 0, rage_top_page: '', dead_clicks: 0, dead_unique_elements: 0, dead_top_page: '', prev_rage_clicks: 0, prev_dead_clicks: 0 })
}
export function getRageClicks(siteId: string, startDate?: string, endDate?: string, limit = 10, pagePath?: string): Promise<{ items: FrustrationElement[], total: number }> {
const params = buildQuery({ startDate, endDate, limit })
const pageFilter = pagePath ? `&page_path=${encodeURIComponent(pagePath)}` : ''
return apiRequest<{ items: FrustrationElement[], total: number }>(`/sites/${siteId}/frustration/rage-clicks${params}${pageFilter}`)
.then(r => r ?? { items: [], total: 0 })
}
export function getDeadClicks(siteId: string, startDate?: string, endDate?: string, limit = 10, pagePath?: string): Promise<{ items: FrustrationElement[], total: number }> {
const params = buildQuery({ startDate, endDate, limit })
const pageFilter = pagePath ? `&page_path=${encodeURIComponent(pagePath)}` : ''
return apiRequest<{ items: FrustrationElement[], total: number }>(`/sites/${siteId}/frustration/dead-clicks${params}${pageFilter}`)
.then(r => r ?? { items: [], total: 0 })
}
export function getFrustrationByPage(siteId: string, startDate?: string, endDate?: string, limit = 20): Promise<FrustrationByPage[]> {
return apiRequest<{ pages: FrustrationByPage[] }>(`/sites/${siteId}/frustration/by-page${buildQuery({ startDate, endDate, limit })}`)
.then(r => r?.pages ?? [])
}

View File

@@ -7,6 +7,7 @@ import { LoadingOverlay, useSessionSync, SessionExpiryWarning } from '@ciphera-n
import { logoutAction, getSessionAction, setSessionAction } from '@/app/actions/auth'
import { getUserOrganizations, switchContext } from '@/lib/api/organization'
import { logger } from '@/lib/utils/logger'
import { cleanupStaleStorage } from '@/lib/utils/storage-cleanup'
interface User {
id: string
@@ -90,7 +91,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const logout = useCallback(async () => {
setIsLoggingOut(true)
await logoutAction()
try { await logoutAction() } catch { /* stale build — continue with client-side cleanup */ }
localStorage.removeItem('user')
localStorage.removeItem('ciphera_token_refreshed_at')
localStorage.removeItem('ciphera_last_activity')
@@ -131,8 +132,17 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
// Initial load
useEffect(() => {
const init = async () => {
cleanupStaleStorage()
// * 1. Check server-side session (cookies)
let session = await getSessionAction()
let session: Awaited<ReturnType<typeof getSessionAction>> = null
try {
session = await getSessionAction()
} catch {
// * Stale build — treat as no session. The login page will redirect
// * to the auth service via window.location.href (full navigation),
// * which fetches fresh HTML/JS from the server on return.
}
// * 2. If no access_token but refresh_token may exist, try refresh (fixes 15-min inactivity logout)
if (!session && typeof window !== 'undefined') {
@@ -142,7 +152,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
credentials: 'include',
})
if (refreshRes.ok) {
session = await getSessionAction()
try {
session = await getSessionAction()
} catch {
// * Stale build — fall through as no session
}
}
}

205
lib/country-centroids.ts Normal file
View File

@@ -0,0 +1,205 @@
/**
* Country centroids: ISO 3166-1 alpha-2 → { lat, lng }
* Used to place markers on the DottedMap for visitor locations.
*/
export const countryCentroids: Record<string, { lat: number; lng: number }> = {
AD: { lat: 42.5, lng: 1.5 },
AE: { lat: 24.0, lng: 54.0 },
AF: { lat: 33.0, lng: 65.0 },
AG: { lat: 17.1, lng: -61.8 },
AL: { lat: 41.0, lng: 20.0 },
AM: { lat: 40.0, lng: 45.0 },
AO: { lat: -12.5, lng: 18.5 },
AR: { lat: -34.0, lng: -64.0 },
AT: { lat: 47.3, lng: 13.3 },
AU: { lat: -25.0, lng: 134.0 },
AZ: { lat: 40.5, lng: 47.5 },
BA: { lat: 44.0, lng: 17.8 },
BB: { lat: 13.2, lng: -59.5 },
BD: { lat: 24.0, lng: 90.0 },
BE: { lat: 50.8, lng: 4.0 },
BF: { lat: 13.0, lng: -1.5 },
BG: { lat: 43.0, lng: 25.0 },
BH: { lat: 26.0, lng: 50.6 },
BI: { lat: -3.5, lng: 29.9 },
BJ: { lat: 9.3, lng: 2.3 },
BN: { lat: 4.5, lng: 114.7 },
BO: { lat: -17.0, lng: -65.0 },
BR: { lat: -10.0, lng: -55.0 },
BS: { lat: 24.3, lng: -76.0 },
BT: { lat: 27.5, lng: 90.5 },
BW: { lat: -22.0, lng: 24.0 },
BY: { lat: 53.0, lng: 28.0 },
BZ: { lat: 17.3, lng: -88.8 },
CA: { lat: 56.0, lng: -96.0 },
CD: { lat: -3.0, lng: 23.0 },
CF: { lat: 7.0, lng: 21.0 },
CG: { lat: -1.0, lng: 15.0 },
CH: { lat: 47.0, lng: 8.0 },
CI: { lat: 8.0, lng: -5.5 },
CL: { lat: -30.0, lng: -71.0 },
CM: { lat: 6.0, lng: 12.5 },
CN: { lat: 35.0, lng: 105.0 },
CO: { lat: 4.0, lng: -72.0 },
CR: { lat: 10.0, lng: -84.0 },
CU: { lat: 22.0, lng: -79.5 },
CV: { lat: 16.0, lng: -24.0 },
CY: { lat: 35.0, lng: 33.0 },
CZ: { lat: 49.8, lng: 15.5 },
DE: { lat: 51.2, lng: 10.4 },
DJ: { lat: 11.5, lng: 43.1 },
DK: { lat: 56.0, lng: 10.0 },
DM: { lat: 15.4, lng: -61.4 },
DO: { lat: 19.0, lng: -70.7 },
DZ: { lat: 28.0, lng: 3.0 },
EC: { lat: -2.0, lng: -77.5 },
EE: { lat: 59.0, lng: 26.0 },
EG: { lat: 27.0, lng: 30.0 },
ER: { lat: 15.0, lng: 39.0 },
ES: { lat: 40.0, lng: -4.0 },
ET: { lat: 8.0, lng: 38.0 },
FI: { lat: 64.0, lng: 26.0 },
FJ: { lat: -18.0, lng: 175.0 },
FM: { lat: 6.9, lng: 158.2 },
FR: { lat: 46.0, lng: 2.0 },
GA: { lat: -1.0, lng: 11.8 },
GB: { lat: 54.0, lng: -2.0 },
GD: { lat: 12.1, lng: -61.7 },
GE: { lat: 42.0, lng: 43.5 },
GH: { lat: 8.0, lng: -2.0 },
GM: { lat: 13.5, lng: -15.3 },
GN: { lat: 11.0, lng: -10.0 },
GQ: { lat: 2.0, lng: 10.0 },
GR: { lat: 39.0, lng: 22.0 },
GT: { lat: 15.5, lng: -90.3 },
GW: { lat: 12.0, lng: -15.0 },
GY: { lat: 5.0, lng: -59.0 },
HK: { lat: 22.3, lng: 114.2 },
HN: { lat: 15.0, lng: -86.5 },
HR: { lat: 45.2, lng: 15.5 },
HT: { lat: 19.0, lng: -72.4 },
HU: { lat: 47.0, lng: 20.0 },
ID: { lat: -5.0, lng: 120.0 },
IE: { lat: 53.0, lng: -8.0 },
IL: { lat: 31.5, lng: 34.8 },
IN: { lat: 20.0, lng: 77.0 },
IQ: { lat: 33.0, lng: 44.0 },
IR: { lat: 32.0, lng: 53.0 },
IS: { lat: 65.0, lng: -18.0 },
IT: { lat: 42.8, lng: 12.8 },
JM: { lat: 18.3, lng: -77.4 },
JO: { lat: 31.0, lng: 36.0 },
JP: { lat: 36.0, lng: 138.0 },
KE: { lat: 1.0, lng: 38.0 },
KG: { lat: 41.0, lng: 75.0 },
KH: { lat: 13.0, lng: 105.0 },
KI: { lat: 1.4, lng: 173.0 },
KM: { lat: -12.2, lng: 44.2 },
KN: { lat: 17.3, lng: -62.7 },
KP: { lat: 40.0, lng: 127.0 },
KR: { lat: 37.0, lng: 127.5 },
KW: { lat: 29.5, lng: 47.8 },
KZ: { lat: 48.0, lng: 68.0 },
LA: { lat: 18.0, lng: 105.0 },
LB: { lat: 33.9, lng: 35.8 },
LC: { lat: 13.9, lng: -61.0 },
LI: { lat: 47.2, lng: 9.5 },
LK: { lat: 7.0, lng: 81.0 },
LR: { lat: 6.5, lng: -9.5 },
LS: { lat: -29.5, lng: 28.5 },
LT: { lat: 56.0, lng: 24.0 },
LU: { lat: 49.8, lng: 6.2 },
LV: { lat: 57.0, lng: 25.0 },
LY: { lat: 25.0, lng: 17.0 },
MA: { lat: 32.0, lng: -5.0 },
MC: { lat: 43.7, lng: 7.4 },
MD: { lat: 47.0, lng: 29.0 },
ME: { lat: 42.5, lng: 19.3 },
MG: { lat: -20.0, lng: 47.0 },
MK: { lat: 41.8, lng: 22.0 },
ML: { lat: 17.0, lng: -4.0 },
MM: { lat: 22.0, lng: 98.0 },
MN: { lat: 46.0, lng: 105.0 },
MO: { lat: 22.2, lng: 113.5 },
MR: { lat: 20.0, lng: -12.0 },
MT: { lat: 35.9, lng: 14.4 },
MU: { lat: -20.3, lng: 57.6 },
MV: { lat: 3.2, lng: 73.2 },
MW: { lat: -13.5, lng: 34.0 },
MX: { lat: 23.0, lng: -102.0 },
MY: { lat: 2.5, lng: 112.5 },
MZ: { lat: -18.3, lng: 35.0 },
NA: { lat: -22.0, lng: 17.0 },
NE: { lat: 16.0, lng: 8.0 },
NG: { lat: 10.0, lng: 8.0 },
NI: { lat: 13.0, lng: -85.0 },
NL: { lat: 52.5, lng: 5.8 },
NO: { lat: 62.0, lng: 10.0 },
NP: { lat: 28.0, lng: 84.0 },
NR: { lat: -0.5, lng: 166.9 },
NZ: { lat: -41.0, lng: 174.0 },
OM: { lat: 21.0, lng: 57.0 },
PA: { lat: 9.0, lng: -80.0 },
PE: { lat: -10.0, lng: -76.0 },
PG: { lat: -6.0, lng: 147.0 },
PH: { lat: 13.0, lng: 122.0 },
PK: { lat: 30.0, lng: 70.0 },
PL: { lat: 52.0, lng: 20.0 },
PR: { lat: 18.3, lng: -66.6 },
PS: { lat: 31.9, lng: 35.2 },
PT: { lat: 39.5, lng: -8.0 },
PW: { lat: 7.5, lng: 134.6 },
PY: { lat: -23.0, lng: -58.0 },
QA: { lat: 25.5, lng: 51.3 },
RO: { lat: 46.0, lng: 25.0 },
RS: { lat: 44.0, lng: 21.0 },
RU: { lat: 60.0, lng: 100.0 },
RW: { lat: -2.0, lng: 29.9 },
SA: { lat: 24.0, lng: 45.0 },
SB: { lat: -8.0, lng: 159.0 },
SC: { lat: -4.7, lng: 55.5 },
SD: { lat: 15.0, lng: 30.0 },
SE: { lat: 62.0, lng: 15.0 },
SG: { lat: 1.4, lng: 103.8 },
SI: { lat: 46.1, lng: 15.0 },
SK: { lat: 48.7, lng: 19.5 },
SL: { lat: 8.5, lng: -11.8 },
SM: { lat: 43.9, lng: 12.4 },
SN: { lat: 14.5, lng: -14.5 },
SO: { lat: 5.0, lng: 46.0 },
SR: { lat: 4.0, lng: -56.0 },
SS: { lat: 7.0, lng: 30.0 },
ST: { lat: 1.0, lng: 7.0 },
SV: { lat: 13.8, lng: -88.9 },
SY: { lat: 35.0, lng: 38.0 },
SZ: { lat: -26.5, lng: 31.5 },
TD: { lat: 15.0, lng: 19.0 },
TG: { lat: 8.0, lng: 1.2 },
TH: { lat: 15.0, lng: 100.0 },
TJ: { lat: 39.0, lng: 71.0 },
TL: { lat: -8.8, lng: 126.0 },
TM: { lat: 40.0, lng: 60.0 },
TN: { lat: 34.0, lng: 9.0 },
TO: { lat: -20.0, lng: -175.0 },
TR: { lat: 39.0, lng: 35.0 },
TT: { lat: 10.5, lng: -61.3 },
TV: { lat: -8.0, lng: 178.0 },
TW: { lat: 23.5, lng: 121.0 },
TZ: { lat: -6.0, lng: 35.0 },
UA: { lat: 49.0, lng: 32.0 },
UG: { lat: 1.0, lng: 32.0 },
US: { lat: 39.8, lng: -98.5 },
UY: { lat: -33.0, lng: -56.0 },
UZ: { lat: 41.0, lng: 64.0 },
VA: { lat: 41.9, lng: 12.5 },
VC: { lat: 13.3, lng: -61.2 },
VE: { lat: 8.0, lng: -66.0 },
VN: { lat: 16.0, lng: 108.0 },
VU: { lat: -16.0, lng: 167.0 },
WS: { lat: -13.8, lng: -172.1 },
XK: { lat: 42.6, lng: 21.0 },
YE: { lat: 15.0, lng: 48.0 },
ZA: { lat: -29.0, lng: 24.0 },
ZM: { lat: -15.0, lng: 28.0 },
ZW: { lat: -20.0, lng: 30.0 },
}

42
lib/swr/cache-provider.ts Normal file
View File

@@ -0,0 +1,42 @@
// * Bounded LRU cache provider for SWR
// * Prevents unbounded memory growth during long sessions across many sites
const MAX_CACHE_ENTRIES = 200
export function boundedCacheProvider() {
const map = new Map()
const accessOrder: string[] = []
const touch = (key: string) => {
const idx = accessOrder.indexOf(key)
if (idx > -1) accessOrder.splice(idx, 1)
accessOrder.push(key)
}
const evict = () => {
while (map.size > MAX_CACHE_ENTRIES && accessOrder.length > 0) {
const oldest = accessOrder.shift()!
map.delete(oldest)
}
}
return {
get(key: string) {
if (map.has(key)) touch(key)
return map.get(key)
},
set(key: string, value: any) {
map.set(key, value)
touch(key)
evict()
},
delete(key: string) {
map.delete(key)
const idx = accessOrder.indexOf(key)
if (idx > -1) accessOrder.splice(idx, 1)
},
keys() {
return map.keys()
},
}
}

View File

@@ -15,13 +15,25 @@ import {
getRealtime,
getStats,
getDailyStats,
getBehavior,
} from '@/lib/api/stats'
import {
getJourneyTransitions,
getJourneyTopPaths,
getJourneyEntryPoints,
type TransitionsResponse,
type TopPath as JourneyTopPath,
type EntryPoint,
} from '@/lib/api/journeys'
import { listAnnotations } from '@/lib/api/annotations'
import type { Annotation } from '@/lib/api/annotations'
import { getSite } from '@/lib/api/sites'
import type { Site } from '@/lib/api/sites'
import type {
Stats,
DailyStat,
CampaignStat,
DashboardData,
DashboardOverviewData,
DashboardPagesData,
DashboardLocationsData,
@@ -29,12 +41,13 @@ import type {
DashboardReferrersData,
DashboardPerformanceData,
DashboardGoalsData,
BehaviorData,
} from '@/lib/api/stats'
// * SWR fetcher functions
const fetchers = {
site: (siteId: string) => getSite(siteId),
dashboard: (siteId: string, start: string, end: string) => getDashboard(siteId, start, end),
dashboard: (siteId: string, start: string, end: string, interval?: string, filters?: string) => getDashboard(siteId, start, end, 10, interval, filters),
dashboardOverview: (siteId: string, start: string, end: string, interval?: string, filters?: string) => getDashboardOverview(siteId, start, end, interval, filters),
dashboardPages: (siteId: string, start: string, end: string, filters?: string) => getDashboardPages(siteId, start, end, undefined, filters),
dashboardLocations: (siteId: string, start: string, end: string, filters?: string) => getDashboardLocations(siteId, start, end, undefined, undefined, filters),
@@ -48,6 +61,14 @@ const fetchers = {
realtime: (siteId: string) => getRealtime(siteId),
campaigns: (siteId: string, start: string, end: string, limit: number) =>
getCampaigns(siteId, start, end, limit),
annotations: (siteId: string, start: string, end: string) => listAnnotations(siteId, start, end),
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 }),
journeyTopPaths: (siteId: string, start: string, end: string, limit?: number, minSessions?: number, entryPath?: string) =>
getJourneyTopPaths(siteId, start, end, { limit, minSessions, entryPath }),
journeyEntryPoints: (siteId: string, start: string, end: string) =>
getJourneyEntryPoints(siteId, start, end),
}
// * Standard SWR config for dashboard data
@@ -78,14 +99,15 @@ export function useSite(siteId: string) {
)
}
// * Hook for dashboard summary data (refreshed less frequently)
export function useDashboard(siteId: string, start: string, end: string) {
return useSWR(
siteId && start && end ? ['dashboard', siteId, start, end] : null,
() => fetchers.dashboard(siteId, start, end),
// * Hook for full dashboard data (single request replaces 7 focused hooks)
// * The backend runs all queries in parallel and caches the result in Redis (30s TTL)
export function useDashboard(siteId: string, start: string, end: string, interval?: string, filters?: string) {
return useSWR<DashboardData>(
siteId && start && end ? ['dashboard', siteId, start, end, interval, filters] : null,
() => fetchers.dashboard(siteId, start, end, interval, filters),
{
...dashboardSWRConfig,
// * Refresh every 60 seconds for dashboard summary
// * Refresh every 60 seconds for dashboard data
refreshInterval: 60 * 1000,
// * Deduping interval to prevent duplicate requests
dedupingInterval: 10 * 1000,
@@ -247,5 +269,70 @@ export function useCampaigns(siteId: string, start: string, end: string, limit =
)
}
// * Hook for annotations data
export function useAnnotations(siteId: string, startDate: string, endDate: string) {
return useSWR<Annotation[]>(
siteId && startDate && endDate ? ['annotations', siteId, startDate, endDate] : null,
() => fetchers.annotations(siteId, startDate, endDate),
{
...dashboardSWRConfig,
refreshInterval: 60 * 1000,
dedupingInterval: 10 * 1000,
}
)
}
// * Hook for bundled behavior data (all frustration signals in one request)
export function useBehavior(siteId: string, start: string, end: string) {
return useSWR<BehaviorData>(
siteId && start && end ? ['behavior', siteId, start, end] : null,
() => fetchers.behavior(siteId, start, end),
{
...dashboardSWRConfig,
refreshInterval: 60 * 1000,
dedupingInterval: 10 * 1000,
}
)
}
// * Hook for journey flow transitions (Sankey diagram data)
export function useJourneyTransitions(siteId: string, start: string, end: string, depth?: number, minSessions?: number, entryPath?: string) {
return useSWR<TransitionsResponse>(
siteId && start && end ? ['journeyTransitions', siteId, start, end, depth, minSessions, entryPath] : null,
() => fetchers.journeyTransitions(siteId, start, end, depth, minSessions, entryPath),
{
...dashboardSWRConfig,
refreshInterval: 60 * 1000,
dedupingInterval: 10 * 1000,
}
)
}
// * Hook for top journey paths
export function useJourneyTopPaths(siteId: string, start: string, end: string, limit?: number, minSessions?: number, entryPath?: string) {
return useSWR<JourneyTopPath[]>(
siteId && start && end ? ['journeyTopPaths', siteId, start, end, limit, minSessions, entryPath] : null,
() => fetchers.journeyTopPaths(siteId, start, end, limit, minSessions, entryPath),
{
...dashboardSWRConfig,
refreshInterval: 60 * 1000,
dedupingInterval: 10 * 1000,
}
)
}
// * Hook for journey entry points (refreshes less frequently)
export function useJourneyEntryPoints(siteId: string, start: string, end: string) {
return useSWR<EntryPoint[]>(
siteId && start && end ? ['journeyEntryPoints', siteId, start, end] : null,
() => fetchers.journeyEntryPoints(siteId, start, end),
{
...dashboardSWRConfig,
refreshInterval: 5 * 60 * 1000,
dedupingInterval: 30 * 1000,
}
)
}
// * Re-export for convenience
export { fetchers }

1
lib/utils.ts Normal file
View File

@@ -0,0 +1 @@
export { cn } from '@ciphera-net/ui'

View File

@@ -1,101 +1,96 @@
import React from 'react'
import {
Globe,
WindowsLogo,
AppleLogo,
LinuxLogo,
AndroidLogo,
Question,
DeviceMobile,
DeviceTablet,
Desktop,
GoogleLogo,
FacebookLogo,
XLogo,
LinkedinLogo,
InstagramLogo,
GithubLogo,
YoutubeLogo,
RedditLogo,
Robot,
Link,
WhatsappLogo,
TelegramLogo,
SnapchatLogo,
PinterestLogo,
ThreadsLogo,
} from '@phosphor-icons/react'
/**
* Google's public favicon service base URL.
* Append `?domain=<host>&sz=<px>` to get a favicon.
*/
export const FAVICON_SERVICE_URL = 'https://www.google.com/s2/favicons'
import {
FaChrome,
FaFirefox,
FaSafari,
FaEdge,
FaOpera,
FaInternetExplorer,
FaWindows,
FaApple,
FaLinux,
FaAndroid,
FaDesktop,
FaMobileAlt,
FaTabletAlt,
FaGoogle,
FaFacebook,
FaLinkedin,
FaInstagram,
FaGithub,
FaYoutube,
FaReddit,
FaQuestion,
FaGlobe
} from 'react-icons/fa'
import { FaXTwitter } from 'react-icons/fa6'
import { SiBrave, SiOpenai, SiPerplexity, SiAnthropic, SiGooglegemini } from 'react-icons/si'
import { RiRobot2Fill } from 'react-icons/ri'
import { MdDeviceUnknown, MdSmartphone, MdTabletMac, MdDesktopWindows } from 'react-icons/md'
export function getBrowserIcon(browserName: string) {
if (!browserName) return <FaGlobe className="text-neutral-400" />
const lower = browserName.toLowerCase()
if (lower.includes('chrome')) return <FaChrome className="text-blue-500" />
if (lower.includes('firefox')) return <FaFirefox className="text-orange-500" />
if (lower.includes('safari')) return <FaSafari className="text-blue-400" />
if (lower.includes('edge')) return <FaEdge className="text-blue-600" />
if (lower.includes('opera')) return <FaOpera className="text-red-500" />
if (lower.includes('ie') || lower.includes('explorer')) return <FaInternetExplorer className="text-blue-500" />
if (lower.includes('brave')) return <SiBrave className="text-orange-600" />
return <FaGlobe className="text-neutral-400" />
if (!browserName) return <Globe className="text-neutral-400" />
return <Globe className="text-neutral-500" />
}
export function getOSIcon(osName: string) {
if (!osName) return <MdDeviceUnknown className="text-neutral-400" />
if (!osName) return <Question className="text-neutral-400" />
const lower = osName.toLowerCase()
if (lower.includes('win')) return <FaWindows className="text-blue-500" />
if (lower.includes('mac') || lower.includes('ios')) return <FaApple className="text-neutral-800 dark:text-neutral-200" />
if (lower.includes('linux') || lower.includes('ubuntu') || lower.includes('debian')) return <FaLinux className="text-neutral-800 dark:text-neutral-200" />
if (lower.includes('android')) return <FaAndroid className="text-green-500" />
return <MdDeviceUnknown className="text-neutral-400" />
if (lower.includes('win')) return <WindowsLogo className="text-blue-500" />
if (lower.includes('mac') || lower.includes('ios')) return <AppleLogo className="text-neutral-800 dark:text-neutral-200" />
if (lower.includes('linux') || lower.includes('ubuntu') || lower.includes('debian')) return <LinuxLogo className="text-neutral-800 dark:text-neutral-200" />
if (lower.includes('android')) return <AndroidLogo className="text-green-500" />
return <Question className="text-neutral-400" />
}
export function getDeviceIcon(deviceName: string) {
if (!deviceName) return <MdDeviceUnknown className="text-neutral-400" />
if (!deviceName) return <Question className="text-neutral-400" />
const lower = deviceName.toLowerCase()
if (lower.includes('mobile') || lower.includes('phone')) return <MdSmartphone className="text-neutral-500" />
if (lower.includes('tablet') || lower.includes('ipad')) return <MdTabletMac className="text-neutral-500" />
if (lower.includes('desktop') || lower.includes('laptop')) return <MdDesktopWindows className="text-neutral-500" />
return <MdDeviceUnknown className="text-neutral-400" />
if (lower.includes('mobile') || lower.includes('phone')) return <DeviceMobile className="text-neutral-500" />
if (lower.includes('tablet') || lower.includes('ipad')) return <DeviceTablet className="text-neutral-500" />
if (lower.includes('desktop') || lower.includes('laptop')) return <Desktop className="text-neutral-500" />
return <Question className="text-neutral-400" />
}
export function getReferrerIcon(referrerName: string) {
if (!referrerName) return <FaGlobe className="text-neutral-400" />
if (!referrerName) return <Globe className="text-neutral-400" />
const lower = referrerName.toLowerCase()
if (lower.includes('google')) return <FaGoogle className="text-blue-500" />
if (lower.includes('facebook')) return <FaFacebook className="text-blue-600" />
if (lower.includes('twitter') || lower.includes('t.co') || lower.includes('x.com')) return <FaXTwitter className="text-neutral-800 dark:text-neutral-200" />
if (lower.includes('linkedin')) return <FaLinkedin className="text-blue-700" />
if (lower.includes('instagram')) return <FaInstagram className="text-pink-600" />
if (lower.includes('github')) return <FaGithub className="text-neutral-800 dark:text-neutral-200" />
if (lower.includes('youtube')) return <FaYoutube className="text-red-600" />
if (lower.includes('reddit')) return <FaReddit className="text-orange-600" />
if (lower.includes('google')) return <GoogleLogo className="text-blue-500" />
if (lower.includes('facebook')) return <FacebookLogo className="text-blue-600" />
if (lower.includes('twitter') || lower.includes('t.co') || lower.includes('x.com')) return <XLogo className="text-neutral-800 dark:text-neutral-200" />
if (lower.includes('linkedin')) return <LinkedinLogo className="text-blue-700" />
if (lower.includes('instagram')) return <InstagramLogo className="text-pink-600" />
if (lower.includes('github')) return <GithubLogo className="text-neutral-800 dark:text-neutral-200" />
if (lower.includes('youtube')) return <YoutubeLogo className="text-red-600" />
if (lower.includes('reddit')) return <RedditLogo className="text-orange-600" />
if (lower.includes('whatsapp')) return <WhatsappLogo className="text-green-500" />
if (lower.includes('telegram')) return <TelegramLogo className="text-blue-500" />
if (lower.includes('snapchat')) return <SnapchatLogo className="text-yellow-400" />
if (lower.includes('pinterest')) return <PinterestLogo className="text-red-600" />
if (lower.includes('threads')) return <ThreadsLogo className="text-neutral-800 dark:text-neutral-200" />
// AI assistants and search tools
if (lower.includes('chatgpt') || lower.includes('openai')) return <SiOpenai className="text-neutral-800 dark:text-neutral-200" />
if (lower.includes('perplexity')) return <SiPerplexity className="text-teal-600" />
if (lower.includes('claude') || lower.includes('anthropic')) return <SiAnthropic className="text-orange-500" />
if (lower.includes('gemini')) return <SiGooglegemini className="text-blue-500" />
if (lower.includes('copilot')) return <FaGlobe className="text-blue-500" />
if (lower.includes('deepseek')) return <RiRobot2Fill className="text-blue-600" />
if (lower.includes('grok') || lower.includes('x.ai')) return <FaXTwitter className="text-neutral-800 dark:text-neutral-200" />
if (lower.includes('phind')) return <RiRobot2Fill className="text-purple-600" />
if (lower.includes('you.com')) return <RiRobot2Fill className="text-indigo-600" />
if (lower.includes('chatgpt') || lower.includes('openai')) return <Robot className="text-neutral-800 dark:text-neutral-200" />
if (lower.includes('perplexity')) return <Robot className="text-teal-600" />
if (lower.includes('claude') || lower.includes('anthropic')) return <Robot className="text-orange-500" />
if (lower.includes('gemini')) return <Robot className="text-blue-500" />
if (lower.includes('copilot')) return <Robot className="text-blue-500" />
if (lower.includes('deepseek')) return <Robot className="text-blue-600" />
if (lower.includes('grok') || lower.includes('x.ai')) return <XLogo className="text-neutral-800 dark:text-neutral-200" />
if (lower.includes('phind')) return <Robot className="text-purple-600" />
if (lower.includes('you.com')) return <Robot className="text-indigo-600" />
// Shared Link (unattributed deep-page traffic)
if (lower === 'shared link') return <Link className="text-neutral-500" />
// Try to use a generic globe or maybe check if it is a URL
return <FaGlobe className="text-neutral-400" />
return <Globe className="text-neutral-400" />
}
const REFERRER_NO_FAVICON = ['direct', 'unknown', '']
const REFERRER_NO_FAVICON = ['direct', 'shared link', 'unknown', '']
/** Common subdomains to skip when deriving the main label (e.g. l.instagram.com → instagram). */
const REFERRER_SUBDOMAIN_SKIP = new Set([
@@ -118,6 +113,7 @@ const REFERRER_DISPLAY_OVERRIDES: Record<string, string> = {
telegram: 'Telegram',
pinterest: 'Pinterest',
snapchat: 'Snapchat',
threads: 'Threads',
tumblr: 'Tumblr',
quora: 'Quora',
't.co': 'X',
@@ -222,6 +218,8 @@ export function getReferrerFavicon(referrer: string): string | null {
if (!referrer || typeof referrer !== 'string') return null
const normalized = referrer.trim().toLowerCase()
if (REFERRER_NO_FAVICON.includes(normalized)) return null
// Plain names without a dot (e.g. "Instagram", "WhatsApp") are not real domains
if (!normalized.includes('.')) return null
try {
const url = new URL(referrer.startsWith('http') ? referrer : `https://${referrer}`)
if (REFERRER_USE_X_ICON.has(url.hostname.toLowerCase())) return null

View File

@@ -0,0 +1,17 @@
// * Cleans up stale localStorage entries on app initialization
// * Prevents accumulation from abandoned OAuth flows
export function cleanupStaleStorage() {
if (typeof window === 'undefined') return
try {
// * PKCE keys are only needed during the OAuth callback
// * If we're not on the callback page, they're stale leftovers
if (!window.location.pathname.includes('/auth/callback')) {
localStorage.removeItem('oauth_state')
localStorage.removeItem('oauth_code_verifier')
}
} catch {
// * Ignore errors (private browsing, storage disabled, etc.)
}
}

View File

@@ -16,7 +16,7 @@ const cspDirectives = [
"img-src 'self' data: blob: https://www.google.com https://*.gstatic.com https://ciphera.net",
"font-src 'self'",
`connect-src 'self' https://*.ciphera.net https://ciphera.net https://www.google.com https://*.gstatic.com https://cdn.jsdelivr.net${process.env.NODE_ENV === 'development' ? ' http://localhost:*' : ''}`,
"worker-src 'self'",
"worker-src 'self' blob:",
"frame-src 'none'",
"object-src 'none'",
"base-uri 'self'",
@@ -30,7 +30,7 @@ const nextConfig: NextConfig = {
// * Privacy-first: Disable analytics and telemetry
productionBrowserSourceMaps: false,
experimental: {
optimizePackageImports: ['react-icons'],
optimizePackageImports: ['@phosphor-icons/react'],
},
images: {
remotePatterns: [
@@ -47,6 +47,14 @@ const nextConfig: NextConfig = {
},
async headers() {
return [
{
// * 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).*)',
headers: [
{ key: 'Cache-Control', value: 'no-cache, must-revalidate' },
],
},
{
source: '/(.*)',
headers: [

2218
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "pulse-frontend",
"version": "0.13.0-alpha",
"version": "0.15.0-alpha",
"private": true,
"scripts": {
"dev": "next dev",
@@ -12,29 +12,33 @@
"test:watch": "vitest"
},
"dependencies": {
"@ciphera-net/ui": "^0.0.92",
"@ciphera-net/ui": "^0.2.5",
"@ducanh2912/next-pwa": "^10.2.9",
"@radix-ui/react-icons": "^1.3.0",
"@phosphor-icons/react": "^2.1.10",
"@simplewebauthn/browser": "^13.2.2",
"@stripe/react-stripe-js": "^5.6.0",
"@stripe/stripe-js": "^8.7.0",
"@tanstack/react-virtual": "^3.13.21",
"axios": "^1.13.2",
"class-variance-authority": "^0.7.1",
"cobe": "^0.6.5",
"country-flag-icons": "^1.6.4",
"d3-sankey": "^0.12.3",
"d3-scale": "^4.0.2",
"framer-motion": "^12.23.26",
"html-to-image": "^1.11.13",
"i18n-iso-countries": "^7.14.0",
"iso-3166-2": "^1.0.0",
"jspdf": "^4.0.0",
"jspdf-autotable": "^5.0.7",
"motion": "^12.35.2",
"next": "^16.1.1",
"radix-ui": "^1.4.3",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-icons": "^5.5.0",
"react-markdown": "^10.1.0",
"react-simple-maps": "^3.0.0",
"recharts": "^2.15.0",
"sonner": "^2.0.7",
"svg-dotted-map": "^2.0.1",
"swr": "^2.3.3",
"xlsx": "^0.18.5"
},
@@ -48,11 +52,11 @@
"@tailwindcss/typography": "^0.5.19",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/d3-sankey": "^0.12.5",
"@types/d3-scale": "^4.0.9",
"@types/node": "^20.14.12",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/react-simple-maps": "^3.0.6",
"@vitejs/plugin-react": "^5.1.4",
"autoprefixer": "^10.4.19",
"eslint": "^9.39.2",

View File

@@ -424,6 +424,251 @@
}, { passive: true });
}
// * Strip HTML tags from a string (used for sanitizing attribute values)
function stripHtml(str) {
if (typeof str !== 'string') return '';
return str.replace(/<[^>]*>/g, '').trim();
}
// * Build a compact element identifier string for frustration tracking
// * Format: tag#id.class1.class2[href="/path"]
function getElementIdentifier(el) {
if (!el || !el.tagName) return '';
var result = el.tagName.toLowerCase();
// * Add #id if present
if (el.id) {
result += '#' + stripHtml(el.id);
}
// * Add classes (handle SVG elements where className is SVGAnimatedString)
var rawClassName = el.className;
if (rawClassName && typeof rawClassName !== 'string' && rawClassName.baseVal !== undefined) {
rawClassName = rawClassName.baseVal;
}
if (typeof rawClassName === 'string' && rawClassName.trim()) {
var classes = rawClassName.trim().split(/\s+/);
var filtered = [];
for (var ci = 0; ci < classes.length && filtered.length < 5; ci++) {
var cls = classes[ci];
if (cls.length > 50) continue;
if (/^(ng-|js-|is-|has-|animate)/.test(cls)) continue;
filtered.push(cls);
}
if (filtered.length > 0) {
result += '.' + filtered.join('.');
}
}
// * Add key attributes
var attrs = ['href', 'role', 'type', 'name', 'data-action'];
for (var ai = 0; ai < attrs.length; ai++) {
var attrName = attrs[ai];
var attrVal = el.getAttribute(attrName);
if (attrVal !== null && attrVal !== '') {
var sanitized = stripHtml(attrVal);
if (sanitized.length > 50) sanitized = sanitized.substring(0, 50);
result += '[' + attrName + '="' + sanitized + '"]';
}
}
// * Truncate to max 200 chars
if (result.length > 200) {
result = result.substring(0, 200);
}
return result;
}
// * Auto-track rage clicks (rapid repeated clicks on the same element)
// * Fires rage_click when same element is clicked 3+ times within 800ms
// * Opt-out: add data-no-rage to the script tag
if (!script.hasAttribute('data-no-rage')) {
var rageClickHistory = {}; // * selector -> { times: [timestamps], lastFired: 0 }
var RAGE_CLICK_THRESHOLD = 3;
var RAGE_CLICK_WINDOW = 800;
var RAGE_CLICK_DEBOUNCE = 5000;
var RAGE_CLEANUP_INTERVAL = 10000;
// * Cleanup stale rage click entries every 10 seconds
setInterval(function() {
var now = Date.now();
for (var key in rageClickHistory) {
if (!rageClickHistory.hasOwnProperty(key)) continue;
var entry = rageClickHistory[key];
// * Remove if last click was more than 10 seconds ago
if (entry.times.length === 0 || now - entry.times[entry.times.length - 1] > RAGE_CLEANUP_INTERVAL) {
delete rageClickHistory[key];
}
}
}, RAGE_CLEANUP_INTERVAL);
document.addEventListener('click', function(e) {
var el = e.target;
if (!el || !el.tagName) return;
var selector = getElementIdentifier(el);
if (!selector) return;
var now = Date.now();
var currentPath = window.location.pathname + window.location.search;
if (!rageClickHistory[selector]) {
rageClickHistory[selector] = { times: [], lastFired: 0 };
}
var entry = rageClickHistory[selector];
// * Add current click timestamp
entry.times.push(now);
// * Remove clicks outside the time window
while (entry.times.length > 0 && now - entry.times[0] > RAGE_CLICK_WINDOW) {
entry.times.shift();
}
// * Check if rage click threshold is met
if (entry.times.length >= RAGE_CLICK_THRESHOLD) {
// * Debounce: max one rage_click per element per 5 seconds
if (now - entry.lastFired >= RAGE_CLICK_DEBOUNCE) {
var clickCount = entry.times.length;
trackCustomEvent('rage_click', {
selector: selector,
click_count: String(clickCount),
page_path: currentPath,
x: String(Math.round(e.clientX)),
y: String(Math.round(e.clientY))
});
entry.lastFired = now;
}
// * Reset tracker after firing or debounce skip
entry.times = [];
}
}, true); // * Capture phase
}
// * Auto-track dead clicks (clicks on interactive elements that produce no effect)
// * Fires dead_click when an interactive element is clicked but no DOM change, navigation,
// * or network request occurs within 1 second
// * Opt-out: add data-no-dead to the script tag
if (!script.hasAttribute('data-no-dead')) {
var INTERACTIVE_SELECTOR = 'a,button,input,select,textarea,[role="button"],[role="link"],[role="tab"],[role="menuitem"],[onclick],[tabindex]';
var DEAD_CLICK_DEBOUNCE = 10000;
var DEAD_CLEANUP_INTERVAL = 30000;
var deadClickDebounce = {}; // * selector -> lastFiredTimestamp
// * Cleanup stale dead click debounce entries every 30 seconds
setInterval(function() {
var now = Date.now();
for (var key in deadClickDebounce) {
if (!deadClickDebounce.hasOwnProperty(key)) continue;
if (now - deadClickDebounce[key] > DEAD_CLEANUP_INTERVAL) {
delete deadClickDebounce[key];
}
}
}, DEAD_CLEANUP_INTERVAL);
// * Polyfill check for Element.matches
var matchesFn = (function() {
var ep = Element.prototype;
return ep.matches || ep.msMatchesSelector || ep.webkitMatchesSelector || null;
})();
// * Find the nearest interactive element by walking up max 3 levels
function findInteractiveElement(el) {
if (!matchesFn) return null;
var depth = 0;
var current = el;
while (current && depth <= 3) {
if (current.nodeType === 1 && matchesFn.call(current, INTERACTIVE_SELECTOR)) {
return current;
}
current = current.parentElement;
depth++;
}
return null;
}
document.addEventListener('click', function(e) {
var target = findInteractiveElement(e.target);
if (!target) return;
var selector = getElementIdentifier(target);
if (!selector) return;
var now = Date.now();
// * Debounce: max one dead_click per element per 10 seconds
if (deadClickDebounce[selector] && now - deadClickDebounce[selector] < DEAD_CLICK_DEBOUNCE) {
return;
}
var currentPath = window.location.pathname + window.location.search;
var clickX = String(Math.round(e.clientX));
var clickY = String(Math.round(e.clientY));
var effectDetected = false;
var hrefBefore = location.href;
var mutationObs = null;
var perfObs = null;
var cleanupTimer = null;
function cleanup() {
if (mutationObs) { try { mutationObs.disconnect(); } catch (ex) {} mutationObs = null; }
if (perfObs) { try { perfObs.disconnect(); } catch (ex) {} perfObs = null; }
if (cleanupTimer) { clearTimeout(cleanupTimer); cleanupTimer = null; }
}
function onEffect() {
effectDetected = true;
cleanup();
}
// * Set up MutationObserver to detect DOM changes on the element and its parent
if (typeof MutationObserver !== 'undefined') {
try {
mutationObs = new MutationObserver(function() {
onEffect();
});
var mutOpts = { childList: true, attributes: true, characterData: true, subtree: true };
mutationObs.observe(target, mutOpts);
var parent = target.parentElement;
if (parent && parent.tagName !== 'HTML' && parent.tagName !== 'BODY') {
mutationObs.observe(parent, { childList: true });
}
} catch (ex) {
mutationObs = null;
}
}
// * Set up PerformanceObserver to detect network requests
if (typeof PerformanceObserver !== 'undefined') {
try {
perfObs = new PerformanceObserver(function() {
onEffect();
});
perfObs.observe({ type: 'resource' });
} catch (ex) {
perfObs = null;
}
}
// * After 1 second, check if any effect was detected
cleanupTimer = setTimeout(function() {
cleanup();
// * Also check if navigation occurred
if (effectDetected || location.href !== hrefBefore) return;
deadClickDebounce[selector] = Date.now();
trackCustomEvent('dead_click', {
selector: selector,
page_path: currentPath,
x: clickX,
y: clickY
});
}, 1000);
}, true); // * Capture phase
}
// * Auto-track outbound link clicks and file downloads (on by default)
// * Opt-out: add data-no-outbound or data-no-downloads to the script tag
var trackOutbound = !script.hasAttribute('data-no-outbound');

View File

@@ -8,6 +8,51 @@
--color-success: #10B981;
--color-warning: #F59E0B;
--color-error: #EF4444;
/* * Chart colors */
--chart-1: #FD5E0F;
--chart-2: #3b82f6;
--chart-3: #22c55e;
--chart-4: #a855f7;
--chart-5: #f59e0b;
--chart-grid: #f5f5f5;
--chart-axis: #a3a3a3;
/* * shadcn-compatible semantic tokens (for 21st.dev components) */
--background: 255 255 255;
--foreground: 23 23 23;
--card: 255 255 255;
--card-foreground: 23 23 23;
--popover: 255 255 255;
--popover-foreground: 23 23 23;
--primary: 253 94 15;
--primary-foreground: 255 255 255;
--secondary: 245 245 245;
--secondary-foreground: 23 23 23;
--destructive-foreground: 255 255 255;
}
.dark {
--chart-1: #FD5E0F;
--chart-2: #60a5fa;
--chart-3: #4ade80;
--chart-4: #c084fc;
--chart-5: #fbbf24;
--chart-grid: #262626;
--chart-axis: #737373;
/* * shadcn-compatible dark mode overrides */
--background: 10 10 10;
--foreground: 250 250 250;
--card: 23 23 23;
--card-foreground: 250 250 250;
--popover: 23 23 23;
--popover-foreground: 250 250 250;
--primary: 253 94 15;
--primary-foreground: 255 255 255;
--secondary: 38 38 38;
--secondary-foreground: 250 250 250;
--destructive-foreground: 255 255 255;
}
body {

View File

@@ -13,9 +13,45 @@ const config: Config = {
],
theme: {
extend: {
keyframes: {
'cell-highlight': {
'0%': { backgroundColor: 'transparent' },
'100%': { backgroundColor: 'var(--highlight)' },
},
'cell-flash': {
'0%': { backgroundColor: 'transparent' },
'50%': { backgroundColor: 'var(--highlight)' },
'100%': { backgroundColor: 'transparent' },
},
},
animation: {
'cell-highlight': 'cell-highlight 0.5s ease forwards',
'cell-flash': 'cell-flash 0.6s ease forwards',
},
fontFamily: {
sans: ['var(--font-plus-jakarta-sans)', 'system-ui', 'sans-serif'],
},
colors: {
background: 'rgb(var(--background) / <alpha-value>)',
foreground: 'rgb(var(--foreground) / <alpha-value>)',
card: {
DEFAULT: 'rgb(var(--card) / <alpha-value>)',
foreground: 'rgb(var(--card-foreground) / <alpha-value>)',
},
popover: {
DEFAULT: 'rgb(var(--popover) / <alpha-value>)',
foreground: 'rgb(var(--popover-foreground) / <alpha-value>)',
},
primary: {
DEFAULT: 'rgb(var(--primary) / <alpha-value>)',
foreground: 'rgb(var(--primary-foreground) / <alpha-value>)',
},
secondary: {
DEFAULT: 'rgb(var(--secondary) / <alpha-value>)',
foreground: 'rgb(var(--secondary-foreground) / <alpha-value>)',
},
border: 'rgb(var(--border) / <alpha-value>)',
},
},
},
plugins: [