Compare commits
230 Commits
0.12.0-alp
...
0.15.0-alp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
484300c307 | ||
|
|
9fb19c18e8 | ||
|
|
0112004457 | ||
|
|
063a21adeb | ||
|
|
90de83ad6d | ||
|
|
a3fa48732a | ||
|
|
a637d32446 | ||
|
|
df394b85ef | ||
|
|
4e7c495160 | ||
|
|
9c8943d1e3 | ||
|
|
e7debdeb41 | ||
|
|
3df93bb227 | ||
|
|
3bde3fd4e1 | ||
|
|
5cdf353233 | ||
|
|
683bbce817 | ||
|
|
828e930a69 | ||
|
|
54daf14c6a | ||
|
|
281a9f237a | ||
|
|
4b10f8c1fc | ||
|
|
31286c45f4 | ||
|
|
908606ade2 | ||
|
|
4cd9544672 | ||
|
|
49aa8aae60 | ||
|
|
b3e335ec6c | ||
|
|
e7e76bb3db | ||
|
|
dc1030036c | ||
|
|
0fa6c4aaf4 | ||
|
|
c669035718 | ||
|
|
7336f9126e | ||
|
|
6964be9610 | ||
|
|
bae492e8d9 | ||
|
|
03e3f41e48 | ||
|
|
eb17e8e8d6 | ||
|
|
540c774100 | ||
|
|
3bf832af92 | ||
|
|
5050422a60 | ||
|
|
13f6f53868 | ||
|
|
bf7fe87120 | ||
|
|
d4dc45e82b | ||
|
|
0889079372 | ||
|
|
2f01be1c67 | ||
|
|
585f37f444 | ||
|
|
1f64bec46d | ||
|
|
9179e058f7 | ||
|
|
d5aafdc48a | ||
|
|
062d0a2b44 | ||
|
|
46084b71a6 | ||
|
|
a00042c557 | ||
|
|
c17a856224 | ||
|
|
953762075b | ||
|
|
fb47716711 | ||
|
|
247a0b3460 | ||
|
|
9e6e2a2214 | ||
|
|
b05f7bbcf6 | ||
|
|
1417c952c6 | ||
|
|
a22333bbc2 | ||
|
|
27a9836d5a | ||
|
|
c6ec4671a4 | ||
|
|
acf7b16dde | ||
|
|
31aff95552 | ||
|
|
d728b49f67 | ||
|
|
eeb46affda | ||
|
|
cf5fbb6f8e | ||
|
|
bb9e907a50 | ||
|
|
7fe8c3818f | ||
|
|
3fc0dec9d9 | ||
|
|
7bd922a012 | ||
|
|
7e91e08532 | ||
|
|
cb6c03432c | ||
|
|
bc299fe9a0 | ||
|
|
632530af7f | ||
|
|
ffbfcf342f | ||
|
|
602f7350b8 | ||
|
|
c15737b9c6 | ||
|
|
a189952fad | ||
|
|
428a6fd18d | ||
|
|
136ceff962 | ||
|
|
eb872dbc5a | ||
|
|
956cfbcf35 | ||
|
|
b5dd5e7082 | ||
|
|
34eca64967 | ||
|
|
1c5ca7fa54 | ||
|
|
275503ae8f | ||
|
|
73db65c0b2 | ||
|
|
0754cb0e4f | ||
|
|
1ba6bf6a84 | ||
|
|
72011dea5c | ||
|
|
7431f2b78d | ||
|
|
bf37add366 | ||
|
|
ca60379e5e | ||
|
|
b30619e6b4 | ||
|
|
0f5d5338f3 | ||
|
|
faa2f50d6e | ||
|
|
55bf20c58d | ||
|
|
2fa3540a48 | ||
|
|
c2d5935394 | ||
|
|
8136268988 | ||
|
|
15d41f5bd9 | ||
|
|
37eb49eb37 | ||
|
|
3d12f35331 | ||
|
|
205cdf314c | ||
|
|
502f4952fc | ||
|
|
f10b903a80 | ||
|
|
848bde237f | ||
|
|
835c284a6b | ||
|
|
beee87bd2e | ||
|
|
bcaa5c25f8 | ||
|
|
d863004d5f | ||
|
|
00d8656ad2 | ||
|
|
64a8652423 | ||
|
|
a99d13309f | ||
|
|
7aa809c8a0 | ||
|
|
ca71c1646d | ||
|
|
3587f93645 | ||
|
|
e07fd3f0e8 | ||
|
|
05d13bff81 | ||
|
|
a9f42acbf6 | ||
|
|
a60efeb6a7 | ||
|
|
88f02a244b | ||
|
|
8c5b452f73 | ||
|
|
5f797112ec | ||
|
|
ae0f6b8ffa | ||
|
|
4babbc7555 | ||
|
|
01f6d8d065 | ||
|
|
628749a416 | ||
|
|
b88f4d438b | ||
|
|
2776c803f1 | ||
|
|
c46d463533 | ||
|
|
6f964f38f3 | ||
|
|
330cc134aa | ||
|
|
92fae83772 | ||
|
|
242c76b763 | ||
|
|
9f2032fc32 | ||
|
|
cc4f924fb8 | ||
|
|
5625703168 | ||
|
|
7175de44af | ||
|
|
033d735c3a | ||
|
|
5721d25291 | ||
|
|
536aebc086 | ||
|
|
8c9c711296 | ||
|
|
652c93cbd0 | ||
|
|
2d7e13b098 | ||
|
|
58c151e2b0 | ||
|
|
1a75b44c68 | ||
|
|
9629a5788c | ||
|
|
464a361094 | ||
|
|
12ae1a9175 | ||
|
|
3268a70baa | ||
|
|
9dba2cf2e2 | ||
|
|
efd647d856 | ||
|
|
df2f38eb83 | ||
|
|
c065853800 | ||
|
|
f58154f18d | ||
|
|
31416f0eb2 | ||
|
|
6ccc26ab48 | ||
|
|
cbf48318ce | ||
|
|
874ff61a46 | ||
|
|
0dfd0ccb3c | ||
|
|
56225bb1ad | ||
|
|
ad747b1772 | ||
|
|
3f81cb0e48 | ||
|
|
86c11dc16f | ||
|
|
5fc6f183db | ||
|
|
4d99334bcf | ||
|
|
3002c4f58c | ||
|
|
a05e2e94b8 | ||
|
|
7ff5be7c4e | ||
|
|
f516c59d32 | ||
|
|
b6199e8a3a | ||
|
|
7f9ad0e977 | ||
|
|
397a5afef9 | ||
|
|
6f1956b740 | ||
|
|
831fd86f67 | ||
|
|
2f5bcf479a | ||
|
|
ad806e0427 | ||
|
|
6338d1dfe7 | ||
|
|
d2dfe62993 | ||
|
|
cc268c320e | ||
|
|
985978dd8f | ||
|
|
8ebd8ba9e1 | ||
|
|
dd8e101f69 | ||
|
|
ece8cda334 | ||
|
|
74ee64a560 | ||
|
|
641a3deebb | ||
|
|
77dc61e7d0 | ||
|
|
dee7089925 | ||
|
|
2acfd90bbd | ||
|
|
34e59894af | ||
|
|
7fc40f2a83 | ||
|
|
068943974e | ||
|
|
2c82c1a52a | ||
|
|
b046978256 | ||
|
|
7be30b57b5 | ||
|
|
386b4a8c44 | ||
|
|
34053004c0 | ||
|
|
0809c37067 | ||
|
|
ec96fa8a0d | ||
|
|
0865774686 | ||
|
|
5677f30f3b | ||
|
|
8b1d196812 | ||
|
|
53a0341925 | ||
|
|
e72e6f2ec5 | ||
|
|
acede8ca54 | ||
|
|
6d360cf1ac | ||
|
|
7865b41722 | ||
|
|
48cf9a1f62 | ||
|
|
f469d0d755 | ||
|
|
88956879de | ||
|
|
564c853f7f | ||
|
|
c9fd949ae1 | ||
|
|
70f46ba63c | ||
|
|
7d3f1cb10a | ||
|
|
fd1386b80d | ||
|
|
501932849b | ||
|
|
b7426d6128 | ||
|
|
dfa887147a | ||
|
|
4de4e05ccb | ||
|
|
d7eb10e815 | ||
|
|
8a7076ee1b | ||
|
|
67c9bdd3e0 | ||
|
|
3ecd2abf63 | ||
|
|
baceb6e8a8 | ||
|
|
fba1fd99c2 | ||
|
|
c9123832a5 | ||
|
|
95920e4724 | ||
|
|
67f6690258 | ||
|
|
5b388808b6 | ||
|
|
27158f7bfc | ||
|
|
bc5e20ab7b | ||
|
|
6bb23bc22a |
147
CHANGELOG.md
147
CHANGELOG.md
@@ -4,7 +4,149 @@ 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
|
||||
|
||||
### Added
|
||||
|
||||
- **Dashboard filtering.** Filter your entire dashboard by any dimension — browser, country, page, device, OS, referrer, or UTM parameters. A single "Filter" button lets you browse dimensions, see real values from your data with visitor counts, search or type a custom value, and apply — all in a quick dropdown. Active filters appear as removable pills above your charts. Stack multiple filters to narrow things down. Filters are saved in the URL so you can bookmark or share a filtered view.
|
||||
- **Click any item to filter.** Click a referrer, browser, country, page, or any other item in your dashboard panels to instantly filter the entire dashboard to just that traffic.
|
||||
- **Hover percentages.** Hover over any item in Pages, Locations, Technology, or Referrers to see what percentage of total traffic it represents.
|
||||
- **Custom event properties.** Your custom events can now carry extra context — for example, `pulse.track('signup', { plan: 'pro', source: 'landing' })`. Click any event in Goals & Events to see a breakdown of its properties and values, no setup needed.
|
||||
- **AI traffic source identification.** Pulse recognizes visitors from ChatGPT, Perplexity, Claude, Gemini, Copilot, DeepSeek, Grok, Meta AI, You.com, and Phind. These appear in Referrers with proper icons and names instead of raw URLs.
|
||||
- **Automatic outbound link tracking.** Tracks when visitors click links to other websites. Shows up as "outbound link" events in Goals & Events — no setup needed.
|
||||
- **Automatic file download tracking.** Downloads of PDFs, ZIPs, Excel, Word, MP3s, and 20+ other formats are recorded as "file download" events automatically.
|
||||
- **Automatic 404 detection.** Detects when visitors land on pages that don't exist and records "404" events so you can find and fix broken links.
|
||||
- **Automatic scroll depth tracking.** Tracks how far visitors scroll — at 25%, 50%, 75%, and 100% — helping you understand which content keeps people reading.
|
||||
|
||||
### Improved
|
||||
|
||||
- **Chart rebuilt from scratch.** Cleaner stat cards, wider Y-axis that no longer clips labels, whole-number ticks for visitor and pageview counts, lighter grid lines, streamlined toolbar, and a properly positioned live indicator.
|
||||
- **Campaigns panel redesigned.** Clean row-based layout with UTM medium and campaign always visible below the source name. Now sits in a half-width grid next to Goals & Events.
|
||||
- **Better filter design.** Solid brand-colored filter pills that are easy to spot in light and dark mode. A funnel icon on the filter button. Click any pill to remove it.
|
||||
- **Underline tab switchers.** Pages, Locations, and Technology panels now use clean underline tabs instead of pill-style switchers.
|
||||
- **"View all" at the bottom.** The expand action on each panel is now a subtle "View all" link at the bottom of the list instead of an icon in the header.
|
||||
- **Faster dashboard loading.** Each section loads independently with smart caching. Data refreshes in the background, and switching tabs pauses updates to save resources — resuming when you return.
|
||||
- **Smoother navigation.** Switching pages, changing organizations, or signing in no longer triggers unnecessary background requests.
|
||||
- **Loading screen while redirecting to sign-in.** The login page now shows the Pulse logo and a message instead of a blank white screen.
|
||||
- **More reliable billing.** Plan changes, cancellations, and invoice views now handle session expiry and errors gracefully.
|
||||
- **Stronger browser security.** Your browser now only loads scripts, styles, and images from trusted sources, adding protection against cross-site scripting.
|
||||
- **More resilient analytics processing.** The system that processes your events now recovers automatically from unexpected errors instead of stopping silently.
|
||||
- **Dashboard stays responsive under heavy traffic.** Parallel queries are limited during peak usage, and in-progress queries are cancelled when you navigate away.
|
||||
- **Cleaner error messages.** Invalid form submissions show a simple message instead of exposing internal details.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Tracking script now works on all tracked websites.** Page views were silently failing due to two separate issues. Both are fixed — your dashboard receives visits from all registered domains as expected.
|
||||
- **Rate limiting works correctly.** A bug was treating all visitors as the same person, so one heavy user could block everyone. Each visitor is now identified individually.
|
||||
- **Real-time visitor count no longer stops updating.** The live counter would hit a rate limit and stop refreshing. It now has enough headroom for normal usage.
|
||||
- **Team members can view real-time data.** Previously only the site creator could see live visitors. Now any team member in the same organization has access.
|
||||
- **Funnel details load correctly.** Opening a funnel previously showed an error. Funnels now display step-by-step conversion data as expected.
|
||||
- **Consistent date handling.** Funnels now use the same date format as the rest of Pulse, so date pickers and bookmarked links work correctly everywhere.
|
||||
- **Deleting a site cleans up all data.** Orphaned analytics events are now removed automatically before the site is deleted.
|
||||
- **App switcher and site icons load correctly.** Logos and favicons were blocked by a security policy. Fixed by allowing images from Ciphera and Google's favicon service.
|
||||
- **Current session highlighted in settings.** The active session marker now works correctly.
|
||||
- **Notifications load on sign-in.** The notification bell no longer errors briefly after signing in.
|
||||
- **Duplicate filters no longer stack.** Clicking the same item twice no longer adds the same filter again.
|
||||
- **Campaigns respect active filters.** The Campaigns panel now filters along with everything else instead of always showing all campaigns.
|
||||
- **No duplicate "Direct" in referrer filter.** The referrer suggestions no longer show "Direct" twice.
|
||||
- **Filter dropdowns show all your data.** Previously limited to 10 items — now loads up to 100 values.
|
||||
- **Chart Y-axis shows whole numbers.** Visitor and pageview counts no longer show fractional values like "0.75 visitors".
|
||||
- **Duplicate goal names detected reliably.** Goal name uniqueness checks now work correctly regardless of your setup.
|
||||
- **Health checks stay accurate.** The backend health check no longer falsely reports the service as unhealthy after sustained traffic.
|
||||
|
||||
## [0.12.0-alpha] - 2026-03-01
|
||||
|
||||
@@ -223,7 +365,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
|
||||
---
|
||||
|
||||
[Unreleased]: https://github.com/ciphera-net/pulse/compare/v0.12.0-alpha...HEAD
|
||||
[Unreleased]: https://github.com/ciphera-net/pulse/compare/v0.13.0-alpha...HEAD
|
||||
[0.13.0-alpha]: https://github.com/ciphera-net/pulse/releases/tag/v0.13.0-alpha
|
||||
[0.12.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.11.1-alpha...v0.12.0-alpha
|
||||
[0.11.1-alpha]: https://github.com/ciphera-net/pulse/compare/v0.11.0-alpha...v0.11.1-alpha
|
||||
[0.11.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.10.0-alpha...v0.11.0-alpha
|
||||
|
||||
@@ -2,19 +2,10 @@
|
||||
|
||||
import { cookies } from 'next/headers'
|
||||
import { logger } from '@/lib/utils/logger'
|
||||
import { getCookieDomain } from '@/lib/utils/cookies'
|
||||
|
||||
const AUTH_API_URL = process.env.NEXT_PUBLIC_AUTH_API_URL || process.env.NEXT_PUBLIC_AUTH_URL || 'http://localhost:8081'
|
||||
|
||||
// * Determine cookie domain dynamically
|
||||
// * In production (on ciphera.net), we want to share cookies with subdomains (e.g. pulse-api.ciphera.net)
|
||||
// * In local dev (localhost), we don't set a domain
|
||||
const getCookieDomain = () => {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
return '.ciphera.net'
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
interface AuthResponse {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
import { cookies } from 'next/headers'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getCookieDomain } from '@/lib/utils/cookies'
|
||||
|
||||
const AUTH_API_URL = process.env.NEXT_PUBLIC_AUTH_API_URL || process.env.NEXT_PUBLIC_AUTH_URL || 'http://localhost:8081'
|
||||
|
||||
// * Determine cookie domain dynamically
|
||||
const getCookieDomain = () => {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
return '.ciphera.net'
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export async function POST() {
|
||||
const cookieStore = await cookies()
|
||||
const refreshToken = cookieStore.get('refresh_token')?.value
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
@@ -114,7 +114,7 @@ export default function FAQPage() {
|
||||
|
||||
<div className="max-w-3xl mx-auto">
|
||||
{faqs.map((faq, index) => (
|
||||
<FAQItem key={index} faq={faq} index={index} />
|
||||
<FAQItem key={faq.question} faq={faq} index={index} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -9,10 +9,12 @@ import { useOnlineStatus } from '@/lib/hooks/useOnlineStatus'
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { logger } from '@/lib/utils/logger'
|
||||
import { getUserOrganizations, switchContext } from '@/lib/api/organization'
|
||||
import { getUserOrganizations, switchContext, type OrganizationMember } from '@/lib/api/organization'
|
||||
import { setSessionAction } from '@/app/actions/auth'
|
||||
import { LoadingOverlay } from '@ciphera-net/ui'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { SettingsModalProvider, useSettingsModal } from '@/lib/settings-modal-context'
|
||||
import SettingsModalWrapper from '@/components/settings/SettingsModalWrapper'
|
||||
|
||||
const ORG_SWITCH_KEY = 'pulse_switching_org'
|
||||
|
||||
@@ -44,11 +46,12 @@ const CIPHERA_APPS: CipheraApp[] = [
|
||||
},
|
||||
]
|
||||
|
||||
export default function LayoutContent({ children }: { children: React.ReactNode }) {
|
||||
function LayoutInner({ children }: { children: React.ReactNode }) {
|
||||
const auth = useAuth()
|
||||
const router = useRouter()
|
||||
const isOnline = useOnlineStatus()
|
||||
const [orgs, setOrgs] = useState<any[]>([])
|
||||
const { openSettings } = useSettingsModal()
|
||||
const [orgs, setOrgs] = useState<OrganizationMember[]>([])
|
||||
const [isSwitchingOrg, setIsSwitchingOrg] = useState(() => {
|
||||
if (typeof window === 'undefined') return false
|
||||
return sessionStorage.getItem(ORG_SWITCH_KEY) === 'true'
|
||||
@@ -87,7 +90,7 @@ export default function LayoutContent({ children }: { children: React.ReactNode
|
||||
const handleCreateOrganization = () => {
|
||||
router.push('/onboarding')
|
||||
}
|
||||
|
||||
|
||||
const showOfflineBar = Boolean(auth.user && !isOnline);
|
||||
const barHeightRem = 2.5;
|
||||
const headerHeightRem = 6;
|
||||
@@ -100,9 +103,9 @@ export default function LayoutContent({ children }: { children: React.ReactNode
|
||||
return (
|
||||
<>
|
||||
{auth.user && <OfflineBanner isOnline={isOnline} />}
|
||||
<Header
|
||||
auth={auth}
|
||||
LinkComponent={Link}
|
||||
<Header
|
||||
auth={auth}
|
||||
LinkComponent={Link}
|
||||
logoSrc="/pulse_icon_no_margins.png"
|
||||
appName="Pulse"
|
||||
orgs={orgs}
|
||||
@@ -117,6 +120,7 @@ export default function LayoutContent({ children }: { children: React.ReactNode
|
||||
rightSideActions={auth.user ? <NotificationCenter /> : null}
|
||||
apps={CIPHERA_APPS}
|
||||
currentAppId="pulse"
|
||||
onOpenSettings={openSettings}
|
||||
customNavItems={
|
||||
<>
|
||||
{!auth.user && (
|
||||
@@ -136,11 +140,20 @@ export default function LayoutContent({ children }: { children: React.ReactNode
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
<Footer
|
||||
<Footer
|
||||
LinkComponent={Link}
|
||||
appName="Pulse"
|
||||
isAuthenticated={!!auth.user}
|
||||
/>
|
||||
<SettingsModalWrapper />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function LayoutContent({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<SettingsModalProvider>
|
||||
<LayoutInner>{children}</LayoutInner>
|
||||
</SettingsModalProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { initiateOAuthFlow } from '@/lib/api/oauth'
|
||||
import { LoadingOverlay } from '@ciphera-net/ui'
|
||||
|
||||
export default function LoginPage() {
|
||||
useEffect(() => {
|
||||
@@ -9,5 +10,10 @@ export default function LoginPage() {
|
||||
initiateOAuthFlow()
|
||||
}, [])
|
||||
|
||||
return null
|
||||
return (
|
||||
<LoadingOverlay
|
||||
logoSrc="/pulse_icon_no_margins.png"
|
||||
title="Redirecting to log in..."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
10
app/page.tsx
10
app/page.tsx
@@ -78,8 +78,8 @@ function ComparisonSection() {
|
||||
{ feature: "GDPR Compliant", pulse: true, ga: "Complex" },
|
||||
{ feature: "Script Size", pulse: "< 1 KB", ga: "45 KB+" },
|
||||
{ feature: "Data Ownership", pulse: "Yours", ga: "Google's" },
|
||||
].map((row, i) => (
|
||||
<tr key={i} className="hover:bg-neutral-50/50 dark:hover:bg-neutral-800/50 transition-colors">
|
||||
].map((row) => (
|
||||
<tr key={row.feature} className="hover:bg-neutral-50/50 dark:hover:bg-neutral-800/50 transition-colors">
|
||||
<td className="p-6 text-neutral-900 dark:text-white font-medium">{row.feature}</td>
|
||||
<td className="p-6">
|
||||
{row.pulse === true ? (
|
||||
@@ -303,7 +303,7 @@ export default function HomePage() {
|
||||
{ icon: ZapIcon, title: "Lightweight", desc: "Our script is less than 1kb. It won't slow down your site or affect your SEO." }
|
||||
].map((feature, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
key={feature.title}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -1,532 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useAuth } from '@/lib/auth/context'
|
||||
import ProfileSettings from '@/components/settings/ProfileSettings'
|
||||
import TrustedDevicesCard from '@/components/settings/TrustedDevicesCard'
|
||||
import SecurityActivityCard from '@/components/settings/SecurityActivityCard'
|
||||
import { updateUserPreferences } from '@/lib/api/user'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import {
|
||||
UserIcon,
|
||||
LockIcon,
|
||||
BoxIcon,
|
||||
ChevronRightIcon,
|
||||
ChevronDownIcon,
|
||||
ExternalLinkIcon,
|
||||
} from '@ciphera-net/ui'
|
||||
|
||||
// Inline SVG icons not available in ciphera-ui
|
||||
function BellIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
|
||||
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Types ---
|
||||
|
||||
type ProfileSubTab = 'profile' | 'security' | 'preferences'
|
||||
type NotificationSubTab = 'security' | 'center'
|
||||
|
||||
type ActiveSelection =
|
||||
| { section: 'profile'; subTab: ProfileSubTab }
|
||||
| { section: 'notifications'; subTab: NotificationSubTab }
|
||||
| { section: 'account' }
|
||||
| { section: 'devices' }
|
||||
| { section: 'activity' }
|
||||
|
||||
type ExpandableSection = 'profile' | 'notifications' | 'account'
|
||||
|
||||
// --- Sidebar Components ---
|
||||
|
||||
function SectionHeader({
|
||||
expanded,
|
||||
active,
|
||||
onToggle,
|
||||
icon: Icon,
|
||||
label,
|
||||
description,
|
||||
hasChildren = true,
|
||||
}: {
|
||||
expanded: boolean
|
||||
active: boolean
|
||||
onToggle: () => void
|
||||
icon: React.ElementType
|
||||
label: string
|
||||
description?: string
|
||||
hasChildren?: boolean
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className={`w-full flex items-start gap-3 px-4 py-3 text-left rounded-xl transition-all duration-200 ${
|
||||
active
|
||||
? 'bg-brand-orange/10 text-brand-orange'
|
||||
: 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-5 h-5 mt-0.5 shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="font-medium">{label}</span>
|
||||
{description && (
|
||||
<p className={`text-xs mt-0.5 ${active ? 'text-brand-orange/70' : 'text-neutral-500'}`}>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{hasChildren ? (
|
||||
<ChevronDownIcon
|
||||
className={`w-4 h-4 shrink-0 mt-1 transition-transform duration-200 ${
|
||||
expanded ? '' : '-rotate-90'
|
||||
}`}
|
||||
/>
|
||||
) : (
|
||||
<ChevronRightIcon className={`w-4 h-4 shrink-0 mt-1 transition-transform ${active ? 'rotate-90' : ''}`} />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function SubItem({
|
||||
active,
|
||||
onClick,
|
||||
label,
|
||||
external = false,
|
||||
}: {
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
label: string
|
||||
external?: boolean
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`w-full flex items-center gap-2.5 pl-12 pr-4 py-2 text-sm text-left rounded-lg transition-all duration-150 ${
|
||||
active
|
||||
? 'text-brand-orange font-medium bg-brand-orange/5'
|
||||
: 'text-neutral-500 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-50 dark:hover:bg-neutral-800/50'
|
||||
}`}
|
||||
>
|
||||
<span className="flex-1">{label}</span>
|
||||
{external && <ExternalLinkIcon className="w-3 h-3 opacity-60" />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function ExpandableSubItems({ expanded, children }: { expanded: boolean; children: React.ReactNode }) {
|
||||
return (
|
||||
<AnimatePresence initial={false}>
|
||||
{expanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2, ease: 'easeInOut' }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="py-1 space-y-0.5">
|
||||
{children}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Content Components ---
|
||||
|
||||
// Security Alerts Card (granular security toggles)
|
||||
const SECURITY_ALERT_OPTIONS = [
|
||||
{ key: 'login_alerts', label: 'Login Activity', description: 'New device sign-ins and suspicious login attempts.' },
|
||||
{ key: 'password_alerts', label: 'Password Changes', description: 'Password changes and session revocations.' },
|
||||
{ key: 'two_factor_alerts', label: 'Two-Factor Authentication', description: '2FA enabled/disabled and recovery code changes.' },
|
||||
]
|
||||
|
||||
function SecurityAlertsCard() {
|
||||
const { user } = useAuth()
|
||||
const [emailNotifications, setEmailNotifications] = useState<Record<string, boolean>>({})
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.preferences?.email_notifications) {
|
||||
setEmailNotifications(user.preferences.email_notifications)
|
||||
} else {
|
||||
const defaults = SECURITY_ALERT_OPTIONS.reduce((acc, option) => ({
|
||||
...acc,
|
||||
[option.key]: true
|
||||
}), {} as Record<string, boolean>)
|
||||
setEmailNotifications(defaults)
|
||||
}
|
||||
}, [user])
|
||||
|
||||
const handleToggle = async (key: string) => {
|
||||
const newState = {
|
||||
...emailNotifications,
|
||||
[key]: !emailNotifications[key]
|
||||
}
|
||||
setEmailNotifications(newState)
|
||||
try {
|
||||
await updateUserPreferences({
|
||||
email_notifications: newState as { new_file_received: boolean; file_downloaded: boolean; login_alerts: boolean; password_alerts: boolean; two_factor_alerts: boolean }
|
||||
})
|
||||
} catch {
|
||||
setEmailNotifications(prev => ({
|
||||
...prev,
|
||||
[key]: !prev[key]
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-neutral-900 rounded-2xl border border-neutral-200 dark:border-neutral-800 p-6 shadow-sm">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="p-2 rounded-lg bg-brand-orange/10">
|
||||
<BellIcon className="w-5 h-5 text-brand-orange" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-white">Security Alerts</h2>
|
||||
<p className="text-sm text-neutral-500">Choose which security events trigger email alerts</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{SECURITY_ALERT_OPTIONS.map((item) => (
|
||||
<div
|
||||
key={item.key}
|
||||
className={`flex items-center justify-between p-4 border rounded-xl transition-all duration-200 ${
|
||||
emailNotifications[item.key]
|
||||
? 'bg-orange-50 dark:bg-brand-orange/10 border-brand-orange shadow-sm'
|
||||
: 'bg-white dark:bg-neutral-900 border-neutral-200 dark:border-neutral-800'
|
||||
}`}
|
||||
>
|
||||
<div className="space-y-0.5">
|
||||
<span className={`block text-sm font-medium transition-colors duration-200 ${
|
||||
emailNotifications[item.key] ? 'text-brand-orange' : 'text-neutral-900 dark:text-white'
|
||||
}`}>
|
||||
{item.label}
|
||||
</span>
|
||||
<span className={`block text-xs transition-colors duration-200 ${
|
||||
emailNotifications[item.key] ? 'text-brand-orange/80' : 'text-neutral-500 dark:text-neutral-400'
|
||||
}`}>
|
||||
{item.description}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleToggle(item.key)}
|
||||
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 ${
|
||||
emailNotifications[item.key] ? 'bg-brand-orange' : 'bg-neutral-200 dark:bg-neutral-700'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
||||
emailNotifications[item.key] ? 'translate-x-5' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AccountManagementCard() {
|
||||
const accountLinks = [
|
||||
{
|
||||
label: 'Profile & Personal Info',
|
||||
description: 'Update your name, email, and avatar',
|
||||
href: 'https://auth.ciphera.net/settings',
|
||||
icon: UserIcon,
|
||||
},
|
||||
{
|
||||
label: 'Security & 2FA',
|
||||
description: 'Password, two-factor authentication, and passkeys',
|
||||
href: 'https://auth.ciphera.net/settings?tab=security',
|
||||
icon: LockIcon,
|
||||
},
|
||||
{
|
||||
label: 'Active Sessions',
|
||||
description: 'Manage devices logged into your account',
|
||||
href: 'https://auth.ciphera.net/settings?tab=sessions',
|
||||
icon: BoxIcon,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-neutral-900 rounded-2xl border border-neutral-200 dark:border-neutral-800 p-6 shadow-sm">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="p-2 rounded-lg bg-brand-orange/10">
|
||||
<UserIcon className="w-5 h-5 text-brand-orange" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-white">Ciphera Account</h2>
|
||||
<p className="text-sm text-neutral-500">Manage your account across all Ciphera products</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{accountLinks.map((link) => (
|
||||
<a
|
||||
key={link.label}
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-start gap-3 p-3 rounded-xl border border-neutral-200 dark:border-neutral-800 hover:border-brand-orange/30 hover:bg-brand-orange/5 transition-all group"
|
||||
>
|
||||
<link.icon className="w-5 h-5 text-neutral-400 group-hover:text-brand-orange shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-neutral-900 dark:text-white group-hover:text-brand-orange">
|
||||
{link.label}
|
||||
</span>
|
||||
<ExternalLinkIcon className="w-3.5 h-3.5 text-neutral-400" />
|
||||
</div>
|
||||
<p className="text-sm text-neutral-500 mt-0.5">{link.description}</p>
|
||||
</div>
|
||||
<ChevronRightIcon className="w-4 h-4 text-neutral-400 shrink-0 mt-1" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-800">
|
||||
<p className="text-xs text-neutral-500">
|
||||
These settings apply to your Ciphera Account and affect all products (Drop, Pulse, and Auth).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Main Settings Section ---
|
||||
|
||||
function AppSettingsSection() {
|
||||
const [active, setActive] = useState<ActiveSelection>({ section: 'profile', subTab: 'profile' })
|
||||
const [expanded, setExpanded] = useState<Set<ExpandableSection>>(new Set(['profile']))
|
||||
|
||||
const toggleSection = (section: ExpandableSection) => {
|
||||
setExpanded(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(section)) {
|
||||
next.delete(section)
|
||||
} else {
|
||||
next.add(section)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const selectSubTab = (selection: ActiveSelection) => {
|
||||
setActive(selection)
|
||||
if ('subTab' in selection) {
|
||||
setExpanded(prev => new Set(prev).add(selection.section as ExpandableSection))
|
||||
}
|
||||
}
|
||||
|
||||
const renderContent = () => {
|
||||
switch (active.section) {
|
||||
case 'profile':
|
||||
return <ProfileSettings activeTab={active.subTab} />
|
||||
case 'notifications':
|
||||
if (active.subTab === 'security') return <SecurityAlertsCard />
|
||||
if (active.subTab === 'center') return (
|
||||
<div className="bg-white dark:bg-neutral-900 rounded-2xl border border-neutral-200 dark:border-neutral-800 p-8 shadow-sm">
|
||||
<div className="text-center max-w-md mx-auto">
|
||||
<BellIcon className="w-12 h-12 text-neutral-300 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-neutral-900 dark:text-white mb-2">Notification Center</h3>
|
||||
<p className="text-sm text-neutral-500 mb-4">
|
||||
View and manage all your notifications in one place.
|
||||
</p>
|
||||
<Link
|
||||
href="/notifications"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-brand-orange text-white rounded-lg hover:bg-brand-orange/90 transition-colors"
|
||||
>
|
||||
Open Notification Center
|
||||
<ChevronRightIcon className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
return null
|
||||
case 'account':
|
||||
return <AccountManagementCard />
|
||||
case 'devices':
|
||||
return <TrustedDevicesCard />
|
||||
case 'activity':
|
||||
return <SecurityActivityCard />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col lg:flex-row gap-8">
|
||||
{/* Sidebar Navigation */}
|
||||
<nav className="w-full lg:w-72 flex-shrink-0 space-y-6">
|
||||
{/* Pulse Settings Section */}
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-neutral-400 uppercase tracking-wider mb-3 px-4">
|
||||
Pulse Settings
|
||||
</h3>
|
||||
<div className="space-y-1">
|
||||
<div>
|
||||
<SectionHeader
|
||||
expanded={expanded.has('profile')}
|
||||
active={active.section === 'profile'}
|
||||
onToggle={() => {
|
||||
toggleSection('profile')
|
||||
if (!expanded.has('profile')) {
|
||||
selectSubTab({ section: 'profile', subTab: 'profile' })
|
||||
}
|
||||
}}
|
||||
icon={UserIcon}
|
||||
label="Profile & Preferences"
|
||||
description="Your profile and sharing defaults"
|
||||
/>
|
||||
<ExpandableSubItems expanded={expanded.has('profile')}>
|
||||
<SubItem
|
||||
active={active.section === 'profile' && active.subTab === 'profile'}
|
||||
onClick={() => selectSubTab({ section: 'profile', subTab: 'profile' })}
|
||||
label="Profile"
|
||||
/>
|
||||
<SubItem
|
||||
active={active.section === 'profile' && active.subTab === 'security'}
|
||||
onClick={() => selectSubTab({ section: 'profile', subTab: 'security' })}
|
||||
label="Security"
|
||||
/>
|
||||
<SubItem
|
||||
active={active.section === 'profile' && active.subTab === 'preferences'}
|
||||
onClick={() => selectSubTab({ section: 'profile', subTab: 'preferences' })}
|
||||
label="Preferences"
|
||||
/>
|
||||
</ExpandableSubItems>
|
||||
</div>
|
||||
|
||||
{/* Notifications (expandable) */}
|
||||
<div>
|
||||
<SectionHeader
|
||||
expanded={expanded.has('notifications')}
|
||||
active={active.section === 'notifications'}
|
||||
onToggle={() => {
|
||||
toggleSection('notifications')
|
||||
if (!expanded.has('notifications')) {
|
||||
selectSubTab({ section: 'notifications', subTab: 'security' })
|
||||
}
|
||||
}}
|
||||
icon={BellIcon}
|
||||
label="Notifications"
|
||||
description="Email and in-app notifications"
|
||||
/>
|
||||
<ExpandableSubItems expanded={expanded.has('notifications')}>
|
||||
<SubItem
|
||||
active={active.section === 'notifications' && active.subTab === 'security'}
|
||||
onClick={() => selectSubTab({ section: 'notifications', subTab: 'security' })}
|
||||
label="Security Alerts"
|
||||
/>
|
||||
<SubItem
|
||||
active={active.section === 'notifications' && active.subTab === 'center'}
|
||||
onClick={() => selectSubTab({ section: 'notifications', subTab: 'center' })}
|
||||
label="Notification Center"
|
||||
/>
|
||||
</ExpandableSubItems>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ciphera Account Section */}
|
||||
<div className="pt-4 border-t border-neutral-200 dark:border-neutral-800">
|
||||
<h3 className="text-xs font-semibold text-neutral-400 uppercase tracking-wider mb-3 px-4">
|
||||
Ciphera Account
|
||||
</h3>
|
||||
<div>
|
||||
<SectionHeader
|
||||
expanded={expanded.has('account')}
|
||||
active={active.section === 'account' || active.section === 'devices' || active.section === 'activity'}
|
||||
onToggle={() => {
|
||||
toggleSection('account')
|
||||
if (!expanded.has('account')) {
|
||||
setActive({ section: 'account' })
|
||||
}
|
||||
}}
|
||||
icon={LockIcon}
|
||||
label="Manage Account"
|
||||
description="Security, 2FA, and sessions"
|
||||
/>
|
||||
<ExpandableSubItems expanded={expanded.has('account')}>
|
||||
<SubItem
|
||||
active={false}
|
||||
onClick={() => window.open('https://auth.ciphera.net/settings', '_blank')}
|
||||
label="Profile & Personal Info"
|
||||
external
|
||||
/>
|
||||
<SubItem
|
||||
active={false}
|
||||
onClick={() => window.open('https://auth.ciphera.net/settings?tab=security', '_blank')}
|
||||
label="Security & 2FA"
|
||||
external
|
||||
/>
|
||||
<SubItem
|
||||
active={false}
|
||||
onClick={() => window.open('https://auth.ciphera.net/settings?tab=sessions', '_blank')}
|
||||
label="Active Sessions"
|
||||
external
|
||||
/>
|
||||
<SubItem
|
||||
active={active.section === 'devices'}
|
||||
onClick={() => setActive({ section: 'devices' })}
|
||||
label="Trusted Devices"
|
||||
/>
|
||||
<SubItem
|
||||
active={active.section === 'activity'}
|
||||
onClick={() => setActive({ section: 'activity' })}
|
||||
label="Security Activity"
|
||||
/>
|
||||
</ExpandableSubItems>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{renderContent()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SettingsPageClient() {
|
||||
const { user } = useAuth()
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Page Header */}
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-neutral-900 dark:text-white">Settings</h1>
|
||||
<p className="mt-2 text-neutral-600 dark:text-neutral-400">
|
||||
Manage your Pulse preferences and Ciphera account settings
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Breadcrumb / Context */}
|
||||
<div className="flex items-center gap-2 text-sm text-neutral-500">
|
||||
<span>You are signed in as</span>
|
||||
<span className="font-medium text-neutral-900 dark:text-white">{user?.email}</span>
|
||||
<span>•</span>
|
||||
<a
|
||||
href="https://auth.ciphera.net/settings"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-brand-orange hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
Manage in Ciphera Account
|
||||
<ExternalLinkIcon className="w-3 h-3" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Settings Content */}
|
||||
<AppSettingsSection />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import SettingsPageClient from './SettingsPageClient'
|
||||
|
||||
export const metadata = {
|
||||
title: 'Settings - Pulse',
|
||||
description: 'Manage your account settings',
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
return (
|
||||
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
|
||||
<SettingsPageClient />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -238,6 +238,7 @@ export default function PublicDashboardPage() {
|
||||
setCaptchaToken(token || '')
|
||||
}}
|
||||
apiUrl={process.env.NEXT_PUBLIC_CAPTCHA_API_URL}
|
||||
action="share-access"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
|
||||
20
app/sites/[id]/SiteLayoutShell.tsx
Normal file
20
app/sites/[id]/SiteLayoutShell.tsx
Normal 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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
168
app/sites/[id]/behavior/page.tsx
Normal file
168
app/sites/[id]/behavior/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 */}
|
||||
@@ -278,7 +194,7 @@ export default function FunnelReportPage() {
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-800">
|
||||
{stats.steps.map((step, i) => (
|
||||
<tr key={i} className="hover:bg-neutral-50 dark:hover:bg-neutral-800/30 transition-colors">
|
||||
<tr key={step.step.name} className="hover:bg-neutral-50 dark:hover:bg-neutral-800/30 transition-colors">
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-6 h-6 rounded-full bg-neutral-100 dark:bg-neutral-800 flex items-center justify-center text-xs font-medium text-neutral-600 dark:text-neutral-400">
|
||||
|
||||
@@ -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`}
|
||||
@@ -149,7 +149,7 @@ export default function CreateFunnelPage() {
|
||||
</div>
|
||||
|
||||
{steps.map((step, index) => (
|
||||
<div key={index} className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-4">
|
||||
<div key={`step-${index}`} className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="mt-3 text-neutral-400">
|
||||
<div className="w-6 h-6 rounded-full bg-neutral-100 dark:bg-neutral-800 flex items-center justify-center text-sm font-medium text-neutral-600 dark:text-neutral-400">
|
||||
|
||||
@@ -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 ? (
|
||||
@@ -117,7 +109,7 @@ export default function FunnelsPage() {
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-4">
|
||||
{funnel.steps.map((step, i) => (
|
||||
<div key={i} className="flex items-center text-sm text-neutral-500">
|
||||
<div key={step.name} className="flex items-center text-sm text-neutral-500">
|
||||
<span className="px-2 py-1 bg-neutral-100 dark:bg-neutral-800 rounded-lg text-neutral-700 dark:text-neutral-300">
|
||||
{step.name}
|
||||
</span>
|
||||
|
||||
13
app/sites/[id]/journeys/error.tsx
Normal file
13
app/sites/[id]/journeys/error.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
9
app/sites/[id]/journeys/layout.tsx
Normal file
9
app/sites/[id]/journeys/layout.tsx
Normal 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
|
||||
}
|
||||
179
app/sites/[id]/journeys/page.tsx
Normal file
179
app/sites/[id]/journeys/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -1,99 +1,389 @@
|
||||
'use client'
|
||||
|
||||
import { useAuth } from '@/lib/auth/context'
|
||||
|
||||
import { logger } from '@/lib/utils/logger'
|
||||
import { useCallback, useEffect, useState, useRef } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { motion } from 'framer-motion'
|
||||
import { getSite, type Site } from '@/lib/api/sites'
|
||||
import { getStats, getRealtime, getDailyStats, getTopPages, getTopReferrers, getCountries, getCities, getRegions, getBrowsers, getOS, getDevices, getScreenResolutions, getEntryPages, getExitPages, getDashboard, getCampaigns, getPerformanceByPage, type Stats, type DailyStat, type PerformanceByPageStat } from '@/lib/api/stats'
|
||||
import { formatNumber, formatDuration, getDateRange } from '@ciphera-net/ui'
|
||||
import { useCallback, useEffect, useRef, useState, useMemo } from 'react'
|
||||
import { useParams, useRouter, useSearchParams } from 'next/navigation'
|
||||
import {
|
||||
getPerformanceByPage,
|
||||
getTopPages,
|
||||
getTopReferrers,
|
||||
getCountries,
|
||||
getCities,
|
||||
getRegions,
|
||||
getBrowsers,
|
||||
getOS,
|
||||
getDevices,
|
||||
getCampaigns,
|
||||
type Stats,
|
||||
type DailyStat,
|
||||
} from '@/lib/api/stats'
|
||||
import { getDateRange, formatDate } from '@ciphera-net/ui'
|
||||
import { toast } from '@ciphera-net/ui'
|
||||
import { getAuthErrorMessage } from '@ciphera-net/ui'
|
||||
import { LoadingOverlay, Button } 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 Campaigns from '@/components/dashboard/Campaigns'
|
||||
|
||||
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 {
|
||||
useDashboard,
|
||||
useRealtime,
|
||||
useStats,
|
||||
useDailyStats,
|
||||
useCampaigns,
|
||||
useAnnotations,
|
||||
} from '@/lib/swr/dashboard'
|
||||
import { createAnnotation, updateAnnotation, deleteAnnotation, type AnnotationCategory } from '@/lib/api/annotations'
|
||||
|
||||
function loadSavedSettings(): {
|
||||
type?: string
|
||||
dateRange?: { start: string; end: string }
|
||||
todayInterval?: 'minute' | 'hour'
|
||||
multiDayInterval?: 'hour' | 'day'
|
||||
} | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
try {
|
||||
const saved = localStorage.getItem('pulse_dashboard_settings')
|
||||
return saved ? JSON.parse(saved) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
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 = 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
|
||||
|
||||
const [site, setSite] = useState<Site | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [stats, setStats] = useState<Stats>({ pageviews: 0, visitors: 0, bounce_rate: 0, avg_duration: 0 })
|
||||
const [prevStats, setPrevStats] = useState<Stats | undefined>(undefined)
|
||||
const [realtime, setRealtime] = useState(0)
|
||||
const [dailyStats, setDailyStats] = useState<DailyStat[]>([])
|
||||
const [prevDailyStats, setPrevDailyStats] = useState<DailyStat[] | undefined>(undefined)
|
||||
const [topPages, setTopPages] = useState<any[]>([])
|
||||
const [entryPages, setEntryPages] = useState<any[]>([])
|
||||
const [exitPages, setExitPages] = useState<any[]>([])
|
||||
const [topReferrers, setTopReferrers] = useState<any[]>([])
|
||||
const [countries, setCountries] = useState<any[]>([])
|
||||
const [cities, setCities] = useState<any[]>([])
|
||||
const [regions, setRegions] = useState<any[]>([])
|
||||
const [browsers, setBrowsers] = useState<any[]>([])
|
||||
const [os, setOS] = useState<any[]>([])
|
||||
const [devices, setDevices] = useState<any[]>([])
|
||||
const [screenResolutions, setScreenResolutions] = useState<any[]>([])
|
||||
const [performance, setPerformance] = useState<{ lcp: number, cls: number, inp: number }>({ lcp: 0, cls: 0, inp: 0 })
|
||||
const [performanceByPage, setPerformanceByPage] = useState<PerformanceByPageStat[] | null>(null)
|
||||
const [goalCounts, setGoalCounts] = useState<Array<{ event_name: string; count: number }>>([])
|
||||
const [campaigns, setCampaigns] = useState<any[]>([])
|
||||
const [dateRange, setDateRange] = useState(getDateRange(30))
|
||||
// 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'
|
||||
)
|
||||
const [multiDayInterval, setMultiDayInterval] = useState<'hour' | 'day'>(
|
||||
() => loadSavedSettings()?.multiDayInterval || 'day'
|
||||
)
|
||||
const [isDatePickerOpen, setIsDatePickerOpen] = useState(false)
|
||||
const [isExportModalOpen, setIsExportModalOpen] = useState(false)
|
||||
const [todayInterval, setTodayInterval] = useState<'minute' | 'hour'>('hour')
|
||||
const [multiDayInterval, setMultiDayInterval] = useState<'hour' | 'day'>('day')
|
||||
const [isSettingsLoaded, setIsSettingsLoaded] = useState(false)
|
||||
const [lastUpdatedAt, setLastUpdatedAt] = useState<number | null>(null)
|
||||
const [, setTick] = useState(0)
|
||||
const lastUpdatedAtRef = useRef<number | null>(null)
|
||||
|
||||
// Load settings from localStorage
|
||||
useEffect(() => {
|
||||
try {
|
||||
const savedSettings = localStorage.getItem('pulse_dashboard_settings')
|
||||
if (savedSettings) {
|
||||
const settings = JSON.parse(savedSettings)
|
||||
|
||||
// Restore date range
|
||||
if (settings.type === 'today') {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
setDateRange({ start: today, end: today })
|
||||
} else if (settings.type === '7') {
|
||||
setDateRange(getDateRange(7))
|
||||
} else if (settings.type === '30') {
|
||||
setDateRange(getDateRange(30))
|
||||
} else if (settings.type === 'custom' && settings.dateRange) {
|
||||
setDateRange(settings.dateRange)
|
||||
}
|
||||
// Dimension filters state
|
||||
const searchParams = useSearchParams()
|
||||
const [filters, setFilters] = useState<DimensionFilter[]>(() => {
|
||||
const raw = searchParams.get('filters')
|
||||
return raw ? parseFiltersFromURL(raw) : []
|
||||
})
|
||||
const filtersParam = useMemo(() => serializeFilters(filters), [filters])
|
||||
|
||||
// Restore intervals
|
||||
if (settings.todayInterval) setTodayInterval(settings.todayInterval)
|
||||
if (settings.multiDayInterval) setMultiDayInterval(settings.multiDayInterval)
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Failed to load dashboard settings', e)
|
||||
} finally {
|
||||
setIsSettingsLoaded(true)
|
||||
}
|
||||
// Selected event for property breakdown
|
||||
const [selectedEvent, setSelectedEvent] = useState<string | null>(null)
|
||||
|
||||
const handleAddFilter = useCallback((filter: DimensionFilter) => {
|
||||
setFilters(prev => {
|
||||
const isDuplicate = prev.some(
|
||||
f => f.dimension === filter.dimension && f.operator === filter.operator && f.values.join(';') === filter.values.join(';')
|
||||
)
|
||||
if (isDuplicate) return prev
|
||||
return [...prev, filter]
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleRemoveFilter = useCallback((index: number) => {
|
||||
setFilters(prev => prev.filter((_, i) => i !== index))
|
||||
}, [])
|
||||
|
||||
const handleClearFilters = useCallback(() => {
|
||||
setFilters([])
|
||||
}, [])
|
||||
|
||||
// Fetch full suggestion list (up to 100) when a dimension is selected in the filter dropdown
|
||||
const handleFetchSuggestions = useCallback(async (dimension: string): Promise<FilterSuggestion[]> => {
|
||||
const start = dateRange.start
|
||||
const end = dateRange.end
|
||||
const f = filtersParam || undefined
|
||||
const limit = 100
|
||||
|
||||
try {
|
||||
const regionNames = (() => { try { return new Intl.DisplayNames(['en'], { type: 'region' }) } catch { return null } })()
|
||||
|
||||
switch (dimension) {
|
||||
case 'page': {
|
||||
const data = await getTopPages(siteId, start, end, limit, f)
|
||||
return data.map(p => ({ value: p.path, label: p.path, count: p.pageviews }))
|
||||
}
|
||||
case 'referrer': {
|
||||
const data = await getTopReferrers(siteId, start, end, limit, f)
|
||||
return data.filter(r => r.referrer && r.referrer !== '').map(r => ({ value: r.referrer, label: r.referrer, count: r.pageviews }))
|
||||
}
|
||||
case 'country': {
|
||||
const data = await getCountries(siteId, start, end, limit, f)
|
||||
return data.filter(c => c.country && c.country !== 'Unknown').map(c => ({ value: c.country, label: regionNames?.of(c.country) ?? c.country, count: c.pageviews }))
|
||||
}
|
||||
case 'city': {
|
||||
const data = await getCities(siteId, start, end, limit, f)
|
||||
return data.filter(c => c.city && c.city !== 'Unknown').map(c => ({ value: c.city, label: c.city, count: c.pageviews }))
|
||||
}
|
||||
case 'region': {
|
||||
const data = await getRegions(siteId, start, end, limit, f)
|
||||
return data.filter(r => r.region && r.region !== 'Unknown').map(r => ({ value: r.region, label: r.region, count: r.pageviews }))
|
||||
}
|
||||
case 'browser': {
|
||||
const data = await getBrowsers(siteId, start, end, limit, f)
|
||||
return data.filter(b => b.browser && b.browser !== 'Unknown').map(b => ({ value: b.browser, label: b.browser, count: b.pageviews }))
|
||||
}
|
||||
case 'os': {
|
||||
const data = await getOS(siteId, start, end, limit, f)
|
||||
return data.filter(o => o.os && o.os !== 'Unknown').map(o => ({ value: o.os, label: o.os, count: o.pageviews }))
|
||||
}
|
||||
case 'device': {
|
||||
const data = await getDevices(siteId, start, end, limit, f)
|
||||
return data.filter(d => d.device && d.device !== 'Unknown').map(d => ({ value: d.device, label: d.device, count: d.pageviews }))
|
||||
}
|
||||
case 'utm_source':
|
||||
case 'utm_medium':
|
||||
case 'utm_campaign': {
|
||||
const data = await getCampaigns(siteId, start, end, limit, f)
|
||||
const map = new Map<string, number>()
|
||||
const field = dimension === 'utm_source' ? 'source' : dimension === 'utm_medium' ? 'medium' : 'campaign'
|
||||
data.forEach(c => {
|
||||
const val = c[field]
|
||||
if (val) map.set(val, (map.get(val) ?? 0) + c.pageviews)
|
||||
})
|
||||
return [...map.entries()].map(([v, count]) => ({ value: v, label: v, count }))
|
||||
}
|
||||
default:
|
||||
return []
|
||||
}
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}, [siteId, dateRange.start, dateRange.end, filtersParam])
|
||||
|
||||
// Sync filters to URL
|
||||
useEffect(() => {
|
||||
const url = new URL(window.location.href)
|
||||
if (filtersParam) {
|
||||
url.searchParams.set('filters', filtersParam)
|
||||
} else {
|
||||
url.searchParams.delete('filters')
|
||||
}
|
||||
window.history.replaceState({}, '', url.toString())
|
||||
}, [filtersParam])
|
||||
|
||||
const interval = dateRange.start === dateRange.end ? todayInterval : multiDayInterval
|
||||
|
||||
// Previous period date range for comparison
|
||||
const prevRange = useMemo(() => {
|
||||
const startDate = new Date(dateRange.start)
|
||||
const endDate = new Date(dateRange.end)
|
||||
const duration = endDate.getTime() - startDate.getTime()
|
||||
if (duration === 0) {
|
||||
const prevEnd = new Date(startDate.getTime() - 24 * 60 * 60 * 1000)
|
||||
return { start: prevEnd.toISOString().split('T')[0], end: prevEnd.toISOString().split('T')[0] }
|
||||
}
|
||||
const prevEnd = new Date(startDate.getTime() - 24 * 60 * 60 * 1000)
|
||||
const prevStart = new Date(prevEnd.getTime() - duration)
|
||||
return { start: prevStart.toISOString().split('T')[0], end: prevEnd.toISOString().split('T')[0] }
|
||||
}, [dateRange])
|
||||
|
||||
// Single dashboard request replaces 7 focused hooks (overview, pages, locations,
|
||||
// devices, referrers, performance, goals). The backend runs all queries in parallel
|
||||
// and caches the result in Redis, reducing requests from 12 to 6 per refresh cycle.
|
||||
const { data: dashboard, isLoading: dashboardLoading, error: dashboardError } = useDashboard(siteId, dateRange.start, dateRange.end, interval, filtersParam || undefined)
|
||||
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)
|
||||
|
||||
// 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 = dashboard?.top_pages ?? []
|
||||
if (topPages.length > 0) {
|
||||
s.page = topPages.map(p => ({ value: p.path, label: p.path, count: p.pageviews }))
|
||||
}
|
||||
|
||||
// Referrers
|
||||
const refs = dashboard?.top_referrers ?? []
|
||||
if (refs.length > 0) {
|
||||
s.referrer = refs.filter(r => r.referrer && r.referrer !== '').map(r => ({
|
||||
value: r.referrer,
|
||||
label: r.referrer,
|
||||
count: r.pageviews,
|
||||
}))
|
||||
}
|
||||
|
||||
// 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 => ({
|
||||
value: c.country,
|
||||
label: regionNames?.of(c.country) ?? c.country,
|
||||
count: c.pageviews,
|
||||
}))
|
||||
}
|
||||
|
||||
// Regions
|
||||
const regs = dashboard?.regions ?? []
|
||||
if (regs.length > 0) {
|
||||
s.region = regs.filter(r => r.region && r.region !== 'Unknown').map(r => ({
|
||||
value: r.region,
|
||||
label: r.region,
|
||||
count: r.pageviews,
|
||||
}))
|
||||
}
|
||||
|
||||
// Cities
|
||||
const cts = dashboard?.cities ?? []
|
||||
if (cts.length > 0) {
|
||||
s.city = cts.filter(c => c.city && c.city !== 'Unknown').map(c => ({
|
||||
value: c.city,
|
||||
label: c.city,
|
||||
count: c.pageviews,
|
||||
}))
|
||||
}
|
||||
|
||||
// Browsers
|
||||
const brs = dashboard?.browsers ?? []
|
||||
if (brs.length > 0) {
|
||||
s.browser = brs.filter(b => b.browser && b.browser !== 'Unknown').map(b => ({
|
||||
value: b.browser,
|
||||
label: b.browser,
|
||||
count: b.pageviews,
|
||||
}))
|
||||
}
|
||||
|
||||
// OS
|
||||
const oses = dashboard?.os ?? []
|
||||
if (oses.length > 0) {
|
||||
s.os = oses.filter(o => o.os && o.os !== 'Unknown').map(o => ({
|
||||
value: o.os,
|
||||
label: o.os,
|
||||
count: o.pageviews,
|
||||
}))
|
||||
}
|
||||
|
||||
// Devices
|
||||
const devs = dashboard?.devices ?? []
|
||||
if (devs.length > 0) {
|
||||
s.device = devs.filter(d => d.device && d.device !== 'Unknown').map(d => ({
|
||||
value: d.device,
|
||||
label: d.device,
|
||||
count: d.pageviews,
|
||||
}))
|
||||
}
|
||||
|
||||
// UTM from campaigns
|
||||
const camps = campaigns ?? []
|
||||
if (camps.length > 0) {
|
||||
const sources = new Map<string, number>()
|
||||
const mediums = new Map<string, number>()
|
||||
const campNames = new Map<string, number>()
|
||||
camps.forEach(c => {
|
||||
if (c.source) sources.set(c.source, (sources.get(c.source) ?? 0) + c.pageviews)
|
||||
if (c.medium) mediums.set(c.medium, (mediums.get(c.medium) ?? 0) + c.pageviews)
|
||||
if (c.campaign) campNames.set(c.campaign, (campNames.get(c.campaign) ?? 0) + c.pageviews)
|
||||
})
|
||||
if (sources.size > 0) s.utm_source = [...sources.entries()].map(([v, c]) => ({ value: v, label: v, count: c }))
|
||||
if (mediums.size > 0) s.utm_medium = [...mediums.entries()].map(([v, c]) => ({ value: v, label: v, count: c }))
|
||||
if (campNames.size > 0) s.utm_campaign = [...campNames.entries()].map(([v, c]) => ({ value: v, label: v, count: c }))
|
||||
}
|
||||
|
||||
return s
|
||||
}, [dashboard, campaigns])
|
||||
|
||||
// Show error toast on fetch failure
|
||||
useEffect(() => {
|
||||
if (dashboardError) {
|
||||
toast.error('Failed to load dashboard analytics')
|
||||
}
|
||||
}, [dashboardError])
|
||||
|
||||
// Track when data was last updated (for "Live · Xs ago" display)
|
||||
useEffect(() => {
|
||||
if (dashboard) lastUpdatedAtRef.current = Date.now()
|
||||
}, [dashboard])
|
||||
|
||||
// Save settings to localStorage
|
||||
const saveSettings = (type: string, newDateRange?: { start: string, end: string }) => {
|
||||
const saveSettings = (type: string, newDateRange?: { start: string; end: string }) => {
|
||||
try {
|
||||
const settings = {
|
||||
type,
|
||||
@@ -110,11 +400,8 @@ export default function SiteDashboardPage() {
|
||||
|
||||
// Save intervals when they change
|
||||
useEffect(() => {
|
||||
if (!isSettingsLoaded) return
|
||||
|
||||
// Determine current type
|
||||
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'
|
||||
@@ -127,160 +414,15 @@ export default function SiteDashboardPage() {
|
||||
lastUpdated: Date.now()
|
||||
}
|
||||
localStorage.setItem('pulse_dashboard_settings', JSON.stringify(settings))
|
||||
}, [todayInterval, multiDayInterval, isSettingsLoaded]) // dateRange is handled in saveSettings/onChange
|
||||
|
||||
// * Tick every 1s so "Live · Xs ago" counts in real time
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => setTick((t) => t + 1), 1000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
const getPreviousDateRange = useCallback((start: string, end: string) => {
|
||||
const startDate = new Date(start)
|
||||
const endDate = new Date(end)
|
||||
const duration = endDate.getTime() - startDate.getTime()
|
||||
if (duration === 0) {
|
||||
const prevEnd = new Date(startDate.getTime() - 24 * 60 * 60 * 1000)
|
||||
return { start: prevEnd.toISOString().split('T')[0], end: prevEnd.toISOString().split('T')[0] }
|
||||
}
|
||||
const prevEnd = new Date(startDate.getTime() - 24 * 60 * 60 * 1000)
|
||||
const prevStart = new Date(prevEnd.getTime() - duration)
|
||||
return { start: prevStart.toISOString().split('T')[0], end: prevEnd.toISOString().split('T')[0] }
|
||||
}, [])
|
||||
|
||||
// * Visibility-aware polling intervals
|
||||
// * Historical data: 60s when visible, paused when hidden
|
||||
// * Real-time data: 5s when visible, 30s when hidden
|
||||
const [isVisible, setIsVisible] = useState(true)
|
||||
const dashboardIntervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const realtimeIntervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
// * Track visibility state
|
||||
useEffect(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
const visible = document.visibilityState === 'visible'
|
||||
setIsVisible(visible)
|
||||
}
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
return () => document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
}, [])
|
||||
|
||||
const loadData = useCallback(async (silent = false) => {
|
||||
try {
|
||||
if (!silent) setLoading(true)
|
||||
const interval = dateRange.start === dateRange.end ? todayInterval : multiDayInterval
|
||||
|
||||
const [data, prevStatsData, prevDailyStatsData, campaignsData] = await Promise.all([
|
||||
getDashboard(siteId, dateRange.start, dateRange.end, 10, interval),
|
||||
(async () => {
|
||||
const prevRange = getPreviousDateRange(dateRange.start, dateRange.end)
|
||||
return getStats(siteId, prevRange.start, prevRange.end)
|
||||
})(),
|
||||
(async () => {
|
||||
const prevRange = getPreviousDateRange(dateRange.start, dateRange.end)
|
||||
return getDailyStats(siteId, prevRange.start, prevRange.end, interval)
|
||||
})(),
|
||||
getCampaigns(siteId, dateRange.start, dateRange.end, 100),
|
||||
])
|
||||
|
||||
setSite(data.site)
|
||||
setStats(data.stats || { pageviews: 0, visitors: 0, bounce_rate: 0, avg_duration: 0 })
|
||||
setRealtime(data.realtime_visitors || 0)
|
||||
setDailyStats(Array.isArray(data.daily_stats) ? data.daily_stats : [])
|
||||
|
||||
setPrevStats(prevStatsData)
|
||||
setPrevDailyStats(prevDailyStatsData)
|
||||
|
||||
setTopPages(Array.isArray(data.top_pages) ? data.top_pages : [])
|
||||
setEntryPages(Array.isArray(data.entry_pages) ? data.entry_pages : [])
|
||||
setExitPages(Array.isArray(data.exit_pages) ? data.exit_pages : [])
|
||||
setTopReferrers(Array.isArray(data.top_referrers) ? data.top_referrers : [])
|
||||
setCountries(Array.isArray(data.countries) ? data.countries : [])
|
||||
setCities(Array.isArray(data.cities) ? data.cities : [])
|
||||
setRegions(Array.isArray(data.regions) ? data.regions : [])
|
||||
setBrowsers(Array.isArray(data.browsers) ? data.browsers : [])
|
||||
setOS(Array.isArray(data.os) ? data.os : [])
|
||||
setDevices(Array.isArray(data.devices) ? data.devices : [])
|
||||
setScreenResolutions(Array.isArray(data.screen_resolutions) ? data.screen_resolutions : [])
|
||||
setPerformance(data.performance || { lcp: 0, cls: 0, inp: 0 })
|
||||
setPerformanceByPage(data.performance_by_page ?? null)
|
||||
setGoalCounts(Array.isArray(data.goal_counts) ? data.goal_counts : [])
|
||||
setCampaigns(Array.isArray(campaignsData) ? campaignsData : [])
|
||||
setLastUpdatedAt(Date.now())
|
||||
} catch (error: unknown) {
|
||||
if (!silent) {
|
||||
toast.error(getAuthErrorMessage(error) || 'Failed to load dashboard analytics')
|
||||
}
|
||||
} finally {
|
||||
if (!silent) setLoading(false)
|
||||
}
|
||||
}, [siteId, dateRange, todayInterval, multiDayInterval])
|
||||
|
||||
const loadRealtime = useCallback(async () => {
|
||||
try {
|
||||
const data = await getRealtime(siteId)
|
||||
setRealtime(data.visitors)
|
||||
} catch (error) {
|
||||
// * Silently fail for realtime updates
|
||||
}
|
||||
}, [siteId])
|
||||
|
||||
// * Visibility-aware polling for dashboard data (historical)
|
||||
// * Refreshes every 60 seconds when tab is visible, pauses when hidden
|
||||
useEffect(() => {
|
||||
if (!isSettingsLoaded) return
|
||||
|
||||
// * Initial load
|
||||
loadData()
|
||||
|
||||
// * Clear existing interval
|
||||
if (dashboardIntervalRef.current) {
|
||||
clearInterval(dashboardIntervalRef.current)
|
||||
}
|
||||
|
||||
// * Only poll when visible (saves server resources when tab is backgrounded)
|
||||
if (isVisible) {
|
||||
dashboardIntervalRef.current = setInterval(() => {
|
||||
loadData(true)
|
||||
}, 60000) // * 60 seconds for historical data
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (dashboardIntervalRef.current) {
|
||||
clearInterval(dashboardIntervalRef.current)
|
||||
}
|
||||
}
|
||||
}, [siteId, dateRange, todayInterval, multiDayInterval, isSettingsLoaded, loadData, isVisible])
|
||||
|
||||
// * Visibility-aware polling for realtime data
|
||||
// * Refreshes every 5 seconds when visible, every 30 seconds when hidden
|
||||
useEffect(() => {
|
||||
if (!isSettingsLoaded) return
|
||||
|
||||
// * Clear existing interval
|
||||
if (realtimeIntervalRef.current) {
|
||||
clearInterval(realtimeIntervalRef.current)
|
||||
}
|
||||
|
||||
// * Different intervals based on visibility
|
||||
const interval = isVisible ? 5000 : 30000 // * 5s visible, 30s hidden
|
||||
|
||||
realtimeIntervalRef.current = setInterval(() => {
|
||||
loadRealtime()
|
||||
}, interval)
|
||||
|
||||
return () => {
|
||||
if (realtimeIntervalRef.current) {
|
||||
clearInterval(realtimeIntervalRef.current)
|
||||
}
|
||||
}
|
||||
}, [siteId, isSettingsLoaded, loadRealtime, isVisible])
|
||||
}, [todayInterval, multiDayInterval]) // eslint-disable-line react-hooks/exhaustive-deps -- dateRange saved via saveSettings
|
||||
|
||||
useEffect(() => {
|
||||
if (site?.domain) document.title = `${site.domain} | Pulse`
|
||||
}, [site?.domain])
|
||||
|
||||
const showSkeleton = useMinimumLoading(loading)
|
||||
// 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 />
|
||||
@@ -288,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">
|
||||
@@ -312,11 +449,10 @@ export default function SiteDashboardPage() {
|
||||
{site.domain}
|
||||
</p>
|
||||
</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>
|
||||
@@ -325,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">
|
||||
@@ -341,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)
|
||||
}
|
||||
}}
|
||||
@@ -375,57 +513,44 @@ 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>
|
||||
|
||||
{/* Dimension Filters */}
|
||||
<div className="flex items-center gap-2 flex-wrap mb-2">
|
||||
<AddFilterDropdown onAdd={handleAddFilter} suggestions={filterSuggestions} onFetchSuggestions={handleFetchSuggestions} />
|
||||
<FilterBar filters={filters} onRemove={handleRemoveFilter} onClear={handleClearFilters} />
|
||||
</div>
|
||||
|
||||
{/* Advanced Chart with Integrated Stats */}
|
||||
<div className="mb-8">
|
||||
<Chart
|
||||
data={dailyStats}
|
||||
<Chart
|
||||
data={dailyStats}
|
||||
prevData={prevDailyStats}
|
||||
stats={stats}
|
||||
stats={stats}
|
||||
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>
|
||||
|
||||
@@ -433,8 +558,8 @@ export default function SiteDashboardPage() {
|
||||
{site.enable_performance_insights && (
|
||||
<div className="mb-8">
|
||||
<PerformanceStats
|
||||
stats={performance}
|
||||
performanceByPage={performanceByPage}
|
||||
stats={dashboard?.performance ?? { lcp: 0, cls: 0, inp: 0 }}
|
||||
performanceByPage={dashboard?.performance_by_page ?? null}
|
||||
siteId={siteId}
|
||||
startDate={dateRange.start}
|
||||
endDate={dateRange.end}
|
||||
@@ -445,57 +570,77 @@ export default function SiteDashboardPage() {
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2 mb-8">
|
||||
<ContentStats
|
||||
topPages={topPages}
|
||||
entryPages={entryPages}
|
||||
exitPages={exitPages}
|
||||
topPages={dashboard?.top_pages ?? []}
|
||||
entryPages={dashboard?.entry_pages ?? []}
|
||||
exitPages={dashboard?.exit_pages ?? []}
|
||||
domain={site.domain}
|
||||
collectPagePaths={site.collect_page_paths ?? true}
|
||||
siteId={siteId}
|
||||
dateRange={dateRange}
|
||||
onFilter={handleAddFilter}
|
||||
/>
|
||||
<TopReferrers
|
||||
referrers={topReferrers}
|
||||
referrers={dashboard?.top_referrers ?? []}
|
||||
collectReferrers={site.collect_referrers ?? true}
|
||||
siteId={siteId}
|
||||
dateRange={dateRange}
|
||||
onFilter={handleAddFilter}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2 mb-8">
|
||||
<Locations
|
||||
countries={countries}
|
||||
cities={cities}
|
||||
regions={regions}
|
||||
countries={dashboard?.countries ?? []}
|
||||
cities={dashboard?.cities ?? []}
|
||||
regions={dashboard?.regions ?? []}
|
||||
geoDataLevel={site.collect_geo_data || 'full'}
|
||||
siteId={siteId}
|
||||
dateRange={dateRange}
|
||||
onFilter={handleAddFilter}
|
||||
/>
|
||||
<TechSpecs
|
||||
browsers={browsers}
|
||||
os={os}
|
||||
devices={devices}
|
||||
screenResolutions={screenResolutions}
|
||||
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}
|
||||
dateRange={dateRange}
|
||||
onFilter={handleAddFilter}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Campaigns Report */}
|
||||
<div className="mb-8">
|
||||
<Campaigns siteId={siteId} dateRange={dateRange} />
|
||||
<div className="grid gap-6 lg:grid-cols-2 mb-8">
|
||||
<Campaigns siteId={siteId} dateRange={dateRange} filters={filtersParam || undefined} onFilter={handleAddFilter} />
|
||||
<PeakHours siteId={siteId} dateRange={dateRange} />
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<GoalStats goalCounts={goalCounts} />
|
||||
<GoalStats
|
||||
goalCounts={(dashboard?.goal_counts ?? []).filter(g => !/^scroll_\d+$/.test(g.event_name))}
|
||||
onSelectEvent={setSelectedEvent}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Event Properties Breakdown */}
|
||||
{selectedEvent && (
|
||||
<div className="mb-8">
|
||||
<EventProperties
|
||||
siteId={siteId}
|
||||
eventName={selectedEvent}
|
||||
dateRange={dateRange}
|
||||
onClose={() => setSelectedEvent(null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DatePicker
|
||||
isOpen={isDatePickerOpen}
|
||||
onClose={() => setIsDatePickerOpen(false)}
|
||||
onApply={(range) => {
|
||||
setDateRange(range)
|
||||
setPeriod('custom')
|
||||
saveSettings('custom', range)
|
||||
setIsDatePickerOpen(false)
|
||||
}}
|
||||
@@ -507,10 +652,10 @@ export default function SiteDashboardPage() {
|
||||
onClose={() => setIsExportModalOpen(false)}
|
||||
data={dailyStats}
|
||||
stats={stats}
|
||||
topPages={topPages}
|
||||
topReferrers={topReferrers}
|
||||
topPages={dashboard?.top_pages}
|
||||
topReferrers={dashboard?.top_referrers}
|
||||
campaigns={campaigns}
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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">
|
||||
← 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)
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
interface PasswordInputProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
label?: string
|
||||
placeholder?: string
|
||||
error?: string | null
|
||||
disabled?: boolean
|
||||
required?: boolean
|
||||
className?: string
|
||||
id?: string
|
||||
autoComplete?: string
|
||||
minLength?: number
|
||||
onFocus?: () => void
|
||||
onBlur?: () => void
|
||||
}
|
||||
|
||||
export default function PasswordInput({
|
||||
value,
|
||||
onChange,
|
||||
label = 'Password',
|
||||
placeholder = 'Enter password',
|
||||
error,
|
||||
disabled = false,
|
||||
required = false,
|
||||
className = '',
|
||||
id,
|
||||
autoComplete,
|
||||
minLength,
|
||||
onFocus,
|
||||
onBlur
|
||||
}: PasswordInputProps) {
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const inputId = id || 'password-input'
|
||||
const errorId = `${inputId}-error`
|
||||
|
||||
return (
|
||||
<div className={`space-y-1.5 ${className}`}>
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className="block text-sm font-medium text-neutral-700 dark:text-neutral-300"
|
||||
>
|
||||
{label}
|
||||
{required && <span className="text-brand-orange text-xs ml-1">(Required)</span>}
|
||||
</label>
|
||||
)}
|
||||
<div className="relative group">
|
||||
<input
|
||||
id={inputId}
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
autoComplete={autoComplete}
|
||||
minLength={minLength}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
aria-invalid={!!error}
|
||||
aria-describedby={error ? errorId : undefined}
|
||||
className={`w-full pl-11 pr-12 py-3 border rounded-lg bg-neutral-50/50 dark:bg-neutral-900/50 focus:bg-white dark:focus:bg-neutral-900
|
||||
transition-all duration-200 outline-none disabled:opacity-50 disabled:cursor-not-allowed dark:text-white
|
||||
${error
|
||||
? 'border-red-300 dark:border-red-800 focus:border-red-500 focus:ring-4 focus:ring-red-500/10'
|
||||
: 'border-neutral-200 dark:border-neutral-800 hover:border-brand-orange/50 focus:border-brand-orange focus:ring-4 focus:ring-brand-orange/10'
|
||||
}`}
|
||||
/>
|
||||
|
||||
{/* Lock Icon (Left) */}
|
||||
<div className={`absolute left-3.5 top-1/2 -translate-y-1/2 pointer-events-none transition-colors duration-200
|
||||
${error ? 'text-red-400' : 'text-neutral-400 dark:text-neutral-500 group-focus-within:text-brand-orange'}`}>
|
||||
<svg aria-hidden="true" className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Toggle Visibility Button (Right) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
disabled={disabled}
|
||||
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-1.5 rounded-lg text-neutral-400 dark:text-neutral-500
|
||||
hover:text-neutral-600 dark:hover:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-all duration-200 focus:outline-none"
|
||||
>
|
||||
{showPassword ? (
|
||||
<svg aria-hidden="true" className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg aria-hidden="true" className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<p id={errorId} role="alert" className="text-xs text-red-500 font-medium ml-1">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
12
components/SWRProvider.tsx
Normal file
12
components/SWRProvider.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { PlusIcon, PersonIcon, CubeIcon, CheckIcon } from '@radix-ui/react-icons'
|
||||
import { switchContext, OrganizationMember } from '@/lib/api/organization'
|
||||
import { setSessionAction } from '@/app/actions/auth'
|
||||
import { logger } from '@/lib/utils/logger'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function OrganizationSwitcher({ orgs, activeOrgId }: { orgs: OrganizationMember[], activeOrgId: string | null }) {
|
||||
const router = useRouter()
|
||||
const [switching, setSwitching] = useState<string | null>(null)
|
||||
|
||||
const handleSwitch = async (orgId: string | null) => {
|
||||
setSwitching(orgId || 'personal')
|
||||
try {
|
||||
// * If orgId is null, we can't switch context via API in the same way if strict mode is on
|
||||
// * Pulse doesn't support personal organization context.
|
||||
// * So we should probably NOT show the "Personal" option in Pulse if strict mode is enforced.
|
||||
// * However, to match Drop exactly, we might want to show it but have it fail or redirect?
|
||||
// * Let's assume for now we want to match Drop's UI structure.
|
||||
|
||||
if (!orgId) {
|
||||
// * Pulse doesn't support personal context.
|
||||
// * We could redirect to onboarding or show an error.
|
||||
// * For now, let's just return to avoid breaking.
|
||||
return
|
||||
}
|
||||
|
||||
const { access_token } = await switchContext(orgId)
|
||||
|
||||
// * Update session cookie via server action
|
||||
// * Note: switchContext only returns access_token, we keep existing refresh token
|
||||
await setSessionAction(access_token)
|
||||
|
||||
sessionStorage.setItem('pulse_switching_org', 'true')
|
||||
window.location.reload()
|
||||
|
||||
} catch (err) {
|
||||
logger.error('Failed to switch organization', err)
|
||||
setSwitching(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-b border-neutral-100 dark:border-neutral-800 pb-2 mb-2" role="group" aria-label="Organizations">
|
||||
<div className="px-3 py-2 text-xs font-medium text-neutral-500 uppercase tracking-wider" aria-hidden="true">
|
||||
Organizations
|
||||
</div>
|
||||
|
||||
{/* Personal organization - HIDDEN IN PULSE (Strict Mode) */}
|
||||
{/*
|
||||
<button
|
||||
onClick={() => handleSwitch(null)}
|
||||
className={`w-full flex items-center justify-between px-3 py-2 text-sm rounded-lg transition-colors group ${
|
||||
!activeOrgId ? 'bg-neutral-100 dark:bg-neutral-800' : 'hover:bg-neutral-50 dark:hover:bg-neutral-800/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-5 w-5 rounded bg-neutral-200 dark:bg-neutral-700 flex items-center justify-center">
|
||||
<PersonIcon className="h-3 w-3 text-neutral-500 dark:text-neutral-400" />
|
||||
</div>
|
||||
<span className="text-neutral-700 dark:text-neutral-300">Personal</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{switching === 'personal' && <span className="text-xs text-neutral-400">Loading...</span>}
|
||||
{!activeOrgId && !switching && <CheckIcon className="h-4 w-4 text-neutral-600 dark:text-neutral-400" />}
|
||||
</div>
|
||||
</button>
|
||||
*/}
|
||||
|
||||
{/* Organization list */}
|
||||
{orgs.map((org) => (
|
||||
<button
|
||||
key={org.organization_id}
|
||||
onClick={() => handleSwitch(org.organization_id)}
|
||||
aria-current={activeOrgId === org.organization_id ? 'true' : undefined}
|
||||
aria-busy={switching === org.organization_id ? 'true' : undefined}
|
||||
className={`w-full flex items-center justify-between px-3 py-2 text-sm rounded-lg transition-colors mt-1 ${
|
||||
activeOrgId === org.organization_id ? 'bg-neutral-100 dark:bg-neutral-800' : 'hover:bg-neutral-50 dark:hover:bg-neutral-800/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-5 w-5 rounded bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
|
||||
<CubeIcon className="h-3 w-3 text-blue-600 dark:text-blue-400" aria-hidden="true" />
|
||||
</div>
|
||||
<span className="text-neutral-700 dark:text-neutral-300 truncate max-w-[140px]">
|
||||
{org.organization_name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{switching === org.organization_id && <span className="text-xs text-neutral-400" aria-live="polite">Switching…</span>}
|
||||
{activeOrgId === org.organization_id && !switching && (
|
||||
<>
|
||||
<CheckIcon className="h-4 w-4 text-neutral-600 dark:text-neutral-400" aria-hidden="true" />
|
||||
<span className="sr-only">(current)</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Create New */}
|
||||
<Link
|
||||
href="/onboarding"
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-neutral-500 hover:text-blue-600 dark:text-neutral-400 dark:hover:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/10 rounded-lg transition-colors mt-1"
|
||||
>
|
||||
<div className="h-5 w-5 rounded border border-dashed border-neutral-300 dark:border-neutral-600 flex items-center justify-center" aria-hidden="true">
|
||||
<PlusIcon className="h-3 w-3" />
|
||||
</div>
|
||||
<span>Create Organization</span>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
114
components/behavior/FrustrationByPageTable.tsx
Normal file
114
components/behavior/FrustrationByPageTable.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
124
components/behavior/FrustrationSummaryCards.tsx
Normal file
124
components/behavior/FrustrationSummaryCards.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
220
components/behavior/FrustrationTable.tsx
Normal file
220
components/behavior/FrustrationTable.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
166
components/behavior/FrustrationTrend.tsx
Normal file
166
components/behavior/FrustrationTrend.tsx
Normal 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
323
components/charts/chart.tsx
Normal 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,
|
||||
}
|
||||
8
components/charts/index.ts
Normal file
8
components/charts/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
type ChartConfig,
|
||||
} from './chart'
|
||||
234
components/dashboard/AddFilterDropdown.tsx
Normal file
234
components/dashboard/AddFilterDropdown.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { DIMENSION_LABELS, OPERATORS, OPERATOR_LABELS, type DimensionFilter } from '@/lib/filters'
|
||||
|
||||
export interface FilterSuggestion {
|
||||
value: string
|
||||
label: string
|
||||
count?: number
|
||||
}
|
||||
|
||||
export interface FilterSuggestions {
|
||||
[dimension: string]: FilterSuggestion[]
|
||||
}
|
||||
|
||||
interface AddFilterDropdownProps {
|
||||
onAdd: (filter: DimensionFilter) => void
|
||||
suggestions?: FilterSuggestions
|
||||
onFetchSuggestions?: (dimension: string) => Promise<FilterSuggestion[]>
|
||||
}
|
||||
|
||||
const ALL_DIMS = ['page', 'referrer', 'country', 'region', 'city', 'browser', 'os', 'device', 'utm_source', 'utm_medium', 'utm_campaign']
|
||||
|
||||
export default function AddFilterDropdown({ onAdd, suggestions = {}, onFetchSuggestions }: AddFilterDropdownProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [selectedDim, setSelectedDim] = useState<string | null>(null)
|
||||
const [operator, setOperator] = useState<DimensionFilter['operator']>('is')
|
||||
const [search, setSearch] = useState('')
|
||||
const [fetchedSuggestions, setFetchedSuggestions] = useState<FilterSuggestion[]>([])
|
||||
const [isFetching, setIsFetching] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Close on outside click or Escape
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
handleClose()
|
||||
}
|
||||
}
|
||||
function handleEsc(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') handleClose()
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
document.addEventListener('keydown', handleEsc)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClick)
|
||||
document.removeEventListener('keydown', handleEsc)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
// Focus search input when a dimension is selected
|
||||
useEffect(() => {
|
||||
if (selectedDim) inputRef.current?.focus()
|
||||
}, [selectedDim])
|
||||
|
||||
// Fetch full suggestions when a dimension is selected
|
||||
useEffect(() => {
|
||||
if (!selectedDim || !onFetchSuggestions) {
|
||||
setFetchedSuggestions([])
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
setIsFetching(true)
|
||||
onFetchSuggestions(selectedDim).then(data => {
|
||||
if (!cancelled) {
|
||||
setFetchedSuggestions(data)
|
||||
setIsFetching(false)
|
||||
}
|
||||
}).catch(() => {
|
||||
if (!cancelled) setIsFetching(false)
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [selectedDim, onFetchSuggestions])
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setIsOpen(false)
|
||||
setSelectedDim(null)
|
||||
setOperator('is')
|
||||
setSearch('')
|
||||
setFetchedSuggestions([])
|
||||
}, [])
|
||||
|
||||
function handleSelectValue(value: string) {
|
||||
onAdd({ dimension: selectedDim!, operator, values: [value] })
|
||||
handleClose()
|
||||
}
|
||||
|
||||
function handleSubmitCustom() {
|
||||
if (!search.trim() || !selectedDim) return
|
||||
onAdd({ dimension: selectedDim, operator, values: [search.trim()] })
|
||||
handleClose()
|
||||
}
|
||||
|
||||
// Use fetched data if available, fall back to prop suggestions
|
||||
const dimSuggestions = selectedDim
|
||||
? (fetchedSuggestions.length > 0 ? fetchedSuggestions : (suggestions[selectedDim] || []))
|
||||
: []
|
||||
const filtered = dimSuggestions.filter(s =>
|
||||
s.label.toLowerCase().includes(search.toLowerCase()) ||
|
||||
s.value.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (isOpen) { handleClose() } else { setIsOpen(true) }
|
||||
}}
|
||||
className={`inline-flex items-center gap-2 px-3 py-1.5 text-xs font-medium rounded-lg transition-all cursor-pointer ${
|
||||
isOpen
|
||||
? 'bg-brand-orange/10 text-brand-orange border border-brand-orange/30'
|
||||
: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-200 dark:hover:bg-neutral-700 hover:text-neutral-900 dark:hover:text-white border border-transparent'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 0 1-.659 1.591l-5.432 5.432a2.25 2.25 0 0 0-.659 1.591v2.927a2.25 2.25 0 0 1-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 0 0-.659-1.591L3.659 7.409A2.25 2.25 0 0 1 3 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0 1 12 3Z" />
|
||||
</svg>
|
||||
Filter
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute top-full left-0 mt-1.5 z-50 bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 rounded-xl shadow-xl overflow-hidden min-w-[280px]">
|
||||
{!selectedDim ? (
|
||||
/* Step 1: Dimension list */
|
||||
<div className="py-1">
|
||||
{ALL_DIMS.map(dim => (
|
||||
<button
|
||||
key={dim}
|
||||
onClick={() => setSelectedDim(dim)}
|
||||
className="w-full flex items-center justify-between px-4 py-2.5 text-sm text-left hover:bg-neutral-50 dark:hover:bg-neutral-800 transition-colors cursor-pointer"
|
||||
>
|
||||
<span className="text-neutral-900 dark:text-white font-medium">{DIMENSION_LABELS[dim]}</span>
|
||||
<svg className="w-3.5 h-3.5 text-neutral-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
/* Step 2: Operator + search + values */
|
||||
<>
|
||||
{/* Header with back button */}
|
||||
<div className="flex items-center gap-2 px-3 pt-3 pb-2">
|
||||
<button
|
||||
onClick={() => { setSelectedDim(null); setSearch(''); setOperator('is'); setFetchedSuggestions([]) }}
|
||||
className="p-1 text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors cursor-pointer rounded-md hover:bg-neutral-100 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="text-sm font-semibold text-neutral-900 dark:text-white">
|
||||
{DIMENSION_LABELS[selectedDim]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Operator pills */}
|
||||
<div className="flex gap-1 px-3 pb-2 flex-wrap">
|
||||
{OPERATORS.map(op => (
|
||||
<button
|
||||
key={op}
|
||||
onClick={() => setOperator(op)}
|
||||
className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors cursor-pointer ${
|
||||
operator === op
|
||||
? 'bg-brand-orange text-white'
|
||||
: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-500 dark:text-neutral-400 hover:bg-neutral-200 dark:hover:bg-neutral-700'
|
||||
}`}
|
||||
>
|
||||
{OPERATOR_LABELS[op]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Search input */}
|
||||
<div className="px-3 pb-2">
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') {
|
||||
if (filtered.length === 1) {
|
||||
handleSelectValue(filtered[0].value)
|
||||
} else {
|
||||
handleSubmitCustom()
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder={`Search ${DIMENSION_LABELS[selectedDim]?.toLowerCase()}...`}
|
||||
className="w-full px-3 py-2 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 focus:outline-none focus:ring-2 focus:ring-brand-orange/40 focus:border-brand-orange transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Values list */}
|
||||
{isFetching ? (
|
||||
<div className="px-4 py-6 text-center">
|
||||
<div className="inline-block w-4 h-4 border-2 border-neutral-300 dark:border-neutral-600 border-t-brand-orange rounded-full animate-spin" />
|
||||
</div>
|
||||
) : filtered.length > 0 ? (
|
||||
<div className="max-h-52 overflow-y-auto border-t border-neutral-100 dark:border-neutral-800">
|
||||
{filtered.map(s => (
|
||||
<button
|
||||
key={s.value}
|
||||
onClick={() => handleSelectValue(s.value)}
|
||||
className="w-full flex items-center justify-between px-4 py-2 text-sm text-left hover:bg-neutral-50 dark:hover:bg-neutral-800 transition-colors cursor-pointer"
|
||||
>
|
||||
<span className="truncate text-neutral-900 dark:text-white">{s.label}</span>
|
||||
{s.count !== undefined && (
|
||||
<span className="text-xs text-neutral-400 dark:text-neutral-500 ml-2 tabular-nums flex-shrink-0">
|
||||
{s.count.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : search.trim() ? (
|
||||
<div className="px-3 py-3 border-t border-neutral-100 dark:border-neutral-800">
|
||||
<button
|
||||
onClick={handleSubmitCustom}
|
||||
className="w-full px-3 py-2 text-sm font-medium bg-brand-orange text-white rounded-lg hover:bg-brand-orange/90 transition-colors cursor-pointer"
|
||||
>
|
||||
Filter by “{search.trim()}”
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,58 +5,39 @@ import { logger } from '@/lib/utils/logger'
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import { formatNumber } from '@ciphera-net/ui'
|
||||
import { Modal, ArrowRightIcon, Button } from '@ciphera-net/ui'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
import { ChevronDownIcon, DownloadIcon } 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 { PlusIcon } from '@radix-ui/react-icons'
|
||||
import { Megaphone, FrameCornersIcon } from '@phosphor-icons/react'
|
||||
import UtmBuilder from '@/components/tools/UtmBuilder'
|
||||
import { type DimensionFilter } from '@/lib/filters'
|
||||
|
||||
interface CampaignsProps {
|
||||
siteId: string
|
||||
dateRange: { start: string, end: string }
|
||||
filters?: string
|
||||
onFilter?: (filter: DimensionFilter) => void
|
||||
}
|
||||
|
||||
const LIMIT = 7
|
||||
const EMPTY_LABEL = '—'
|
||||
|
||||
type SortKey = 'source' | 'medium' | 'campaign' | 'visitors' | 'pageviews'
|
||||
type SortDir = 'asc' | 'desc'
|
||||
|
||||
function sortCampaigns(data: CampaignStat[], key: SortKey, dir: SortDir): CampaignStat[] {
|
||||
return [...data].sort((a, b) => {
|
||||
const av = key === 'visitors' ? a.visitors : key === 'pageviews' ? a.pageviews : (a[key] || '').toLowerCase()
|
||||
const bv = key === 'visitors' ? b.visitors : key === 'pageviews' ? b.pageviews : (b[key] || '').toLowerCase()
|
||||
if (typeof av === 'number' && typeof bv === 'number') {
|
||||
return dir === 'asc' ? av - bv : bv - av
|
||||
}
|
||||
const cmp = String(av).localeCompare(String(bv))
|
||||
return dir === 'asc' ? cmp : -cmp
|
||||
})
|
||||
}
|
||||
|
||||
function campaignRowKey(item: CampaignStat): string {
|
||||
return `${item.source}|${item.medium}|${item.campaign}`
|
||||
}
|
||||
|
||||
export default function Campaigns({ siteId, dateRange }: CampaignsProps) {
|
||||
export default function Campaigns({ siteId, dateRange, filters, onFilter }: CampaignsProps) {
|
||||
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)
|
||||
const [sortKey, setSortKey] = useState<SortKey>('visitors')
|
||||
const [sortDir, setSortDir] = useState<SortDir>('desc')
|
||||
const [faviconFailed, setFaviconFailed] = useState<Set<string>>(new Set())
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const result = await getCampaigns(siteId, dateRange.start, dateRange.end, 10)
|
||||
const result = await getCampaigns(siteId, dateRange.start, dateRange.end, 10, filters)
|
||||
setData(result)
|
||||
} catch (e) {
|
||||
logger.error(e)
|
||||
@@ -65,14 +46,14 @@ export default function Campaigns({ siteId, dateRange }: CampaignsProps) {
|
||||
}
|
||||
}
|
||||
fetchData()
|
||||
}, [siteId, dateRange])
|
||||
}, [siteId, dateRange, filters])
|
||||
|
||||
useEffect(() => {
|
||||
if (isModalOpen) {
|
||||
const fetchFullData = async () => {
|
||||
setIsLoadingFull(true)
|
||||
try {
|
||||
const result = await getCampaigns(siteId, dateRange.start, dateRange.end, 100)
|
||||
const result = await getCampaigns(siteId, dateRange.start, dateRange.end, 100, filters)
|
||||
setFullData(result)
|
||||
} catch (e) {
|
||||
logger.error(e)
|
||||
@@ -84,29 +65,22 @@ export default function Campaigns({ siteId, dateRange }: CampaignsProps) {
|
||||
} else {
|
||||
setFullData([])
|
||||
}
|
||||
}, [isModalOpen, siteId, dateRange])
|
||||
}, [isModalOpen, siteId, dateRange, filters])
|
||||
|
||||
const sortedData = useMemo(
|
||||
() => sortCampaigns(data, sortKey, sortDir),
|
||||
[data, sortKey, sortDir]
|
||||
() => [...data].sort((a, b) => b.visitors - a.visitors),
|
||||
[data]
|
||||
)
|
||||
const sortedFullData = useMemo(
|
||||
() => sortCampaigns(fullData.length > 0 ? fullData : data, sortKey, sortDir),
|
||||
[fullData, data, sortKey, sortDir]
|
||||
() => [...(fullData.length > 0 ? fullData : data)].sort((a, b) => b.visitors - a.visitors),
|
||||
[fullData, data]
|
||||
)
|
||||
|
||||
const totalVisitors = sortedData.reduce((sum, c) => sum + c.visitors, 0)
|
||||
const hasData = data.length > 0
|
||||
const displayedData = hasData ? sortedData.slice(0, LIMIT) : []
|
||||
const emptySlots = Math.max(0, LIMIT - displayedData.length)
|
||||
const showViewAll = hasData && data.length > LIMIT
|
||||
|
||||
const handleSort = (key: SortKey) => {
|
||||
if (sortKey === key) {
|
||||
setSortDir(d => d === 'asc' ? 'desc' : 'asc')
|
||||
} else {
|
||||
setSortKey(key)
|
||||
setSortDir(key === 'visitors' || key === 'pageviews' ? 'desc' : 'asc')
|
||||
}
|
||||
}
|
||||
const emptySlots = Math.max(0, LIMIT - displayedData.length)
|
||||
|
||||
function renderSourceIcon(source: string) {
|
||||
const faviconUrl = getReferrerFavicon(source)
|
||||
@@ -128,13 +102,13 @@ export default function Campaigns({ siteId, dateRange }: CampaignsProps) {
|
||||
}
|
||||
|
||||
const handleExportCampaigns = () => {
|
||||
const rows = sortedData.length > 0 ? sortedData : data
|
||||
const rows = sortedFullData.length > 0 ? sortedFullData : sortedData
|
||||
if (rows.length === 0) return
|
||||
const header = ['Source', 'Medium', 'Campaign', 'Visitors', 'Pageviews']
|
||||
const csvRows = [
|
||||
header.join(','),
|
||||
...rows.map(r =>
|
||||
[r.source, r.medium || EMPTY_LABEL, r.campaign || EMPTY_LABEL, r.visitors, r.pageviews].join(',')
|
||||
[r.source, r.medium || '', r.campaign || '', r.visitors, r.pageviews].join(',')
|
||||
),
|
||||
]
|
||||
const blob = new Blob([csvRows.join('\n')], { type: 'text/csv;charset=utf-8;' })
|
||||
@@ -148,195 +122,171 @@ export default function Campaigns({ siteId, dateRange }: CampaignsProps) {
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const SortHeader = ({ label, colKey, className = '' }: { label: string; colKey: SortKey; className?: string }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSort(colKey)}
|
||||
className={`inline-flex items-center gap-1 text-xs font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-inset rounded ${className}`}
|
||||
aria-label={`Sort by ${label}`}
|
||||
>
|
||||
{label}
|
||||
{sortKey === colKey ? (
|
||||
<ChevronDownIcon className={`w-3 h-3 text-brand-orange ${sortDir === 'asc' ? 'rotate-180' : ''}`} />
|
||||
) : (
|
||||
<span className="w-3 h-3 inline-block text-neutral-400" aria-hidden />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
|
||||
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">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
|
||||
Campaigns
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{hasData && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleExportCampaigns}
|
||||
className="h-8 px-3 text-xs gap-2"
|
||||
>
|
||||
<DownloadIcon className="w-3.5 h-3.5" />
|
||||
Export
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setIsBuilderOpen(true)}
|
||||
className="h-8 px-3 text-xs gap-2"
|
||||
>
|
||||
<PlusIcon className="w-3.5 h-3.5" />
|
||||
Build URL
|
||||
</Button>
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
|
||||
Campaigns
|
||||
</h3>
|
||||
{showViewAll && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="h-8 px-3 text-xs"
|
||||
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"
|
||||
>
|
||||
View All
|
||||
</Button>
|
||||
<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"
|
||||
>
|
||||
Build URL
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-2 flex-1 min-h-[270px]">
|
||||
<div className="grid grid-cols-12 gap-2 mb-2 px-2">
|
||||
<div className="col-span-4 h-4 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800" />
|
||||
<div className="col-span-2 h-4 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800" />
|
||||
<div className="col-span-2 h-4 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800" />
|
||||
<div className="col-span-2 h-4 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800" />
|
||||
<div className="col-span-2 h-4 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800" />
|
||||
</div>
|
||||
{Array.from({ length: 7 }).map((_, i) => (
|
||||
<div key={`skeleton-${i}`} className="grid grid-cols-12 gap-2 h-9 px-2 -mx-2">
|
||||
<div className="col-span-4 h-4 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800" />
|
||||
<div className="col-span-2 h-4 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800" />
|
||||
<div className="col-span-2 h-4 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800" />
|
||||
<div className="col-span-2 h-4 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800" />
|
||||
<div className="col-span-2 h-4 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800" />
|
||||
<div className="space-y-2 flex-1 min-h-[270px]">
|
||||
{isLoading ? (
|
||||
<ListSkeleton rows={LIMIT} />
|
||||
) : hasData ? (
|
||||
<>
|
||||
{displayedData.map((item) => {
|
||||
return (
|
||||
<div
|
||||
key={`${item.source}|${item.medium}|${item.campaign}`}
|
||||
onClick={() => onFilter?.({ dimension: 'utm_source', operator: 'is', values: [item.source] })}
|
||||
className={`flex items-center justify-between py-1.5 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors${onFilter ? ' cursor-pointer' : ''}`}
|
||||
>
|
||||
<div className="flex-1 text-neutral-900 dark:text-white flex items-center gap-3 min-w-0">
|
||||
{renderSourceIcon(item.source)}
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium 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-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">
|
||||
{totalVisitors > 0 ? `${Math.round((item.visitors / totalVisitors) * 100)}%` : ''}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
|
||||
{formatNumber(item.visitors)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{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">
|
||||
<Megaphone className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : hasData ? (
|
||||
<div className="space-y-2 flex-1 min-h-[270px]">
|
||||
<div className="grid grid-cols-12 gap-2 text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-2 px-2">
|
||||
<div className="col-span-4">
|
||||
<SortHeader label="Source" colKey="source" className="text-left" />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<SortHeader label="Medium" colKey="medium" className="text-left" />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<SortHeader label="Campaign" colKey="campaign" className="text-left" />
|
||||
</div>
|
||||
<div className="col-span-2 text-right">
|
||||
<SortHeader label="Visitors" colKey="visitors" className="text-right justify-end" />
|
||||
</div>
|
||||
<div className="col-span-2 text-right">
|
||||
<SortHeader label="Pageviews" colKey="pageviews" className="text-right justify-end" />
|
||||
</div>
|
||||
</div>
|
||||
{displayedData.map((item) => (
|
||||
<div
|
||||
key={campaignRowKey(item)}
|
||||
className="grid grid-cols-12 gap-2 items-center h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors text-sm"
|
||||
<h4 className="font-semibold text-neutral-900 dark:text-white">
|
||||
Track your marketing campaigns
|
||||
</h4>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs">
|
||||
Add UTM parameters to your links to see campaign performance here.
|
||||
</p>
|
||||
<Link
|
||||
href="/installation"
|
||||
className="inline-flex items-center gap-2 text-sm font-medium text-brand-orange hover:text-brand-orange/90 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange/20 rounded"
|
||||
>
|
||||
<div className="col-span-4 flex items-center gap-3 truncate">
|
||||
{renderSourceIcon(item.source)}
|
||||
<span className="truncate text-neutral-900 dark:text-white font-medium" title={item.source}>
|
||||
{getReferrerDisplayName(item.source)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="col-span-2 truncate text-neutral-500 dark:text-neutral-400" title={item.medium}>
|
||||
{item.medium || EMPTY_LABEL}
|
||||
</div>
|
||||
<div className="col-span-2 truncate text-neutral-500 dark:text-neutral-400" title={item.campaign}>
|
||||
{item.campaign || EMPTY_LABEL}
|
||||
</div>
|
||||
<div className="col-span-2 text-right font-semibold text-neutral-900 dark:text-white">
|
||||
{formatNumber(item.visitors)}
|
||||
</div>
|
||||
<div className="col-span-2 text-right text-neutral-600 dark:text-neutral-400">
|
||||
{formatNumber(item.pageviews)}
|
||||
</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-[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">
|
||||
<FaBullhorn className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
|
||||
Learn more
|
||||
<ArrowRightIcon className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
<h4 className="font-semibold text-neutral-900 dark:text-white">
|
||||
Track your marketing campaigns
|
||||
</h4>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-md">
|
||||
Add <code className="px-1.5 py-0.5 rounded bg-neutral-200 dark:bg-neutral-700 text-xs font-mono">utm_source</code>, <code className="px-1.5 py-0.5 rounded bg-neutral-200 dark:bg-neutral-700 text-xs font-mono">utm_medium</code>, and <code className="px-1.5 py-0.5 rounded bg-neutral-200 dark:bg-neutral-700 text-xs font-mono">utm_campaign</code> parameters to your links to see them here.
|
||||
</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"
|
||||
>
|
||||
Read documentation
|
||||
<ArrowRightIcon className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
title="All Campaigns"
|
||||
onClose={() => { setIsModalOpen(false); setModalSearch('') }}
|
||||
title="Campaigns"
|
||||
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 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">
|
||||
<TableSkeleton rows={10} cols={5} />
|
||||
<ListSkeleton rows={10} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-12 gap-2 text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-2 px-2 sticky top-0 bg-white dark:bg-neutral-900 py-2 z-10">
|
||||
<div className="col-span-4">Source</div>
|
||||
<div className="col-span-2">Medium</div>
|
||||
<div className="col-span-2">Campaign</div>
|
||||
<div className="col-span-2 text-right">Visitors</div>
|
||||
<div className="col-span-2 text-right">Pageviews</div>
|
||||
</div>
|
||||
{sortedFullData.map((item) => (
|
||||
<div
|
||||
key={campaignRowKey(item)}
|
||||
className="grid grid-cols-12 gap-2 items-center py-2 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors text-sm border-b border-neutral-100 dark:border-neutral-800 last:border-0"
|
||||
>
|
||||
<div className="col-span-4 flex items-center gap-3 truncate">
|
||||
{renderSourceIcon(item.source)}
|
||||
<span className="truncate text-neutral-900 dark:text-white font-medium" title={item.source}>
|
||||
{getReferrerDisplayName(item.source)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="col-span-2 truncate text-neutral-500 dark:text-neutral-400" title={item.medium}>
|
||||
{item.medium || EMPTY_LABEL}
|
||||
</div>
|
||||
<div className="col-span-2 truncate text-neutral-500 dark:text-neutral-400" title={item.campaign}>
|
||||
{item.campaign || EMPTY_LABEL}
|
||||
</div>
|
||||
<div className="col-span-2 text-right font-semibold text-neutral-900 dark:text-white">
|
||||
{formatNumber(item.visitors)}
|
||||
</div>
|
||||
<div className="col-span-2 text-right text-neutral-600 dark:text-neutral-400">
|
||||
{formatNumber(item.pageviews)}
|
||||
</div>
|
||||
) : (() => {
|
||||
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"
|
||||
>
|
||||
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>
|
||||
</Modal>
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,16 @@
|
||||
'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 {
|
||||
topPages: TopPage[]
|
||||
@@ -16,16 +20,18 @@ interface ContentStatsProps {
|
||||
collectPagePaths?: boolean
|
||||
siteId: string
|
||||
dateRange: { start: string, end: string }
|
||||
onFilter?: (filter: DimensionFilter) => void
|
||||
}
|
||||
|
||||
type Tab = 'top_pages' | 'entry_pages' | 'exit_pages'
|
||||
|
||||
const LIMIT = 7
|
||||
|
||||
export default function ContentStats({ topPages, entryPages, exitPages, domain, collectPagePaths = true, siteId, dateRange }: ContentStatsProps) {
|
||||
export default function ContentStats({ topPages, entryPages, exitPages, domain, collectPagePaths = true, siteId, dateRange, onFilter }: ContentStatsProps) {
|
||||
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)
|
||||
|
||||
@@ -76,6 +82,7 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain,
|
||||
}
|
||||
|
||||
const data = getData()
|
||||
const totalPageviews = data.reduce((sum, p) => sum + p.pageviews, 0)
|
||||
const hasData = data && data.length > 0
|
||||
const displayedData = hasData ? data.slice(0, LIMIT) : []
|
||||
const emptySlots = Math.max(0, LIMIT - displayedData.length)
|
||||
@@ -93,33 +100,41 @@ 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">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
|
||||
Content
|
||||
Pages
|
||||
</h3>
|
||||
{showViewAll && (
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="text-xs font-medium text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded"
|
||||
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"
|
||||
>
|
||||
View All
|
||||
<FrameCornersIcon className="w-4 h-4" weight="bold" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex p-1 bg-neutral-100 dark:bg-neutral-800 rounded-lg" role="tablist" aria-label="Content view tabs" onKeyDown={handleTabKeyDown}>
|
||||
<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
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
role="tab"
|
||||
aria-selected={activeTab === tab}
|
||||
className={`px-3 py-1 text-xs font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange ${
|
||||
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
|
||||
? 'bg-white dark:bg-neutral-700 text-neutral-900 dark:text-white shadow-sm'
|
||||
: 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white'
|
||||
? '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>
|
||||
@@ -132,21 +147,31 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain,
|
||||
</div>
|
||||
) : hasData ? (
|
||||
<>
|
||||
{displayedData.map((page, index) => (
|
||||
<div key={index} 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">
|
||||
{displayedData.map((page) => (
|
||||
<div
|
||||
key={page.path}
|
||||
onClick={() => onFilter?.({ dimension: 'page', operator: 'is', values: [page.path] })}
|
||||
className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors${onFilter ? ' cursor-pointer' : ''}`}
|
||||
>
|
||||
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center">
|
||||
<span className="truncate">{page.path}</span>
|
||||
<a
|
||||
href={`https://${domain.replace(/^https?:\/\//, '')}${page.path}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:underline flex items-center"
|
||||
onClick={e => e.stopPropagation()}
|
||||
className="ml-2 flex-shrink-0"
|
||||
>
|
||||
{page.path}
|
||||
<ArrowUpRightIcon className="w-3 h-3 ml-2 text-neutral-400 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
<ArrowUpRightIcon className="w-3 h-3 text-neutral-400 opacity-0 group-hover:opacity-100 transition-opacity hover:text-brand-orange" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-neutral-600 dark:text-neutral-400 ml-4">
|
||||
{formatNumber(page.pageviews)}
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
|
||||
{totalPageviews > 0 ? `${Math.round((page.pageviews / totalPageviews) * 100)}%` : ''}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
|
||||
{formatNumber(page.pageviews)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -172,34 +197,57 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain,
|
||||
|
||||
<Modal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
title={`Content - ${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, index) => (
|
||||
<div key={index} 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>
|
||||
</>
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { formatNumber } from '@ciphera-net/ui'
|
||||
import * as Flags from 'country-flag-icons/react/3x2'
|
||||
import WorldMap from './WorldMap'
|
||||
import { GlobeIcon } from '@ciphera-net/ui'
|
||||
|
||||
interface LocationProps {
|
||||
countries: Array<{ country: string; pageviews: number }>
|
||||
cities: Array<{ city: string; country: string; pageviews: number }>
|
||||
}
|
||||
|
||||
type Tab = 'countries' | 'cities'
|
||||
|
||||
export default function Locations({ countries, cities }: LocationProps) {
|
||||
const [activeTab, setActiveTab] = useState<Tab>('countries')
|
||||
|
||||
const getFlagComponent = (countryCode: string) => {
|
||||
if (!countryCode || countryCode === 'Unknown') return null
|
||||
// * The API returns 2-letter country codes (e.g. US, DE)
|
||||
// * We cast it to the flag component name
|
||||
const FlagComponent = (Flags as Record<string, React.ComponentType<{ className?: string }>>)[countryCode]
|
||||
return FlagComponent ? <FlagComponent className="w-5 h-5 rounded-sm shadow-sm" /> : null
|
||||
}
|
||||
|
||||
const getCountryName = (code: string) => {
|
||||
if (!code || code === 'Unknown') return 'Unknown'
|
||||
try {
|
||||
const regionNames = new Intl.DisplayNames(['en'], { type: 'region' })
|
||||
return regionNames.of(code) || code
|
||||
} catch (e) {
|
||||
return code
|
||||
}
|
||||
}
|
||||
|
||||
const renderContent = () => {
|
||||
if (activeTab === 'countries') {
|
||||
if (!countries || countries.length === 0) {
|
||||
return (
|
||||
<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" />
|
||||
</div>
|
||||
<h4 className="font-semibold text-neutral-900 dark:text-white">
|
||||
No location data yet
|
||||
</h4>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs">
|
||||
Visitor locations will appear here based on anonymous geographic data.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<WorldMap data={countries} />
|
||||
<div className="space-y-3">
|
||||
{countries.map((country, index) => (
|
||||
<div key={index} className="flex items-center justify-between">
|
||||
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
|
||||
<span className="shrink-0">{getFlagComponent(country.country)}</span>
|
||||
<span className="truncate">{getCountryName(country.country)}</span>
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-neutral-600 dark:text-neutral-400 ml-4">
|
||||
{formatNumber(country.pageviews)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (activeTab === 'cities') {
|
||||
if (!cities || cities.length === 0) {
|
||||
return (
|
||||
<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" />
|
||||
</div>
|
||||
<h4 className="font-semibold text-neutral-900 dark:text-white">
|
||||
No city data yet
|
||||
</h4>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs">
|
||||
City-level visitor data will appear as traffic grows.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{cities.map((city, index) => (
|
||||
<div key={index} className="flex items-center justify-between">
|
||||
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
|
||||
<span className="shrink-0">{getFlagComponent(city.country)}</span>
|
||||
<span className="truncate">{city.city === 'Unknown' ? 'Unknown' : city.city}</span>
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-neutral-600 dark:text-neutral-400 ml-4">
|
||||
{formatNumber(city.pageviews)}
|
||||
</div>
|
||||
</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">
|
||||
<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 p-1 bg-neutral-100 dark:bg-neutral-800 rounded-lg">
|
||||
<button
|
||||
onClick={() => setActiveTab('countries')}
|
||||
className={`px-3 py-1 text-xs font-medium rounded-lg transition-colors ${
|
||||
activeTab === 'countries'
|
||||
? 'bg-white dark:bg-neutral-700 text-neutral-900 dark:text-white shadow-sm'
|
||||
: 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Countries
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('cities')}
|
||||
className={`px-3 py-1 text-xs font-medium rounded-lg transition-colors ${
|
||||
activeTab === 'cities'
|
||||
? 'bg-white dark:bg-neutral-700 text-neutral-900 dark:text-white shadow-sm'
|
||||
: 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Cities
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{renderContent()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
160
components/dashboard/DottedMap.tsx
Normal file
160
components/dashboard/DottedMap.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
108
components/dashboard/EventProperties.tsx
Normal file
108
components/dashboard/EventProperties.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { formatNumber } from '@ciphera-net/ui'
|
||||
import { getEventPropertyKeys, getEventPropertyValues, type EventPropertyKey, type EventPropertyValue } from '@/lib/api/stats'
|
||||
|
||||
interface EventPropertiesProps {
|
||||
siteId: string
|
||||
eventName: string
|
||||
dateRange: { start: string; end: string }
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function EventProperties({ siteId, eventName, dateRange, onClose }: EventPropertiesProps) {
|
||||
const [keys, setKeys] = useState<EventPropertyKey[]>([])
|
||||
const [selectedKey, setSelectedKey] = useState<string | null>(null)
|
||||
const [values, setValues] = useState<EventPropertyValue[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
getEventPropertyKeys(siteId, eventName, dateRange.start, dateRange.end)
|
||||
.then(k => {
|
||||
setKeys(k)
|
||||
if (k.length > 0) setSelectedKey(k[0].key)
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [siteId, eventName, dateRange.start, dateRange.end])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedKey) return
|
||||
getEventPropertyValues(siteId, eventName, selectedKey, dateRange.start, dateRange.end)
|
||||
.then(setValues)
|
||||
}, [siteId, eventName, selectedKey, dateRange.start, dateRange.end])
|
||||
|
||||
const maxCount = values.length > 0 ? values[0].count : 1
|
||||
|
||||
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-4">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
|
||||
Properties: <span className="text-brand-orange">{eventName.replace(/_/g, ' ')}</span>
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors cursor-pointer"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="animate-pulse space-y-3">
|
||||
{[1, 2, 3].map(i => (
|
||||
<div key={i} className="h-8 bg-neutral-100 dark:bg-neutral-800 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : keys.length === 0 ? (
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 py-4 text-center">
|
||||
No properties recorded for this event yet.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex gap-2 mb-4 flex-wrap">
|
||||
{keys.map(k => (
|
||||
<button
|
||||
key={k.key}
|
||||
onClick={() => setSelectedKey(k.key)}
|
||||
className={`px-3 py-1 text-xs font-medium rounded-full transition-colors cursor-pointer ${
|
||||
selectedKey === k.key
|
||||
? 'bg-brand-orange text-white'
|
||||
: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-200 dark:hover:bg-neutral-700'
|
||||
}`}
|
||||
>
|
||||
{k.key}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{values.map(v => (
|
||||
<div key={v.value} className="flex items-center gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm font-medium text-neutral-900 dark:text-white truncate">
|
||||
{v.value}
|
||||
</span>
|
||||
<span className="text-xs font-semibold text-brand-orange tabular-nums ml-2">
|
||||
{formatNumber(v.count)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-1.5 bg-neutral-100 dark:bg-neutral-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-brand-orange/60 rounded-full transition-all"
|
||||
style={{ width: `${(v.count / maxCount) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
39
components/dashboard/FilterBar.tsx
Normal file
39
components/dashboard/FilterBar.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
'use client'
|
||||
|
||||
import { type DimensionFilter, filterLabel } from '@/lib/filters'
|
||||
|
||||
interface FilterBarProps {
|
||||
filters: DimensionFilter[]
|
||||
onRemove: (index: number) => void
|
||||
onClear: () => void
|
||||
}
|
||||
|
||||
export default function FilterBar({ filters, onRemove, onClear }: FilterBarProps) {
|
||||
if (filters.length === 0) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
{filters.map((f, i) => (
|
||||
<button
|
||||
key={`${f.dimension}-${f.operator}-${f.values.join(',')}`}
|
||||
onClick={() => onRemove(i)}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg bg-brand-orange text-white hover:bg-brand-orange/80 transition-colors cursor-pointer group"
|
||||
title={`Remove filter: ${filterLabel(f)}`}
|
||||
>
|
||||
<span>{filterLabel(f)}</span>
|
||||
<svg className="w-3 h-3 opacity-70 group-hover:opacity-100" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
))}
|
||||
{filters.length > 1 && (
|
||||
<button
|
||||
onClick={onClear}
|
||||
className="px-2 py-1.5 text-xs font-medium text-neutral-500 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white transition-colors cursor-pointer"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
113
components/dashboard/Globe.tsx
Normal file
113
components/dashboard/Globe.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -7,13 +7,16 @@ import type { GoalCountStat } from '@/lib/api/stats'
|
||||
|
||||
interface GoalStatsProps {
|
||||
goalCounts: GoalCountStat[]
|
||||
onSelectEvent?: (eventName: string) => void
|
||||
}
|
||||
|
||||
const LIMIT = 10
|
||||
|
||||
export default function GoalStats({ goalCounts }: GoalStatsProps) {
|
||||
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">
|
||||
@@ -24,23 +27,34 @@ export default function GoalStats({ goalCounts }: 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}
|
||||
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"
|
||||
onClick={() => onSelectEvent?.(row.event_name)}
|
||||
className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors${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>
|
||||
@@ -52,7 +66,7 @@ export default function GoalStats({ goalCounts }: 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" />
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
'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'
|
||||
|
||||
interface LocationProps {
|
||||
countries: Array<{ country: string; pageviews: number }>
|
||||
@@ -20,20 +25,38 @@ interface LocationProps {
|
||||
geoDataLevel?: 'full' | 'country' | 'none'
|
||||
siteId: string
|
||||
dateRange: { start: string, end: string }
|
||||
onFilter?: (filter: DimensionFilter) => void
|
||||
}
|
||||
|
||||
type Tab = 'map' | 'countries' | 'regions' | 'cities'
|
||||
type Tab = 'map' | 'globe' | 'countries' | 'regions' | 'cities'
|
||||
|
||||
const LIMIT = 7
|
||||
|
||||
export default function Locations({ countries, cities, regions, geoDataLevel = 'full', siteId, dateRange }: LocationProps) {
|
||||
const TAB_TO_DIMENSION: Record<string, string> = { countries: 'country', regions: 'region', cities: 'city' }
|
||||
|
||||
export default function Locations({ countries, cities, regions, geoDataLevel = 'full', siteId, dateRange, onFilter }: LocationProps) {
|
||||
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 () => {
|
||||
@@ -65,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':
|
||||
@@ -170,14 +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 hasData = activeTab === 'map'
|
||||
const totalPageviews = data.reduce((sum, item) => sum + item.pageviews, 0)
|
||||
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') {
|
||||
@@ -191,35 +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">
|
||||
<div className="flex items-center gap-4">
|
||||
<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="text-xs font-medium text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded"
|
||||
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"
|
||||
>
|
||||
View All
|
||||
<FrameCornersIcon className="w-4 h-4" weight="bold" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex p-1 bg-neutral-100 dark:bg-neutral-800 rounded-lg" role="tablist" aria-label="Location view tabs" onKeyDown={handleTabKeyDown}>
|
||||
{(['map', 'countries', 'regions', 'cities'] as Tab[]).map((tab) => (
|
||||
<div className="flex gap-1" role="tablist" aria-label="Location view tabs" onKeyDown={handleTabKeyDown}>
|
||||
{(['map', 'globe', 'countries', 'regions', 'cities'] as Tab[]).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
role="tab"
|
||||
aria-selected={activeTab === tab}
|
||||
className={`px-3 py-1 text-xs font-medium rounded-lg transition-colors capitalize focus:outline-none focus:ring-2 focus:ring-brand-orange ${
|
||||
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
|
||||
? 'bg-white dark:bg-neutral-700 text-neutral-900 dark:text-white shadow-sm'
|
||||
: 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white'
|
||||
? '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>
|
||||
@@ -230,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" />
|
||||
@@ -247,26 +284,38 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
|
||||
) : (
|
||||
hasData ? (
|
||||
<>
|
||||
{displayedData.map((item, index) => (
|
||||
<div key={index} 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 truncate text-neutral-900 dark:text-white flex items-center gap-3">
|
||||
{activeTab === 'countries' && <span className="shrink-0">{getFlagComponent(item.country ?? '')}</span>}
|
||||
{activeTab !== 'countries' && <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>
|
||||
{displayedData.map((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={() => canFilter && onFilter({ dimension: dim, operator: 'is', values: [filterValue!] })}
|
||||
className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors${canFilter ? ' cursor-pointer' : ''}`}
|
||||
>
|
||||
<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">
|
||||
{totalPageviews > 0 ? `${Math.round((item.pageviews / totalPageviews) * 100)}%` : ''}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
|
||||
{formatNumber(item.pageviews)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-neutral-600 dark:text-neutral-400 ml-4">
|
||||
{formatNumber(item.pageviews)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
{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">
|
||||
@@ -287,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, index) => (
|
||||
<div key={index} 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>
|
||||
</>
|
||||
|
||||
271
components/dashboard/PeakHours.tsx
Normal file
271
components/dashboard/PeakHours.tsx
Normal 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 '12am–2am'
|
||||
if (hour === 12) return '12pm–2pm'
|
||||
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'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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
89
components/dashboard/ScrollDepth.tsx
Normal file
89
components/dashboard/ScrollDepth.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
'use client'
|
||||
|
||||
import { PolarAngleAxis, PolarGrid, Radar, RadarChart, Tooltip } from 'recharts'
|
||||
import { BarChartIcon } from '@ciphera-net/ui'
|
||||
import type { GoalCountStat } from '@/lib/api/stats'
|
||||
|
||||
interface ScrollDepthProps {
|
||||
goalCounts: GoalCountStat[]
|
||||
totalPageviews: number
|
||||
}
|
||||
|
||||
const THRESHOLDS = [25, 50, 75, 100] as const
|
||||
|
||||
export default function ScrollDepth({ goalCounts, totalPageviews }: ScrollDepthProps) {
|
||||
const scrollCounts = new Map<number, number>()
|
||||
for (const row of goalCounts) {
|
||||
const match = row.event_name.match(/^scroll_(\d+)$/)
|
||||
if (match) {
|
||||
scrollCounts.set(Number(match[1]), row.count)
|
||||
}
|
||||
}
|
||||
|
||||
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-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="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-[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>
|
||||
<h4 className="font-semibold text-neutral-900 dark:text-white">
|
||||
No scroll data yet
|
||||
</h4>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-md">
|
||||
Scroll depth tracking is automatic — data will appear here once visitors start scrolling on your pages.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
64
components/dashboard/SiteNav.tsx
Normal file
64
components/dashboard/SiteNav.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
'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'
|
||||
|
||||
interface TechSpecsProps {
|
||||
browsers: Array<{ browser: string; pageviews: number }>
|
||||
@@ -19,16 +22,25 @@ interface TechSpecsProps {
|
||||
collectScreenResolution?: boolean
|
||||
siteId: string
|
||||
dateRange: { start: string, end: string }
|
||||
onFilter?: (filter: DimensionFilter) => void
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
export default function TechSpecs({ browsers, os, devices, screenResolutions, collectDeviceInfo = true, collectScreenResolution = true, siteId, dateRange }: TechSpecsProps) {
|
||||
const TAB_TO_DIMENSION: Record<string, string> = { browsers: 'browser', os: 'os', devices: 'device' }
|
||||
|
||||
export default function TechSpecs({ browsers, os, devices, screenResolutions, collectDeviceInfo = true, collectScreenResolution = true, siteId, dateRange, onFilter }: TechSpecsProps) {
|
||||
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)
|
||||
@@ -55,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) {
|
||||
@@ -79,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 []
|
||||
}
|
||||
@@ -108,6 +120,7 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
|
||||
|
||||
const rawData = getRawData()
|
||||
const data = filterUnknown(rawData)
|
||||
const totalPageviews = data.reduce((sum, item) => sum + item.pageviews, 0)
|
||||
const hasData = data && data.length > 0
|
||||
const displayedData = hasData ? data.slice(0, LIMIT) : []
|
||||
const emptySlots = Math.max(0, LIMIT - displayedData.length)
|
||||
@@ -117,33 +130,41 @@ 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">
|
||||
<div className="flex items-center gap-4">
|
||||
<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="text-xs font-medium text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded"
|
||||
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"
|
||||
>
|
||||
View All
|
||||
<FrameCornersIcon className="w-4 h-4" weight="bold" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex p-1 bg-neutral-100 dark:bg-neutral-800 rounded-lg" role="tablist" aria-label="Technology view tabs" onKeyDown={handleTabKeyDown}>
|
||||
<div className="flex gap-1" role="tablist" aria-label="Technology view tabs" onKeyDown={handleTabKeyDown}>
|
||||
{(['browsers', 'os', 'devices', 'screens'] as Tab[]).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
role="tab"
|
||||
aria-selected={activeTab === tab}
|
||||
className={`px-3 py-1 text-xs font-medium rounded-lg transition-colors capitalize focus:outline-none focus:ring-2 focus:ring-brand-orange ${
|
||||
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
|
||||
? 'bg-white dark:bg-neutral-700 text-neutral-900 dark:text-white shadow-sm'
|
||||
: 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white'
|
||||
? '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>
|
||||
@@ -156,17 +177,30 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
|
||||
</div>
|
||||
) : hasData ? (
|
||||
<>
|
||||
{displayedData.map((item, index) => (
|
||||
<div key={index} 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 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>
|
||||
{displayedData.map((item) => {
|
||||
const dim = TAB_TO_DIMENSION[activeTab]
|
||||
const canFilter = onFilter && dim
|
||||
return (
|
||||
<div
|
||||
key={item.name}
|
||||
onClick={() => canFilter && onFilter({ dimension: dim, operator: 'is', values: [item.name] })}
|
||||
className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors${canFilter ? ' cursor-pointer' : ''}`}
|
||||
>
|
||||
<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">
|
||||
{totalPageviews > 0 ? `${Math.round((item.pageviews / totalPageviews) * 100)}%` : ''}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
|
||||
{formatNumber(item.pageviews)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-neutral-600 dark:text-neutral-400 ml-4">
|
||||
{formatNumber(item.pageviews)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
{Array.from({ length: emptySlots }).map((_, i) => (
|
||||
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
|
||||
))}
|
||||
@@ -189,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, index) => (
|
||||
<div key={index} 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>
|
||||
</>
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { formatNumber } from '@ciphera-net/ui'
|
||||
import { LayoutDashboardIcon } from '@ciphera-net/ui'
|
||||
|
||||
interface TopPagesProps {
|
||||
pages: Array<{ path: string; pageviews: number }>
|
||||
}
|
||||
|
||||
export default function TopPages({ pages }: TopPagesProps) {
|
||||
if (!pages || pages.length === 0) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 flex flex-col">
|
||||
<h3 className="text-lg font-semibold mb-4 text-neutral-900 dark:text-white">
|
||||
Top Pages
|
||||
</h3>
|
||||
<div className="flex-1 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">
|
||||
<LayoutDashboardIcon className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
|
||||
</div>
|
||||
<h4 className="font-semibold text-neutral-900 dark:text-white">
|
||||
No page data yet
|
||||
</h4>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs">
|
||||
Your most visited pages will appear here as traffic arrives.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
|
||||
<h3 className="text-lg font-semibold mb-4 text-neutral-900 dark:text-white">
|
||||
Top Pages
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{pages.map((page, index) => (
|
||||
<div key={index} className="flex items-center justify-between">
|
||||
<div className="flex-1 truncate text-neutral-900 dark:text-white">
|
||||
{page.path}
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-neutral-600 dark:text-neutral-400 ml-4">
|
||||
{formatNumber(page.pageviews)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,21 +5,26 @@ 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'
|
||||
|
||||
interface TopReferrersProps {
|
||||
referrers: Array<{ referrer: string; pageviews: number }>
|
||||
collectReferrers?: boolean
|
||||
siteId: string
|
||||
dateRange: { start: string, end: string }
|
||||
onFilter?: (filter: DimensionFilter) => void
|
||||
}
|
||||
|
||||
const LIMIT = 7
|
||||
|
||||
export default function TopReferrers({ referrers, collectReferrers = true, siteId, dateRange }: TopReferrersProps) {
|
||||
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())
|
||||
@@ -31,6 +36,7 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
|
||||
|
||||
const mergedReferrers = mergeReferrersByDisplayName(filteredReferrers)
|
||||
|
||||
const totalPageviews = mergedReferrers.reduce((sum, r) => sum + r.pageviews, 0)
|
||||
const hasData = mergedReferrers.length > 0
|
||||
const displayedReferrers = hasData ? mergedReferrers.slice(0, LIMIT) : []
|
||||
const emptySlots = Math.max(0, LIMIT - displayedReferrers.length)
|
||||
@@ -82,17 +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">
|
||||
Top Referrers
|
||||
</h3>
|
||||
{showViewAll && (
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="text-xs font-medium text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded"
|
||||
>
|
||||
View All
|
||||
</button>
|
||||
)}
|
||||
<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]">
|
||||
@@ -102,14 +111,23 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
|
||||
</div>
|
||||
) : hasData ? (
|
||||
<>
|
||||
{displayedReferrers.map((ref, index) => (
|
||||
<div key={index} 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">
|
||||
{displayedReferrers.map((ref) => (
|
||||
<div
|
||||
key={ref.referrer}
|
||||
onClick={() => onFilter?.({ dimension: 'referrer', operator: 'is', values: [ref.referrer] })}
|
||||
className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors${onFilter ? ' cursor-pointer' : ''}`}
|
||||
>
|
||||
<div 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 className="flex items-center gap-2 ml-4">
|
||||
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
|
||||
{totalPageviews > 0 ? `${Math.round((ref.pageviews / totalPageviews) * 100)}%` : ''}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
|
||||
{formatNumber(ref.pageviews)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -135,27 +153,55 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
|
||||
|
||||
<Modal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
title="Top Referrers"
|
||||
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, index) => (
|
||||
<div key={index} 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>
|
||||
</>
|
||||
|
||||
53
components/dashboard/VirtualList.tsx
Normal file
53
components/dashboard/VirtualList.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
457
components/journeys/SankeyDiagram.tsx
Normal file
457
components/journeys/SankeyDiagram.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
87
components/journeys/TopPathsTable.tsx
Normal file
87
components/journeys/TopPathsTable.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -9,10 +9,12 @@ import { setup2FA, verify2FA, disable2FA, regenerateRecoveryCodes } from '@/lib/
|
||||
import { registerPasskey, listPasskeys, deletePasskey } from '@/lib/api/webauthn'
|
||||
|
||||
interface Props {
|
||||
activeTab?: 'profile' | 'security' | 'preferences'
|
||||
activeTab?: 'profile' | 'security' | 'preferences' | 'danger-zone'
|
||||
borderless?: boolean
|
||||
hideDangerZone?: boolean
|
||||
}
|
||||
|
||||
export default function ProfileSettings({ activeTab }: Props = {}) {
|
||||
export default function ProfileSettings({ activeTab, borderless, hideDangerZone }: Props = {}) {
|
||||
const { user, refresh, logout } = useAuth()
|
||||
|
||||
if (!user) return null
|
||||
@@ -61,6 +63,8 @@ export default function ProfileSettings({ activeTab }: Props = {}) {
|
||||
activeTab={activeTab}
|
||||
hideNav={activeTab !== undefined}
|
||||
hideNotifications
|
||||
borderless={borderless}
|
||||
hideDangerZone={hideDangerZone}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
124
components/settings/SettingsModalWrapper.tsx
Normal file
124
components/settings/SettingsModalWrapper.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { SettingsModal, type SettingsSection } from '@ciphera-net/ui'
|
||||
import { UserIcon, LockIcon, BellIcon, ChevronRightIcon } from '@ciphera-net/ui'
|
||||
import { NotificationToggleList, type NotificationOption } from '@ciphera-net/ui'
|
||||
import ProfileSettings from '@/components/settings/ProfileSettings'
|
||||
import TrustedDevicesCard from '@/components/settings/TrustedDevicesCard'
|
||||
import SecurityActivityCard from '@/components/settings/SecurityActivityCard'
|
||||
import { useSettingsModal } from '@/lib/settings-modal-context'
|
||||
import { useAuth } from '@/lib/auth/context'
|
||||
import { updateUserPreferences } from '@/lib/api/user'
|
||||
|
||||
// --- Security Alerts ---
|
||||
|
||||
const SECURITY_ALERT_OPTIONS: NotificationOption[] = [
|
||||
{ key: 'login_alerts', label: 'Login Activity', description: 'New device sign-ins and suspicious login attempts.' },
|
||||
{ key: 'password_alerts', label: 'Password Changes', description: 'Password changes and session revocations.' },
|
||||
{ key: 'two_factor_alerts', label: 'Two-Factor Authentication', description: '2FA enabled/disabled and recovery code changes.' },
|
||||
]
|
||||
|
||||
function SecurityAlertsCard() {
|
||||
const { user } = useAuth()
|
||||
const [emailNotifications, setEmailNotifications] = useState<Record<string, boolean>>({})
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.preferences?.email_notifications) {
|
||||
setEmailNotifications(user.preferences.email_notifications)
|
||||
} else {
|
||||
const defaults = SECURITY_ALERT_OPTIONS.reduce((acc, option) => ({
|
||||
...acc,
|
||||
[option.key]: true
|
||||
}), {} as Record<string, boolean>)
|
||||
setEmailNotifications(defaults)
|
||||
}
|
||||
}, [user])
|
||||
|
||||
const handleToggle = async (key: string) => {
|
||||
const newState = {
|
||||
...emailNotifications,
|
||||
[key]: !emailNotifications[key]
|
||||
}
|
||||
setEmailNotifications(newState)
|
||||
try {
|
||||
await updateUserPreferences({
|
||||
email_notifications: newState as { new_file_received: boolean; file_downloaded: boolean; login_alerts: boolean; password_alerts: boolean; two_factor_alerts: boolean }
|
||||
})
|
||||
} catch {
|
||||
setEmailNotifications(prev => ({
|
||||
...prev,
|
||||
[key]: !prev[key]
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<NotificationToggleList
|
||||
title="Security Alerts"
|
||||
description="Choose which security events trigger email alerts"
|
||||
icon={<BellIcon className="w-5 h-5 text-brand-orange" />}
|
||||
options={SECURITY_ALERT_OPTIONS}
|
||||
values={emailNotifications}
|
||||
onToggle={handleToggle}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Notification Center Placeholder ---
|
||||
|
||||
function NotificationCenterPlaceholder() {
|
||||
return (
|
||||
<div className="text-center max-w-md mx-auto py-8">
|
||||
<BellIcon className="w-12 h-12 text-neutral-300 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-neutral-900 dark:text-white mb-2">Notification Center</h3>
|
||||
<p className="text-sm text-neutral-500 mb-4">View and manage all your notifications in one place.</p>
|
||||
<Link href="/notifications" className="inline-flex items-center gap-2 px-4 py-2 bg-brand-orange text-white rounded-lg hover:bg-brand-orange/90 transition-colors">
|
||||
Open Notification Center
|
||||
<ChevronRightIcon className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Main Wrapper ---
|
||||
|
||||
export default function SettingsModalWrapper() {
|
||||
const { isOpen, closeSettings } = useSettingsModal()
|
||||
|
||||
const sections: SettingsSection[] = [
|
||||
{
|
||||
id: 'pulse',
|
||||
label: 'Account',
|
||||
icon: UserIcon,
|
||||
defaultExpanded: true,
|
||||
items: [
|
||||
{ id: 'profile', label: 'Profile', content: <ProfileSettings activeTab="profile" borderless hideDangerZone /> },
|
||||
{ id: 'security', label: 'Security', content: <ProfileSettings activeTab="security" borderless /> },
|
||||
{ id: 'preferences', label: 'Preferences', content: <ProfileSettings activeTab="preferences" borderless /> },
|
||||
{ id: 'danger-zone', label: 'Danger Zone', content: <ProfileSettings activeTab="danger-zone" borderless /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'security-section',
|
||||
label: 'Security',
|
||||
icon: LockIcon,
|
||||
items: [
|
||||
{ id: 'devices', label: 'Trusted Devices', content: <TrustedDevicesCard /> },
|
||||
{ id: 'activity', label: 'Security Activity', content: <SecurityActivityCard /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'notifications',
|
||||
label: 'Notifications',
|
||||
icon: BellIcon,
|
||||
items: [
|
||||
{ id: 'security-alerts', label: 'Security Alerts', content: <SecurityAlertsCard /> },
|
||||
{ id: 'center', label: 'Notification Center', content: <NotificationCenterPlaceholder /> },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
return <SettingsModal open={isOpen} onClose={closeSettings} sections={sections} />
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
67
components/ui/avatar.tsx
Normal 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
230
components/ui/badge-2.tsx
Normal 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
412
components/ui/button-1.tsx
Normal 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
147
components/ui/card.tsx
Normal 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 };
|
||||
935
components/ui/funnel-chart.tsx
Normal file
935
components/ui/funnel-chart.tsx
Normal 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;
|
||||
290
components/ui/line-charts-6.tsx
Normal file
290
components/ui/line-charts-6.tsx
Normal 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
58
lib/api/annotations.ts
Normal 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',
|
||||
})
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { API_URL } from './client'
|
||||
import apiRequest from './client'
|
||||
|
||||
export interface TaxID {
|
||||
type: string
|
||||
@@ -31,39 +31,12 @@ export interface SubscriptionDetails {
|
||||
next_invoice_period_end?: number
|
||||
}
|
||||
|
||||
async function billingFetch<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||
const url = `${API_URL}${endpoint}`
|
||||
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
credentials: 'include', // Send cookies
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.json().catch(() => ({
|
||||
error: 'Unknown error',
|
||||
message: `HTTP ${response.status}: ${response.statusText}`,
|
||||
}))
|
||||
throw new Error(errorBody.message || errorBody.error || 'Request failed')
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export async function getSubscription(): Promise<SubscriptionDetails> {
|
||||
return await billingFetch<SubscriptionDetails>('/api/billing/subscription', {
|
||||
method: 'GET',
|
||||
})
|
||||
return apiRequest<SubscriptionDetails>('/api/billing/subscription')
|
||||
}
|
||||
|
||||
export async function createPortalSession(): Promise<{ url: string }> {
|
||||
return await billingFetch<{ url: string }>('/api/billing/portal', {
|
||||
return apiRequest<{ url: string }>('/api/billing/portal', {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
@@ -74,7 +47,7 @@ export interface CancelSubscriptionParams {
|
||||
}
|
||||
|
||||
export async function cancelSubscription(params?: CancelSubscriptionParams): Promise<{ ok: boolean; at_period_end: boolean }> {
|
||||
return await billingFetch<{ ok: boolean; at_period_end: boolean }>('/api/billing/cancel', {
|
||||
return apiRequest<{ ok: boolean; at_period_end: boolean }>('/api/billing/cancel', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ at_period_end: params?.at_period_end ?? true }),
|
||||
})
|
||||
@@ -82,7 +55,7 @@ export async function cancelSubscription(params?: CancelSubscriptionParams): Pro
|
||||
|
||||
/** Clears cancel_at_period_end so the subscription continues past the current period. */
|
||||
export async function resumeSubscription(): Promise<{ ok: boolean }> {
|
||||
return await billingFetch<{ ok: boolean }>('/api/billing/resume', {
|
||||
return apiRequest<{ ok: boolean }>('/api/billing/resume', {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
@@ -100,7 +73,7 @@ export interface PreviewInvoiceResult {
|
||||
}
|
||||
|
||||
export async function previewInvoice(params: ChangePlanParams): Promise<PreviewInvoiceResult | null> {
|
||||
const res = await billingFetch<PreviewInvoiceResult | Record<string, never>>('/api/billing/preview-invoice', {
|
||||
const res = await apiRequest<PreviewInvoiceResult | Record<string, never>>('/api/billing/preview-invoice', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(params),
|
||||
})
|
||||
@@ -111,7 +84,7 @@ export async function previewInvoice(params: ChangePlanParams): Promise<PreviewI
|
||||
}
|
||||
|
||||
export async function changePlan(params: ChangePlanParams): Promise<{ ok: boolean }> {
|
||||
return await billingFetch<{ ok: boolean }>('/api/billing/change-plan', {
|
||||
return apiRequest<{ ok: boolean }>('/api/billing/change-plan', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(params),
|
||||
})
|
||||
@@ -124,7 +97,7 @@ export interface CreateCheckoutParams {
|
||||
}
|
||||
|
||||
export async function createCheckoutSession(params: CreateCheckoutParams): Promise<{ url: string }> {
|
||||
return await billingFetch<{ url: string }>('/api/billing/checkout', {
|
||||
return apiRequest<{ url: string }>('/api/billing/checkout', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(params),
|
||||
})
|
||||
@@ -142,7 +115,5 @@ export interface Invoice {
|
||||
}
|
||||
|
||||
export async function getInvoices(): Promise<Invoice[]> {
|
||||
return await billingFetch<Invoice[]>('/api/billing/invoices', {
|
||||
method: 'GET',
|
||||
})
|
||||
return apiRequest<Invoice[]>('/api/billing/invoices')
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -64,27 +64,10 @@ export async function deleteFunnel(siteId: string, funnelId: string): Promise<vo
|
||||
})
|
||||
}
|
||||
|
||||
const DATE_ONLY_REGEX = /^\d{4}-\d{2}-\d{2}$/
|
||||
|
||||
/** Normalize date-only (YYYY-MM-DD) to RFC3339 for backend funnel stats API. Uses UTC for boundaries (API/server timestamps are UTC). */
|
||||
function toRFC3339Range(from: string, to: string): { from: string; to: string } {
|
||||
return {
|
||||
from: DATE_ONLY_REGEX.test(from) ? `${from}T00:00:00.000Z` : from,
|
||||
to: DATE_ONLY_REGEX.test(to) ? `${to}T23:59:59.999Z` : to,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getFunnelStats(siteId: string, funnelId: string, from?: string, to?: string): Promise<FunnelStats> {
|
||||
export async function getFunnelStats(siteId: string, funnelId: string, startDate?: string, endDate?: string): Promise<FunnelStats> {
|
||||
const params = new URLSearchParams()
|
||||
if (from && to) {
|
||||
const { from: fromRfc, to: toRfc } = toRFC3339Range(from, to)
|
||||
params.append('from', fromRfc)
|
||||
params.append('to', toRfc)
|
||||
} else if (from) {
|
||||
params.append('from', DATE_ONLY_REGEX.test(from) ? `${from}T00:00:00.000Z` : from)
|
||||
} else if (to) {
|
||||
params.append('to', DATE_ONLY_REGEX.test(to) ? `${to}T23:59:59.999Z` : to)
|
||||
}
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
const queryString = params.toString() ? `?${params.toString()}` : ''
|
||||
return apiRequest<FunnelStats>(`/sites/${siteId}/funnels/${funnelId}/stats${queryString}`)
|
||||
}
|
||||
|
||||
93
lib/api/journeys.ts
Normal file
93
lib/api/journeys.ts
Normal 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 ?? [])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
80
lib/api/report-schedules.ts
Normal file
80
lib/api/report-schedules.ts
Normal 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',
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
638
lib/api/stats.ts
638
lib/api/stats.ts
@@ -1,6 +1,8 @@
|
||||
import apiRequest from './client'
|
||||
import { Site } from './sites'
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface Stats {
|
||||
pageviews: number
|
||||
visitors: number
|
||||
@@ -11,7 +13,7 @@ export interface Stats {
|
||||
export interface TopPage {
|
||||
path: string
|
||||
pageviews: number
|
||||
visits?: number // For entry/exit pages
|
||||
visits?: number
|
||||
}
|
||||
|
||||
export interface ScreenResolutionStat {
|
||||
@@ -101,6 +103,36 @@ 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) {
|
||||
if (auth?.password) params.append('password', auth.password)
|
||||
if (auth?.captcha?.captcha_id) params.append('captcha_id', auth.captcha.captcha_id)
|
||||
@@ -108,198 +140,117 @@ function appendAuthParams(params: URLSearchParams, auth?: AuthParams) {
|
||||
if (auth?.captcha?.captcha_token) params.append('captcha_token', auth.captcha.captcha_token)
|
||||
}
|
||||
|
||||
export async function getStats(siteId: string, startDate?: string, endDate?: string): Promise<Stats> {
|
||||
function buildQuery(
|
||||
opts: {
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
limit?: number
|
||||
interval?: string
|
||||
countryLimit?: number
|
||||
sort?: string
|
||||
filters?: string
|
||||
},
|
||||
auth?: AuthParams
|
||||
): string {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
if (opts.startDate) params.append('start_date', opts.startDate)
|
||||
if (opts.endDate) params.append('end_date', opts.endDate)
|
||||
if (opts.limit != null) params.append('limit', opts.limit.toString())
|
||||
if (opts.interval) params.append('interval', opts.interval)
|
||||
if (opts.countryLimit != null) params.append('country_limit', opts.countryLimit.toString())
|
||||
if (opts.sort) params.append('sort', opts.sort)
|
||||
if (opts.filters) params.append('filters', opts.filters)
|
||||
if (auth) appendAuthParams(params, auth)
|
||||
const query = params.toString()
|
||||
return apiRequest<Stats>(`/sites/${siteId}/stats${query ? `?${query}` : ''}`)
|
||||
return query ? `?${query}` : ''
|
||||
}
|
||||
|
||||
export async function getPublicStats(siteId: string, startDate?: string, endDate?: string, auth?: AuthParams): Promise<Stats> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
appendAuthParams(params, auth)
|
||||
const query = params.toString()
|
||||
return apiRequest<Stats>(`/public/sites/${siteId}/stats${query ? `?${query}` : ''}`)
|
||||
/** Factory for endpoints that return an array nested under a response key. */
|
||||
function createListFetcher<T>(path: string, field: string, defaultLimit = 10) {
|
||||
return (siteId: string, startDate?: string, endDate?: string, limit = defaultLimit, filters?: string): Promise<T[]> =>
|
||||
apiRequest<Record<string, T[]>>(`/sites/${siteId}/${path}${buildQuery({ startDate, endDate, limit, filters })}`)
|
||||
.then(r => r?.[field] || [])
|
||||
}
|
||||
|
||||
export async function getRealtime(siteId: string): Promise<RealtimeStats> {
|
||||
// ─── List Endpoints ─────────────────────────────────────────────────
|
||||
|
||||
export const getTopPages = createListFetcher<TopPage>('pages', 'pages')
|
||||
export const getTopReferrers = createListFetcher<TopReferrer>('referrers', 'referrers')
|
||||
export const getCountries = createListFetcher<CountryStat>('countries', 'countries')
|
||||
export const getCities = createListFetcher<CityStat>('cities', 'cities')
|
||||
export const getRegions = createListFetcher<RegionStat>('regions', 'regions')
|
||||
export const getBrowsers = createListFetcher<BrowserStat>('browsers', 'browsers')
|
||||
export const getOS = createListFetcher<OSStat>('os', 'os')
|
||||
export const getDevices = createListFetcher<DeviceStat>('devices', 'devices')
|
||||
export const getEntryPages = createListFetcher<TopPage>('entry-pages', 'pages')
|
||||
export const getExitPages = createListFetcher<TopPage>('exit-pages', 'pages')
|
||||
export const getScreenResolutions = createListFetcher<ScreenResolutionStat>('screen-resolutions', 'screen_resolutions')
|
||||
export const getGoalStats = createListFetcher<GoalCountStat>('goals/stats', 'goal_counts', 20)
|
||||
export const getCampaigns = createListFetcher<CampaignStat>('campaigns', 'campaigns')
|
||||
|
||||
// ─── Stats & Realtime ───────────────────────────────────────────────
|
||||
|
||||
export function getStats(siteId: string, startDate?: string, endDate?: string, filters?: string): Promise<Stats> {
|
||||
return apiRequest<Stats>(`/sites/${siteId}/stats${buildQuery({ startDate, endDate, filters })}`)
|
||||
}
|
||||
|
||||
export function getPublicStats(siteId: string, startDate?: string, endDate?: string, auth?: AuthParams): Promise<Stats> {
|
||||
return apiRequest<Stats>(`/public/sites/${siteId}/stats${buildQuery({ startDate, endDate }, auth)}`)
|
||||
}
|
||||
|
||||
export function getRealtime(siteId: string): Promise<RealtimeStats> {
|
||||
return apiRequest<RealtimeStats>(`/sites/${siteId}/realtime`)
|
||||
}
|
||||
|
||||
export async function getPublicRealtime(siteId: string, auth?: AuthParams): Promise<RealtimeStats> {
|
||||
const params = new URLSearchParams()
|
||||
appendAuthParams(params, auth)
|
||||
return apiRequest<RealtimeStats>(`/public/sites/${siteId}/realtime?${params.toString()}`)
|
||||
export function getPublicRealtime(siteId: string, auth?: AuthParams): Promise<RealtimeStats> {
|
||||
return apiRequest<RealtimeStats>(`/public/sites/${siteId}/realtime${buildQuery({}, auth)}`)
|
||||
}
|
||||
|
||||
export async function getTopPages(siteId: string, startDate?: string, endDate?: string, limit = 10): Promise<TopPage[]> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
params.append('limit', limit.toString())
|
||||
return apiRequest<{ pages: TopPage[] }>(`/sites/${siteId}/pages?${params.toString()}`).then(r => r?.pages || [])
|
||||
// ─── Daily Stats ────────────────────────────────────────────────────
|
||||
|
||||
export function getDailyStats(siteId: string, startDate?: string, endDate?: string, interval?: string, filters?: string): Promise<DailyStat[]> {
|
||||
return apiRequest<{ stats: DailyStat[] }>(`/sites/${siteId}/daily${buildQuery({ startDate, endDate, interval, filters })}`)
|
||||
.then(r => r?.stats || [])
|
||||
}
|
||||
|
||||
export async function getTopReferrers(siteId: string, startDate?: string, endDate?: string, limit = 10): Promise<TopReferrer[]> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
params.append('limit', limit.toString())
|
||||
return apiRequest<{ referrers: TopReferrer[] }>(`/sites/${siteId}/referrers?${params.toString()}`).then(r => r?.referrers || [])
|
||||
export function getPublicDailyStats(siteId: string, startDate?: string, endDate?: string, interval?: string, auth?: AuthParams): Promise<DailyStat[]> {
|
||||
return apiRequest<{ stats: DailyStat[] }>(`/public/sites/${siteId}/daily${buildQuery({ startDate, endDate, interval }, auth)}`)
|
||||
.then(r => r?.stats || [])
|
||||
}
|
||||
|
||||
export async function getCountries(siteId: string, startDate?: string, endDate?: string, limit = 10): Promise<CountryStat[]> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
params.append('limit', limit.toString())
|
||||
return apiRequest<{ countries: CountryStat[] }>(`/sites/${siteId}/countries?${params.toString()}`).then(r => r?.countries || [])
|
||||
// ─── Public Campaigns ───────────────────────────────────────────────
|
||||
|
||||
export function getPublicCampaigns(siteId: string, startDate?: string, endDate?: string, limit = 10, auth?: AuthParams): Promise<CampaignStat[]> {
|
||||
return apiRequest<{ campaigns: CampaignStat[] }>(`/public/sites/${siteId}/campaigns${buildQuery({ startDate, endDate, limit }, auth)}`)
|
||||
.then(r => r?.campaigns || [])
|
||||
}
|
||||
|
||||
export async function getCities(siteId: string, startDate?: string, endDate?: string, limit = 10): Promise<CityStat[]> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
params.append('limit', limit.toString())
|
||||
return apiRequest<{ cities: CityStat[] }>(`/sites/${siteId}/cities?${params.toString()}`).then(r => r?.cities || [])
|
||||
}
|
||||
// ─── Performance By Page ────────────────────────────────────────────
|
||||
|
||||
export async function getRegions(siteId: string, startDate?: string, endDate?: string, limit = 10): Promise<RegionStat[]> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
params.append('limit', limit.toString())
|
||||
return apiRequest<{ regions: RegionStat[] }>(`/sites/${siteId}/regions?${params.toString()}`).then(r => r?.regions || [])
|
||||
}
|
||||
|
||||
export async function getBrowsers(siteId: string, startDate?: string, endDate?: string, limit = 10): Promise<BrowserStat[]> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
params.append('limit', limit.toString())
|
||||
return apiRequest<{ browsers: BrowserStat[] }>(`/sites/${siteId}/browsers?${params.toString()}`).then(r => r?.browsers || [])
|
||||
}
|
||||
|
||||
export async function getOS(siteId: string, startDate?: string, endDate?: string, limit = 10): Promise<OSStat[]> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
params.append('limit', limit.toString())
|
||||
return apiRequest<{ os: OSStat[] }>(`/sites/${siteId}/os?${params.toString()}`).then(r => r?.os || [])
|
||||
}
|
||||
|
||||
export async function getDevices(siteId: string, startDate?: string, endDate?: string, limit = 10): Promise<DeviceStat[]> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
params.append('limit', limit.toString())
|
||||
return apiRequest<{ devices: DeviceStat[] }>(`/sites/${siteId}/devices?${params.toString()}`).then(r => r?.devices || [])
|
||||
}
|
||||
|
||||
export async function getDailyStats(siteId: string, startDate?: string, endDate?: string, interval?: string): Promise<DailyStat[]> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
if (interval) params.append('interval', interval)
|
||||
return apiRequest<{ stats: DailyStat[] }>(`/sites/${siteId}/daily?${params.toString()}`).then(r => r?.stats || [])
|
||||
}
|
||||
|
||||
export async function getPublicDailyStats(siteId: string, startDate?: string, endDate?: string, interval?: string, auth?: AuthParams): Promise<DailyStat[]> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
if (interval) params.append('interval', interval)
|
||||
appendAuthParams(params, auth)
|
||||
return apiRequest<{ stats: DailyStat[] }>(`/public/sites/${siteId}/daily?${params.toString()}`).then(r => r?.stats || [])
|
||||
}
|
||||
|
||||
export async function getEntryPages(siteId: string, startDate?: string, endDate?: string, limit = 10): Promise<TopPage[]> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
params.append('limit', limit.toString())
|
||||
return apiRequest<{ pages: TopPage[] }>(`/sites/${siteId}/entry-pages?${params.toString()}`).then(r => r?.pages || [])
|
||||
}
|
||||
|
||||
export async function getExitPages(siteId: string, startDate?: string, endDate?: string, limit = 10): Promise<TopPage[]> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
params.append('limit', limit.toString())
|
||||
return apiRequest<{ pages: TopPage[] }>(`/sites/${siteId}/exit-pages?${params.toString()}`).then(r => r?.pages || [])
|
||||
}
|
||||
|
||||
export async function getScreenResolutions(siteId: string, startDate?: string, endDate?: string, limit = 10): Promise<ScreenResolutionStat[]> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
params.append('limit', limit.toString())
|
||||
return apiRequest<{ screen_resolutions: ScreenResolutionStat[] }>(`/sites/${siteId}/screen-resolutions?${params.toString()}`).then(r => r?.screen_resolutions || [])
|
||||
}
|
||||
|
||||
export async function getPerformanceByPage(
|
||||
export function getPerformanceByPage(
|
||||
siteId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
opts?: { limit?: number; sort?: 'lcp' | 'cls' | 'inp' }
|
||||
): Promise<PerformanceByPageStat[]> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
if (opts?.limit != null) params.append('limit', String(opts.limit))
|
||||
if (opts?.sort) params.append('sort', opts.sort)
|
||||
const res = await apiRequest<{ performance_by_page: PerformanceByPageStat[] }>(
|
||||
`/sites/${siteId}/performance-by-page?${params.toString()}`
|
||||
)
|
||||
return res?.performance_by_page ?? []
|
||||
return apiRequest<{ performance_by_page: PerformanceByPageStat[] }>(
|
||||
`/sites/${siteId}/performance-by-page${buildQuery({ startDate, endDate, limit: opts?.limit, sort: opts?.sort })}`
|
||||
).then(r => r?.performance_by_page ?? [])
|
||||
}
|
||||
|
||||
export async function getPublicPerformanceByPage(
|
||||
export function getPublicPerformanceByPage(
|
||||
siteId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
opts?: { limit?: number; sort?: 'lcp' | 'cls' | 'inp' },
|
||||
auth?: AuthParams
|
||||
): Promise<PerformanceByPageStat[]> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
if (opts?.limit != null) params.append('limit', String(opts.limit))
|
||||
if (opts?.sort) params.append('sort', opts.sort)
|
||||
appendAuthParams(params, auth)
|
||||
const res = await apiRequest<{ performance_by_page: PerformanceByPageStat[] }>(
|
||||
`/public/sites/${siteId}/performance-by-page?${params.toString()}`
|
||||
)
|
||||
return res?.performance_by_page ?? []
|
||||
return apiRequest<{ performance_by_page: PerformanceByPageStat[] }>(
|
||||
`/public/sites/${siteId}/performance-by-page${buildQuery({ startDate, endDate, limit: opts?.limit, sort: opts?.sort }, auth)}`
|
||||
).then(r => r?.performance_by_page ?? [])
|
||||
}
|
||||
|
||||
export async function getGoalStats(siteId: string, startDate?: string, endDate?: string, limit = 20): Promise<GoalCountStat[]> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
params.append('limit', limit.toString())
|
||||
return apiRequest<{ goal_counts: GoalCountStat[] }>(`/sites/${siteId}/goals/stats?${params.toString()}`).then(r => r?.goal_counts || [])
|
||||
}
|
||||
|
||||
export async function getCampaigns(siteId: string, startDate?: string, endDate?: string, limit = 10): Promise<CampaignStat[]> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
params.append('limit', limit.toString())
|
||||
return apiRequest<{ campaigns: CampaignStat[] }>(`/sites/${siteId}/campaigns?${params.toString()}`).then(r => r?.campaigns || [])
|
||||
}
|
||||
|
||||
export async function getPublicCampaigns(siteId: string, startDate?: string, endDate?: string, limit = 10, auth?: AuthParams): Promise<CampaignStat[]> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
params.append('limit', limit.toString())
|
||||
appendAuthParams(params, auth)
|
||||
return apiRequest<{ campaigns: CampaignStat[] }>(`/public/sites/${siteId}/campaigns?${params.toString()}`).then(r => r?.campaigns || [])
|
||||
}
|
||||
// ─── Full Dashboard ─────────────────────────────────────────────────
|
||||
|
||||
export interface DashboardData {
|
||||
site: Site
|
||||
@@ -322,16 +273,11 @@ export interface DashboardData {
|
||||
goal_counts?: GoalCountStat[]
|
||||
}
|
||||
|
||||
export async function getDashboard(siteId: string, startDate?: string, endDate?: string, limit = 10, interval?: string): Promise<DashboardData> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
if (interval) params.append('interval', interval)
|
||||
params.append('limit', limit.toString())
|
||||
return apiRequest<DashboardData>(`/sites/${siteId}/dashboard?${params.toString()}`)
|
||||
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 async function getPublicDashboard(
|
||||
export function getPublicDashboard(
|
||||
siteId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
@@ -340,21 +286,12 @@ export async function getPublicDashboard(
|
||||
password?: string,
|
||||
captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string }
|
||||
): Promise<DashboardData> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
if (interval) params.append('interval', interval)
|
||||
|
||||
appendAuthParams(params, { password, captcha })
|
||||
|
||||
params.append('limit', limit.toString())
|
||||
return apiRequest<DashboardData>(`/public/sites/${siteId}/dashboard?${params.toString()}`)
|
||||
return apiRequest<DashboardData>(
|
||||
`/public/sites/${siteId}/dashboard${buildQuery({ startDate, endDate, limit, interval }, { password, captcha })}`
|
||||
)
|
||||
}
|
||||
|
||||
// * ============================================================================
|
||||
// * Focused Dashboard Endpoints (Fix 4.2: Efficient Data Transfer)
|
||||
// * These split the massive dashboard payload into smaller, focused chunks
|
||||
// * ============================================================================
|
||||
// ─── Focused Dashboard Endpoints ────────────────────────────────────
|
||||
|
||||
export interface DashboardOverviewData {
|
||||
site: Site
|
||||
@@ -363,109 +300,18 @@ export interface DashboardOverviewData {
|
||||
daily_stats: DailyStat[]
|
||||
}
|
||||
|
||||
export async function getDashboardOverview(
|
||||
siteId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
interval?: string
|
||||
): Promise<DashboardOverviewData> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
if (interval) params.append('interval', interval)
|
||||
return apiRequest<DashboardOverviewData>(`/sites/${siteId}/dashboard/overview?${params.toString()}`)
|
||||
}
|
||||
|
||||
export async function getPublicDashboardOverview(
|
||||
siteId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
interval?: string,
|
||||
password?: string,
|
||||
captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string }
|
||||
): Promise<DashboardOverviewData> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
if (interval) params.append('interval', interval)
|
||||
appendAuthParams(params, { password, captcha })
|
||||
return apiRequest<DashboardOverviewData>(`/public/sites/${siteId}/dashboard/overview?${params.toString()}`)
|
||||
}
|
||||
|
||||
export interface DashboardPagesData {
|
||||
top_pages: TopPage[]
|
||||
entry_pages: TopPage[]
|
||||
exit_pages: TopPage[]
|
||||
}
|
||||
|
||||
export async function getDashboardPages(
|
||||
siteId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
limit = 10
|
||||
): Promise<DashboardPagesData> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
params.append('limit', limit.toString())
|
||||
return apiRequest<DashboardPagesData>(`/sites/${siteId}/dashboard/pages?${params.toString()}`)
|
||||
}
|
||||
|
||||
export async function getPublicDashboardPages(
|
||||
siteId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
limit = 10,
|
||||
password?: string,
|
||||
captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string }
|
||||
): Promise<DashboardPagesData> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
params.append('limit', limit.toString())
|
||||
appendAuthParams(params, { password, captcha })
|
||||
return apiRequest<DashboardPagesData>(`/public/sites/${siteId}/dashboard/pages?${params.toString()}`)
|
||||
}
|
||||
|
||||
export interface DashboardLocationsData {
|
||||
countries: CountryStat[]
|
||||
cities: CityStat[]
|
||||
regions: RegionStat[]
|
||||
}
|
||||
|
||||
export async function getDashboardLocations(
|
||||
siteId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
limit = 10,
|
||||
countryLimit = 250
|
||||
): Promise<DashboardLocationsData> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
params.append('limit', limit.toString())
|
||||
params.append('country_limit', countryLimit.toString())
|
||||
return apiRequest<DashboardLocationsData>(`/sites/${siteId}/dashboard/locations?${params.toString()}`)
|
||||
}
|
||||
|
||||
export async function getPublicDashboardLocations(
|
||||
siteId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
limit = 10,
|
||||
countryLimit = 250,
|
||||
password?: string,
|
||||
captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string }
|
||||
): Promise<DashboardLocationsData> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
params.append('limit', limit.toString())
|
||||
params.append('country_limit', countryLimit.toString())
|
||||
appendAuthParams(params, { password, captcha })
|
||||
return apiRequest<DashboardLocationsData>(`/public/sites/${siteId}/dashboard/locations?${params.toString()}`)
|
||||
}
|
||||
|
||||
export interface DashboardDevicesData {
|
||||
browsers: BrowserStat[]
|
||||
os: OSStat[]
|
||||
@@ -473,127 +319,159 @@ export interface DashboardDevicesData {
|
||||
screen_resolutions: ScreenResolutionStat[]
|
||||
}
|
||||
|
||||
export async function getDashboardDevices(
|
||||
siteId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
limit = 10
|
||||
): Promise<DashboardDevicesData> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
params.append('limit', limit.toString())
|
||||
return apiRequest<DashboardDevicesData>(`/sites/${siteId}/dashboard/devices?${params.toString()}`)
|
||||
}
|
||||
|
||||
export async function getPublicDashboardDevices(
|
||||
siteId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
limit = 10,
|
||||
password?: string,
|
||||
captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string }
|
||||
): Promise<DashboardDevicesData> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
params.append('limit', limit.toString())
|
||||
appendAuthParams(params, { password, captcha })
|
||||
return apiRequest<DashboardDevicesData>(`/public/sites/${siteId}/dashboard/devices?${params.toString()}`)
|
||||
}
|
||||
|
||||
export interface DashboardReferrersData {
|
||||
top_referrers: TopReferrer[]
|
||||
}
|
||||
|
||||
export async function getDashboardReferrers(
|
||||
siteId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
limit = 10
|
||||
): Promise<DashboardReferrersData> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
params.append('limit', limit.toString())
|
||||
return apiRequest<DashboardReferrersData>(`/sites/${siteId}/dashboard/referrers?${params.toString()}`)
|
||||
}
|
||||
|
||||
export async function getPublicDashboardReferrers(
|
||||
siteId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
limit = 10,
|
||||
password?: string,
|
||||
captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string }
|
||||
): Promise<DashboardReferrersData> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
params.append('limit', limit.toString())
|
||||
appendAuthParams(params, { password, captcha })
|
||||
return apiRequest<DashboardReferrersData>(`/public/sites/${siteId}/dashboard/referrers?${params.toString()}`)
|
||||
}
|
||||
|
||||
export interface DashboardPerformanceData {
|
||||
performance?: PerformanceStats
|
||||
performance_by_page?: PerformanceByPageStat[]
|
||||
}
|
||||
|
||||
export async function getDashboardPerformance(
|
||||
siteId: string,
|
||||
startDate?: string,
|
||||
endDate?: string
|
||||
): Promise<DashboardPerformanceData> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
return apiRequest<DashboardPerformanceData>(`/sites/${siteId}/dashboard/performance?${params.toString()}`)
|
||||
}
|
||||
|
||||
export async function getPublicDashboardPerformance(
|
||||
siteId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
password?: string,
|
||||
captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string }
|
||||
): Promise<DashboardPerformanceData> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
appendAuthParams(params, { password, captcha })
|
||||
return apiRequest<DashboardPerformanceData>(`/public/sites/${siteId}/dashboard/performance?${params.toString()}`)
|
||||
}
|
||||
|
||||
export interface DashboardGoalsData {
|
||||
goal_counts: GoalCountStat[]
|
||||
}
|
||||
|
||||
export async function getDashboardGoals(
|
||||
siteId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
limit = 10
|
||||
): Promise<DashboardGoalsData> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
params.append('limit', limit.toString())
|
||||
return apiRequest<DashboardGoalsData>(`/sites/${siteId}/dashboard/goals?${params.toString()}`)
|
||||
export function getDashboardOverview(siteId: string, startDate?: string, endDate?: string, interval?: string, filters?: string): Promise<DashboardOverviewData> {
|
||||
return apiRequest<DashboardOverviewData>(`/sites/${siteId}/dashboard/overview${buildQuery({ startDate, endDate, interval, filters })}`)
|
||||
}
|
||||
|
||||
export async function getPublicDashboardGoals(
|
||||
siteId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
limit = 10,
|
||||
password?: string,
|
||||
captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string }
|
||||
): Promise<DashboardGoalsData> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
params.append('limit', limit.toString())
|
||||
appendAuthParams(params, { password, captcha })
|
||||
return apiRequest<DashboardGoalsData>(`/public/sites/${siteId}/dashboard/goals?${params.toString()}`)
|
||||
export function getPublicDashboardOverview(
|
||||
siteId: string, startDate?: string, endDate?: string, interval?: string,
|
||||
password?: string, captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string }
|
||||
): Promise<DashboardOverviewData> {
|
||||
return apiRequest<DashboardOverviewData>(`/public/sites/${siteId}/dashboard/overview${buildQuery({ startDate, endDate, interval }, { password, captcha })}`)
|
||||
}
|
||||
|
||||
export function getDashboardPages(siteId: string, startDate?: string, endDate?: string, limit = 10, filters?: string): Promise<DashboardPagesData> {
|
||||
return apiRequest<DashboardPagesData>(`/sites/${siteId}/dashboard/pages${buildQuery({ startDate, endDate, limit, filters })}`)
|
||||
}
|
||||
|
||||
export function getPublicDashboardPages(
|
||||
siteId: string, startDate?: string, endDate?: string, limit = 10,
|
||||
password?: string, captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string }
|
||||
): Promise<DashboardPagesData> {
|
||||
return apiRequest<DashboardPagesData>(`/public/sites/${siteId}/dashboard/pages${buildQuery({ startDate, endDate, limit }, { password, captcha })}`)
|
||||
}
|
||||
|
||||
export function getDashboardLocations(siteId: string, startDate?: string, endDate?: string, limit = 10, countryLimit = 250, filters?: string): Promise<DashboardLocationsData> {
|
||||
return apiRequest<DashboardLocationsData>(`/sites/${siteId}/dashboard/locations${buildQuery({ startDate, endDate, limit, countryLimit, filters })}`)
|
||||
}
|
||||
|
||||
export function getPublicDashboardLocations(
|
||||
siteId: string, startDate?: string, endDate?: string, limit = 10, countryLimit = 250,
|
||||
password?: string, captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string }
|
||||
): Promise<DashboardLocationsData> {
|
||||
return apiRequest<DashboardLocationsData>(`/public/sites/${siteId}/dashboard/locations${buildQuery({ startDate, endDate, limit, countryLimit }, { password, captcha })}`)
|
||||
}
|
||||
|
||||
export function getDashboardDevices(siteId: string, startDate?: string, endDate?: string, limit = 10, filters?: string): Promise<DashboardDevicesData> {
|
||||
return apiRequest<DashboardDevicesData>(`/sites/${siteId}/dashboard/devices${buildQuery({ startDate, endDate, limit, filters })}`)
|
||||
}
|
||||
|
||||
export function getPublicDashboardDevices(
|
||||
siteId: string, startDate?: string, endDate?: string, limit = 10,
|
||||
password?: string, captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string }
|
||||
): Promise<DashboardDevicesData> {
|
||||
return apiRequest<DashboardDevicesData>(`/public/sites/${siteId}/dashboard/devices${buildQuery({ startDate, endDate, limit }, { password, captcha })}`)
|
||||
}
|
||||
|
||||
export function getDashboardReferrers(siteId: string, startDate?: string, endDate?: string, limit = 10, filters?: string): Promise<DashboardReferrersData> {
|
||||
return apiRequest<DashboardReferrersData>(`/sites/${siteId}/dashboard/referrers${buildQuery({ startDate, endDate, limit, filters })}`)
|
||||
}
|
||||
|
||||
export function getPublicDashboardReferrers(
|
||||
siteId: string, startDate?: string, endDate?: string, limit = 10,
|
||||
password?: string, captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string }
|
||||
): Promise<DashboardReferrersData> {
|
||||
return apiRequest<DashboardReferrersData>(`/public/sites/${siteId}/dashboard/referrers${buildQuery({ startDate, endDate, limit }, { password, captcha })}`)
|
||||
}
|
||||
|
||||
export function getDashboardPerformance(siteId: string, startDate?: string, endDate?: string, filters?: string): Promise<DashboardPerformanceData> {
|
||||
return apiRequest<DashboardPerformanceData>(`/sites/${siteId}/dashboard/performance${buildQuery({ startDate, endDate, filters })}`)
|
||||
}
|
||||
|
||||
export function getPublicDashboardPerformance(
|
||||
siteId: string, startDate?: string, endDate?: string,
|
||||
password?: string, captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string }
|
||||
): Promise<DashboardPerformanceData> {
|
||||
return apiRequest<DashboardPerformanceData>(`/public/sites/${siteId}/dashboard/performance${buildQuery({ startDate, endDate }, { password, captcha })}`)
|
||||
}
|
||||
|
||||
export function getDashboardGoals(siteId: string, startDate?: string, endDate?: string, limit = 10, filters?: string): Promise<DashboardGoalsData> {
|
||||
return apiRequest<DashboardGoalsData>(`/sites/${siteId}/dashboard/goals${buildQuery({ startDate, endDate, limit, filters })}`)
|
||||
}
|
||||
|
||||
export function getPublicDashboardGoals(
|
||||
siteId: string, startDate?: string, endDate?: string, limit = 10,
|
||||
password?: string, captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string }
|
||||
): Promise<DashboardGoalsData> {
|
||||
return apiRequest<DashboardGoalsData>(`/public/sites/${siteId}/dashboard/goals${buildQuery({ startDate, endDate, limit }, { password, captcha })}`)
|
||||
}
|
||||
|
||||
// ─── Event Properties ────────────────────────────────────────────────
|
||||
|
||||
export interface EventPropertyKey {
|
||||
key: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface EventPropertyValue {
|
||||
value: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export function getEventPropertyKeys(siteId: string, eventName: string, startDate?: string, endDate?: string): Promise<EventPropertyKey[]> {
|
||||
return apiRequest<{ keys: EventPropertyKey[] }>(`/sites/${siteId}/goals/${encodeURIComponent(eventName)}/properties${buildQuery({ startDate, endDate })}`)
|
||||
.then(r => r?.keys || [])
|
||||
}
|
||||
|
||||
export function getEventPropertyValues(siteId: string, eventName: string, propName: string, startDate?: string, endDate?: string, limit = 20): Promise<EventPropertyValue[]> {
|
||||
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 ?? [])
|
||||
}
|
||||
|
||||
@@ -18,24 +18,8 @@ export interface Session {
|
||||
}
|
||||
|
||||
export async function getUserSessions(): Promise<{ sessions: Session[] }> {
|
||||
// Hash the current refresh token to identify current session
|
||||
const refreshToken = typeof window !== 'undefined' ? localStorage.getItem('refreshToken') : null
|
||||
let currentTokenHash = ''
|
||||
|
||||
if (refreshToken) {
|
||||
// Hash the refresh token using SHA-256
|
||||
const encoder = new TextEncoder()
|
||||
const data = encoder.encode(refreshToken)
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
||||
currentTokenHash = hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
return apiRequest<{ sessions: Session[] }>('/auth/user/sessions', {
|
||||
headers: currentTokenHash ? {
|
||||
'X-Current-Session-Hash': currentTokenHash,
|
||||
} : undefined,
|
||||
})
|
||||
// Current session is identified server-side via the httpOnly refresh token cookie
|
||||
return apiRequest<{ sessions: Session[] }>('/auth/user/sessions')
|
||||
}
|
||||
|
||||
export async function revokeSession(sessionId: string): Promise<void> {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,10 +201,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
},
|
||||
})
|
||||
|
||||
// * Stable primitives for the effect dependency array — avoids re-running
|
||||
// * on every render when the `user` object reference changes.
|
||||
const isAuthenticated = !!user
|
||||
const userOrgId = user?.org_id
|
||||
|
||||
// * Organization Wall & Auto-Switch
|
||||
useEffect(() => {
|
||||
const checkOrg = async () => {
|
||||
if (!loading && user) {
|
||||
if (!loading && isAuthenticated) {
|
||||
if (pathname?.startsWith('/onboarding')) return
|
||||
if (pathname?.startsWith('/auth/callback')) return
|
||||
|
||||
@@ -204,7 +223,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
// * If user has organizations but no context (org_id), switch to the first one
|
||||
if (!user.org_id && organizations.length > 0) {
|
||||
if (!userOrgId && organizations.length > 0) {
|
||||
const firstOrg = organizations[0]
|
||||
|
||||
try {
|
||||
@@ -235,7 +254,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
checkOrg()
|
||||
}, [loading, user, pathname, router])
|
||||
}, [loading, isAuthenticated, userOrgId, pathname, router])
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, login, logout, refresh, refreshSession }}>
|
||||
|
||||
205
lib/country-centroids.ts
Normal file
205
lib/country-centroids.ts
Normal 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 },
|
||||
}
|
||||
60
lib/filters.ts
Normal file
60
lib/filters.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
// * Dimension filter types and utilities for dashboard filtering
|
||||
|
||||
export interface DimensionFilter {
|
||||
dimension: string
|
||||
operator: 'is' | 'is_not' | 'contains' | 'not_contains'
|
||||
values: string[]
|
||||
}
|
||||
|
||||
export const DIMENSION_LABELS: Record<string, string> = {
|
||||
page: 'Page',
|
||||
referrer: 'Referrer',
|
||||
country: 'Country',
|
||||
city: 'City',
|
||||
region: 'Region',
|
||||
browser: 'Browser',
|
||||
os: 'OS',
|
||||
device: 'Device',
|
||||
utm_source: 'UTM Source',
|
||||
utm_medium: 'UTM Medium',
|
||||
utm_campaign: 'UTM Campaign',
|
||||
}
|
||||
|
||||
export const OPERATOR_LABELS: Record<string, string> = {
|
||||
is: 'is',
|
||||
is_not: 'is not',
|
||||
contains: 'contains',
|
||||
not_contains: 'does not contain',
|
||||
}
|
||||
|
||||
export const DIMENSIONS = Object.keys(DIMENSION_LABELS)
|
||||
export const OPERATORS = Object.keys(OPERATOR_LABELS) as DimensionFilter['operator'][]
|
||||
|
||||
/** Serialize filters to query param format: "browser|is|Chrome,country|is|US" */
|
||||
export function serializeFilters(filters: DimensionFilter[]): string {
|
||||
if (!filters.length) return ''
|
||||
return filters
|
||||
.map(f => `${f.dimension}|${f.operator}|${f.values.join(';')}`)
|
||||
.join(',')
|
||||
}
|
||||
|
||||
/** Parse filters from URL search param string */
|
||||
export function parseFiltersFromURL(raw: string): DimensionFilter[] {
|
||||
if (!raw) return []
|
||||
return raw.split(',').map(part => {
|
||||
const [dimension, operator, valuesRaw] = part.split('|')
|
||||
return {
|
||||
dimension,
|
||||
operator: operator as DimensionFilter['operator'],
|
||||
values: valuesRaw?.split(';') ?? [],
|
||||
}
|
||||
}).filter(f => f.dimension && f.operator && f.values.length > 0)
|
||||
}
|
||||
|
||||
/** Build display label for a filter pill */
|
||||
export function filterLabel(f: DimensionFilter): string {
|
||||
const dim = DIMENSION_LABELS[f.dimension] || f.dimension
|
||||
const op = OPERATOR_LABELS[f.operator] || f.operator
|
||||
const val = f.values.length > 1 ? `${f.values[0]} +${f.values.length - 1}` : f.values[0]
|
||||
return `${dim} ${op} ${val}`
|
||||
}
|
||||
31
lib/settings-modal-context.tsx
Normal file
31
lib/settings-modal-context.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useContext, useState, useCallback } from 'react'
|
||||
|
||||
interface SettingsModalContextType {
|
||||
isOpen: boolean
|
||||
openSettings: () => void
|
||||
closeSettings: () => void
|
||||
}
|
||||
|
||||
const SettingsModalContext = createContext<SettingsModalContextType>({
|
||||
isOpen: false,
|
||||
openSettings: () => {},
|
||||
closeSettings: () => {},
|
||||
})
|
||||
|
||||
export function SettingsModalProvider({ children }: { children: React.ReactNode }) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const openSettings = useCallback(() => setIsOpen(true), [])
|
||||
const closeSettings = useCallback(() => setIsOpen(false), [])
|
||||
|
||||
return (
|
||||
<SettingsModalContext.Provider value={{ isOpen, openSettings, closeSettings }}>
|
||||
{children}
|
||||
</SettingsModalContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useSettingsModal() {
|
||||
return useContext(SettingsModalContext)
|
||||
}
|
||||
42
lib/swr/cache-provider.ts
Normal file
42
lib/swr/cache-provider.ts
Normal 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()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -11,15 +11,29 @@ import {
|
||||
getDashboardReferrers,
|
||||
getDashboardPerformance,
|
||||
getDashboardGoals,
|
||||
getCampaigns,
|
||||
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,
|
||||
@@ -27,23 +41,34 @@ 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),
|
||||
dashboardOverview: (siteId: string, start: string, end: string) => getDashboardOverview(siteId, start, end),
|
||||
dashboardPages: (siteId: string, start: string, end: string) => getDashboardPages(siteId, start, end),
|
||||
dashboardLocations: (siteId: string, start: string, end: string) => getDashboardLocations(siteId, start, end),
|
||||
dashboardDevices: (siteId: string, start: string, end: string) => getDashboardDevices(siteId, start, end),
|
||||
dashboardReferrers: (siteId: string, start: string, end: string) => getDashboardReferrers(siteId, start, end),
|
||||
dashboardPerformance: (siteId: string, start: string, end: string) => getDashboardPerformance(siteId, start, end),
|
||||
dashboardGoals: (siteId: string, start: string, end: string) => getDashboardGoals(siteId, start, end),
|
||||
stats: (siteId: string, start: string, end: string) => getStats(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),
|
||||
dashboardDevices: (siteId: string, start: string, end: string, filters?: string) => getDashboardDevices(siteId, start, end, undefined, filters),
|
||||
dashboardReferrers: (siteId: string, start: string, end: string, filters?: string) => getDashboardReferrers(siteId, start, end, undefined, filters),
|
||||
dashboardPerformance: (siteId: string, start: string, end: string, filters?: string) => getDashboardPerformance(siteId, start, end, filters),
|
||||
dashboardGoals: (siteId: string, start: string, end: string, filters?: string) => getDashboardGoals(siteId, start, end, undefined, filters),
|
||||
stats: (siteId: string, start: string, end: string, filters?: string) => getStats(siteId, start, end, filters),
|
||||
dailyStats: (siteId: string, start: string, end: string, interval: 'hour' | 'day' | 'minute') =>
|
||||
getDailyStats(siteId, start, end, interval),
|
||||
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
|
||||
@@ -74,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,
|
||||
@@ -90,10 +116,10 @@ export function useDashboard(siteId: string, start: string, end: string) {
|
||||
}
|
||||
|
||||
// * Hook for stats (refreshed less frequently)
|
||||
export function useStats(siteId: string, start: string, end: string) {
|
||||
export function useStats(siteId: string, start: string, end: string, filters?: string) {
|
||||
return useSWR<Stats>(
|
||||
siteId && start && end ? ['stats', siteId, start, end] : null,
|
||||
() => fetchers.stats(siteId, start, end),
|
||||
siteId && start && end ? ['stats', siteId, start, end, filters] : null,
|
||||
() => fetchers.stats(siteId, start, end, filters),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
// * Refresh every 60 seconds for stats
|
||||
@@ -140,10 +166,10 @@ export function useRealtime(siteId: string, refreshInterval: number = 5000) {
|
||||
}
|
||||
|
||||
// * Hook for focused dashboard overview data (Fix 4.2: Efficient Data Transfer)
|
||||
export function useDashboardOverview(siteId: string, start: string, end: string) {
|
||||
export function useDashboardOverview(siteId: string, start: string, end: string, interval?: string, filters?: string) {
|
||||
return useSWR<DashboardOverviewData>(
|
||||
siteId && start && end ? ['dashboardOverview', siteId, start, end] : null,
|
||||
() => fetchers.dashboardOverview(siteId, start, end),
|
||||
siteId && start && end ? ['dashboardOverview', siteId, start, end, interval, filters] : null,
|
||||
() => fetchers.dashboardOverview(siteId, start, end, interval, filters),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
refreshInterval: 60 * 1000,
|
||||
@@ -153,10 +179,10 @@ export function useDashboardOverview(siteId: string, start: string, end: string)
|
||||
}
|
||||
|
||||
// * Hook for focused dashboard pages data
|
||||
export function useDashboardPages(siteId: string, start: string, end: string) {
|
||||
export function useDashboardPages(siteId: string, start: string, end: string, filters?: string) {
|
||||
return useSWR<DashboardPagesData>(
|
||||
siteId && start && end ? ['dashboardPages', siteId, start, end] : null,
|
||||
() => fetchers.dashboardPages(siteId, start, end),
|
||||
siteId && start && end ? ['dashboardPages', siteId, start, end, filters] : null,
|
||||
() => fetchers.dashboardPages(siteId, start, end, filters),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
refreshInterval: 60 * 1000,
|
||||
@@ -166,10 +192,10 @@ export function useDashboardPages(siteId: string, start: string, end: string) {
|
||||
}
|
||||
|
||||
// * Hook for focused dashboard locations data
|
||||
export function useDashboardLocations(siteId: string, start: string, end: string) {
|
||||
export function useDashboardLocations(siteId: string, start: string, end: string, filters?: string) {
|
||||
return useSWR<DashboardLocationsData>(
|
||||
siteId && start && end ? ['dashboardLocations', siteId, start, end] : null,
|
||||
() => fetchers.dashboardLocations(siteId, start, end),
|
||||
siteId && start && end ? ['dashboardLocations', siteId, start, end, filters] : null,
|
||||
() => fetchers.dashboardLocations(siteId, start, end, filters),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
refreshInterval: 60 * 1000,
|
||||
@@ -179,10 +205,10 @@ export function useDashboardLocations(siteId: string, start: string, end: string
|
||||
}
|
||||
|
||||
// * Hook for focused dashboard devices data
|
||||
export function useDashboardDevices(siteId: string, start: string, end: string) {
|
||||
export function useDashboardDevices(siteId: string, start: string, end: string, filters?: string) {
|
||||
return useSWR<DashboardDevicesData>(
|
||||
siteId && start && end ? ['dashboardDevices', siteId, start, end] : null,
|
||||
() => fetchers.dashboardDevices(siteId, start, end),
|
||||
siteId && start && end ? ['dashboardDevices', siteId, start, end, filters] : null,
|
||||
() => fetchers.dashboardDevices(siteId, start, end, filters),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
refreshInterval: 60 * 1000,
|
||||
@@ -192,10 +218,10 @@ export function useDashboardDevices(siteId: string, start: string, end: string)
|
||||
}
|
||||
|
||||
// * Hook for focused dashboard referrers data
|
||||
export function useDashboardReferrers(siteId: string, start: string, end: string) {
|
||||
export function useDashboardReferrers(siteId: string, start: string, end: string, filters?: string) {
|
||||
return useSWR<DashboardReferrersData>(
|
||||
siteId && start && end ? ['dashboardReferrers', siteId, start, end] : null,
|
||||
() => fetchers.dashboardReferrers(siteId, start, end),
|
||||
siteId && start && end ? ['dashboardReferrers', siteId, start, end, filters] : null,
|
||||
() => fetchers.dashboardReferrers(siteId, start, end, filters),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
refreshInterval: 60 * 1000,
|
||||
@@ -205,10 +231,10 @@ export function useDashboardReferrers(siteId: string, start: string, end: string
|
||||
}
|
||||
|
||||
// * Hook for focused dashboard performance data
|
||||
export function useDashboardPerformance(siteId: string, start: string, end: string) {
|
||||
export function useDashboardPerformance(siteId: string, start: string, end: string, filters?: string) {
|
||||
return useSWR<DashboardPerformanceData>(
|
||||
siteId && start && end ? ['dashboardPerformance', siteId, start, end] : null,
|
||||
() => fetchers.dashboardPerformance(siteId, start, end),
|
||||
siteId && start && end ? ['dashboardPerformance', siteId, start, end, filters] : null,
|
||||
() => fetchers.dashboardPerformance(siteId, start, end, filters),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
refreshInterval: 60 * 1000,
|
||||
@@ -218,10 +244,10 @@ export function useDashboardPerformance(siteId: string, start: string, end: stri
|
||||
}
|
||||
|
||||
// * Hook for focused dashboard goals data
|
||||
export function useDashboardGoals(siteId: string, start: string, end: string) {
|
||||
export function useDashboardGoals(siteId: string, start: string, end: string, filters?: string) {
|
||||
return useSWR<DashboardGoalsData>(
|
||||
siteId && start && end ? ['dashboardGoals', siteId, start, end] : null,
|
||||
() => fetchers.dashboardGoals(siteId, start, end),
|
||||
siteId && start && end ? ['dashboardGoals', siteId, start, end, filters] : null,
|
||||
() => fetchers.dashboardGoals(siteId, start, end, filters),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
refreshInterval: 60 * 1000,
|
||||
@@ -230,5 +256,83 @@ export function useDashboardGoals(siteId: string, start: string, end: string) {
|
||||
)
|
||||
}
|
||||
|
||||
// * Hook for campaigns data (used by export modal)
|
||||
export function useCampaigns(siteId: string, start: string, end: string, limit = 100) {
|
||||
return useSWR<CampaignStat[]>(
|
||||
siteId && start && end ? ['campaigns', siteId, start, end, limit] : null,
|
||||
() => fetchers.campaigns(siteId, start, end, limit),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
refreshInterval: 60 * 1000,
|
||||
dedupingInterval: 10 * 1000,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// * 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
1
lib/utils.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { cn } from '@ciphera-net/ui'
|
||||
@@ -1,95 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import {
|
||||
getRequestIdFromError,
|
||||
formatErrorMessage,
|
||||
logErrorWithRequestId,
|
||||
getSupportMessage,
|
||||
} from '../errorHandler'
|
||||
import { setLastRequestId, clearLastRequestId } from '../requestId'
|
||||
|
||||
beforeEach(() => {
|
||||
clearLastRequestId()
|
||||
})
|
||||
|
||||
describe('getRequestIdFromError', () => {
|
||||
it('extracts request ID from error response body', () => {
|
||||
const errorData = { error: { request_id: 'REQ123_abc' } }
|
||||
expect(getRequestIdFromError(errorData)).toBe('REQ123_abc')
|
||||
})
|
||||
|
||||
it('falls back to last stored request ID when not in response', () => {
|
||||
setLastRequestId('REQfallback_xyz')
|
||||
expect(getRequestIdFromError({ error: {} })).toBe('REQfallback_xyz')
|
||||
})
|
||||
|
||||
it('falls back to last stored request ID when no error data', () => {
|
||||
setLastRequestId('REQfallback_xyz')
|
||||
expect(getRequestIdFromError()).toBe('REQfallback_xyz')
|
||||
})
|
||||
|
||||
it('returns null when no ID available anywhere', () => {
|
||||
expect(getRequestIdFromError()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatErrorMessage', () => {
|
||||
it('returns plain message when no request ID available', () => {
|
||||
expect(formatErrorMessage('Something failed')).toBe('Something failed')
|
||||
})
|
||||
|
||||
it('appends request ID in development mode', () => {
|
||||
const original = process.env.NODE_ENV
|
||||
process.env.NODE_ENV = 'development'
|
||||
setLastRequestId('REQ123_abc')
|
||||
|
||||
const msg = formatErrorMessage('Something failed')
|
||||
expect(msg).toContain('Something failed')
|
||||
expect(msg).toContain('REQ123_abc')
|
||||
|
||||
process.env.NODE_ENV = original
|
||||
})
|
||||
|
||||
it('appends request ID when showRequestId option is set', () => {
|
||||
setLastRequestId('REQ123_abc')
|
||||
const msg = formatErrorMessage('Something failed', undefined, { showRequestId: true })
|
||||
expect(msg).toContain('REQ123_abc')
|
||||
})
|
||||
})
|
||||
|
||||
describe('logErrorWithRequestId', () => {
|
||||
it('logs with request ID when available', () => {
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
setLastRequestId('REQ123_abc')
|
||||
|
||||
logErrorWithRequestId('TestContext', new Error('fail'))
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('REQ123_abc'),
|
||||
expect.any(Error)
|
||||
)
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
it('logs without request ID when not available', () => {
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
logErrorWithRequestId('TestContext', new Error('fail'))
|
||||
|
||||
expect(spy).toHaveBeenCalledWith('[TestContext]', expect.any(Error))
|
||||
spy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSupportMessage', () => {
|
||||
it('includes request ID when available', () => {
|
||||
const errorData = { error: { request_id: 'REQ123_abc' } }
|
||||
const msg = getSupportMessage(errorData)
|
||||
expect(msg).toContain('REQ123_abc')
|
||||
expect(msg).toContain('contact support')
|
||||
})
|
||||
|
||||
it('returns generic message when no request ID', () => {
|
||||
const msg = getSupportMessage()
|
||||
expect(msg).toBe('If this persists, please contact support.')
|
||||
})
|
||||
})
|
||||
@@ -7,23 +7,25 @@ describe('logger', () => {
|
||||
|
||||
it('calls console.error in development', async () => {
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
process.env.NODE_ENV = 'development'
|
||||
vi.stubEnv('NODE_ENV', 'development')
|
||||
|
||||
const { logger } = await import('../logger')
|
||||
logger.error('test error')
|
||||
|
||||
expect(spy).toHaveBeenCalledWith('test error')
|
||||
spy.mockRestore()
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('calls console.warn in development', async () => {
|
||||
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
process.env.NODE_ENV = 'development'
|
||||
vi.stubEnv('NODE_ENV', 'development')
|
||||
|
||||
const { logger } = await import('../logger')
|
||||
logger.warn('test warning')
|
||||
|
||||
expect(spy).toHaveBeenCalledWith('test warning')
|
||||
spy.mockRestore()
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
})
|
||||
|
||||
9
lib/utils/cookies.ts
Normal file
9
lib/utils/cookies.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
// * Determine cookie domain dynamically.
|
||||
// * In production (on ciphera.net), we share cookies across subdomains.
|
||||
// * In local dev (localhost), we don't set a domain.
|
||||
export const getCookieDomain = (): string | undefined => {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
return '.ciphera.net'
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user