Compare commits
115 Commits
0.11.0-alp
...
0.13.0-alp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
3da2472c86 | ||
|
|
29e84e3a4f | ||
|
|
b3a303d6df | ||
|
|
ac1ed58127 | ||
|
|
805617a290 | ||
|
|
6bb356697b | ||
|
|
9a39745323 | ||
|
|
b5f83ce582 | ||
|
|
bce56fa64d | ||
|
|
5ef6eafc63 | ||
|
|
15f82eee00 | ||
|
|
7053cf5d5e | ||
|
|
c4e95268fe | ||
|
|
fcd36dcaeb | ||
|
|
c436680876 | ||
|
|
cba6347d70 | ||
|
|
ba24c24f41 | ||
|
|
22bc18a7cc | ||
|
|
a928d2577b | ||
|
|
8589842f16 | ||
|
|
3ff5ee4b6c | ||
|
|
67dcca660e | ||
|
|
d14911baf9 | ||
|
|
4e140c853f | ||
|
|
335cfc1a00 | ||
|
|
052c49ace2 | ||
|
|
f933c2fb71 | ||
|
|
908b8c0900 | ||
|
|
e5ad4cf2f6 | ||
|
|
b4b1348a94 | ||
|
|
0022e7b335 | ||
|
|
a9aaf24456 | ||
|
|
e7e217777a | ||
|
|
704a38f3df | ||
|
|
4cff0c621d | ||
|
|
36774cc995 | ||
|
|
3efd23b386 | ||
|
|
3aa0d7ae7c | ||
|
|
faa0bfe64a | ||
|
|
209ec1608a | ||
|
|
bcc02c93a0 | ||
|
|
f994141d64 | ||
|
|
86cc27a10c | ||
|
|
1edd78672e | ||
|
|
40fe34014c | ||
|
|
c89d9ce485 | ||
|
|
72745bd41a | ||
|
|
30b450cdb6 | ||
|
|
3fe20a4b1b | ||
|
|
b0c15d6464 | ||
|
|
892ba4cb11 | ||
|
|
2cb8ffddec | ||
|
|
801dc1d773 | ||
|
|
1484ade717 | ||
|
|
ef041d9a01 | ||
|
|
6fb4da5a69 | ||
|
|
3cb5416251 | ||
|
|
f62d142adb | ||
|
|
dd9d4c5ac2 | ||
|
|
27b3aa8380 | ||
|
|
b54af6c03a | ||
|
|
2889b0bb0a | ||
|
|
bd17bb45c4 |
33
.github/workflows/test.yml
vendored
Normal file
33
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
# * Runs unit tests on push/PR to main and staging.
|
||||
name: Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, staging]
|
||||
pull_request:
|
||||
branches: [main, staging]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: unit-tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.PKG_READ_TOKEN }}
|
||||
|
||||
- name: Run tests
|
||||
run: npm test
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -37,5 +37,6 @@ next-env.d.ts
|
||||
|
||||
# PWA
|
||||
public/sw.js
|
||||
public/sw 2.js
|
||||
public/workbox-*.js
|
||||
public/swe-worker-*.js
|
||||
|
||||
122
CHANGELOG.md
122
CHANGELOG.md
@@ -6,6 +6,111 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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
|
||||
|
||||
### Added
|
||||
|
||||
- **Automated testing for improved reliability.** Pulse now has a comprehensive test suite that verifies critical parts of the app work correctly before every release. This covers login and session protection, error tracking, online/offline detection, and background data refreshing. These checks run automatically so regressions are caught before they reach you.
|
||||
- **App Switcher in User Menu.** Click your profile in the top right and you'll now see a "Ciphera Apps" section. Expand it to quickly jump between Pulse, Drop (file sharing), and your Ciphera Account settings. This makes it easier to discover and navigate between Ciphera products without signing in again.
|
||||
- **Session synchronization across tabs.** When you sign out in one browser tab, you're now automatically signed out in all other tabs of the same app. This prevents situations where you might still appear signed in on another tab after logging out. The same applies to signing in — when you sign in on one tab, other tabs will update to reflect your authenticated state.
|
||||
- **Session expiration warning.** You'll now see a heads-up banner 3 minutes before your session expires, giving you time to click "Stay signed in" to extend your session. If you ignore it or dismiss it, your session will end naturally after the 15-minute timeout for security. If you interact with the app (click, type, scroll) while the warning is showing, it automatically extends your session.
|
||||
- **Faster billing page loading.** Your subscription details now load much quicker when you visit the billing page. Previously, several requests to our payment provider were made one after another, which could add 1-2 seconds to the page load. Now these happen simultaneously, cutting the wait time significantly. If any request takes too long, we gracefully continue so you always see your billing information without frustrating delays.
|
||||
- **Faster funnel analysis for multi-step conversions.** We've significantly improved how conversion funnels are calculated. Instead of scanning your data multiple times for each step in a funnel, we now do it in a single efficient pass. This means complex funnels with multiple steps load almost instantly instead of taking seconds—or even timing out. We've also added a reasonable limit of 5 steps per funnel to ensure optimal performance.
|
||||
- **More reliable database connections under heavy load.** We've optimized how Pulse manages its database connections to handle much higher traffic without issues. By increasing the connection pool size and improving how connections are reused, your dashboard stays responsive even when thousands of users are viewing analytics simultaneously. We also added better monitoring so we can detect and address connection issues before they affect you.
|
||||
- **Better support for growing teams and traffic.** We've added infrastructure improvements that allow Pulse to run smoothly across multiple servers. When you scale up to handle more traffic, our background processes—like daily analytics calculations and data cleanup—will coordinate automatically so they don't conflict with each other. This ensures reliable performance as your team and data grow.
|
||||
- **Smarter protection for heavy dashboard operations.** We've implemented a new tiered rate limiting system that treats complex dashboard queries differently from simple requests. Expensive operations—like loading your full dashboard with all its charts and data—now have their own dedicated limits to prevent anyone from accidentally overwhelming the system with too many rapid refreshes. This keeps everything running smoothly for everyone, especially during busy periods.
|
||||
- **Smarter caching for faster dashboard loading.** We've added intelligent caching headers to our API responses, so your browser can remember recently loaded data and show it instantly when you navigate between pages. This works alongside our existing server-side caching to make your dashboard feel even more responsive—especially when switching between different date ranges or sections.
|
||||
- **More flexible uptime monitoring.** We've made our uptime checker more adaptable to different needs. Instead of a fixed limit on how many websites we can check simultaneously, you can now configure this based on your requirements. This means faster uptime checks for busy sites with many monitors, while keeping things efficient for smaller setups.
|
||||
- **Smarter data cleanup for better performance.** We've improved how old analytics data is cleaned up to keep everything running smoothly. Instead of deleting large amounts of data all at once—which could slow things down—we now remove old data in small, efficient batches. This ensures your dashboard stays fast and responsive even as we clean up months of historical data behind the scenes.
|
||||
- **Faster analytics processing for all sites.** We've upgraded how your daily analytics are calculated behind the scenes. Instead of processing sites one by one, we now analyze multiple sites simultaneously using a smart parallel system. This means your daily stats—like visitor counts and page views—are updated more quickly and consistently, even as we handle data from thousands of websites.
|
||||
- **Lighter dashboard data transfers.** Your dashboard now loads data in smaller, focused pieces instead of one massive bundle. This means faster loading times—especially on slower connections—and your analytics appear section by section as they become ready, rather than making you wait for everything at once.
|
||||
- **Smarter data fetching.** Your dashboard now automatically prevents duplicate requests when multiple components ask for the same data at the same time. It also briefly caches recent responses, so switching between pages feels instant while still keeping everything up to date. This reduces server load and makes the app feel snappier.
|
||||
- **Smarter dashboard updates.** Your dashboard now knows when you're actively viewing it versus when it's in the background. When you switch to another tab, we intelligently slow down data refreshes to save resources, then instantly catch up when you return. This keeps your analytics current without putting unnecessary load on the system.
|
||||
- **Instant real-time visitor counts.** Your dashboard's "current visitors" counter now updates lightning-fast using an optimized tracking system. Instead of scanning your entire database, we maintain a live session index that shows active visitors in milliseconds—even when thousands of people are browsing your sites simultaneously.
|
||||
- **Faster event tracking.** Your analytics data is now captured instantly without slowing down your website. We've switched to asynchronous processing that collects events in batches of 100, so your visitors' page views and interactions are recorded with zero impact on their browsing experience, even during traffic spikes.
|
||||
- **Faster dashboard loading.** Your site analytics now load almost instantly, even during busy periods. Behind the scenes, we've added intelligent caching that remembers your dashboard data for 30 seconds and refreshes it automatically in the background. Real-time visitor counts are updated every 5 seconds so you always see current activity without waiting.
|
||||
- **Better data management for long-term performance.** We've restructured how your analytics data is stored so the app stays fast even as you collect months of data. Old data is now automatically organized by month and cleaned up efficiently based on your retention settings, keeping everything running smoothly no matter how much traffic you get.
|
||||
- **Smarter database indexing.** We've optimized how your analytics data is indexed, making common queries—like loading your dashboard or filtering by date—significantly faster. This also reduces storage overhead, keeping the app lean as your data grows.
|
||||
- **Faster dashboard statistics.** Loading stats for any date range is now much quicker. Instead of recalculating from scratch every time, we use pre-computed daily summaries so your analytics appear instantly, even for months of data.
|
||||
- **Performance insights. Track how fast your site loads with Core Web Vitals (page load speed, layout shifts, responsiveness). Turn it on in Site Settings → Data & Privacy to see a performance widget on your dashboard.
|
||||
- **Goals & Events.** Define custom goals (e.g. signup, purchase) and track them with `pulse.track()` in your snippet. Counts appear on your dashboard once you add goals in Site Settings → Goals & Events.
|
||||
- **2FA recovery codes backup.** When you enable 2FA, you receive recovery codes. You can now regenerate new codes (with password confirmation) from Settings and download them as a `.txt` file. Regenerating invalidates all existing codes.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Request ID tracing for debugging.** All API requests now include a unique Request ID header (`X-Request-ID`) that helps trace requests across frontend and backend services. When errors occur, the Request ID is included in the response, making it easy to find the exact request in server logs for debugging.
|
||||
- **App Switcher now shows consistent order.** The Ciphera Apps menu now always displays apps in the same order: Pulse, Drop, Auth — regardless of which app you're currently using. Previously, the current app was shown first, causing the order to change depending on context. This creates a more predictable navigation experience.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Shopify and embedded site tracking.** The Pulse tracking script now loads correctly when embedded on third-party sites like Shopify stores, WooCommerce, or custom storefronts. Previously, tracking failed because the script was redirected to the login page instead of loading.
|
||||
- **Opening Pulse from the Ciphera hub.** Clicking Pulse on the auth apps page (auth.ciphera.net/apps) now signs you in correctly instead of showing "Invalid state". Previously, leftover OAuth data from a past login attempt could block the session flow; the callback now detects redirects from the hub (no `state` in the URL), clears stale PKCE storage, and completes token exchange.
|
||||
- **Admin organizations list.** Organizations that created a site but never subscribed now appear in the admin list. Previously only orgs with a billing row were shown.
|
||||
- **Sign in after inactivity.** Clicking "Sign in" after a period of inactivity no longer does nothing. Previously, stale refresh cookies caused the middleware to redirect away from the login page; now only a valid access token triggers that redirect, so you can complete OAuth sign-in when your session has expired.
|
||||
- **Frequent re-login.** You no longer have to sign in multiple times a day. When the access token expires after 15 minutes of inactivity, the app now automatically refreshes it using your refresh token on the next page load, so you stay logged in for up to 30 days.
|
||||
- **2FA disable now requires password confirmation.** Disabling 2FA sends the derived password to the backend for verification. This prevents an attacker with a hijacked session from stripping 2FA.
|
||||
- **More accurate visitor tracking.** We fixed rare edge cases where visitor counts could be slightly off during busy traffic spikes. Previously, the timestamp-based session ID generation could occasionally create overlapping identifiers. Every visitor now gets a truly unique UUID that never overlaps with others, ensuring your analytics are always precise.
|
||||
- **More reliable background processing.** When multiple Pulse servers are running, background tasks like daily analytics calculations and data cleanup now coordinate more safely. Previously, under rare timing conditions, two servers could accidentally run the same task at the same time, which could lead to slightly inaccurate stats. Each server now holds a unique token that prevents one from interfering with another's work.
|
||||
- **Cross-tab sign-out cleanup.** Signing out in one tab now fully clears your session data in all other tabs. Previously, some session-related entries were left behind, which could briefly show stale state before the redirect completed.
|
||||
- **Settings sidebar highlight.** The "Manage Account" section in Settings now stays highlighted when you're viewing Trusted Devices or Security Activity. Previously, navigating to a sub-page removed the highlight from the parent section, making it unclear which group you were in.
|
||||
- **More accurate readiness checks.** The service health endpoint now actively verifies that the cache and real-time tracker are reachable, not just configured. Previously, the readiness check only confirmed these services were set up—not that they were actually responding—so the API could report "ready" even when Redis or the tracker was down.
|
||||
|
||||
## [0.11.1-alpha] - 2026-02-23
|
||||
|
||||
### Changed
|
||||
|
||||
- **Safer sign-in from the Ciphera hub.** When you open Pulse from the Ciphera Apps page, your credentials are no longer visible in the browser address bar. Sign-in now uses a secure one-time code that expires in seconds, so your session stays private even if someone sees your screen or browser history.
|
||||
|
||||
## [0.11.0-alpha] - 2026-02-22
|
||||
|
||||
### Added
|
||||
@@ -18,10 +123,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
- **Better form experience.** Forms now auto-focus the first field when they open, text inputs enforce character limits with a visible counter when you're close, and the settings page warns you before navigating away with unsaved changes.
|
||||
- **Accessibility improvements.** The notification bell, workspace switcher, and all dashboard tabs are now fully keyboard-navigable. Screen readers announce unread counts, active organizations, and tab changes correctly. Decorative icons are hidden from assistive technology.
|
||||
- **Smooth organization switching.** Switching between organizations now shows a branded loading screen instead of a blank flash while the page reloads.
|
||||
- **Graceful server shutdown.** Deployments no longer kill in-flight requests or interrupt background tasks. The server finishes ongoing work before shutting down.
|
||||
- **Database connection pooling.** The backend now limits and recycles database connections, preventing exhaustion under load and reducing query latency.
|
||||
- **Date range validation.** Analytics, funnel, and uptime queries now reject invalid date ranges (end before start, or spans longer than a year) instead of silently returning empty or oversized results.
|
||||
- **Excluded paths limit.** Sites can now have up to 50 excluded paths. Previously there was no cap, which could slow down event processing.
|
||||
- **Graceful server shutdown.** Deployments no longer kill in-flight requests or interrupt background tasks. The server finishes ongoing work before shutting down, so your active sessions aren't cut off mid-action.
|
||||
- **Database connection pooling.** The backend now limits and recycles database connections, preventing exhaustion under load and keeping queries fast even with many concurrent users.
|
||||
- **Date range validation.** Analytics, funnel, and uptime queries now reject invalid date ranges (end before start, or spans longer than a year) and show a clear error instead of empty or confusing results.
|
||||
- **Excluded paths limit.** Sites can now have up to 50 excluded paths. Previously there was no cap, which could slow down event processing; the limit keeps things fast while still giving you flexibility.
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -29,8 +134,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
- **Clearer error messages.** When something goes wrong, the error message now tells you what failed (e.g. "Failed to load uptime monitors") instead of a generic "Failed to load data".
|
||||
- **Faster favicon loading.** Site icons in the dashboard, referrers, and campaigns now use Next.js image optimization for better caching and lazy loading.
|
||||
- **Tighter name limits.** Site, funnel, and monitor names are now capped at 100 characters instead of 255 — long enough for any real name, short enough to not break the UI.
|
||||
- **Stricter type safety.** Eliminated all `any` types and `@ts-ignore` suppressions across the codebase, so the TypeScript compiler catches more bugs at build time.
|
||||
- **Smaller page downloads.** Icon imports are now tree-shaken so only the icons actually used are included in the bundle.
|
||||
- **Stricter type safety.** Eliminated all `any` types and `@ts-ignore` suppressions across the codebase, so the TypeScript compiler catches more bugs at build time and fewer edge cases slip through.
|
||||
- **Smaller page downloads.** Icon imports are now tree-shaken so only the icons actually used are included in the bundle, reducing download size and speeding up page loads.
|
||||
- **Removed debug logs.** Auth and organization-switching details no longer leak into the browser console in production. Error logs are now also suppressed in production and only appear during development.
|
||||
|
||||
### Fixed
|
||||
@@ -168,7 +273,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
|
||||
---
|
||||
|
||||
[Unreleased]: https://github.com/ciphera-net/pulse/compare/v0.11.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
|
||||
[0.10.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.9.0-alpha...v0.10.0-alpha
|
||||
[0.9.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.8.0-alpha...v0.9.0-alpha
|
||||
|
||||
99
__tests__/middleware.test.ts
Normal file
99
__tests__/middleware.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { NextRequest } from 'next/server'
|
||||
import { middleware } from '../middleware'
|
||||
|
||||
function createRequest(path: string, cookies: Record<string, string> = {}): NextRequest {
|
||||
const url = new URL(path, 'http://localhost:3000')
|
||||
const req = new NextRequest(url)
|
||||
for (const [name, value] of Object.entries(cookies)) {
|
||||
req.cookies.set(name, value)
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
describe('middleware', () => {
|
||||
describe('public routes', () => {
|
||||
const publicPaths = [
|
||||
'/',
|
||||
'/login',
|
||||
'/signup',
|
||||
'/auth/callback',
|
||||
'/pricing',
|
||||
'/features',
|
||||
'/about',
|
||||
'/faq',
|
||||
'/changelog',
|
||||
'/installation',
|
||||
'/script.js',
|
||||
]
|
||||
|
||||
publicPaths.forEach((path) => {
|
||||
it(`allows unauthenticated access to ${path}`, () => {
|
||||
const res = middleware(createRequest(path))
|
||||
// NextResponse.next() does not set a Location header
|
||||
expect(res.headers.get('Location')).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('public prefixes', () => {
|
||||
it('allows /share/* without auth', () => {
|
||||
const res = middleware(createRequest('/share/abc123'))
|
||||
expect(res.headers.get('Location')).toBeNull()
|
||||
})
|
||||
|
||||
it('allows /integrations without auth', () => {
|
||||
const res = middleware(createRequest('/integrations'))
|
||||
expect(res.headers.get('Location')).toBeNull()
|
||||
})
|
||||
|
||||
it('allows /docs without auth', () => {
|
||||
const res = middleware(createRequest('/docs'))
|
||||
expect(res.headers.get('Location')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('protected routes', () => {
|
||||
it('redirects unauthenticated users to /login', () => {
|
||||
const res = middleware(createRequest('/sites'))
|
||||
expect(res.headers.get('Location')).toContain('/login')
|
||||
})
|
||||
|
||||
it('redirects unauthenticated users from /settings to /login', () => {
|
||||
const res = middleware(createRequest('/settings'))
|
||||
expect(res.headers.get('Location')).toContain('/login')
|
||||
})
|
||||
|
||||
it('allows access with access_token cookie', () => {
|
||||
const res = middleware(createRequest('/sites', { access_token: 'tok' }))
|
||||
expect(res.headers.get('Location')).toBeNull()
|
||||
})
|
||||
|
||||
it('allows access with refresh_token cookie only', () => {
|
||||
const res = middleware(createRequest('/sites', { refresh_token: 'tok' }))
|
||||
expect(res.headers.get('Location')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('auth-only route redirects', () => {
|
||||
it('redirects authenticated user from /login to /', () => {
|
||||
const res = middleware(createRequest('/login', { access_token: 'tok' }))
|
||||
const location = res.headers.get('Location')
|
||||
expect(location).not.toBeNull()
|
||||
expect(new URL(location!).pathname).toBe('/')
|
||||
})
|
||||
|
||||
it('redirects authenticated user from /signup to /', () => {
|
||||
const res = middleware(createRequest('/signup', { access_token: 'tok' }))
|
||||
const location = res.headers.get('Location')
|
||||
expect(location).not.toBeNull()
|
||||
expect(new URL(location!).pathname).toBe('/')
|
||||
})
|
||||
|
||||
it('does NOT redirect from /login with only refresh_token (stale session)', () => {
|
||||
const res = middleware(createRequest('/login', { refresh_token: 'tok' }))
|
||||
// Should allow through to /login since only refresh_token is present
|
||||
expect(res.headers.get('Location')).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
@@ -33,19 +24,23 @@ interface UserPayload {
|
||||
/** Error type returned to client for mapping to user-facing copy (no sensitive details). */
|
||||
export type AuthExchangeErrorType = 'network' | 'expired' | 'invalid' | 'server'
|
||||
|
||||
export async function exchangeAuthCode(code: string, codeVerifier: string, redirectUri: string) {
|
||||
export async function exchangeAuthCode(code: string, codeVerifier: string | null, redirectUri: string) {
|
||||
try {
|
||||
// * IMPORTANT: credentials: 'include' is required to receive httpOnly cookies from Auth API
|
||||
// * The Auth API sets access_token, refresh_token, and csrf_token as httpOnly cookies
|
||||
// * We must forward these to the browser for cross-subdomain auth to work
|
||||
const res = await fetch(`${AUTH_API_URL}/oauth/token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include', // * Critical: receives httpOnly cookies from Auth API
|
||||
body: JSON.stringify({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
client_id: 'pulse-app',
|
||||
redirect_uri: redirectUri,
|
||||
code_verifier: codeVerifier,
|
||||
code_verifier: codeVerifier || '',
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -91,6 +86,50 @@ export async function exchangeAuthCode(code: string, codeVerifier: string, redir
|
||||
maxAge: 60 * 60 * 24 * 30 // 30 days
|
||||
})
|
||||
|
||||
// * Forward cookies from Auth API response to browser
|
||||
// * The Auth API sets httpOnly cookies on auth.ciphera.net - we need to mirror them on pulse.ciphera.net
|
||||
const setCookieHeaders = res.headers.getSetCookie()
|
||||
if (setCookieHeaders && setCookieHeaders.length > 0) {
|
||||
for (const cookieStr of setCookieHeaders) {
|
||||
// * Parse Set-Cookie header (format: name=value; attributes...)
|
||||
const [nameValue] = cookieStr.split(';')
|
||||
const [name, value] = nameValue.trim().split('=')
|
||||
|
||||
if (name && value) {
|
||||
// * Determine if httpOnly (default true for security)
|
||||
const isHttpOnly = cookieStr.toLowerCase().includes('httponly')
|
||||
// * Determine sameSite (default lax)
|
||||
const sameSiteMatch = cookieStr.match(/samesite=(\w+)/i)
|
||||
const sameSite = (sameSiteMatch?.[1]?.toLowerCase() as 'strict' | 'lax' | 'none') || 'lax'
|
||||
// * Extract max-age if present
|
||||
const maxAgeMatch = cookieStr.match(/max-age=(\d+)/i)
|
||||
const maxAge = maxAgeMatch ? parseInt(maxAgeMatch[1], 10) : 60 * 60 * 24 * 30
|
||||
|
||||
cookieStore.set(name.trim(), decodeURIComponent(value.trim()), {
|
||||
httpOnly: isHttpOnly,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: sameSite,
|
||||
path: '/',
|
||||
domain: cookieDomain,
|
||||
maxAge: maxAge
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// * Also check for CSRF token in response header (fallback)
|
||||
const csrfToken = res.headers.get('X-CSRF-Token')
|
||||
if (csrfToken && !cookieStore.get('csrf_token')) {
|
||||
cookieStore.set('csrf_token', csrfToken, {
|
||||
httpOnly: false, // * Must be readable by JS for CSRF protection
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
domain: cookieDomain,
|
||||
maxAge: 60 * 60 * 24 * 30
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: {
|
||||
|
||||
45
app/admin/layout.tsx
Normal file
45
app/admin/layout.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { getAdminMe } from '@/lib/api/admin'
|
||||
import { LoadingOverlay } from '@ciphera-net/ui'
|
||||
|
||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
const [isAdmin, setIsAdmin] = useState<boolean | null>(null)
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
getAdminMe()
|
||||
.then((res) => {
|
||||
if (res.is_admin) {
|
||||
setIsAdmin(true)
|
||||
} else {
|
||||
setIsAdmin(false)
|
||||
// Redirect to home if not admin
|
||||
router.push('/')
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setIsAdmin(false)
|
||||
router.push('/')
|
||||
})
|
||||
}, [router])
|
||||
|
||||
if (isAdmin === null) {
|
||||
return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Checking access..." />
|
||||
}
|
||||
|
||||
if (!isAdmin) {
|
||||
return null // Will redirect
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white">Pulse Admin</h1>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
243
app/admin/orgs/[id]/page.tsx
Normal file
243
app/admin/orgs/[id]/page.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { getAdminOrg, grantPlan, type AdminOrgDetail } from '@/lib/api/admin'
|
||||
import { Button, LoadingOverlay, Select, toast } from '@ciphera-net/ui'
|
||||
|
||||
function formatDate(d: Date) {
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
function formatDateTime(d: Date) {
|
||||
return d.toLocaleDateString('en-US', { dateStyle: 'long' }) + ' ' + d.toLocaleTimeString('en-US', { hour: 'numeric', minute: 'numeric' })
|
||||
}
|
||||
function addMonths(d: Date, months: number) {
|
||||
const out = new Date(d)
|
||||
out.setMonth(out.getMonth() + months)
|
||||
return out
|
||||
}
|
||||
function addYears(d: Date, years: number) {
|
||||
const out = new Date(d)
|
||||
out.setFullYear(out.getFullYear() + years)
|
||||
return out
|
||||
}
|
||||
|
||||
const PLAN_OPTIONS = [
|
||||
{ value: 'free', label: 'Free' },
|
||||
{ value: 'solo', label: 'Solo' },
|
||||
{ value: 'team', label: 'Team' },
|
||||
{ value: 'business', label: 'Business' },
|
||||
]
|
||||
|
||||
const INTERVAL_OPTIONS = [
|
||||
{ value: 'month', label: 'Monthly' },
|
||||
{ value: 'year', label: 'Yearly' },
|
||||
]
|
||||
|
||||
const LIMIT_OPTIONS = [
|
||||
{ value: '1000', label: '1k (Free)' },
|
||||
{ value: '10000', label: '10k (Solo)' },
|
||||
{ value: '100000', label: '100k (Team)' },
|
||||
{ value: '1000000', label: '1M (Business)' },
|
||||
{ value: '5000000', label: '5M' },
|
||||
{ value: '10000000', label: '10M' },
|
||||
]
|
||||
|
||||
export default function AdminOrgDetailPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const orgId = params.id as string
|
||||
|
||||
const [org, setOrg] = useState<AdminOrgDetail | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
// Form state
|
||||
const [planId, setPlanId] = useState('free')
|
||||
const [interval, setInterval] = useState('month')
|
||||
const [limit, setLimit] = useState('1000')
|
||||
const [periodEnd, setPeriodEnd] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (orgId) {
|
||||
getAdminOrg(orgId)
|
||||
.then((data) => {
|
||||
setOrg({ ...data.billing, sites: data.sites })
|
||||
setPlanId(data.billing.plan_id)
|
||||
setInterval(data.billing.billing_interval || 'month')
|
||||
setLimit(data.billing.pageview_limit.toString())
|
||||
|
||||
// Format date for input type="datetime-local" or similar
|
||||
if (data.billing.current_period_end) {
|
||||
setPeriodEnd(new Date(data.billing.current_period_end).toISOString().slice(0, 16))
|
||||
} else {
|
||||
// Default to 1 month from now
|
||||
setPeriodEnd(addMonths(new Date(), 1).toISOString().slice(0, 16))
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error('Failed to load organization')
|
||||
router.push('/admin/orgs')
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
}, [orgId, router])
|
||||
|
||||
const handleGrantPlan = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!org) return
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await grantPlan(org.organization_id, {
|
||||
plan_id: planId,
|
||||
billing_interval: interval,
|
||||
pageview_limit: parseInt(limit),
|
||||
period_end: new Date(periodEnd).toISOString(),
|
||||
})
|
||||
toast.success('Plan granted successfully')
|
||||
router.refresh()
|
||||
// Reload data to show updates
|
||||
const data = await getAdminOrg(orgId)
|
||||
setOrg({ ...data.billing, sites: data.sites })
|
||||
} catch (error) {
|
||||
toast.error('Failed to grant plan')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Loading organization..." />
|
||||
if (!org) return <div>Organization not found</div>
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-4xl mx-auto">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white">
|
||||
{org.business_name || 'Unnamed Organization'}
|
||||
</h2>
|
||||
<span className="text-sm font-mono text-neutral-500">{org.organization_id}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* Current Status */}
|
||||
<div className="rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-6 shadow-sm">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white mb-4">Current Status</h3>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<span className="text-neutral-500">Plan:</span>
|
||||
<span className="font-medium">{org.plan_id}</span>
|
||||
|
||||
<span className="text-neutral-500">Status:</span>
|
||||
<span className="font-medium">{org.subscription_status}</span>
|
||||
|
||||
<span className="text-neutral-500">Limit:</span>
|
||||
<span className="font-medium">{new Intl.NumberFormat().format(org.pageview_limit)}</span>
|
||||
|
||||
<span className="text-neutral-500">Interval:</span>
|
||||
<span className="font-medium">{org.billing_interval}</span>
|
||||
|
||||
<span className="text-neutral-500">Period End:</span>
|
||||
<span className="font-medium">
|
||||
{org.current_period_end ? formatDateTime(new Date(org.current_period_end)) : '-'}
|
||||
</span>
|
||||
|
||||
<span className="text-neutral-500">Stripe Cust:</span>
|
||||
<span className="font-mono text-xs">{org.stripe_customer_id || '-'}</span>
|
||||
|
||||
<span className="text-neutral-500">Stripe Sub:</span>
|
||||
<span className="font-mono text-xs">{org.stripe_subscription_id || '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sites */}
|
||||
<div className="rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-6 shadow-sm">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white mb-4">Sites ({org.sites.length})</h3>
|
||||
<ul className="space-y-2 max-h-60 overflow-y-auto">
|
||||
{org.sites.map((site) => (
|
||||
<li key={site.id} className="flex justify-between items-center text-sm p-2 bg-neutral-50 dark:bg-neutral-900 rounded">
|
||||
<span className="font-medium">{site.domain}</span>
|
||||
<span className="text-neutral-500 text-xs">{formatDate(new Date(site.created_at))}</span>
|
||||
</li>
|
||||
))}
|
||||
{org.sites.length === 0 && <li className="text-neutral-500 text-sm">No sites found</li>}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grant Plan Form */}
|
||||
<div className="rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-6 shadow-sm">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white mb-4">Grant Plan (Manual Override)</h3>
|
||||
<form onSubmit={handleGrantPlan} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Plan Tier</label>
|
||||
<Select
|
||||
value={planId}
|
||||
onChange={setPlanId}
|
||||
options={PLAN_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Billing Interval</label>
|
||||
<Select
|
||||
value={interval}
|
||||
onChange={setInterval}
|
||||
options={INTERVAL_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Pageview Limit</label>
|
||||
<Select
|
||||
value={limit}
|
||||
onChange={setLimit}
|
||||
options={LIMIT_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Period End Date (UTC)</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={periodEnd}
|
||||
onChange={(e) => setPeriodEnd(e.target.value)}
|
||||
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 focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-offset-2"
|
||||
required
|
||||
/>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPeriodEnd(addMonths(new Date(), 1).toISOString().slice(0, 16))}
|
||||
className="text-xs text-blue-500 hover:underline"
|
||||
>
|
||||
+1 Month
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPeriodEnd(addYears(new Date(), 1).toISOString().slice(0, 16))}
|
||||
className="text-xs text-blue-500 hover:underline"
|
||||
>
|
||||
+1 Year
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPeriodEnd(addYears(new Date(), 100).toISOString().slice(0, 16))}
|
||||
className="text-xs text-blue-500 hover:underline"
|
||||
>
|
||||
Forever
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 flex justify-end">
|
||||
<Button type="submit" disabled={submitting} variant="primary">
|
||||
{submitting ? 'Granting...' : 'Grant Plan'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
108
app/admin/orgs/page.tsx
Normal file
108
app/admin/orgs/page.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { listAdminOrgs, type AdminOrgSummary } from '@/lib/api/admin'
|
||||
import { Button, LoadingOverlay, toast } from '@ciphera-net/ui'
|
||||
|
||||
function formatDate(d: Date) {
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
|
||||
function CopyableOrgId({ id }: { id: string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const copy = useCallback(() => {
|
||||
navigator.clipboard.writeText(id)
|
||||
setCopied(true)
|
||||
toast.success('Org ID copied to clipboard')
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}, [id])
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={copy}
|
||||
className="font-mono text-xs text-neutral-500 hover:text-brand-orange dark:hover:text-brand-orange cursor-pointer transition-colors text-left"
|
||||
title="Click to copy"
|
||||
>
|
||||
{copied ? 'Copied!' : `${id.substring(0, 8)}...`}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AdminOrgsPage() {
|
||||
const [orgs, setOrgs] = useState<AdminOrgSummary[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
listAdminOrgs()
|
||||
.then(setOrgs)
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Loading organizations..." />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold text-neutral-900 dark:text-white">Organizations</h2>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-6 shadow-sm">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white mb-4">All Organizations</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="border-b border-neutral-200 dark:border-neutral-800">
|
||||
<tr>
|
||||
<th className="px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">Name</th>
|
||||
<th className="px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">Org ID</th>
|
||||
<th className="px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">Plan</th>
|
||||
<th className="px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">Status</th>
|
||||
<th className="px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">Limit</th>
|
||||
<th className="px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">Updated</th>
|
||||
<th className="px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-800">
|
||||
{orgs.map((org) => (
|
||||
<tr key={org.organization_id} className="hover:bg-neutral-50 dark:hover:bg-neutral-900/50">
|
||||
<td className="px-4 py-3 text-neutral-900 dark:text-white font-medium">
|
||||
{org.business_name || 'N/A'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<CopyableOrgId id={org.organization_id} />
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||
org.plan_id === 'business' ? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400' :
|
||||
org.plan_id === 'team' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' :
|
||||
org.plan_id === 'solo' ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' :
|
||||
'bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-400'
|
||||
}`}>
|
||||
{org.plan_id}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-neutral-600 dark:text-neutral-300">
|
||||
{org.subscription_status || '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-neutral-600 dark:text-neutral-300">
|
||||
{new Intl.NumberFormat().format(org.pageview_limit)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-neutral-500 text-xs">
|
||||
{formatDate(new Date(org.updated_at))}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Link href={`/admin/orgs/${org.organization_id}`}>
|
||||
<Button variant="ghost">Manage</Button>
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
20
app/admin/page.tsx
Normal file
20
app/admin/page.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function AdminDashboard() {
|
||||
return (
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
<Link
|
||||
href="/admin/orgs"
|
||||
className="block transition-transform hover:scale-[1.02] rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-6 shadow-sm"
|
||||
>
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">Organizations</h3>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-1">Manage organization plans and limits</p>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-4">
|
||||
View all organizations, check billing status, and manually grant plans.
|
||||
</p>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -37,6 +30,9 @@ export async function POST() {
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
// * Get CSRF token from Auth API response header (for cookie rotation)
|
||||
const csrfToken = res.headers.get('X-CSRF-Token')
|
||||
|
||||
cookieStore.set('access_token', data.access_token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
@@ -55,6 +51,18 @@ export async function POST() {
|
||||
maxAge: 60 * 60 * 24 * 30
|
||||
})
|
||||
|
||||
// * Set/update CSRF token cookie (non-httpOnly, for JS access)
|
||||
if (csrfToken) {
|
||||
cookieStore.set('csrf_token', csrfToken, {
|
||||
httpOnly: false, // * Must be readable by JS for CSRF protection
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
domain: cookieDomain,
|
||||
maxAge: 60 * 60 * 24 * 30
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, access_token: data.access_token })
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: 'Internal error' }, { status: 500 })
|
||||
|
||||
@@ -5,7 +5,7 @@ import { logger } from '@/lib/utils/logger'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { useAuth } from '@/lib/auth/context'
|
||||
import { AUTH_URL, default as apiRequest } from '@/lib/api/client'
|
||||
import { exchangeAuthCode, setSessionAction } from '@/app/actions/auth'
|
||||
import { exchangeAuthCode } from '@/app/actions/auth'
|
||||
import { authMessageFromErrorType, type AuthErrorType } from '@ciphera-net/ui'
|
||||
import { LoadingOverlay } from '@ciphera-net/ui'
|
||||
|
||||
@@ -21,7 +21,7 @@ function AuthCallbackContent() {
|
||||
const code = searchParams.get('code')
|
||||
const codeVerifier = localStorage.getItem('oauth_code_verifier')
|
||||
const redirectUri = typeof window !== 'undefined' ? window.location.origin + '/auth/callback' : ''
|
||||
if (!code || !codeVerifier) return
|
||||
if (!code) return
|
||||
const result = await exchangeAuthCode(code, codeVerifier, redirectUri)
|
||||
if (result.success && result.user) {
|
||||
// * Fetch full profile (including display_name) before navigating so header shows correct name on first paint
|
||||
@@ -47,59 +47,28 @@ function AuthCallbackContent() {
|
||||
}, [searchParams, login, router])
|
||||
|
||||
useEffect(() => {
|
||||
// * Prevent double execution (React Strict Mode or fast re-renders)
|
||||
if (processedRef.current && !isRetrying) return
|
||||
|
||||
// * Check for direct token passing (from auth-frontend direct login)
|
||||
// * This flow exposes tokens in URL, kept for legacy support.
|
||||
// * Recommended: Use Authorization Code flow (below)
|
||||
const token = searchParams.get('token')
|
||||
const refreshToken = searchParams.get('refresh_token')
|
||||
|
||||
if (token && refreshToken) {
|
||||
processedRef.current = true
|
||||
const handleDirectTokens = async () => {
|
||||
const result = await setSessionAction(token, refreshToken)
|
||||
if (result.success && result.user) {
|
||||
// * Fetch full profile (including display_name) before navigating so header shows correct name on first paint
|
||||
try {
|
||||
const fullProfile = await apiRequest<{ id: string; email: string; display_name?: string; totp_enabled: boolean; org_id?: string; role?: string }>('/auth/user/me')
|
||||
const merged = { ...fullProfile, org_id: result.user.org_id ?? fullProfile.org_id, role: result.user.role ?? fullProfile.role }
|
||||
login(merged)
|
||||
} catch {
|
||||
login(result.user)
|
||||
}
|
||||
if (typeof window !== 'undefined' && localStorage.getItem('pulse_pending_checkout')) {
|
||||
router.push('/welcome')
|
||||
} else {
|
||||
const raw = searchParams.get('returnTo') || '/'
|
||||
const safe = (typeof raw === 'string' && raw.startsWith('/') && !raw.startsWith('//')) ? raw : '/'
|
||||
router.push(safe)
|
||||
}
|
||||
} else {
|
||||
setError(authMessageFromErrorType('invalid'))
|
||||
}
|
||||
}
|
||||
handleDirectTokens()
|
||||
return
|
||||
}
|
||||
|
||||
const code = searchParams.get('code')
|
||||
if (!code) return
|
||||
|
||||
const state = searchParams.get('state')
|
||||
|
||||
if (!code || !state) return
|
||||
|
||||
const storedState = localStorage.getItem('oauth_state')
|
||||
const codeVerifier = localStorage.getItem('oauth_code_verifier')
|
||||
|
||||
if (!codeVerifier) {
|
||||
setError('Missing code verifier')
|
||||
return
|
||||
}
|
||||
if (state !== storedState) {
|
||||
logger.error('State mismatch', { received: state, stored: storedState })
|
||||
setError('Invalid state')
|
||||
return
|
||||
// * Session flow (from auth hub): redirect has code but no state. Clear stale PKCE
|
||||
// * data from any previous app-initiated OAuth so exchange proceeds without validation.
|
||||
if (!state) {
|
||||
localStorage.removeItem('oauth_state')
|
||||
localStorage.removeItem('oauth_code_verifier')
|
||||
} else {
|
||||
// * Full OAuth flow (app-initiated): validate state + use PKCE
|
||||
const isFullOAuth = !!storedState && !!codeVerifier
|
||||
if (isFullOAuth && state !== storedState) {
|
||||
logger.error('State mismatch', { received: state, stored: storedState })
|
||||
setError('Invalid state')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
processedRef.current = true
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -2,25 +2,56 @@
|
||||
|
||||
import { OfflineBanner } from '@/components/OfflineBanner'
|
||||
import { Footer } from '@/components/Footer'
|
||||
import { Header } from '@ciphera-net/ui'
|
||||
import { Header, type CipheraApp } from '@ciphera-net/ui'
|
||||
import NotificationCenter from '@/components/notifications/NotificationCenter'
|
||||
import { useAuth } from '@/lib/auth/context'
|
||||
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'
|
||||
|
||||
export default function LayoutContent({ children }: { children: React.ReactNode }) {
|
||||
// * Available Ciphera apps for the app switcher
|
||||
const CIPHERA_APPS: CipheraApp[] = [
|
||||
{
|
||||
id: 'pulse',
|
||||
name: 'Pulse',
|
||||
description: 'Your current app — Privacy-first analytics',
|
||||
icon: 'https://ciphera.net/pulse_icon_no_margins.png',
|
||||
href: 'https://pulse.ciphera.net',
|
||||
isAvailable: false, // * Current app
|
||||
},
|
||||
{
|
||||
id: 'drop',
|
||||
name: 'Drop',
|
||||
description: 'Secure file sharing',
|
||||
icon: 'https://ciphera.net/drop_icon_no_margins.png',
|
||||
href: 'https://drop.ciphera.net',
|
||||
isAvailable: true,
|
||||
},
|
||||
{
|
||||
id: 'auth',
|
||||
name: 'Auth',
|
||||
description: 'Your Ciphera account settings',
|
||||
icon: 'https://ciphera.net/auth_icon_no_margins.png',
|
||||
href: 'https://auth.ciphera.net',
|
||||
isAvailable: true,
|
||||
},
|
||||
]
|
||||
|
||||
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'
|
||||
@@ -59,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;
|
||||
@@ -72,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}
|
||||
@@ -87,6 +118,9 @@ export default function LayoutContent({ children }: { children: React.ReactNode
|
||||
showPricing={true}
|
||||
topOffset={showOfflineBar ? `${barHeightRem}rem` : undefined}
|
||||
rightSideActions={auth.user ? <NotificationCenter /> : null}
|
||||
apps={CIPHERA_APPS}
|
||||
currentAppId="pulse"
|
||||
onOpenSettings={openSettings}
|
||||
customNavItems={
|
||||
<>
|
||||
{!auth.user && (
|
||||
@@ -106,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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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..."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import ProfileSettings from '@/components/settings/ProfileSettings'
|
||||
|
||||
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">
|
||||
<ProfileSettings />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -278,7 +278,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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -117,7 +117,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>
|
||||
|
||||
@@ -2,15 +2,26 @@
|
||||
|
||||
import { useAuth } from '@/lib/auth/context'
|
||||
import { logger } from '@/lib/utils/logger'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { useCallback, useEffect, useState, useMemo } from 'react'
|
||||
import { useParams, useRouter, useSearchParams } 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 {
|
||||
getPerformanceByPage,
|
||||
getTopPages,
|
||||
getTopReferrers,
|
||||
getCountries,
|
||||
getCities,
|
||||
getRegions,
|
||||
getBrowsers,
|
||||
getOS,
|
||||
getDevices,
|
||||
getCampaigns,
|
||||
type Stats,
|
||||
type DailyStat,
|
||||
} from '@/lib/api/stats'
|
||||
import { getDateRange } 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 { DashboardSkeleton, useMinimumLoading } from '@/components/skeletons'
|
||||
import ExportModal from '@/components/dashboard/ExportModal'
|
||||
@@ -21,7 +32,51 @@ import TechSpecs from '@/components/dashboard/TechSpecs'
|
||||
import Chart from '@/components/dashboard/Chart'
|
||||
import PerformanceStats from '@/components/dashboard/PerformanceStats'
|
||||
import GoalStats from '@/components/dashboard/GoalStats'
|
||||
import ScrollDepth from '@/components/dashboard/ScrollDepth'
|
||||
import Campaigns from '@/components/dashboard/Campaigns'
|
||||
import FilterBar from '@/components/dashboard/FilterBar'
|
||||
import AddFilterDropdown, { type FilterSuggestion, type FilterSuggestions } from '@/components/dashboard/AddFilterDropdown'
|
||||
import EventProperties from '@/components/dashboard/EventProperties'
|
||||
import { type DimensionFilter, serializeFilters, parseFiltersFromURL } from '@/lib/filters'
|
||||
import {
|
||||
useDashboardOverview,
|
||||
useDashboardPages,
|
||||
useDashboardLocations,
|
||||
useDashboardDevices,
|
||||
useDashboardReferrers,
|
||||
useDashboardPerformance,
|
||||
useDashboardGoals,
|
||||
useRealtime,
|
||||
useStats,
|
||||
useDailyStats,
|
||||
useCampaigns,
|
||||
} from '@/lib/swr/dashboard'
|
||||
|
||||
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 getInitialDateRange(): { start: string; end: string } {
|
||||
const settings = loadSavedSettings()
|
||||
if (settings?.type === 'today') {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
return { start: today, end: today }
|
||||
}
|
||||
if (settings?.type === '7') return getDateRange(7)
|
||||
if (settings?.type === 'custom' && settings.dateRange) return settings.dateRange
|
||||
return getDateRange(30)
|
||||
}
|
||||
|
||||
export default function SiteDashboardPage() {
|
||||
const { user } = useAuth()
|
||||
@@ -31,69 +86,279 @@ export default function SiteDashboardPage() {
|
||||
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 [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)
|
||||
|
||||
// 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)
|
||||
// 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 (e) {
|
||||
logger.error('Failed to load dashboard settings', e)
|
||||
} finally {
|
||||
setIsSettingsLoaded(true)
|
||||
} 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])
|
||||
|
||||
// SWR hooks - replace manual useState + useEffect + setInterval polling
|
||||
// Each hook handles its own refresh interval, deduplication, and error retry
|
||||
// Filters are included in cache keys so changing filters auto-refetches
|
||||
const { data: overview, isLoading: overviewLoading, error: overviewError } = useDashboardOverview(siteId, dateRange.start, dateRange.end, interval, filtersParam || undefined)
|
||||
const { data: pages } = useDashboardPages(siteId, dateRange.start, dateRange.end, filtersParam || undefined)
|
||||
const { data: locations } = useDashboardLocations(siteId, dateRange.start, dateRange.end, filtersParam || undefined)
|
||||
const { data: devicesData } = useDashboardDevices(siteId, dateRange.start, dateRange.end, filtersParam || undefined)
|
||||
const { data: referrers } = useDashboardReferrers(siteId, dateRange.start, dateRange.end, filtersParam || undefined)
|
||||
const { data: performanceData } = useDashboardPerformance(siteId, dateRange.start, dateRange.end, filtersParam || undefined)
|
||||
const { data: goalsData } = useDashboardGoals(siteId, dateRange.start, dateRange.end, filtersParam || undefined)
|
||||
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)
|
||||
|
||||
// Derive typed values from SWR data
|
||||
const site = overview?.site ?? null
|
||||
const stats: Stats = overview?.stats ?? { pageviews: 0, visitors: 0, bounce_rate: 0, avg_duration: 0 }
|
||||
const realtime = realtimeData?.visitors ?? overview?.realtime_visitors ?? 0
|
||||
const dailyStats: DailyStat[] = overview?.daily_stats ?? []
|
||||
|
||||
// Build filter suggestions from current dashboard data
|
||||
const filterSuggestions = useMemo<FilterSuggestions>(() => {
|
||||
const s: FilterSuggestions = {}
|
||||
|
||||
// Pages
|
||||
const topPages = pages?.top_pages ?? []
|
||||
if (topPages.length > 0) {
|
||||
s.page = topPages.map(p => ({ value: p.path, label: p.path, count: p.pageviews }))
|
||||
}
|
||||
|
||||
// Referrers
|
||||
const refs = referrers?.top_referrers ?? []
|
||||
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 = locations?.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 = locations?.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 = locations?.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 = devicesData?.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 = devicesData?.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 = devicesData?.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
|
||||
}, [pages, referrers, locations, devicesData, campaigns])
|
||||
|
||||
// Show error toast on fetch failure
|
||||
useEffect(() => {
|
||||
if (overviewError) {
|
||||
toast.error('Failed to load dashboard analytics')
|
||||
}
|
||||
}, [overviewError])
|
||||
|
||||
// Track when data was last updated (for "Live · Xs ago" display)
|
||||
useEffect(() => {
|
||||
if (overview) setLastUpdatedAt(Date.now())
|
||||
}, [overview])
|
||||
|
||||
// Tick every 1s so "Live · Xs ago" counts in real time
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setTick((t) => t + 1), 1000)
|
||||
return () => clearInterval(timer)
|
||||
}, [])
|
||||
|
||||
// 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,9 +375,6 @@ 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]
|
||||
if (dateRange.start === today && dateRange.end === today) type = 'today'
|
||||
@@ -127,101 +389,13 @@ 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] }
|
||||
}, [])
|
||||
|
||||
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])
|
||||
|
||||
useEffect(() => {
|
||||
if (isSettingsLoaded) loadData()
|
||||
const interval = setInterval(() => {
|
||||
loadData(true)
|
||||
loadRealtime()
|
||||
}, 30000)
|
||||
return () => clearInterval(interval)
|
||||
}, [siteId, dateRange, todayInterval, multiDayInterval, isSettingsLoaded, loadData, loadRealtime])
|
||||
}, [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)
|
||||
const showSkeleton = useMinimumLoading(overviewLoading)
|
||||
|
||||
if (showSkeleton) {
|
||||
return <DashboardSkeleton />
|
||||
@@ -253,7 +427,7 @@ export default function SiteDashboardPage() {
|
||||
{site.domain}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Realtime Indicator */}
|
||||
<button
|
||||
onClick={() => router.push(`/sites/${siteId}/realtime`)}
|
||||
@@ -353,12 +527,18 @@ export default function SiteDashboardPage() {
|
||||
</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}
|
||||
@@ -374,8 +554,8 @@ export default function SiteDashboardPage() {
|
||||
{site.enable_performance_insights && (
|
||||
<div className="mb-8">
|
||||
<PerformanceStats
|
||||
stats={performance}
|
||||
performanceByPage={performanceByPage}
|
||||
stats={performanceData?.performance ?? { lcp: 0, cls: 0, inp: 0 }}
|
||||
performanceByPage={performanceData?.performance_by_page ?? null}
|
||||
siteId={siteId}
|
||||
startDate={dateRange.start}
|
||||
endDate={dateRange.end}
|
||||
@@ -386,52 +566,71 @@ export default function SiteDashboardPage() {
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2 mb-8">
|
||||
<ContentStats
|
||||
topPages={topPages}
|
||||
entryPages={entryPages}
|
||||
exitPages={exitPages}
|
||||
topPages={pages?.top_pages ?? []}
|
||||
entryPages={pages?.entry_pages ?? []}
|
||||
exitPages={pages?.exit_pages ?? []}
|
||||
domain={site.domain}
|
||||
collectPagePaths={site.collect_page_paths ?? true}
|
||||
siteId={siteId}
|
||||
dateRange={dateRange}
|
||||
onFilter={handleAddFilter}
|
||||
/>
|
||||
<TopReferrers
|
||||
referrers={topReferrers}
|
||||
referrers={referrers?.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={locations?.countries ?? []}
|
||||
cities={locations?.cities ?? []}
|
||||
regions={locations?.regions ?? []}
|
||||
geoDataLevel={site.collect_geo_data || 'full'}
|
||||
siteId={siteId}
|
||||
dateRange={dateRange}
|
||||
onFilter={handleAddFilter}
|
||||
/>
|
||||
<TechSpecs
|
||||
browsers={browsers}
|
||||
os={os}
|
||||
devices={devices}
|
||||
screenResolutions={screenResolutions}
|
||||
browsers={devicesData?.browsers ?? []}
|
||||
os={devicesData?.os ?? []}
|
||||
devices={devicesData?.devices ?? []}
|
||||
screenResolutions={devicesData?.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} />
|
||||
<GoalStats
|
||||
goalCounts={(goalsData?.goal_counts ?? []).filter(g => !/^scroll_\d+$/.test(g.event_name))}
|
||||
onSelectEvent={setSelectedEvent}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<GoalStats goalCounts={goalCounts} />
|
||||
<ScrollDepth goalCounts={goalsData?.goal_counts ?? []} totalPageviews={stats.pageviews} />
|
||||
</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)}
|
||||
@@ -448,8 +647,8 @@ export default function SiteDashboardPage() {
|
||||
onClose={() => setIsExportModalOpen(false)}
|
||||
data={dailyStats}
|
||||
stats={stats}
|
||||
topPages={topPages}
|
||||
topReferrers={topReferrers}
|
||||
topPages={pages?.top_pages}
|
||||
topReferrers={referrers?.top_referrers}
|
||||
campaigns={campaigns}
|
||||
/>
|
||||
</motion.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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
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,37 @@ 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 { 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 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 [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 +44,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 +63,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 +100,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,22 +120,6 @@ 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">
|
||||
@@ -171,124 +127,87 @@ export default function Campaigns({ siteId, dateRange }: CampaignsProps) {
|
||||
<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>
|
||||
{showViewAll && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="h-8 px-3 text-xs"
|
||||
>
|
||||
View All
|
||||
</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>
|
||||
)
|
||||
})}
|
||||
{showViewAll ? (
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="flex items-center justify-center gap-1.5 h-9 w-full text-xs font-medium text-neutral-400 dark:text-neutral-500 hover:text-brand-orange dark:hover:text-brand-orange transition-colors cursor-pointer rounded-lg px-2 -mx-2"
|
||||
>
|
||||
View all
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
) : (
|
||||
Array.from({ length: emptySlots }).map((_, i) => (
|
||||
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
|
||||
))
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="h-full flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
|
||||
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
|
||||
<FaBullhorn className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
|
||||
</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:outline-none focus:ring-2 focus: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
|
||||
@@ -296,45 +215,51 @@ export default function Campaigns({ siteId, dateRange }: CampaignsProps) {
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
title="All Campaigns"
|
||||
>
|
||||
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-2">
|
||||
<div className="space-y-1 max-h-[60vh] overflow-y-auto pr-2">
|
||||
{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="flex items-center justify-end mb-2">
|
||||
<button
|
||||
onClick={handleExportCampaigns}
|
||||
className="text-xs font-medium text-neutral-400 hover:text-brand-orange transition-colors cursor-pointer"
|
||||
>
|
||||
<div className="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>
|
||||
Export CSV
|
||||
</button>
|
||||
</div>
|
||||
{sortedFullData.map((item) => {
|
||||
return (
|
||||
<div
|
||||
key={`${item.source}|${item.medium}|${item.campaign}`}
|
||||
className="flex items-center justify-between py-2 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors"
|
||||
>
|
||||
<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="font-semibold text-neutral-900 dark:text-white">
|
||||
{formatNumber(item.visitors)}
|
||||
</span>
|
||||
<span className="text-neutral-400 dark:text-neutral-500 w-16 text-right">
|
||||
{formatNumber(item.pageviews)} pv
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="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>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -10,12 +10,10 @@ import {
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
ReferenceLine,
|
||||
} from 'recharts'
|
||||
import type { TooltipProps } from 'recharts'
|
||||
import { formatNumber, formatDuration, formatUpdatedAgo } from '@ciphera-net/ui'
|
||||
import Sparkline from './Sparkline'
|
||||
import { ArrowUpRightIcon, ArrowDownRightIcon, BarChartIcon, Select, Button, DownloadIcon } from '@ciphera-net/ui'
|
||||
import { ArrowUpRightIcon, ArrowDownRightIcon, BarChartIcon, Select, DownloadIcon } from '@ciphera-net/ui'
|
||||
import { Checkbox } from '@ciphera-net/ui'
|
||||
|
||||
const COLORS = {
|
||||
@@ -26,6 +24,7 @@ const COLORS = {
|
||||
|
||||
const CHART_COLORS_LIGHT = {
|
||||
border: 'var(--color-neutral-200)',
|
||||
grid: 'var(--color-neutral-100)',
|
||||
text: 'var(--color-neutral-900)',
|
||||
textMuted: 'var(--color-neutral-500)',
|
||||
axis: 'var(--color-neutral-400)',
|
||||
@@ -35,6 +34,7 @@ const CHART_COLORS_LIGHT = {
|
||||
|
||||
const CHART_COLORS_DARK = {
|
||||
border: 'var(--color-neutral-700)',
|
||||
grid: 'var(--color-neutral-800)',
|
||||
text: 'var(--color-neutral-50)',
|
||||
textMuted: 'var(--color-neutral-400)',
|
||||
axis: 'var(--color-neutral-500)',
|
||||
@@ -68,119 +68,29 @@ interface ChartProps {
|
||||
setTodayInterval: (interval: 'minute' | 'hour') => void
|
||||
multiDayInterval: 'hour' | 'day'
|
||||
setMultiDayInterval: (interval: 'hour' | 'day') => void
|
||||
/** Optional: callback when user requests chart export (parent can open ExportModal or handle export) */
|
||||
onExportChart?: () => void
|
||||
/** Optional: timestamp of last data fetch for "Live · Xs ago" indicator */
|
||||
lastUpdatedAt?: number | null
|
||||
}
|
||||
|
||||
type MetricType = 'pageviews' | 'visitors' | 'bounce_rate' | 'avg_duration'
|
||||
|
||||
// * Custom tooltip with comparison and theme-aware styling
|
||||
function ChartTooltip({
|
||||
active,
|
||||
payload,
|
||||
label,
|
||||
metric,
|
||||
metricLabel,
|
||||
formatNumberFn,
|
||||
showComparison,
|
||||
prevPeriodLabel,
|
||||
colors,
|
||||
}: {
|
||||
active?: boolean
|
||||
payload?: Array<{ payload: { prevPageviews?: number; prevVisitors?: number; prevBounceRate?: number; prevAvgDuration?: number }; value: number }>
|
||||
label?: string
|
||||
metric: MetricType
|
||||
metricLabel: string
|
||||
formatNumberFn: (n: number) => string
|
||||
showComparison: boolean
|
||||
prevPeriodLabel?: string
|
||||
colors: typeof CHART_COLORS_LIGHT
|
||||
}) {
|
||||
if (!active || !payload?.length || !label) return null
|
||||
// * Recharts sends one payload entry per Area; order can be [prevSeries, currentSeries].
|
||||
// * Use the entry for the current metric so the tooltip shows today's value, not yesterday's.
|
||||
type PayloadItem = { dataKey?: string; value?: number; payload: { prevPageviews?: number; prevVisitors?: number; prevBounceRate?: number; prevAvgDuration?: number; visitors?: number; pageviews?: number; bounce_rate?: number; avg_duration?: number } }
|
||||
const items = payload as PayloadItem[]
|
||||
const current = items.find((p) => p.dataKey === metric) ?? items[items.length - 1]
|
||||
const value = Number(current?.value ?? (current?.payload as Record<string, number>)?.[metric] ?? 0)
|
||||
|
||||
let prev: number | undefined
|
||||
switch (metric) {
|
||||
case 'visitors': prev = current?.payload?.prevVisitors; break;
|
||||
case 'pageviews': prev = current?.payload?.prevPageviews; break;
|
||||
case 'bounce_rate': prev = current?.payload?.prevBounceRate; break;
|
||||
case 'avg_duration': prev = current?.payload?.prevAvgDuration; break;
|
||||
}
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
const hasPrev = showComparison && prev != null
|
||||
const delta =
|
||||
hasPrev && (prev as number) > 0
|
||||
? Math.round(((value - (prev as number)) / (prev as number)) * 100)
|
||||
: null
|
||||
|
||||
const formatValue = (v: number) => {
|
||||
if (metric === 'bounce_rate') return `${Math.round(v)}%`
|
||||
if (metric === 'avg_duration') return formatDuration(v)
|
||||
return formatNumberFn(v)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg border px-4 py-3 shadow-lg transition-shadow duration-300"
|
||||
style={{
|
||||
backgroundColor: colors.tooltipBg,
|
||||
borderColor: colors.tooltipBorder,
|
||||
}}
|
||||
>
|
||||
<div className="text-xs font-medium" style={{ color: colors.textMuted, marginBottom: 6 }}>
|
||||
{label}
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-base font-bold" style={{ color: colors.text }}>
|
||||
{formatValue(value)}
|
||||
</span>
|
||||
<span className="text-xs" style={{ color: colors.textMuted }}>
|
||||
{metricLabel}
|
||||
</span>
|
||||
</div>
|
||||
{hasPrev && (
|
||||
<div className="mt-1.5 flex items-center gap-2 text-xs" style={{ color: colors.textMuted }}>
|
||||
<span>vs {formatValue(prev as number)} {prevPeriodLabel ? `(${prevPeriodLabel})` : 'prev'}</span>
|
||||
{delta !== null && (
|
||||
<span
|
||||
className="font-medium"
|
||||
style={{
|
||||
color: delta > 0 ? (metric === 'bounce_rate' ? COLORS.danger : COLORS.success) : delta < 0 ? (metric === 'bounce_rate' ? COLORS.success : COLORS.danger) : colors.textMuted,
|
||||
}}
|
||||
>
|
||||
{delta > 0 ? '+' : ''}{delta}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// * Compact Y-axis formatter: 1.5M, 12k, 99
|
||||
function formatAxisValue(value: number): string {
|
||||
if (value >= 1e6) return `${value / 1e6}M`
|
||||
if (value >= 1000) return `${value / 1000}k`
|
||||
if (value >= 1e6) return `${+(value / 1e6).toFixed(1)}M`
|
||||
if (value >= 1000) return `${+(value / 1000).toFixed(1)}k`
|
||||
if (!Number.isInteger(value)) return value.toFixed(1)
|
||||
return String(value)
|
||||
}
|
||||
|
||||
// * Compact duration for Y-axis ticks (avoids truncation: "5m" not "5m 0s")
|
||||
function formatAxisDuration(seconds: number): string {
|
||||
if (!seconds) return '0s'
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = Math.floor(seconds % 60)
|
||||
if (m > 0) return s > 0 ? `${m}m ${s}s` : `${m}m`
|
||||
if (m > 0) return s > 0 ? `${m}m${s}s` : `${m}m`
|
||||
return `${s}s`
|
||||
}
|
||||
|
||||
// * Returns human-readable label for the previous comparison period (e.g. "Feb 10" or "Jan 5 – Feb 4")
|
||||
function getPrevDateRangeLabel(dateRange: { start: string; end: string }): string {
|
||||
const startDate = new Date(dateRange.start)
|
||||
const endDate = new Date(dateRange.end)
|
||||
@@ -197,7 +107,6 @@ function getPrevDateRangeLabel(dateRange: { start: string; end: string }): strin
|
||||
return `${fmt(prevStart)} – ${fmt(prevEnd)}`
|
||||
}
|
||||
|
||||
// * Returns short trend context (e.g. "vs yesterday", "vs previous 7 days")
|
||||
function getTrendContext(dateRange: { start: string; end: string }): string {
|
||||
const startDate = new Date(dateRange.start)
|
||||
const endDate = new Date(dateRange.end)
|
||||
@@ -209,11 +118,88 @@ function getTrendContext(dateRange: { start: string; end: string }): string {
|
||||
return `vs previous ${days} days`
|
||||
}
|
||||
|
||||
export default function Chart({
|
||||
data,
|
||||
prevData,
|
||||
stats,
|
||||
prevStats,
|
||||
// ─── Tooltip ─────────────────────────────────────────────────────────
|
||||
|
||||
function ChartTooltip({
|
||||
active,
|
||||
payload,
|
||||
label,
|
||||
metric,
|
||||
metricLabel,
|
||||
formatNumberFn,
|
||||
showComparison,
|
||||
prevPeriodLabel,
|
||||
colors,
|
||||
}: {
|
||||
active?: boolean
|
||||
payload?: Array<{ payload: Record<string, number>; value: number; dataKey?: string }>
|
||||
label?: string
|
||||
metric: MetricType
|
||||
metricLabel: string
|
||||
formatNumberFn: (n: number) => string
|
||||
showComparison: boolean
|
||||
prevPeriodLabel?: string
|
||||
colors: typeof CHART_COLORS_LIGHT
|
||||
}) {
|
||||
if (!active || !payload?.length || !label) return null
|
||||
|
||||
const current = payload.find((p) => p.dataKey === metric) ?? payload[payload.length - 1]
|
||||
const value = Number(current?.value ?? current?.payload?.[metric] ?? 0)
|
||||
|
||||
const prevKey = metric === 'visitors' ? 'prevVisitors' : metric === 'pageviews' ? 'prevPageviews' : metric === 'bounce_rate' ? 'prevBounceRate' : 'prevAvgDuration'
|
||||
const prev = current?.payload?.[prevKey]
|
||||
|
||||
const hasPrev = showComparison && prev != null
|
||||
const delta = hasPrev && prev > 0 ? Math.round(((value - prev) / prev) * 100) : null
|
||||
|
||||
const formatValue = (v: number) => {
|
||||
if (metric === 'bounce_rate') return `${Math.round(v)}%`
|
||||
if (metric === 'avg_duration') return formatDuration(v)
|
||||
return formatNumberFn(v)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg border px-3.5 py-2.5 shadow-lg"
|
||||
style={{ backgroundColor: colors.tooltipBg, borderColor: colors.tooltipBorder }}
|
||||
>
|
||||
<div className="text-[11px] font-medium mb-1" style={{ color: colors.textMuted }}>
|
||||
{label}
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-sm font-bold" style={{ color: colors.text }}>
|
||||
{formatValue(value)}
|
||||
</span>
|
||||
<span className="text-[11px]" style={{ color: colors.textMuted }}>
|
||||
{metricLabel}
|
||||
</span>
|
||||
</div>
|
||||
{hasPrev && (
|
||||
<div className="mt-1 flex items-center gap-1.5 text-[11px]" style={{ color: colors.textMuted }}>
|
||||
<span>vs {formatValue(prev)} {prevPeriodLabel ? `(${prevPeriodLabel})` : ''}</span>
|
||||
{delta !== null && (
|
||||
<span
|
||||
className="font-medium"
|
||||
style={{
|
||||
color: delta > 0 ? (metric === 'bounce_rate' ? COLORS.danger : COLORS.success) : delta < 0 ? (metric === 'bounce_rate' ? COLORS.success : COLORS.danger) : colors.textMuted,
|
||||
}}
|
||||
>
|
||||
{delta > 0 ? '+' : ''}{delta}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Chart Component ─────────────────────────────────────────────────
|
||||
|
||||
export default function Chart({
|
||||
data,
|
||||
prevData,
|
||||
stats,
|
||||
prevStats,
|
||||
interval,
|
||||
dateRange,
|
||||
todayInterval,
|
||||
@@ -229,24 +215,21 @@ export default function Chart({
|
||||
const { resolvedTheme } = useTheme()
|
||||
|
||||
const handleExportChart = useCallback(async () => {
|
||||
if (onExportChart) {
|
||||
onExportChart()
|
||||
return
|
||||
}
|
||||
if (onExportChart) { onExportChart(); return }
|
||||
if (!chartContainerRef.current) return
|
||||
try {
|
||||
const { toPng } = await import('html-to-image')
|
||||
// Resolve the actual background color from the DOM (CSS vars don't work in html-to-image)
|
||||
const bg = getComputedStyle(chartContainerRef.current).backgroundColor || (resolvedTheme === 'dark' ? '#171717' : '#ffffff')
|
||||
const dataUrl = await toPng(chartContainerRef.current, {
|
||||
cacheBust: true,
|
||||
backgroundColor: resolvedTheme === 'dark' ? 'var(--color-neutral-900)' : '#ffffff',
|
||||
backgroundColor: bg,
|
||||
})
|
||||
const link = document.createElement('a')
|
||||
link.download = `chart-${dateRange.start}-${dateRange.end}.png`
|
||||
link.href = dataUrl
|
||||
link.click()
|
||||
} catch {
|
||||
// Fallback: do nothing if export fails
|
||||
}
|
||||
} catch { /* noop */ }
|
||||
}, [onExportChart, dateRange, resolvedTheme])
|
||||
|
||||
const colors = useMemo(
|
||||
@@ -254,27 +237,24 @@ export default function Chart({
|
||||
[resolvedTheme]
|
||||
)
|
||||
|
||||
// * Align current and previous data
|
||||
// ─── Data ──────────────────────────────────────────────────────────
|
||||
|
||||
const chartData = data.map((item, i) => {
|
||||
// * Try to find matching previous item (assuming same length/order)
|
||||
// * For more robustness, we could match by relative index
|
||||
const prevItem = prevData?.[i]
|
||||
|
||||
// * Format date based on interval
|
||||
|
||||
let formattedDate: string
|
||||
if (interval === 'minute') {
|
||||
formattedDate = new Date(item.date).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
|
||||
} else if (interval === 'hour') {
|
||||
const d = new Date(item.date)
|
||||
const isMidnight = d.getHours() === 0 && d.getMinutes() === 0
|
||||
// * At 12:00 AM: date only (used for X-axis ticks). Non-midnight: date + time for tooltip only.
|
||||
formattedDate = isMidnight
|
||||
? d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + ' 12:00 AM'
|
||||
: d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + ', ' + d.toLocaleTimeString('en-US', { hour: 'numeric', minute: 'numeric' })
|
||||
} else {
|
||||
formattedDate = new Date(item.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
date: formattedDate,
|
||||
originalDate: item.date,
|
||||
@@ -289,7 +269,8 @@ export default function Chart({
|
||||
}
|
||||
})
|
||||
|
||||
// * Calculate trends
|
||||
// ─── Metrics ───────────────────────────────────────────────────────
|
||||
|
||||
const calculateTrend = (current: number, previous?: number) => {
|
||||
if (!previous) return null
|
||||
if (previous === 0) return current > 0 ? 100 : 0
|
||||
@@ -297,282 +278,201 @@ export default function Chart({
|
||||
}
|
||||
|
||||
const metrics = [
|
||||
{
|
||||
id: 'visitors',
|
||||
label: 'Unique Visitors',
|
||||
value: formatNumber(stats.visitors),
|
||||
trend: calculateTrend(stats.visitors, prevStats?.visitors),
|
||||
color: COLORS.brand,
|
||||
invertTrend: false,
|
||||
},
|
||||
{
|
||||
id: 'pageviews',
|
||||
label: 'Total Pageviews',
|
||||
value: formatNumber(stats.pageviews),
|
||||
trend: calculateTrend(stats.pageviews, prevStats?.pageviews),
|
||||
color: COLORS.brand,
|
||||
invertTrend: false,
|
||||
},
|
||||
{
|
||||
id: 'bounce_rate',
|
||||
label: 'Bounce Rate',
|
||||
value: `${Math.round(stats.bounce_rate)}%`,
|
||||
trend: calculateTrend(stats.bounce_rate, prevStats?.bounce_rate),
|
||||
color: COLORS.brand,
|
||||
invertTrend: true, // Lower bounce rate is better
|
||||
},
|
||||
{
|
||||
id: 'avg_duration',
|
||||
label: 'Visit Duration',
|
||||
value: formatDuration(stats.avg_duration),
|
||||
trend: calculateTrend(stats.avg_duration, prevStats?.avg_duration),
|
||||
color: COLORS.brand,
|
||||
invertTrend: false,
|
||||
},
|
||||
] as const
|
||||
{ id: 'visitors' as const, label: 'Unique Visitors', value: formatNumber(stats.visitors), trend: calculateTrend(stats.visitors, prevStats?.visitors), invertTrend: false },
|
||||
{ id: 'pageviews' as const, label: 'Total Pageviews', value: formatNumber(stats.pageviews), trend: calculateTrend(stats.pageviews, prevStats?.pageviews), invertTrend: false },
|
||||
{ id: 'bounce_rate' as const, label: 'Bounce Rate', value: `${Math.round(stats.bounce_rate)}%`, trend: calculateTrend(stats.bounce_rate, prevStats?.bounce_rate), invertTrend: true },
|
||||
{ id: 'avg_duration' as const, label: 'Visit Duration', value: formatDuration(stats.avg_duration), trend: calculateTrend(stats.avg_duration, prevStats?.avg_duration), invertTrend: false },
|
||||
]
|
||||
|
||||
const activeMetric = metrics.find((m) => m.id === metric) || metrics[0]
|
||||
const chartMetric = metric
|
||||
const metricLabel = metrics.find(m => m.id === metric)?.label || 'visitors'
|
||||
const metricLabel = activeMetric.label
|
||||
const prevPeriodLabel = prevData?.length ? getPrevDateRangeLabel(dateRange) : ''
|
||||
const trendContext = getTrendContext(dateRange)
|
||||
|
||||
const avg = chartData.length
|
||||
? chartData.reduce((s, d) => s + (d[chartMetric] as number), 0) / chartData.length
|
||||
: 0
|
||||
|
||||
const hasPrev = !!(prevData?.length && showComparison)
|
||||
const hasData = data.length > 0
|
||||
const hasAnyNonZero = hasData && chartData.some((d) => (d[chartMetric] as number) > 0)
|
||||
const hasAnyNonZero = hasData && chartData.some((d) => (d[metric] as number) > 0)
|
||||
|
||||
// * In hourly view, only show X-axis labels at 12:00 AM (date + 12:00 AM).
|
||||
const midnightTicks =
|
||||
interval === 'hour'
|
||||
? (() => {
|
||||
const t = chartData
|
||||
.filter((_, i) => {
|
||||
const d = new Date(data[i].date)
|
||||
return d.getHours() === 0 && d.getMinutes() === 0
|
||||
})
|
||||
.map((c) => c.date)
|
||||
return t.length > 0 ? t : undefined
|
||||
})()
|
||||
: undefined
|
||||
// Count metrics should never show decimal Y-axis ticks
|
||||
const isCountMetric = metric === 'visitors' || metric === 'pageviews'
|
||||
|
||||
// ─── X-Axis Ticks ─────────────────────────────────────────────────
|
||||
|
||||
const midnightTicks = interval === 'hour'
|
||||
? (() => {
|
||||
const t = chartData
|
||||
.filter((_, i) => { const d = new Date(data[i].date); return d.getHours() === 0 && d.getMinutes() === 0 })
|
||||
.map((c) => c.date)
|
||||
return t.length > 0 ? t : undefined
|
||||
})()
|
||||
: undefined
|
||||
|
||||
// * In daily view, only show the date at each day (12:00 AM / start-of-day mark), no time.
|
||||
const dayTicks = interval === 'day' && chartData.length > 0 ? chartData.map((c) => c.date) : undefined
|
||||
|
||||
// ─── Trend Badge ──────────────────────────────────────────────────
|
||||
|
||||
function TrendBadge({ trend, invert }: { trend: number | null; invert: boolean }) {
|
||||
if (trend === null) return <span className="text-neutral-400 dark:text-neutral-500">—</span>
|
||||
const effective = invert ? -trend : trend
|
||||
const isPositive = effective > 0
|
||||
const isNegative = effective < 0
|
||||
return (
|
||||
<span className={`inline-flex items-center text-xs font-medium ${isPositive ? 'text-emerald-600 dark:text-emerald-400' : isNegative ? 'text-red-500 dark:text-red-400' : 'text-neutral-400'}`}>
|
||||
{isPositive ? <ArrowUpRightIcon className="w-3 h-3 mr-0.5" /> : isNegative ? <ArrowDownRightIcon className="w-3 h-3 mr-0.5" /> : null}
|
||||
{Math.abs(trend)}%
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Render ────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={chartContainerRef}
|
||||
className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden shadow-sm relative"
|
||||
className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden relative"
|
||||
role="region"
|
||||
aria-label={`Analytics chart showing ${metricLabel} over time`}
|
||||
>
|
||||
{/* * Subtle live/updated indicator in bottom-right corner */}
|
||||
{lastUpdatedAt != null && (
|
||||
<div
|
||||
className="absolute bottom-3 right-6 flex items-center gap-2 text-xs text-neutral-500 dark:text-neutral-400 pointer-events-none"
|
||||
title="Data refreshes every 30 seconds"
|
||||
>
|
||||
<span className="relative flex h-1.5 w-1.5">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-500 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-green-500" />
|
||||
</span>
|
||||
Live · {formatUpdatedAgo(lastUpdatedAt)}
|
||||
</div>
|
||||
)}
|
||||
{/* Stats Header (Interactive Tabs) */}
|
||||
{/* Stat Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 divide-x divide-neutral-200 dark:divide-neutral-800 border-b border-neutral-200 dark:border-neutral-800">
|
||||
{metrics.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => setMetric(item.id as MetricType)}
|
||||
aria-pressed={metric === item.id}
|
||||
aria-label={`Show ${item.label} chart`}
|
||||
className={`
|
||||
p-4 sm:p-6 text-left transition-colors relative group
|
||||
hover:bg-neutral-50 dark:hover:bg-neutral-800/50
|
||||
${metric === item.id ? 'bg-neutral-50 dark:bg-neutral-800/50' : ''}
|
||||
cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-orange-500 focus-visible:ring-offset-2
|
||||
`}
|
||||
>
|
||||
<div className={`text-xs font-semibold uppercase tracking-wider mb-1 flex items-center gap-2 ${metric === item.id ? 'text-neutral-900 dark:text-white' : 'text-neutral-500'}`}>
|
||||
{item.label}
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2 flex-wrap">
|
||||
<span className="text-xl sm:text-2xl font-bold text-neutral-900 dark:text-white">
|
||||
{item.value}
|
||||
</span>
|
||||
<span className="flex items-center text-sm font-medium">
|
||||
{item.trend !== null ? (
|
||||
<>
|
||||
<span className={
|
||||
(item.invertTrend ? -item.trend : item.trend) > 0
|
||||
? 'text-emerald-600 dark:text-emerald-500'
|
||||
: (item.invertTrend ? -item.trend : item.trend) < 0
|
||||
? 'text-red-600 dark:text-red-500'
|
||||
: 'text-neutral-500'
|
||||
}>
|
||||
{(item.invertTrend ? -item.trend : item.trend) > 0 ? (
|
||||
<ArrowUpRightIcon className="w-3 h-3 mr-0.5 inline" />
|
||||
) : (item.invertTrend ? -item.trend : item.trend) < 0 ? (
|
||||
<ArrowDownRightIcon className="w-3 h-3 mr-0.5 inline" />
|
||||
) : null}
|
||||
{Math.abs(item.trend)}%
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-neutral-500 dark:text-neutral-400">—</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-0.5">{trendContext}</p>
|
||||
{hasData && (
|
||||
<div className="mt-2">
|
||||
<Sparkline data={chartData} dataKey={item.id} color={item.color} />
|
||||
{metrics.map((item) => {
|
||||
const isActive = metric === item.id
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => setMetric(item.id)}
|
||||
aria-pressed={isActive}
|
||||
aria-label={`Show ${item.label} chart`}
|
||||
className={`p-4 sm:px-6 sm:py-5 text-left transition-colors relative cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange/50 ${isActive ? 'bg-neutral-50 dark:bg-neutral-800/40' : 'hover:bg-neutral-50/50 dark:hover:bg-neutral-800/20'}`}
|
||||
>
|
||||
<div className={`text-[11px] font-semibold uppercase tracking-wider mb-1.5 ${isActive ? 'text-neutral-900 dark:text-white' : 'text-neutral-400 dark:text-neutral-500'}`}>
|
||||
{item.label}
|
||||
</div>
|
||||
)}
|
||||
{metric === item.id && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1" style={{ backgroundColor: item.color }} />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-xl sm:text-2xl font-bold text-neutral-900 dark:text-white">
|
||||
{item.value}
|
||||
</span>
|
||||
<TrendBadge trend={item.trend} invert={item.invertTrend} />
|
||||
</div>
|
||||
<p className="text-[11px] text-neutral-400 dark:text-neutral-500 mt-0.5">{trendContext}</p>
|
||||
{isActive && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-[3px] bg-brand-orange" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Chart Area */}
|
||||
<div className="p-6">
|
||||
{/* Toolbar Row */}
|
||||
<div className="mb-6 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
{/* Left side: Legend */}
|
||||
<div className="flex items-center">
|
||||
<div className="px-4 sm:px-6 pt-4 pb-2">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between gap-3 mb-4">
|
||||
{/* Left: metric label + avg badge */}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs font-medium text-neutral-500 dark:text-neutral-400">
|
||||
{metricLabel}
|
||||
</span>
|
||||
{hasPrev && (
|
||||
<div className="flex items-center gap-4 text-xs font-medium" style={{ color: colors.textMuted }}>
|
||||
<span className="flex items-center gap-2">
|
||||
<span
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: activeMetric.color }}
|
||||
/>
|
||||
<div className="hidden sm:flex items-center gap-3 text-[11px] font-medium text-neutral-400 dark:text-neutral-500 ml-2">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-brand-orange" />
|
||||
Current
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<span
|
||||
className="h-2 w-2 rounded-full border border-dashed"
|
||||
style={{ borderColor: colors.axis }}
|
||||
/>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="h-1.5 w-1.5 rounded-full border border-dashed" style={{ borderColor: colors.axis }} />
|
||||
Previous{prevPeriodLabel ? ` (${prevPeriodLabel})` : ''}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right side: Controls */}
|
||||
<div className="flex flex-wrap items-center gap-3 self-end sm:self-auto">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-neutral-500 dark:text-neutral-400">Group by:</span>
|
||||
{dateRange.start === dateRange.end && (
|
||||
<Select
|
||||
value={todayInterval}
|
||||
onChange={(value) => setTodayInterval(value as 'minute' | 'hour')}
|
||||
options={[
|
||||
{ value: 'minute', label: '1 min' },
|
||||
{ value: 'hour', label: '1 hour' },
|
||||
]}
|
||||
className="min-w-[100px]"
|
||||
/>
|
||||
)}
|
||||
{dateRange.start !== dateRange.end && (
|
||||
<Select
|
||||
value={multiDayInterval}
|
||||
onChange={(value) => setMultiDayInterval(value as 'hour' | 'day')}
|
||||
options={[
|
||||
{ value: 'hour', label: '1 hour' },
|
||||
{ value: 'day', label: '1 day' },
|
||||
]}
|
||||
className="min-w-[100px]"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* Right: controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
{dateRange.start === dateRange.end ? (
|
||||
<Select
|
||||
value={todayInterval}
|
||||
onChange={(value) => setTodayInterval(value as 'minute' | 'hour')}
|
||||
options={[
|
||||
{ value: 'minute', label: '1 min' },
|
||||
{ value: 'hour', label: '1 hour' },
|
||||
]}
|
||||
className="min-w-[90px]"
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
value={multiDayInterval}
|
||||
onChange={(value) => setMultiDayInterval(value as 'hour' | 'day')}
|
||||
options={[
|
||||
{ value: 'hour', label: '1 hour' },
|
||||
{ value: 'day', label: '1 day' },
|
||||
]}
|
||||
className="min-w-[90px]"
|
||||
/>
|
||||
)}
|
||||
|
||||
{prevData?.length ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<Checkbox
|
||||
checked={showComparison}
|
||||
onCheckedChange={setShowComparison}
|
||||
label="Compare"
|
||||
/>
|
||||
{showComparison && prevPeriodLabel && (
|
||||
<span className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
({prevPeriodLabel})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Checkbox
|
||||
checked={showComparison}
|
||||
onCheckedChange={setShowComparison}
|
||||
label="Compare"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
<button
|
||||
onClick={handleExportChart}
|
||||
disabled={!hasData}
|
||||
className="gap-2 py-1.5 px-3 text-sm text-neutral-600 dark:text-neutral-400"
|
||||
className="p-1.5 text-neutral-400 dark:text-neutral-500 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors disabled:opacity-30 cursor-pointer"
|
||||
title="Export chart as PNG"
|
||||
>
|
||||
<DownloadIcon className="w-4 h-4" />
|
||||
Export chart
|
||||
</Button>
|
||||
|
||||
{/* Vertical Separator */}
|
||||
<div className="h-4 w-px bg-neutral-200 dark:bg-neutral-800" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!hasData ? (
|
||||
<div className="flex h-80 flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-neutral-200 dark:border-neutral-700 bg-neutral-50/50 dark:bg-neutral-800/30">
|
||||
<BarChartIcon className="h-12 w-12 text-neutral-300 dark:text-neutral-600" aria-hidden />
|
||||
<p className="text-sm font-medium text-neutral-500 dark:text-neutral-400">
|
||||
No data for this period
|
||||
</p>
|
||||
<p className="text-xs text-neutral-400 dark:text-neutral-500">Try a different date range</p>
|
||||
<div className="flex h-72 flex-col items-center justify-center gap-2">
|
||||
<BarChartIcon className="h-10 w-10 text-neutral-200 dark:text-neutral-700" aria-hidden />
|
||||
<p className="text-sm text-neutral-400 dark:text-neutral-500">No data for this period</p>
|
||||
</div>
|
||||
) : !hasAnyNonZero ? (
|
||||
<div className="flex h-80 flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-neutral-200 dark:border-neutral-700 bg-neutral-50/50 dark:bg-neutral-800/30">
|
||||
<BarChartIcon className="h-12 w-12 text-neutral-300 dark:text-neutral-600" aria-hidden />
|
||||
<p className="text-sm font-medium text-neutral-500 dark:text-neutral-400">
|
||||
No {metricLabel.toLowerCase()} data for this period
|
||||
</p>
|
||||
<p className="text-xs text-neutral-400 dark:text-neutral-500">Try selecting another metric or date range</p>
|
||||
<div className="flex h-72 flex-col items-center justify-center gap-2">
|
||||
<BarChartIcon className="h-10 w-10 text-neutral-200 dark:text-neutral-700" aria-hidden />
|
||||
<p className="text-sm text-neutral-400 dark:text-neutral-500">No {metricLabel.toLowerCase()} recorded</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-[360px] w-full flex flex-col">
|
||||
<div className="text-xs font-medium mb-1 flex-shrink-0" style={{ color: colors.axis }}>
|
||||
{metricLabel}
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: 24, bottom: 24 }}>
|
||||
<div className="h-[320px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chartData} margin={{ top: 8, right: 8, left: 0, bottom: 8 }}>
|
||||
<defs>
|
||||
<linearGradient id={`gradient-${metric}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={activeMetric.color} stopOpacity={0.35} />
|
||||
<stop offset="50%" stopColor={activeMetric.color} stopOpacity={0.12} />
|
||||
<stop offset="100%" stopColor={activeMetric.color} stopOpacity={0} />
|
||||
<stop offset="0%" stopColor={COLORS.brand} stopOpacity={0.25} />
|
||||
<stop offset="100%" stopColor={COLORS.brand} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={colors.border} />
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
vertical={false}
|
||||
stroke={colors.grid}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
stroke={colors.axis}
|
||||
fontSize={12}
|
||||
fontSize={11}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
minTickGap={28}
|
||||
minTickGap={40}
|
||||
ticks={midnightTicks ?? dayTicks}
|
||||
dy={8}
|
||||
/>
|
||||
<YAxis
|
||||
stroke={colors.axis}
|
||||
fontSize={12}
|
||||
fontSize={11}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
domain={[0, 'auto']}
|
||||
width={24}
|
||||
width={40}
|
||||
allowDecimals={!isCountMetric}
|
||||
tickFormatter={(val) => {
|
||||
if (metric === 'bounce_rate') return `${val}%`
|
||||
if (metric === 'avg_duration') return formatAxisDuration(val)
|
||||
@@ -583,12 +483,9 @@ export default function Chart({
|
||||
content={(p: TooltipProps<number, string>) => (
|
||||
<ChartTooltip
|
||||
active={p.active}
|
||||
payload={p.payload as Array<{
|
||||
payload: { prevPageviews?: number; prevVisitors?: number }
|
||||
value: number
|
||||
}>}
|
||||
payload={p.payload as Array<{ payload: Record<string, number>; value: number; dataKey?: string }>}
|
||||
label={p.label as string}
|
||||
metric={chartMetric}
|
||||
metric={metric}
|
||||
metricLabel={metricLabel}
|
||||
formatNumberFn={formatNumber}
|
||||
showComparison={hasPrev}
|
||||
@@ -596,42 +493,23 @@ export default function Chart({
|
||||
colors={colors}
|
||||
/>
|
||||
)}
|
||||
cursor={{ stroke: activeMetric.color, strokeDasharray: '4 4', strokeWidth: 1 }}
|
||||
cursor={{ stroke: colors.axis, strokeOpacity: 0.3, strokeWidth: 1 }}
|
||||
/>
|
||||
|
||||
{avg > 0 && (
|
||||
<ReferenceLine
|
||||
y={avg}
|
||||
stroke={colors.axis}
|
||||
strokeDasharray="4 4"
|
||||
strokeOpacity={0.7}
|
||||
label={{
|
||||
value: `Avg: ${metric === 'bounce_rate' ? `${Math.round(avg)}%` : metric === 'avg_duration' ? formatAxisDuration(avg) : formatAxisValue(avg)}`,
|
||||
position: 'insideTopRight',
|
||||
fill: colors.axis,
|
||||
fontSize: 11,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasPrev && (
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey={
|
||||
chartMetric === 'visitors' ? 'prevVisitors' :
|
||||
chartMetric === 'pageviews' ? 'prevPageviews' :
|
||||
chartMetric === 'bounce_rate' ? 'prevBounceRate' :
|
||||
'prevAvgDuration'
|
||||
}
|
||||
dataKey={metric === 'visitors' ? 'prevVisitors' : metric === 'pageviews' ? 'prevPageviews' : metric === 'bounce_rate' ? 'prevBounceRate' : 'prevAvgDuration'}
|
||||
stroke={colors.axis}
|
||||
strokeWidth={2}
|
||||
strokeDasharray="5 5"
|
||||
strokeWidth={1.5}
|
||||
strokeDasharray="4 4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
dot={false}
|
||||
isAnimationActive
|
||||
animationDuration={500}
|
||||
animationDuration={400}
|
||||
animationEasing="ease-out"
|
||||
/>
|
||||
)}
|
||||
@@ -639,30 +517,42 @@ export default function Chart({
|
||||
<Area
|
||||
type="monotone"
|
||||
baseValue={0}
|
||||
dataKey={chartMetric}
|
||||
stroke={activeMetric.color}
|
||||
strokeWidth={2.5}
|
||||
dataKey={metric}
|
||||
stroke={COLORS.brand}
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fillOpacity={1}
|
||||
fill={`url(#gradient-${metric})`}
|
||||
dot={false}
|
||||
activeDot={{
|
||||
r: 5,
|
||||
r: 4,
|
||||
strokeWidth: 2,
|
||||
fill: resolvedTheme === 'dark' ? 'var(--color-neutral-800)' : '#ffffff',
|
||||
stroke: activeMetric.color,
|
||||
fill: resolvedTheme === 'dark' ? 'var(--color-neutral-900)' : '#ffffff',
|
||||
stroke: COLORS.brand,
|
||||
}}
|
||||
isAnimationActive
|
||||
animationDuration={500}
|
||||
animationDuration={400}
|
||||
animationEasing="ease-out"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Live indicator */}
|
||||
{lastUpdatedAt != null && (
|
||||
<div className="px-4 sm:px-6 pb-3 flex justify-end">
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-neutral-400 dark:text-neutral-500">
|
||||
<span className="relative flex h-1.5 w-1.5">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-500 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-green-500" />
|
||||
</span>
|
||||
Live · {formatUpdatedAgo(lastUpdatedAt)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useTabListKeyboard } from '@/lib/hooks/useTabListKeyboard'
|
||||
import { TopPage, getTopPages, getEntryPages, getExitPages } from '@/lib/api/stats'
|
||||
import { Modal, ArrowUpRightIcon, LayoutDashboardIcon } from '@ciphera-net/ui'
|
||||
import { ListSkeleton } from '@/components/skeletons'
|
||||
import { type DimensionFilter } from '@/lib/filters'
|
||||
|
||||
interface ContentStatsProps {
|
||||
topPages: TopPage[]
|
||||
@@ -16,13 +17,14 @@ 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)
|
||||
@@ -76,6 +78,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,30 +96,20 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain,
|
||||
<>
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
|
||||
Content
|
||||
</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>
|
||||
<div className="flex p-1 bg-neutral-100 dark:bg-neutral-800 rounded-lg" role="tablist" aria-label="Content view tabs" onKeyDown={handleTabKeyDown}>
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
|
||||
Pages
|
||||
</h3>
|
||||
<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={`px-2.5 py-1 text-xs font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange rounded cursor-pointer border-b-2 ${
|
||||
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'
|
||||
? 'border-brand-orange text-neutral-900 dark:text-white'
|
||||
: 'border-transparent text-neutral-400 dark:text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300'
|
||||
}`}
|
||||
>
|
||||
{getTabLabel(tab)}
|
||||
@@ -132,27 +125,49 @@ 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>
|
||||
))}
|
||||
{Array.from({ length: emptySlots }).map((_, i) => (
|
||||
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
|
||||
))}
|
||||
{showViewAll ? (
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="flex items-center justify-center gap-1.5 h-9 w-full text-xs font-medium text-neutral-400 dark:text-neutral-500 hover:text-brand-orange dark:hover:text-brand-orange transition-colors cursor-pointer rounded-lg px-2 -mx-2"
|
||||
>
|
||||
View all
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
) : (
|
||||
Array.from({ length: emptySlots }).map((_, i) => (
|
||||
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
|
||||
))
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="h-full flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
|
||||
@@ -173,7 +188,7 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain,
|
||||
<Modal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
title={`Content - ${getTabLabel(activeTab)}`}
|
||||
title={`Pages - ${getTabLabel(activeTab)}`}
|
||||
>
|
||||
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-2">
|
||||
{isLoadingFull ? (
|
||||
@@ -181,8 +196,8 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain,
|
||||
<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">
|
||||
(fullData.length > 0 ? fullData : data).map((page) => (
|
||||
<div key={page.path} className="flex items-center justify-between py-2 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors">
|
||||
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center">
|
||||
<a
|
||||
href={`https://${domain.replace(/^https?:\/\//, '')}${page.path}`}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -7,11 +7,12 @@ 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
|
||||
|
||||
@@ -28,7 +29,8 @@ export default function GoalStats({ goalCounts }: GoalStatsProps) {
|
||||
{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 py-2 px-3 rounded-lg bg-neutral-50 dark:bg-neutral-800/50 hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors${onSelectEvent ? ' cursor-pointer' : ''}`}
|
||||
>
|
||||
<span className="text-sm font-medium text-neutral-900 dark:text-white truncate">
|
||||
{row.display_name ?? row.event_name.replace(/_/g, ' ')}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { ListSkeleton } from '@/components/skeletons'
|
||||
import { SiTorproject } from 'react-icons/si'
|
||||
import { FaUserSecret, FaSatellite } from 'react-icons/fa'
|
||||
import { getCountries, getCities, getRegions } from '@/lib/api/stats'
|
||||
import { type DimensionFilter } from '@/lib/filters'
|
||||
|
||||
interface LocationProps {
|
||||
countries: Array<{ country: string; pageviews: number }>
|
||||
@@ -20,13 +21,16 @@ interface LocationProps {
|
||||
geoDataLevel?: 'full' | 'country' | 'none'
|
||||
siteId: string
|
||||
dateRange: { start: string, end: string }
|
||||
onFilter?: (filter: DimensionFilter) => void
|
||||
}
|
||||
|
||||
type Tab = 'map' | '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)
|
||||
@@ -172,6 +176,7 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
|
||||
|
||||
const rawData = activeTab === 'map' ? [] : getData()
|
||||
const data = filterUnknown(rawData)
|
||||
const totalPageviews = data.reduce((sum, item) => sum + item.pageviews, 0)
|
||||
const hasData = activeTab === 'map'
|
||||
? (countries && filterUnknown(countries).length > 0)
|
||||
: (data && data.length > 0)
|
||||
@@ -193,30 +198,20 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
|
||||
<>
|
||||
<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">
|
||||
<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"
|
||||
>
|
||||
View All
|
||||
</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}>
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
|
||||
Locations
|
||||
</h3>
|
||||
<div className="flex gap-1" role="tablist" aria-label="Location view tabs" onKeyDown={handleTabKeyDown}>
|
||||
{(['map', '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={`px-2.5 py-1 text-xs font-medium transition-colors capitalize focus:outline-none focus:ring-2 focus:ring-brand-orange rounded cursor-pointer border-b-2 ${
|
||||
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'
|
||||
? 'border-brand-orange text-neutral-900 dark:text-white'
|
||||
: 'border-transparent text-neutral-400 dark:text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300'
|
||||
}`}
|
||||
>
|
||||
{tab}
|
||||
@@ -247,26 +242,50 @@ 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" />
|
||||
))}
|
||||
)
|
||||
})}
|
||||
{showViewAll ? (
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="flex items-center justify-center gap-1.5 h-9 w-full text-xs font-medium text-neutral-400 dark:text-neutral-500 hover:text-brand-orange dark:hover:text-brand-orange transition-colors cursor-pointer rounded-lg px-2 -mx-2"
|
||||
>
|
||||
View all
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
) : (
|
||||
Array.from({ length: emptySlots }).map((_, i) => (
|
||||
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
|
||||
))
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="h-full flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
|
||||
@@ -296,8 +315,8 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
|
||||
<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">
|
||||
(fullData.length > 0 ? fullData : data).map((item) => (
|
||||
<div key={`${item.country ?? ''}-${item.region ?? ''}-${item.city ?? ''}`} className="flex items-center justify-between py-2 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors">
|
||||
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
|
||||
<span className="shrink-0">{getFlagComponent(item.country ?? '')}</span>
|
||||
<span className="truncate">
|
||||
|
||||
80
components/dashboard/ScrollDepth.tsx
Normal file
80
components/dashboard/ScrollDepth.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
'use client'
|
||||
|
||||
import { formatNumber } from '@ciphera-net/ui'
|
||||
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
|
||||
|
||||
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">
|
||||
Scroll Depth
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{hasData ? (
|
||||
<div className="space-y-3 flex-1 min-h-[200px]">
|
||||
{THRESHOLDS.map((threshold) => {
|
||||
const count = scrollCounts.get(threshold) ?? 0
|
||||
const pct = totalPageviews > 0 ? Math.round((count / totalPageviews) * 100) : 0
|
||||
const barWidth = Math.max(pct, 2)
|
||||
|
||||
return (
|
||||
<div key={threshold} className="space-y-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-medium text-neutral-900 dark:text-white">
|
||||
{threshold}%
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-neutral-500 dark:text-neutral-400 tabular-nums">
|
||||
{formatNumber(count)}
|
||||
</span>
|
||||
<span className="font-semibold text-brand-orange tabular-nums w-12 text-right">
|
||||
{pct}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-neutral-100 dark:bg-neutral-800 overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-brand-orange transition-all duration-500"
|
||||
style={{ width: `${barWidth}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 min-h-[200px] 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>
|
||||
)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { MdMonitor } from 'react-icons/md'
|
||||
import { Modal, GridIcon } from '@ciphera-net/ui'
|
||||
import { ListSkeleton } from '@/components/skeletons'
|
||||
import { getBrowsers, getOS, getDevices, getScreenResolutions } from '@/lib/api/stats'
|
||||
import { type DimensionFilter } from '@/lib/filters'
|
||||
|
||||
interface TechSpecsProps {
|
||||
browsers: Array<{ browser: string; pageviews: number }>
|
||||
@@ -19,13 +20,16 @@ interface TechSpecsProps {
|
||||
collectScreenResolution?: boolean
|
||||
siteId: string
|
||||
dateRange: { start: string, end: string }
|
||||
onFilter?: (filter: DimensionFilter) => void
|
||||
}
|
||||
|
||||
type Tab = 'browsers' | 'os' | 'devices' | 'screens'
|
||||
|
||||
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)
|
||||
@@ -108,6 +112,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,30 +122,20 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
|
||||
<>
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<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"
|
||||
>
|
||||
View All
|
||||
</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}>
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
|
||||
Technology
|
||||
</h3>
|
||||
<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={`px-2.5 py-1 text-xs font-medium transition-colors capitalize focus:outline-none focus:ring-2 focus:ring-brand-orange rounded cursor-pointer border-b-2 ${
|
||||
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'
|
||||
? 'border-brand-orange text-neutral-900 dark:text-white'
|
||||
: 'border-transparent text-neutral-400 dark:text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300'
|
||||
}`}
|
||||
>
|
||||
{tab}
|
||||
@@ -156,20 +151,45 @@ 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">{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" />
|
||||
))}
|
||||
)
|
||||
})}
|
||||
{showViewAll ? (
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="flex items-center justify-center gap-1.5 h-9 w-full text-xs font-medium text-neutral-400 dark:text-neutral-500 hover:text-brand-orange dark:hover:text-brand-orange transition-colors cursor-pointer rounded-lg px-2 -mx-2"
|
||||
>
|
||||
View all
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
) : (
|
||||
Array.from({ length: emptySlots }).map((_, i) => (
|
||||
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
|
||||
))
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="h-full flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
|
||||
@@ -198,8 +218,8 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
|
||||
<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">
|
||||
(fullData.length > 0 ? fullData : data).map((item) => (
|
||||
<div key={item.name} className="flex items-center justify-between py-2 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors">
|
||||
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
|
||||
{item.icon && <span className="text-lg">{item.icon}</span>}
|
||||
<span className="truncate">{item.name === 'Unknown' ? 'Unknown' : item.name}</span>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -8,17 +8,19 @@ import { getReferrerDisplayName, getReferrerFavicon, getReferrerIcon, mergeRefer
|
||||
import { Modal, GlobeIcon } from '@ciphera-net/ui'
|
||||
import { ListSkeleton } from '@/components/skeletons'
|
||||
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 [fullData, setFullData] = useState<TopReferrer[]>([])
|
||||
const [isLoadingFull, setIsLoadingFull] = useState(false)
|
||||
@@ -31,6 +33,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)
|
||||
@@ -83,16 +86,8 @@ 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
|
||||
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>
|
||||
|
||||
<div className="space-y-2 flex-1 min-h-[270px]">
|
||||
@@ -102,20 +97,41 @@ 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>
|
||||
))}
|
||||
{Array.from({ length: emptySlots }).map((_, i) => (
|
||||
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
|
||||
))}
|
||||
{showViewAll ? (
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="flex items-center justify-center gap-1.5 h-9 w-full text-xs font-medium text-neutral-400 dark:text-neutral-500 hover:text-brand-orange dark:hover:text-brand-orange transition-colors cursor-pointer rounded-lg px-2 -mx-2"
|
||||
>
|
||||
View all
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
) : (
|
||||
Array.from({ length: emptySlots }).map((_, i) => (
|
||||
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
|
||||
))
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="h-full flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
|
||||
@@ -136,7 +152,7 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
|
||||
<Modal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
title="Top Referrers"
|
||||
title="Referrers"
|
||||
>
|
||||
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-2">
|
||||
{isLoadingFull ? (
|
||||
@@ -144,8 +160,8 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
|
||||
<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">
|
||||
mergeReferrersByDisplayName(fullData.length > 0 ? fullData : filteredReferrers).map((ref) => (
|
||||
<div key={ref.referrer} className="flex items-center justify-between py-2 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors">
|
||||
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
|
||||
{renderReferrerIcon(ref.referrer)}
|
||||
<span className="truncate" title={ref.referrer}>{getReferrerDisplayName(ref.referrer)}</span>
|
||||
|
||||
@@ -6,8 +6,15 @@ import api from '@/lib/api/client'
|
||||
import { deriveAuthKey } from '@/lib/crypto/password'
|
||||
import { deleteAccount, getUserSessions, revokeSession, updateUserPreferences, updateDisplayName } from '@/lib/api/user'
|
||||
import { setup2FA, verify2FA, disable2FA, regenerateRecoveryCodes } from '@/lib/api/2fa'
|
||||
import { registerPasskey, listPasskeys, deletePasskey } from '@/lib/api/webauthn'
|
||||
|
||||
export default function ProfileSettings() {
|
||||
interface Props {
|
||||
activeTab?: 'profile' | 'security' | 'preferences' | 'danger-zone'
|
||||
borderless?: boolean
|
||||
hideDangerZone?: boolean
|
||||
}
|
||||
|
||||
export default function ProfileSettings({ activeTab, borderless, hideDangerZone }: Props = {}) {
|
||||
const { user, refresh, logout } = useAuth()
|
||||
|
||||
if (!user) return null
|
||||
@@ -46,10 +53,18 @@ export default function ProfileSettings() {
|
||||
onRegenerateRecoveryCodes={regenerateRecoveryCodes}
|
||||
onGetSessions={getUserSessions}
|
||||
onRevokeSession={revokeSession}
|
||||
onRegisterPasskey={registerPasskey}
|
||||
onListPasskeys={listPasskeys}
|
||||
onDeletePasskey={deletePasskey}
|
||||
onUpdatePreferences={updateUserPreferences}
|
||||
deriveAuthKey={deriveAuthKey}
|
||||
refreshUser={refresh}
|
||||
logout={logout}
|
||||
activeTab={activeTab}
|
||||
hideNav={activeTab !== undefined}
|
||||
hideNotifications
|
||||
borderless={borderless}
|
||||
hideDangerZone={hideDangerZone}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
216
components/settings/SecurityActivityCard.tsx
Normal file
216
components/settings/SecurityActivityCard.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useAuth } from '@/lib/auth/context'
|
||||
import { getUserActivity, type AuditLogEntry } from '@/lib/api/activity'
|
||||
import { Spinner } from '@ciphera-net/ui'
|
||||
import { formatRelativeTime, formatFullDate } from '@/lib/utils/formatDate'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
const EVENT_LABELS: Record<string, string> = {
|
||||
login_success: 'Sign in',
|
||||
login_failure: 'Failed sign in',
|
||||
oauth_login_success: 'OAuth sign in',
|
||||
oauth_login_failure: 'Failed OAuth sign in',
|
||||
password_change: 'Password changed',
|
||||
'2fa_enabled': '2FA enabled',
|
||||
'2fa_disabled': '2FA disabled',
|
||||
recovery_codes_regenerated: 'Recovery codes regenerated',
|
||||
account_deleted: 'Account deleted',
|
||||
}
|
||||
|
||||
const EVENT_ICONS: Record<string, string> = {
|
||||
login_success: 'M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9',
|
||||
login_failure: 'M12 9v3.75m0-10.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.75c0 5.592 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.249-8.25-3.286zm0 13.036h.008v.008H12v-.008z',
|
||||
oauth_login_success: 'M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9',
|
||||
oauth_login_failure: 'M12 9v3.75m0-10.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.75c0 5.592 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.249-8.25-3.286zm0 13.036h.008v.008H12v-.008z',
|
||||
password_change: 'M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z',
|
||||
'2fa_enabled': 'M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z',
|
||||
'2fa_disabled': 'M12 9v3.75m0-10.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.75c0 5.592 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.249-8.25-3.286zm0 13.036h.008v.008H12v-.008z',
|
||||
recovery_codes_regenerated: 'M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z',
|
||||
account_deleted: 'M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0',
|
||||
}
|
||||
|
||||
function getEventColor(eventType: string, outcome: string): string {
|
||||
if (outcome === 'failure') return 'text-red-500 dark:text-red-400 bg-red-50 dark:bg-red-950/30'
|
||||
if (eventType === '2fa_enabled') return 'text-green-500 dark:text-green-400 bg-green-50 dark:bg-green-950/30'
|
||||
if (eventType === '2fa_disabled') return 'text-amber-500 dark:text-amber-400 bg-amber-50 dark:bg-amber-950/30'
|
||||
if (eventType === 'account_deleted') return 'text-red-500 dark:text-red-400 bg-red-50 dark:bg-red-950/30'
|
||||
if (eventType === 'recovery_codes_regenerated') return 'text-amber-500 dark:text-amber-400 bg-amber-50 dark:bg-amber-950/30'
|
||||
return 'text-neutral-500 dark:text-neutral-400 bg-neutral-100 dark:bg-neutral-800'
|
||||
}
|
||||
|
||||
function getMethodLabel(entry: AuditLogEntry): string | null {
|
||||
const method = entry.metadata?.method
|
||||
if (!method) return null
|
||||
if (method === 'magic_link') return 'Magic link'
|
||||
if (method === 'passkey') return 'Passkey'
|
||||
return method as string
|
||||
}
|
||||
|
||||
function getFailureReason(entry: AuditLogEntry): string | null {
|
||||
if (entry.outcome !== 'failure') return null
|
||||
const reason = entry.metadata?.reason
|
||||
if (!reason) return null
|
||||
const labels: Record<string, string> = {
|
||||
invalid_credentials: 'Invalid credentials',
|
||||
invalid_password: 'Wrong password',
|
||||
account_locked: 'Account locked',
|
||||
email_not_verified: 'Email not verified',
|
||||
invalid_2fa: 'Invalid 2FA code',
|
||||
}
|
||||
return labels[reason as string] || (reason as string).replace(/_/g, ' ')
|
||||
}
|
||||
|
||||
function parseBrowserName(ua: string): string {
|
||||
if (!ua) return 'Unknown'
|
||||
if (ua.includes('Firefox')) return 'Firefox'
|
||||
if (ua.includes('Edg/')) return 'Edge'
|
||||
if (ua.includes('Chrome')) return 'Chrome'
|
||||
if (ua.includes('Safari')) return 'Safari'
|
||||
if (ua.includes('Opera') || ua.includes('OPR')) return 'Opera'
|
||||
return 'Browser'
|
||||
}
|
||||
|
||||
function parseOS(ua: string): string {
|
||||
if (!ua) return ''
|
||||
if (ua.includes('Mac OS X')) return 'macOS'
|
||||
if (ua.includes('Windows')) return 'Windows'
|
||||
if (ua.includes('Linux')) return 'Linux'
|
||||
if (ua.includes('Android')) return 'Android'
|
||||
if (ua.includes('iPhone') || ua.includes('iPad')) return 'iOS'
|
||||
return ''
|
||||
}
|
||||
|
||||
export default function SecurityActivityCard() {
|
||||
const { user } = useAuth()
|
||||
const [entries, setEntries] = useState<AuditLogEntry[]>([])
|
||||
const [totalCount, setTotalCount] = useState(0)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [offset, setOffset] = useState(0)
|
||||
|
||||
const fetchActivity = useCallback(async (currentOffset: number, append: boolean) => {
|
||||
try {
|
||||
const data = await getUserActivity(PAGE_SIZE, currentOffset)
|
||||
const newEntries = data.entries ?? []
|
||||
setEntries(prev => append ? [...prev, ...newEntries] : newEntries)
|
||||
setTotalCount(data.total_count)
|
||||
setHasMore(data.has_more)
|
||||
setOffset(currentOffset + newEntries.length)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load activity')
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return
|
||||
setLoading(true)
|
||||
fetchActivity(0, false).finally(() => setLoading(false))
|
||||
}, [user, fetchActivity])
|
||||
|
||||
const handleLoadMore = async () => {
|
||||
setLoadingMore(true)
|
||||
await fetchActivity(offset, true)
|
||||
setLoadingMore(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-neutral-900 dark:text-white mb-1">Security Activity</h2>
|
||||
<p className="text-neutral-500 dark:text-neutral-400 text-sm mb-6">
|
||||
Recent security events on your account{totalCount > 0 ? ` (${totalCount})` : ''}
|
||||
</p>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="rounded-2xl border border-red-200 dark:border-red-900/50 bg-red-50 dark:bg-red-950/20 p-6 text-center">
|
||||
<p className="text-red-600 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-8 text-center">
|
||||
<svg className="w-12 h-12 mx-auto mb-3 text-neutral-300 dark:text-neutral-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
|
||||
</svg>
|
||||
<p className="text-neutral-500 dark:text-neutral-400">No activity recorded yet.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{entries.map((entry) => {
|
||||
const label = EVENT_LABELS[entry.event_type] || entry.event_type.replace(/_/g, ' ')
|
||||
const color = getEventColor(entry.event_type, entry.outcome)
|
||||
const iconPath = EVENT_ICONS[entry.event_type] || EVENT_ICONS['login_success']
|
||||
const method = getMethodLabel(entry)
|
||||
const reason = getFailureReason(entry)
|
||||
const browser = entry.user_agent ? parseBrowserName(entry.user_agent) : null
|
||||
const os = entry.user_agent ? parseOS(entry.user_agent) : null
|
||||
const deviceStr = [browser, os].filter(Boolean).join(' on ')
|
||||
|
||||
return (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="flex items-start gap-3 rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-4 py-3"
|
||||
>
|
||||
<div className={`flex-shrink-0 w-9 h-9 rounded-lg flex items-center justify-center mt-0.5 ${color}`}>
|
||||
<svg className="w-4.5 h-4.5" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d={iconPath} />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium text-neutral-900 dark:text-white text-sm">
|
||||
{label}
|
||||
</span>
|
||||
{method && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-neutral-100 dark:bg-neutral-800 text-neutral-500 dark:text-neutral-400">
|
||||
{method}
|
||||
</span>
|
||||
)}
|
||||
{entry.outcome === 'failure' && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-red-100 dark:bg-red-950/40 text-red-600 dark:text-red-400">
|
||||
Failed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5 text-xs text-neutral-500 dark:text-neutral-400 flex-wrap">
|
||||
{reason && <span>{reason}</span>}
|
||||
{reason && (deviceStr || entry.ip_address) && <span>·</span>}
|
||||
{deviceStr && <span>{deviceStr}</span>}
|
||||
{deviceStr && entry.ip_address && <span>·</span>}
|
||||
{entry.ip_address && <span>{entry.ip_address}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0 text-right">
|
||||
<span className="text-xs text-neutral-500 dark:text-neutral-400" title={formatFullDate(entry.created_at)}>
|
||||
{formatRelativeTime(entry.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{hasMore && (
|
||||
<div className="pt-2 text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLoadMore}
|
||||
disabled={loadingMore}
|
||||
className="text-sm font-medium text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loadingMore ? 'Loading...' : 'Load more'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
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} />
|
||||
}
|
||||
130
components/settings/TrustedDevicesCard.tsx
Normal file
130
components/settings/TrustedDevicesCard.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useAuth } from '@/lib/auth/context'
|
||||
import { getUserDevices, removeDevice, type TrustedDevice } from '@/lib/api/devices'
|
||||
import { Spinner, toast } from '@ciphera-net/ui'
|
||||
import { formatRelativeTime, formatFullDate } from '@/lib/utils/formatDate'
|
||||
|
||||
function getDeviceIcon(hint: string): string {
|
||||
const h = hint.toLowerCase()
|
||||
if (h.includes('iphone') || h.includes('android') || h.includes('ios')) {
|
||||
return 'M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3'
|
||||
}
|
||||
return 'M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25A2.25 2.25 0 015.25 3h13.5A2.25 2.25 0 0121 5.25z'
|
||||
}
|
||||
|
||||
export default function TrustedDevicesCard() {
|
||||
const { user } = useAuth()
|
||||
const [devices, setDevices] = useState<TrustedDevice[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [removingId, setRemovingId] = useState<string | null>(null)
|
||||
|
||||
const fetchDevices = useCallback(async () => {
|
||||
try {
|
||||
const data = await getUserDevices()
|
||||
setDevices(data.devices ?? [])
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load devices')
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return
|
||||
setLoading(true)
|
||||
fetchDevices().finally(() => setLoading(false))
|
||||
}, [user, fetchDevices])
|
||||
|
||||
const handleRemove = async (device: TrustedDevice) => {
|
||||
if (device.is_current) {
|
||||
toast.error('You cannot remove the device you are currently using.')
|
||||
return
|
||||
}
|
||||
|
||||
setRemovingId(device.id)
|
||||
try {
|
||||
await removeDevice(device.id)
|
||||
setDevices(prev => prev.filter(d => d.id !== device.id))
|
||||
toast.success('Device removed. A new sign-in from it will trigger an alert.')
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to remove device')
|
||||
} finally {
|
||||
setRemovingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-neutral-900 dark:text-white mb-1">Trusted Devices</h2>
|
||||
<p className="text-neutral-500 dark:text-neutral-400 text-sm mb-6">
|
||||
Devices that have signed in to your account. Removing a device means the next sign-in from it will trigger a new device alert.
|
||||
</p>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="rounded-2xl border border-red-200 dark:border-red-900/50 bg-red-50 dark:bg-red-950/20 p-6 text-center">
|
||||
<p className="text-red-600 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
) : devices.length === 0 ? (
|
||||
<div className="rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-8 text-center">
|
||||
<svg className="w-12 h-12 mx-auto mb-3 text-neutral-300 dark:text-neutral-600" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25A2.25 2.25 0 015.25 3h13.5A2.25 2.25 0 0121 5.25z" />
|
||||
</svg>
|
||||
<p className="text-neutral-500 dark:text-neutral-400">No trusted devices yet. They appear after you sign in.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{devices.map((device) => (
|
||||
<div
|
||||
key={device.id}
|
||||
className="flex items-center gap-3 rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-4 py-3"
|
||||
>
|
||||
<div className="flex-shrink-0 w-9 h-9 rounded-lg flex items-center justify-center bg-neutral-100 dark:bg-neutral-800 text-neutral-500 dark:text-neutral-400">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d={getDeviceIcon(device.display_hint)} />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-neutral-900 dark:text-white text-sm truncate">
|
||||
{device.display_hint || 'Unknown device'}
|
||||
</span>
|
||||
{device.is_current && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-green-100 dark:bg-green-950/40 text-green-600 dark:text-green-400 flex-shrink-0">
|
||||
This device
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
<span title={formatFullDate(device.first_seen_at)}>
|
||||
First seen {formatRelativeTime(device.first_seen_at)}
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span title={formatFullDate(device.last_seen_at)}>
|
||||
Last seen {formatRelativeTime(device.last_seen_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!device.is_current && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemove(device)}
|
||||
disabled={removingId === device.id}
|
||||
className="flex-shrink-0 text-xs font-medium text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{removingId === device.id ? 'Removing...' : 'Remove'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -27,14 +27,16 @@ export async function verify2FA(code: string): Promise<Verify2FAResponse> {
|
||||
})
|
||||
}
|
||||
|
||||
export async function disable2FA(): Promise<void> {
|
||||
export async function disable2FA(passwordDerived: string): Promise<void> {
|
||||
return apiRequest<void>('/auth/2fa/disable', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ password: passwordDerived }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function regenerateRecoveryCodes(): Promise<RegenerateCodesResponse> {
|
||||
export async function regenerateRecoveryCodes(passwordDerived: string): Promise<RegenerateCodesResponse> {
|
||||
return apiRequest<RegenerateCodesResponse>('/auth/2fa/recovery', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ password: passwordDerived }),
|
||||
})
|
||||
}
|
||||
|
||||
28
lib/api/activity.ts
Normal file
28
lib/api/activity.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import apiRequest from './client'
|
||||
|
||||
export interface AuditLogEntry {
|
||||
id: string
|
||||
created_at: string
|
||||
event_type: string
|
||||
outcome: string
|
||||
ip_address?: string
|
||||
user_agent?: string
|
||||
metadata?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface ActivityResponse {
|
||||
entries: AuditLogEntry[] | null
|
||||
total_count: number
|
||||
has_more: boolean
|
||||
limit: number
|
||||
offset: number
|
||||
}
|
||||
|
||||
export async function getUserActivity(
|
||||
limit = 20,
|
||||
offset = 0
|
||||
): Promise<ActivityResponse> {
|
||||
return apiRequest<ActivityResponse>(
|
||||
`/auth/user/activity?limit=${limit}&offset=${offset}`
|
||||
)
|
||||
}
|
||||
62
lib/api/admin.ts
Normal file
62
lib/api/admin.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { authFetch } from './client'
|
||||
|
||||
export interface AdminOrgSummary {
|
||||
organization_id: string
|
||||
stripe_customer_id: string
|
||||
stripe_subscription_id: string
|
||||
plan_id: string
|
||||
billing_interval: string
|
||||
pageview_limit: number
|
||||
subscription_status: string
|
||||
current_period_end: string
|
||||
business_name: string
|
||||
last_payment_at?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface Site {
|
||||
id: string
|
||||
domain: string
|
||||
name: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface AdminOrgDetail extends AdminOrgSummary {
|
||||
sites: Site[]
|
||||
}
|
||||
|
||||
export interface GrantPlanParams {
|
||||
plan_id: string
|
||||
billing_interval: string
|
||||
pageview_limit: number
|
||||
period_end: string // ISO date string
|
||||
}
|
||||
|
||||
// Check if current user is admin
|
||||
export async function getAdminMe(): Promise<{ is_admin: boolean }> {
|
||||
try {
|
||||
return await authFetch<{ is_admin: boolean }>('/api/admin/me')
|
||||
} catch (e) {
|
||||
return { is_admin: false }
|
||||
}
|
||||
}
|
||||
|
||||
// List all organizations (admin view)
|
||||
export async function listAdminOrgs(): Promise<AdminOrgSummary[]> {
|
||||
const data = await authFetch<{ organizations: AdminOrgSummary[] }>('/api/admin/orgs')
|
||||
return data.organizations || []
|
||||
}
|
||||
|
||||
// Get details for a specific organization
|
||||
export async function getAdminOrg(orgId: string): Promise<{ billing: AdminOrgSummary; sites: Site[] }> {
|
||||
return await authFetch<{ billing: AdminOrgSummary; sites: Site[] }>(`/api/admin/orgs/${orgId}`)
|
||||
}
|
||||
|
||||
// Grant a plan to an organization manually
|
||||
export async function grantPlan(orgId: string, params: GrantPlanParams): Promise<void> {
|
||||
await authFetch(`/api/admin/orgs/${orgId}/grant-plan`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(params),
|
||||
})
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
/**
|
||||
* HTTP client wrapper for API calls
|
||||
* Includes Request ID propagation for debugging across services
|
||||
*/
|
||||
|
||||
import { authMessageFromStatus, AUTH_ERROR_MESSAGES } from '@ciphera-net/ui'
|
||||
import { generateRequestId, getRequestIdHeader, setLastRequestId } from '@/lib/utils/requestId'
|
||||
|
||||
/** Request timeout in ms; network errors surface as user-facing "Network error, please try again." */
|
||||
const FETCH_TIMEOUT_MS = 30_000
|
||||
@@ -22,6 +24,36 @@ export function getSignupUrl(redirectPath = '/auth/callback') {
|
||||
return `${AUTH_URL}/signup?client_id=pulse-app&redirect_uri=${redirectUri}&response_type=code`
|
||||
}
|
||||
|
||||
// * ============================================================================
|
||||
// * CSRF Token Handling
|
||||
// * ============================================================================
|
||||
|
||||
/**
|
||||
* Get CSRF token from the csrf_token cookie (non-httpOnly)
|
||||
* This is needed for state-changing requests to the Auth API
|
||||
*/
|
||||
function getCSRFToken(): string | null {
|
||||
if (typeof document === 'undefined') return null
|
||||
|
||||
const cookies = document.cookie.split(';')
|
||||
for (const cookie of cookies) {
|
||||
const [name, value] = cookie.trim().split('=')
|
||||
if (name === 'csrf_token') {
|
||||
return decodeURIComponent(value)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a request method requires CSRF protection
|
||||
* State-changing methods (POST, PUT, DELETE, PATCH) need CSRF tokens
|
||||
*/
|
||||
function isStateChangingMethod(method: string): boolean {
|
||||
const stateChangingMethods = ['POST', 'PUT', 'DELETE', 'PATCH']
|
||||
return stateChangingMethods.includes(method.toUpperCase())
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number
|
||||
data?: Record<string, unknown>
|
||||
@@ -58,50 +90,149 @@ function onRefreshFailed(err: unknown) {
|
||||
refreshSubscribers = []
|
||||
}
|
||||
|
||||
// * ============================================================================
|
||||
// * Request Deduplication & Caching
|
||||
// * ============================================================================
|
||||
|
||||
/** Cache TTL in milliseconds (2 seconds) */
|
||||
const CACHE_TTL_MS = 2_000
|
||||
|
||||
/** Stores in-flight requests for deduplication */
|
||||
interface PendingRequest {
|
||||
promise: Promise<unknown>
|
||||
timestamp: number
|
||||
}
|
||||
const pendingRequests = new Map<string, PendingRequest>()
|
||||
|
||||
/** Stores cached responses */
|
||||
interface CachedResponse {
|
||||
data: unknown
|
||||
timestamp: number
|
||||
}
|
||||
const responseCache = new Map<string, CachedResponse>()
|
||||
|
||||
/**
|
||||
* Base API client with error handling
|
||||
* Generate a unique key for a request based on endpoint and options
|
||||
*/
|
||||
function getRequestKey(endpoint: string, options: RequestInit): string {
|
||||
const method = options.method || 'GET'
|
||||
const body = options.body || ''
|
||||
return `${method}:${endpoint}:${body}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired entries from pending requests and response cache
|
||||
*/
|
||||
function cleanupExpiredEntries(): void {
|
||||
const now = Date.now()
|
||||
|
||||
// * Clean up stale pending requests (older than 30 seconds)
|
||||
for (const [key, pending] of pendingRequests.entries()) {
|
||||
if (now - pending.timestamp > 30_000) {
|
||||
pendingRequests.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
// * Clean up stale cached responses (older than CACHE_TTL_MS)
|
||||
for (const [key, cached] of responseCache.entries()) {
|
||||
if (now - cached.timestamp > CACHE_TTL_MS) {
|
||||
responseCache.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Base API client with error handling, request deduplication, and short-term caching
|
||||
*/
|
||||
async function apiRequest<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
// * Skip deduplication for non-GET requests (mutations should always execute)
|
||||
const method = options.method || 'GET'
|
||||
const shouldDedupe = method === 'GET'
|
||||
|
||||
if (shouldDedupe) {
|
||||
// * Clean up expired entries periodically
|
||||
if (pendingRequests.size > 100 || responseCache.size > 100) {
|
||||
cleanupExpiredEntries()
|
||||
}
|
||||
|
||||
const requestKey = getRequestKey(endpoint, options)
|
||||
|
||||
// * Check if we have a recent cached response (within 2 seconds)
|
||||
const cached = responseCache.get(requestKey)
|
||||
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
|
||||
return cached.data as T
|
||||
}
|
||||
|
||||
// * Check if there's an identical request in flight
|
||||
const pending = pendingRequests.get(requestKey)
|
||||
if (pending && Date.now() - pending.timestamp < 30000) {
|
||||
return pending.promise as Promise<T>
|
||||
}
|
||||
}
|
||||
|
||||
// * Determine base URL
|
||||
const isAuthRequest = endpoint.startsWith('/auth')
|
||||
const baseUrl = isAuthRequest ? AUTH_API_URL : API_URL
|
||||
|
||||
|
||||
// * Handle legacy endpoints that already include /api/ prefix
|
||||
const url = endpoint.startsWith('/api/')
|
||||
const url = endpoint.startsWith('/api/')
|
||||
? `${baseUrl}${endpoint}`
|
||||
: `${baseUrl}/api/v1${endpoint}`
|
||||
|
||||
const headers: HeadersInit = {
|
||||
|
||||
// * Generate and store request ID for tracing
|
||||
const requestId = generateRequestId()
|
||||
setLastRequestId(requestId)
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
[getRequestIdHeader()]: requestId,
|
||||
}
|
||||
|
||||
// * Merge any additional headers from options
|
||||
if (options.headers) {
|
||||
const additionalHeaders = options.headers as Record<string, string>
|
||||
Object.entries(additionalHeaders).forEach(([key, value]) => {
|
||||
headers[key] = value
|
||||
})
|
||||
}
|
||||
|
||||
// * 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)) {
|
||||
const csrfToken = getCSRFToken()
|
||||
if (csrfToken) {
|
||||
headers['X-CSRF-Token'] = csrfToken
|
||||
}
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
|
||||
const signal = options.signal ?? controller.signal
|
||||
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
credentials: 'include', // * IMPORTANT: Send cookies
|
||||
signal,
|
||||
})
|
||||
clearTimeout(timeoutId)
|
||||
} catch (e) {
|
||||
clearTimeout(timeoutId)
|
||||
if (e instanceof Error && (e.name === 'AbortError' || e.name === 'TypeError')) {
|
||||
throw new ApiError(AUTH_ERROR_MESSAGES.NETWORK, 0)
|
||||
// * Create the request promise
|
||||
const requestPromise = (async (): Promise<T> => {
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
credentials: 'include', // * IMPORTANT: Send cookies
|
||||
signal,
|
||||
})
|
||||
clearTimeout(timeoutId)
|
||||
} catch (e) {
|
||||
clearTimeout(timeoutId)
|
||||
if (e instanceof Error && (e.name === 'AbortError' || e.name === 'TypeError')) {
|
||||
throw new ApiError(AUTH_ERROR_MESSAGES.NETWORK, 0)
|
||||
}
|
||||
throw e
|
||||
}
|
||||
throw e
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
@@ -182,6 +313,38 @@ async function apiRequest<T>(
|
||||
}
|
||||
|
||||
return response.json()
|
||||
})()
|
||||
|
||||
// * For GET requests, track the promise for deduplication and cache the result
|
||||
if (shouldDedupe) {
|
||||
const requestKey = getRequestKey(endpoint, options)
|
||||
|
||||
// * Store in pending requests
|
||||
pendingRequests.set(requestKey, {
|
||||
promise: requestPromise as Promise<unknown>,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
|
||||
// * Clean up pending request and cache the result when done
|
||||
requestPromise
|
||||
.then((data) => {
|
||||
// * Cache successful response
|
||||
responseCache.set(requestKey, {
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
// * Remove from pending
|
||||
pendingRequests.delete(requestKey)
|
||||
return data
|
||||
})
|
||||
.catch((error) => {
|
||||
// * Remove from pending on error too
|
||||
pendingRequests.delete(requestKey)
|
||||
throw error
|
||||
})
|
||||
}
|
||||
|
||||
return requestPromise
|
||||
}
|
||||
|
||||
export const authFetch = apiRequest
|
||||
|
||||
19
lib/api/devices.ts
Normal file
19
lib/api/devices.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import apiRequest from './client'
|
||||
|
||||
export interface TrustedDevice {
|
||||
id: string
|
||||
display_hint: string
|
||||
first_seen_at: string
|
||||
last_seen_at: string
|
||||
is_current: boolean
|
||||
}
|
||||
|
||||
export async function getUserDevices(): Promise<{ devices: TrustedDevice[] }> {
|
||||
return apiRequest<{ devices: TrustedDevice[] }>('/auth/user/devices')
|
||||
}
|
||||
|
||||
export async function removeDevice(deviceId: string): Promise<void> {
|
||||
return apiRequest<void>(`/auth/user/devices/${deviceId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
@@ -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}`)
|
||||
}
|
||||
|
||||
416
lib/api/stats.ts
416
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,8 @@ export interface AuthParams {
|
||||
captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string }
|
||||
}
|
||||
|
||||
// ─── 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 +112,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,31 +245,160 @@ 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): Promise<DashboardData> {
|
||||
return apiRequest<DashboardData>(`/sites/${siteId}/dashboard${buildQuery({ startDate, endDate, limit, interval })}`)
|
||||
}
|
||||
|
||||
export async function getPublicDashboard(
|
||||
siteId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
limit = 10,
|
||||
interval?: string,
|
||||
export function getPublicDashboard(
|
||||
siteId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
limit = 10,
|
||||
interval?: string,
|
||||
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 ────────────────────────────────────
|
||||
|
||||
export interface DashboardOverviewData {
|
||||
site: Site
|
||||
stats: Stats
|
||||
realtime_visitors: number
|
||||
daily_stats: DailyStat[]
|
||||
}
|
||||
|
||||
export interface DashboardPagesData {
|
||||
top_pages: TopPage[]
|
||||
entry_pages: TopPage[]
|
||||
exit_pages: TopPage[]
|
||||
}
|
||||
|
||||
export interface DashboardLocationsData {
|
||||
countries: CountryStat[]
|
||||
cities: CityStat[]
|
||||
regions: RegionStat[]
|
||||
}
|
||||
|
||||
export interface DashboardDevicesData {
|
||||
browsers: BrowserStat[]
|
||||
os: OSStat[]
|
||||
devices: DeviceStat[]
|
||||
screen_resolutions: ScreenResolutionStat[]
|
||||
}
|
||||
|
||||
export interface DashboardReferrersData {
|
||||
top_referrers: TopReferrer[]
|
||||
}
|
||||
|
||||
export interface DashboardPerformanceData {
|
||||
performance?: PerformanceStats
|
||||
performance_by_page?: PerformanceByPageStat[]
|
||||
}
|
||||
|
||||
export interface DashboardGoalsData {
|
||||
goal_counts: GoalCountStat[]
|
||||
}
|
||||
|
||||
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 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 || [])
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
@@ -48,7 +32,9 @@ export interface UserPreferences {
|
||||
email_notifications: {
|
||||
new_file_received: boolean
|
||||
file_downloaded: boolean
|
||||
security_alerts: boolean
|
||||
login_alerts: boolean
|
||||
password_alerts: boolean
|
||||
two_factor_alerts: boolean
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
54
lib/api/webauthn.ts
Normal file
54
lib/api/webauthn.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* WebAuthn / Passkey API client for settings (list, register, delete).
|
||||
*/
|
||||
|
||||
import { startRegistration, type PublicKeyCredentialCreationOptionsJSON } from '@simplewebauthn/browser'
|
||||
import apiRequest from './client'
|
||||
|
||||
export interface BeginRegistrationResponse {
|
||||
sessionId: string
|
||||
creationOptions: {
|
||||
publicKey: Record<string, unknown>
|
||||
mediation?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface PasskeyCredential {
|
||||
id: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface ListPasskeysResponse {
|
||||
credentials: PasskeyCredential[]
|
||||
}
|
||||
|
||||
export async function registerPasskey(): Promise<void> {
|
||||
const { sessionId, creationOptions } = await apiRequest<BeginRegistrationResponse>(
|
||||
'/auth/webauthn/register/begin',
|
||||
{ method: 'POST' }
|
||||
)
|
||||
const optionsJSON = creationOptions?.publicKey
|
||||
if (!optionsJSON) {
|
||||
throw new Error('Invalid registration options')
|
||||
}
|
||||
const response = await startRegistration({
|
||||
optionsJSON: optionsJSON as unknown as PublicKeyCredentialCreationOptionsJSON,
|
||||
})
|
||||
await apiRequest<{ message: string }>('/auth/webauthn/register/finish', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ sessionId, response }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function listPasskeys(): Promise<ListPasskeysResponse> {
|
||||
return apiRequest<ListPasskeysResponse>('/auth/webauthn/credentials', {
|
||||
method: 'GET',
|
||||
})
|
||||
}
|
||||
|
||||
export async function deletePasskey(credentialId: string): Promise<void> {
|
||||
return apiRequest<void>(
|
||||
`/auth/webauthn/credentials/${encodeURIComponent(credentialId)}`,
|
||||
{ method: 'DELETE' }
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react'
|
||||
import { useRouter, usePathname } from 'next/navigation'
|
||||
import apiRequest from '@/lib/api/client'
|
||||
import { LoadingOverlay } from '@ciphera-net/ui'
|
||||
import { LoadingOverlay, useSessionSync, SessionExpiryWarning } from '@ciphera-net/ui'
|
||||
import { logoutAction, getSessionAction, setSessionAction } from '@/app/actions/auth'
|
||||
import { getUserOrganizations, switchContext } from '@/lib/api/organization'
|
||||
import { logger } from '@/lib/utils/logger'
|
||||
@@ -19,7 +19,9 @@ interface User {
|
||||
email_notifications?: {
|
||||
new_file_received: boolean
|
||||
file_downloaded: boolean
|
||||
security_alerts: boolean
|
||||
login_alerts: boolean
|
||||
password_alerts: boolean
|
||||
two_factor_alerts: boolean
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,9 +51,25 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
|
||||
const refreshToken = useCallback(async (): Promise<boolean> => {
|
||||
try {
|
||||
const res = await fetch('/api/auth/refresh', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
})
|
||||
if (res.ok) {
|
||||
localStorage.setItem('ciphera_token_refreshed_at', Date.now().toString())
|
||||
}
|
||||
return res.ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const login = (userData: User) => {
|
||||
// * We still store user profile in localStorage for optimistic UI, but NOT the token
|
||||
localStorage.setItem('user', JSON.stringify(userData))
|
||||
localStorage.setItem('ciphera_token_refreshed_at', Date.now().toString())
|
||||
setUser(userData)
|
||||
router.refresh()
|
||||
// * Fetch full profile (including display_name) so header shows correct name without page refresh
|
||||
@@ -74,10 +92,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
setIsLoggingOut(true)
|
||||
await logoutAction()
|
||||
localStorage.removeItem('user')
|
||||
// * Clear legacy tokens if they exist
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('refreshToken')
|
||||
|
||||
localStorage.removeItem('ciphera_token_refreshed_at')
|
||||
localStorage.removeItem('ciphera_last_activity')
|
||||
// * Broadcast logout to other tabs (BroadcastChannel will handle if available)
|
||||
if (typeof window !== 'undefined' && 'BroadcastChannel' in window) {
|
||||
const channel = new BroadcastChannel('ciphera_session')
|
||||
channel.postMessage({ type: 'LOGOUT' })
|
||||
channel.close()
|
||||
}
|
||||
setTimeout(() => {
|
||||
window.location.href = '/'
|
||||
}, 500)
|
||||
@@ -110,11 +132,24 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
// * 1. Check server-side session (cookies)
|
||||
const session = await getSessionAction()
|
||||
|
||||
let session = await getSessionAction()
|
||||
|
||||
// * 2. If no access_token but refresh_token may exist, try refresh (fixes 15-min inactivity logout)
|
||||
if (!session && typeof window !== 'undefined') {
|
||||
const refreshRes = await fetch('/api/auth/refresh', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
})
|
||||
if (refreshRes.ok) {
|
||||
session = await getSessionAction()
|
||||
}
|
||||
}
|
||||
|
||||
if (session) {
|
||||
setUser(session)
|
||||
localStorage.setItem('user', JSON.stringify(session))
|
||||
localStorage.setItem('ciphera_token_refreshed_at', Date.now().toString())
|
||||
// * Fetch full profile (including display_name) from API; preserve org_id/role from session
|
||||
try {
|
||||
const userData = await apiRequest<User>('/auth/user/me')
|
||||
@@ -129,22 +164,38 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
localStorage.removeItem('user')
|
||||
setUser(null)
|
||||
}
|
||||
|
||||
// * Clear legacy tokens if they exist (migration)
|
||||
if (localStorage.getItem('token')) {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('refreshToken')
|
||||
}
|
||||
|
||||
setLoading(false)
|
||||
}
|
||||
init()
|
||||
}, [])
|
||||
|
||||
// * Sync session across browser tabs using BroadcastChannel
|
||||
useSessionSync({
|
||||
onLogout: () => {
|
||||
localStorage.removeItem('user')
|
||||
localStorage.removeItem('ciphera_token_refreshed_at')
|
||||
localStorage.removeItem('ciphera_last_activity')
|
||||
window.location.href = '/'
|
||||
},
|
||||
onLogin: (userData) => {
|
||||
setUser(userData as User)
|
||||
router.refresh()
|
||||
},
|
||||
onRefresh: () => {
|
||||
refresh()
|
||||
},
|
||||
})
|
||||
|
||||
// * 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
|
||||
|
||||
@@ -158,7 +209,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 {
|
||||
@@ -189,11 +240,16 @@ 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 }}>
|
||||
{isLoggingOut && <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Pulse" />}
|
||||
<SessionExpiryWarning
|
||||
isAuthenticated={!!user}
|
||||
onRefreshToken={refreshToken}
|
||||
onExpired={logout}
|
||||
/>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
|
||||
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}`
|
||||
}
|
||||
34
lib/hooks/__tests__/useOnlineStatus.test.ts
Normal file
34
lib/hooks/__tests__/useOnlineStatus.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { useOnlineStatus } from '../useOnlineStatus'
|
||||
|
||||
describe('useOnlineStatus', () => {
|
||||
it('returns true initially', () => {
|
||||
const { result } = renderHook(() => useOnlineStatus())
|
||||
expect(result.current).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when offline event fires', () => {
|
||||
const { result } = renderHook(() => useOnlineStatus())
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event('offline'))
|
||||
})
|
||||
|
||||
expect(result.current).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when online event fires after offline', () => {
|
||||
const { result } = renderHook(() => useOnlineStatus())
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event('offline'))
|
||||
})
|
||||
expect(result.current).toBe(false)
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event('online'))
|
||||
})
|
||||
expect(result.current).toBe(true)
|
||||
})
|
||||
})
|
||||
99
lib/hooks/__tests__/useVisibilityPolling.test.ts
Normal file
99
lib/hooks/__tests__/useVisibilityPolling.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { useVisibilityPolling } from '../useVisibilityPolling'
|
||||
|
||||
describe('useVisibilityPolling', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('starts polling and calls callback at the visible interval', () => {
|
||||
const callback = vi.fn()
|
||||
|
||||
renderHook(() =>
|
||||
useVisibilityPolling(callback, {
|
||||
visibleInterval: 1000,
|
||||
hiddenInterval: null,
|
||||
})
|
||||
)
|
||||
|
||||
// Initial call might not happen immediately; advance to trigger interval
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000)
|
||||
})
|
||||
|
||||
expect(callback).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports isPolling as true when active', () => {
|
||||
const callback = vi.fn()
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useVisibilityPolling(callback, {
|
||||
visibleInterval: 1000,
|
||||
hiddenInterval: null,
|
||||
})
|
||||
)
|
||||
|
||||
expect(result.current.isPolling).toBe(true)
|
||||
})
|
||||
|
||||
it('calls callback multiple times over multiple intervals', () => {
|
||||
const callback = vi.fn()
|
||||
|
||||
renderHook(() =>
|
||||
useVisibilityPolling(callback, {
|
||||
visibleInterval: 500,
|
||||
hiddenInterval: null,
|
||||
})
|
||||
)
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1500)
|
||||
})
|
||||
|
||||
expect(callback.mock.calls.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('triggerPoll calls callback immediately', () => {
|
||||
const callback = vi.fn()
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useVisibilityPolling(callback, {
|
||||
visibleInterval: 10000,
|
||||
hiddenInterval: null,
|
||||
})
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.triggerPoll()
|
||||
})
|
||||
|
||||
expect(callback).toHaveBeenCalled()
|
||||
expect(result.current.lastPollTime).not.toBeNull()
|
||||
})
|
||||
|
||||
it('cleans up intervals on unmount', () => {
|
||||
const callback = vi.fn()
|
||||
|
||||
const { unmount } = renderHook(() =>
|
||||
useVisibilityPolling(callback, {
|
||||
visibleInterval: 1000,
|
||||
hiddenInterval: null,
|
||||
})
|
||||
)
|
||||
|
||||
unmount()
|
||||
callback.mockClear()
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(5000)
|
||||
})
|
||||
|
||||
expect(callback).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
128
lib/hooks/useVisibilityPolling.ts
Normal file
128
lib/hooks/useVisibilityPolling.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
// * Custom hook for visibility-aware polling
|
||||
// * Pauses polling when tab is not visible, resumes when visible
|
||||
// * Reduces server load when users aren't actively viewing the dashboard
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
|
||||
interface UseVisibilityPollingOptions {
|
||||
// * Polling interval when tab is visible (in milliseconds)
|
||||
visibleInterval: number
|
||||
// * Polling interval when tab is hidden (in milliseconds, or null to pause)
|
||||
hiddenInterval: number | null
|
||||
}
|
||||
|
||||
interface UseVisibilityPollingReturn {
|
||||
// * Whether polling is currently active
|
||||
isPolling: boolean
|
||||
// * Time since last poll
|
||||
lastPollTime: number | null
|
||||
// * Force a poll immediately
|
||||
triggerPoll: () => void
|
||||
}
|
||||
|
||||
export function useVisibilityPolling(
|
||||
callback: () => void | Promise<void>,
|
||||
options: UseVisibilityPollingOptions
|
||||
): UseVisibilityPollingReturn {
|
||||
const { visibleInterval, hiddenInterval } = options
|
||||
const [isPolling, setIsPolling] = useState(false)
|
||||
const [lastPollTime, setLastPollTime] = useState<number | null>(null)
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const callbackRef = useRef(callback)
|
||||
|
||||
// * Keep callback reference up to date
|
||||
useEffect(() => {
|
||||
callbackRef.current = callback
|
||||
}, [callback])
|
||||
|
||||
// * Get current polling interval based on visibility
|
||||
const getInterval = useCallback((): number | null => {
|
||||
if (typeof document === 'undefined') return null
|
||||
|
||||
const isVisible = document.visibilityState === 'visible'
|
||||
if (isVisible) {
|
||||
return visibleInterval
|
||||
}
|
||||
return hiddenInterval
|
||||
}, [visibleInterval, hiddenInterval])
|
||||
|
||||
// * Start polling with current interval
|
||||
const startPolling = useCallback(() => {
|
||||
const interval = getInterval()
|
||||
if (interval === null) {
|
||||
setIsPolling(false)
|
||||
return
|
||||
}
|
||||
|
||||
setIsPolling(true)
|
||||
|
||||
// * Clear any existing interval
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
}
|
||||
|
||||
// * Set up new interval
|
||||
intervalRef.current = setInterval(() => {
|
||||
callbackRef.current()
|
||||
setLastPollTime(Date.now())
|
||||
}, interval)
|
||||
}, [getInterval])
|
||||
|
||||
// * Stop polling
|
||||
const stopPolling = useCallback(() => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
intervalRef.current = null
|
||||
}
|
||||
setIsPolling(false)
|
||||
}, [])
|
||||
|
||||
// * Trigger immediate poll
|
||||
const triggerPoll = useCallback(() => {
|
||||
callbackRef.current()
|
||||
setLastPollTime(Date.now())
|
||||
|
||||
// * Restart polling timer
|
||||
startPolling()
|
||||
}, [startPolling])
|
||||
|
||||
// * Handle visibility changes
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') return
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
// * Tab became visible - resume polling with visible interval
|
||||
startPolling()
|
||||
// * Trigger immediate poll to get fresh data
|
||||
triggerPoll()
|
||||
} else {
|
||||
// * Tab hidden - switch to hidden interval or pause
|
||||
const interval = getInterval()
|
||||
if (interval === null) {
|
||||
stopPolling()
|
||||
} else {
|
||||
// * Restart with hidden interval
|
||||
startPolling()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// * Listen for visibility changes
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
|
||||
// * Start polling initially
|
||||
startPolling()
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
stopPolling()
|
||||
}
|
||||
}, [startPolling, stopPolling, triggerPoll, getInterval])
|
||||
|
||||
return {
|
||||
isPolling,
|
||||
lastPollTime,
|
||||
triggerPoll,
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
251
lib/swr/dashboard.ts
Normal file
251
lib/swr/dashboard.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
// * SWR configuration for dashboard data fetching
|
||||
// * Implements stale-while-revalidate pattern for efficient data updates
|
||||
|
||||
import useSWR from 'swr'
|
||||
import {
|
||||
getDashboard,
|
||||
getDashboardOverview,
|
||||
getDashboardPages,
|
||||
getDashboardLocations,
|
||||
getDashboardDevices,
|
||||
getDashboardReferrers,
|
||||
getDashboardPerformance,
|
||||
getDashboardGoals,
|
||||
getCampaigns,
|
||||
getRealtime,
|
||||
getStats,
|
||||
getDailyStats,
|
||||
} from '@/lib/api/stats'
|
||||
import { getSite } from '@/lib/api/sites'
|
||||
import type { Site } from '@/lib/api/sites'
|
||||
import type {
|
||||
Stats,
|
||||
DailyStat,
|
||||
CampaignStat,
|
||||
DashboardOverviewData,
|
||||
DashboardPagesData,
|
||||
DashboardLocationsData,
|
||||
DashboardDevicesData,
|
||||
DashboardReferrersData,
|
||||
DashboardPerformanceData,
|
||||
DashboardGoalsData,
|
||||
} 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, 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),
|
||||
}
|
||||
|
||||
// * Standard SWR config for dashboard data
|
||||
const dashboardSWRConfig = {
|
||||
// * Keep stale data visible while revalidating (better UX)
|
||||
revalidateOnFocus: false,
|
||||
// * Revalidate when reconnecting (fresh data after offline)
|
||||
revalidateOnReconnect: true,
|
||||
// * Retry failed requests
|
||||
shouldRetryOnError: true,
|
||||
errorRetryCount: 3,
|
||||
// * Error retry interval with exponential backoff
|
||||
errorRetryInterval: 5000,
|
||||
}
|
||||
|
||||
// * Hook for site data (loads once, refreshes rarely)
|
||||
export function useSite(siteId: string) {
|
||||
return useSWR<Site>(
|
||||
siteId ? ['site', siteId] : null,
|
||||
() => fetchers.site(siteId),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
// * Site data changes rarely, refresh every 5 minutes
|
||||
refreshInterval: 5 * 60 * 1000,
|
||||
// * Deduping interval to prevent duplicate requests
|
||||
dedupingInterval: 30 * 1000,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// * 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),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
// * Refresh every 60 seconds for dashboard summary
|
||||
refreshInterval: 60 * 1000,
|
||||
// * Deduping interval to prevent duplicate requests
|
||||
dedupingInterval: 10 * 1000,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// * Hook for stats (refreshed less frequently)
|
||||
export function useStats(siteId: string, start: string, end: string, filters?: string) {
|
||||
return useSWR<Stats>(
|
||||
siteId && start && end ? ['stats', siteId, start, end, filters] : null,
|
||||
() => fetchers.stats(siteId, start, end, filters),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
// * Refresh every 60 seconds for stats
|
||||
refreshInterval: 60 * 1000,
|
||||
dedupingInterval: 10 * 1000,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// * Hook for daily stats (refreshed less frequently)
|
||||
export function useDailyStats(
|
||||
siteId: string,
|
||||
start: string,
|
||||
end: string,
|
||||
interval: 'hour' | 'day' | 'minute'
|
||||
) {
|
||||
return useSWR<DailyStat[]>(
|
||||
siteId && start && end ? ['dailyStats', siteId, start, end, interval] : null,
|
||||
() => fetchers.dailyStats(siteId, start, end, interval),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
// * Refresh every 60 seconds for chart data
|
||||
refreshInterval: 60 * 1000,
|
||||
dedupingInterval: 10 * 1000,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// * Hook for realtime visitor count (refreshed frequently)
|
||||
export function useRealtime(siteId: string, refreshInterval: number = 5000) {
|
||||
return useSWR<{ visitors: number }>(
|
||||
siteId ? ['realtime', siteId] : null,
|
||||
() => fetchers.realtime(siteId),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
// * Refresh frequently for real-time data (default 5 seconds)
|
||||
refreshInterval,
|
||||
// * Short deduping for real-time
|
||||
dedupingInterval: 2000,
|
||||
// * Keep previous data while loading new data
|
||||
keepPreviousData: true,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// * Hook for focused dashboard overview data (Fix 4.2: Efficient Data Transfer)
|
||||
export function useDashboardOverview(siteId: string, start: string, end: string, interval?: string, filters?: string) {
|
||||
return useSWR<DashboardOverviewData>(
|
||||
siteId && start && end ? ['dashboardOverview', siteId, start, end, interval, filters] : null,
|
||||
() => fetchers.dashboardOverview(siteId, start, end, interval, filters),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
refreshInterval: 60 * 1000,
|
||||
dedupingInterval: 10 * 1000,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// * Hook for focused dashboard pages data
|
||||
export function useDashboardPages(siteId: string, start: string, end: string, filters?: string) {
|
||||
return useSWR<DashboardPagesData>(
|
||||
siteId && start && end ? ['dashboardPages', siteId, start, end, filters] : null,
|
||||
() => fetchers.dashboardPages(siteId, start, end, filters),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
refreshInterval: 60 * 1000,
|
||||
dedupingInterval: 10 * 1000,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// * Hook for focused dashboard locations data
|
||||
export function useDashboardLocations(siteId: string, start: string, end: string, filters?: string) {
|
||||
return useSWR<DashboardLocationsData>(
|
||||
siteId && start && end ? ['dashboardLocations', siteId, start, end, filters] : null,
|
||||
() => fetchers.dashboardLocations(siteId, start, end, filters),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
refreshInterval: 60 * 1000,
|
||||
dedupingInterval: 10 * 1000,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// * Hook for focused dashboard devices data
|
||||
export function useDashboardDevices(siteId: string, start: string, end: string, filters?: string) {
|
||||
return useSWR<DashboardDevicesData>(
|
||||
siteId && start && end ? ['dashboardDevices', siteId, start, end, filters] : null,
|
||||
() => fetchers.dashboardDevices(siteId, start, end, filters),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
refreshInterval: 60 * 1000,
|
||||
dedupingInterval: 10 * 1000,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// * Hook for focused dashboard referrers data
|
||||
export function useDashboardReferrers(siteId: string, start: string, end: string, filters?: string) {
|
||||
return useSWR<DashboardReferrersData>(
|
||||
siteId && start && end ? ['dashboardReferrers', siteId, start, end, filters] : null,
|
||||
() => fetchers.dashboardReferrers(siteId, start, end, filters),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
refreshInterval: 60 * 1000,
|
||||
dedupingInterval: 10 * 1000,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// * Hook for focused dashboard performance data
|
||||
export function useDashboardPerformance(siteId: string, start: string, end: string, filters?: string) {
|
||||
return useSWR<DashboardPerformanceData>(
|
||||
siteId && start && end ? ['dashboardPerformance', siteId, start, end, filters] : null,
|
||||
() => fetchers.dashboardPerformance(siteId, start, end, filters),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
refreshInterval: 60 * 1000,
|
||||
dedupingInterval: 10 * 1000,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// * Hook for focused dashboard goals data
|
||||
export function useDashboardGoals(siteId: string, start: string, end: string, filters?: string) {
|
||||
return useSWR<DashboardGoalsData>(
|
||||
siteId && start && end ? ['dashboardGoals', siteId, start, end, filters] : null,
|
||||
() => fetchers.dashboardGoals(siteId, start, end, filters),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
refreshInterval: 60 * 1000,
|
||||
dedupingInterval: 10 * 1000,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// * 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,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// * Re-export for convenience
|
||||
export { fetchers }
|
||||
31
lib/utils/__tests__/logger.test.ts
Normal file
31
lib/utils/__tests__/logger.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
describe('logger', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it('calls console.error in development', async () => {
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
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(() => {})
|
||||
vi.stubEnv('NODE_ENV', 'development')
|
||||
|
||||
const { logger } = await import('../logger')
|
||||
logger.warn('test warning')
|
||||
|
||||
expect(spy).toHaveBeenCalledWith('test warning')
|
||||
spy.mockRestore()
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
})
|
||||
61
lib/utils/__tests__/requestId.test.ts
Normal file
61
lib/utils/__tests__/requestId.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import {
|
||||
generateRequestId,
|
||||
getRequestIdHeader,
|
||||
setLastRequestId,
|
||||
getLastRequestId,
|
||||
clearLastRequestId,
|
||||
} from '../requestId'
|
||||
|
||||
describe('generateRequestId', () => {
|
||||
it('returns a string starting with REQ', () => {
|
||||
const id = generateRequestId()
|
||||
expect(id).toMatch(/^REQ/)
|
||||
})
|
||||
|
||||
it('contains a timestamp and random segment separated by underscore', () => {
|
||||
const id = generateRequestId()
|
||||
const parts = id.replace('REQ', '').split('_')
|
||||
expect(parts).toHaveLength(2)
|
||||
expect(parts[0].length).toBeGreaterThan(0)
|
||||
expect(parts[1].length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('generates unique IDs across calls', () => {
|
||||
const ids = new Set(Array.from({ length: 100 }, () => generateRequestId()))
|
||||
expect(ids.size).toBe(100)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRequestIdHeader', () => {
|
||||
it('returns X-Request-ID', () => {
|
||||
expect(getRequestIdHeader()).toBe('X-Request-ID')
|
||||
})
|
||||
})
|
||||
|
||||
describe('lastRequestId storage', () => {
|
||||
beforeEach(() => {
|
||||
clearLastRequestId()
|
||||
})
|
||||
|
||||
it('returns null when no ID has been set', () => {
|
||||
expect(getLastRequestId()).toBeNull()
|
||||
})
|
||||
|
||||
it('stores and retrieves a request ID', () => {
|
||||
setLastRequestId('REQ123_abc')
|
||||
expect(getLastRequestId()).toBe('REQ123_abc')
|
||||
})
|
||||
|
||||
it('overwrites previous ID on set', () => {
|
||||
setLastRequestId('first')
|
||||
setLastRequestId('second')
|
||||
expect(getLastRequestId()).toBe('second')
|
||||
})
|
||||
|
||||
it('clears the stored ID', () => {
|
||||
setLastRequestId('REQ123_abc')
|
||||
clearLastRequestId()
|
||||
expect(getLastRequestId()).toBeNull()
|
||||
})
|
||||
})
|
||||
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
|
||||
}
|
||||
30
lib/utils/formatDate.ts
Normal file
30
lib/utils/formatDate.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export function formatRelativeTime(dateStr: string): string {
|
||||
const date = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMin = Math.floor(diffMs / 60000)
|
||||
const diffHr = Math.floor(diffMin / 60)
|
||||
const diffDay = Math.floor(diffHr / 24)
|
||||
|
||||
if (diffMin < 1) return 'Just now'
|
||||
if (diffMin < 60) return `${diffMin}m ago`
|
||||
if (diffHr < 24) return `${diffHr}h ago`
|
||||
if (diffDay < 7) return `${diffDay}d ago`
|
||||
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
export function formatFullDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleString('en-US', {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
@@ -30,7 +30,8 @@ import {
|
||||
FaGlobe
|
||||
} from 'react-icons/fa'
|
||||
import { FaXTwitter } from 'react-icons/fa6'
|
||||
import { SiBrave } from 'react-icons/si'
|
||||
import { SiBrave, SiOpenai, SiPerplexity, SiAnthropic, SiGooglegemini } from 'react-icons/si'
|
||||
import { RiRobot2Fill } from 'react-icons/ri'
|
||||
import { MdDeviceUnknown, MdSmartphone, MdTabletMac, MdDesktopWindows } from 'react-icons/md'
|
||||
|
||||
export function getBrowserIcon(browserName: string) {
|
||||
@@ -79,7 +80,17 @@ export function getReferrerIcon(referrerName: string) {
|
||||
if (lower.includes('github')) return <FaGithub className="text-neutral-800 dark:text-neutral-200" />
|
||||
if (lower.includes('youtube')) return <FaYoutube className="text-red-600" />
|
||||
if (lower.includes('reddit')) return <FaReddit className="text-orange-600" />
|
||||
|
||||
// AI assistants and search tools
|
||||
if (lower.includes('chatgpt') || lower.includes('openai')) return <SiOpenai className="text-neutral-800 dark:text-neutral-200" />
|
||||
if (lower.includes('perplexity')) return <SiPerplexity className="text-teal-600" />
|
||||
if (lower.includes('claude') || lower.includes('anthropic')) return <SiAnthropic className="text-orange-500" />
|
||||
if (lower.includes('gemini')) return <SiGooglegemini className="text-blue-500" />
|
||||
if (lower.includes('copilot')) return <FaGlobe className="text-blue-500" />
|
||||
if (lower.includes('deepseek')) return <RiRobot2Fill className="text-blue-600" />
|
||||
if (lower.includes('grok') || lower.includes('x.ai')) return <FaXTwitter className="text-neutral-800 dark:text-neutral-200" />
|
||||
if (lower.includes('phind')) return <RiRobot2Fill className="text-purple-600" />
|
||||
if (lower.includes('you.com')) return <RiRobot2Fill className="text-indigo-600" />
|
||||
|
||||
// Try to use a generic globe or maybe check if it is a URL
|
||||
return <FaGlobe className="text-neutral-400" />
|
||||
}
|
||||
@@ -111,6 +122,17 @@ const REFERRER_DISPLAY_OVERRIDES: Record<string, string> = {
|
||||
quora: 'Quora',
|
||||
't.co': 'X',
|
||||
'x.com': 'X',
|
||||
// AI assistants and search tools
|
||||
openai: 'ChatGPT',
|
||||
perplexity: 'Perplexity',
|
||||
claude: 'Claude',
|
||||
anthropic: 'Claude',
|
||||
gemini: 'Gemini',
|
||||
copilot: 'Copilot',
|
||||
deepseek: 'DeepSeek',
|
||||
grok: 'Grok',
|
||||
'you': 'You.com',
|
||||
phind: 'Phind',
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
49
lib/utils/requestId.ts
Normal file
49
lib/utils/requestId.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Request ID utilities for tracing API calls across services
|
||||
* Request IDs help debug issues by correlating logs across frontend and backends
|
||||
*
|
||||
* IMPORTANT: This module stores mutable state (lastRequestId) at module scope.
|
||||
* This is safe because apiRequest (the only caller) runs exclusively in the
|
||||
* browser where JS is single-threaded. If this ever needs server-side use,
|
||||
* replace the module variable with AsyncLocalStorage.
|
||||
*/
|
||||
|
||||
const REQUEST_ID_HEADER = 'X-Request-ID'
|
||||
|
||||
/**
|
||||
* Generate a unique request ID
|
||||
* Format: REQ<timestamp>_<random>
|
||||
*/
|
||||
export function generateRequestId(): string {
|
||||
const timestamp = Date.now().toString(36)
|
||||
const random = Math.random().toString(36).substring(2, 8)
|
||||
return `REQ${timestamp}_${random}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Get request ID header name
|
||||
*/
|
||||
export function getRequestIdHeader(): string {
|
||||
return REQUEST_ID_HEADER
|
||||
}
|
||||
|
||||
/**
|
||||
* Store the last request ID for error reporting.
|
||||
* Browser-only — single-threaded, no concurrency risk.
|
||||
*/
|
||||
let lastRequestId: string | null = null
|
||||
|
||||
export function setLastRequestId(id: string): void {
|
||||
lastRequestId = id
|
||||
}
|
||||
|
||||
export function getLastRequestId(): string | null {
|
||||
return lastRequestId
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the stored request ID
|
||||
*/
|
||||
export function clearLastRequestId(): void {
|
||||
lastRequestId = null
|
||||
}
|
||||
@@ -12,6 +12,7 @@ const PUBLIC_ROUTES = new Set([
|
||||
'/faq',
|
||||
'/changelog',
|
||||
'/installation',
|
||||
'/script.js', // * Tracking script – must load without auth for embedded sites (Shopify, etc.)
|
||||
])
|
||||
|
||||
const PUBLIC_PREFIXES = [
|
||||
@@ -34,8 +35,9 @@ export function middleware(request: NextRequest) {
|
||||
const hasRefresh = request.cookies.has('refresh_token')
|
||||
const hasSession = hasAccess || hasRefresh
|
||||
|
||||
// * Authenticated user hitting /login or /signup → send them home
|
||||
if (hasSession && AUTH_ONLY_ROUTES.has(pathname)) {
|
||||
// * Authenticated user (with access token) hitting /login or /signup → send them home.
|
||||
// * Only check access_token; stale refresh_token alone must not block login (fixes post-inactivity sign-in).
|
||||
if (hasAccess && AUTH_ONLY_ROUTES.has(pathname)) {
|
||||
return NextResponse.redirect(new URL('/', request.url))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,27 @@
|
||||
import type { NextConfig } from 'next'
|
||||
const withPWA = require("@ducanh2912/next-pwa").default({
|
||||
import withPWAInit from "@ducanh2912/next-pwa"
|
||||
|
||||
const withPWA = withPWAInit({
|
||||
dest: "public",
|
||||
register: true,
|
||||
skipWaiting: true,
|
||||
disable: process.env.NODE_ENV === "development",
|
||||
});
|
||||
})
|
||||
|
||||
// * CSP directives — restrict resource loading to known origins
|
||||
const cspDirectives = [
|
||||
"default-src 'self'",
|
||||
// Next.js requires 'unsafe-inline' for its bootstrap scripts; 'unsafe-eval' only in dev (HMR)
|
||||
`script-src 'self' 'unsafe-inline'${process.env.NODE_ENV === 'development' ? " 'unsafe-eval'" : ''}`,
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data: blob: https://www.google.com https://*.gstatic.com https://ciphera.net",
|
||||
"font-src 'self'",
|
||||
`connect-src 'self' https://*.ciphera.net https://ciphera.net https://www.google.com https://*.gstatic.com https://cdn.jsdelivr.net${process.env.NODE_ENV === 'development' ? ' http://localhost:*' : ''}`,
|
||||
"worker-src 'self'",
|
||||
"frame-src 'none'",
|
||||
"object-src 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self' https://*.ciphera.net",
|
||||
].join('; ')
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
reactStrictMode: true,
|
||||
@@ -22,6 +39,10 @@ const nextConfig: NextConfig = {
|
||||
hostname: 'www.google.com',
|
||||
pathname: '/s2/favicons**',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'ciphera.net',
|
||||
},
|
||||
],
|
||||
},
|
||||
async headers() {
|
||||
@@ -41,6 +62,7 @@ const nextConfig: NextConfig = {
|
||||
key: 'Strict-Transport-Security',
|
||||
value: 'max-age=63072000; includeSubDomains; preload',
|
||||
},
|
||||
{ key: 'Content-Security-Policy', value: cspDirectives },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
2392
package-lock.json
generated
2392
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
21
package.json
21
package.json
@@ -1,18 +1,21 @@
|
||||
{
|
||||
"name": "pulse-frontend",
|
||||
"version": "0.11.0-alpha",
|
||||
"version": "0.13.0-alpha",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build --webpack",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"type-check": "tsc --noEmit"
|
||||
"type-check": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ciphera-net/ui": "^0.0.58",
|
||||
"@ciphera-net/ui": "^0.0.92",
|
||||
"@ducanh2912/next-pwa": "^10.2.9",
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
"@simplewebauthn/browser": "^13.2.2",
|
||||
"@stripe/react-stripe-js": "^5.6.0",
|
||||
"@stripe/stripe-js": "^8.7.0",
|
||||
"axios": "^1.13.2",
|
||||
@@ -32,6 +35,7 @@
|
||||
"react-simple-maps": "^3.0.0",
|
||||
"recharts": "^2.15.0",
|
||||
"sonner": "^2.0.7",
|
||||
"swr": "^2.3.3",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"overrides": {
|
||||
@@ -42,16 +46,21 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/d3-scale": "^4.0.9",
|
||||
"@types/node": "^20.14.12",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-simple-maps": "^3.0.6",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-next": "^16.1.1",
|
||||
"jsdom": "^28.1.0",
|
||||
"postcss": "^8.4.40",
|
||||
"tailwindcss": "^3.4.7",
|
||||
"typescript": "5.9.3"
|
||||
"typescript": "5.9.3",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 7.8 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 22 KiB |
117
public/script.js
117
public/script.js
@@ -299,6 +299,10 @@
|
||||
if (url !== lastUrl) {
|
||||
lastUrl = url;
|
||||
trackPageview();
|
||||
// * Check for 404 after SPA navigation (deferred so title updates first)
|
||||
setTimeout(check404, 100);
|
||||
// * Reset scroll depth tracking for the new page
|
||||
if (trackScroll) scrollFired = {};
|
||||
}
|
||||
}
|
||||
new MutationObserver(onUrlChange).observe(document, { subtree: true, childList: true });
|
||||
@@ -308,13 +312,17 @@
|
||||
history.replaceState = function() { _replace.apply(this, arguments); onUrlChange(); };
|
||||
|
||||
// * Track popstate (browser back/forward)
|
||||
window.addEventListener('popstate', trackPageview);
|
||||
window.addEventListener('popstate', function() {
|
||||
trackPageview();
|
||||
setTimeout(check404, 100);
|
||||
if (trackScroll) scrollFired = {};
|
||||
});
|
||||
|
||||
// * Custom events / goals: validate event name (letters, numbers, underscores only; max 64 chars)
|
||||
var EVENT_NAME_MAX = 64;
|
||||
var EVENT_NAME_REGEX = /^[a-zA-Z0-9_]+$/;
|
||||
|
||||
function trackCustomEvent(eventName) {
|
||||
function trackCustomEvent(eventName, props) {
|
||||
if (typeof eventName !== 'string' || !eventName.trim()) return;
|
||||
var name = eventName.trim().toLowerCase();
|
||||
if (name.length > EVENT_NAME_MAX || !EVENT_NAME_REGEX.test(name)) {
|
||||
@@ -334,6 +342,20 @@
|
||||
session_id: getSessionId(),
|
||||
name: name,
|
||||
};
|
||||
// * Attach custom properties if provided (max 30 props, key max 200 chars, value max 2000 chars)
|
||||
if (props && typeof props === 'object' && !Array.isArray(props)) {
|
||||
var sanitized = {};
|
||||
var count = 0;
|
||||
for (var key in props) {
|
||||
if (!props.hasOwnProperty(key)) continue;
|
||||
if (count >= 30) break;
|
||||
var k = String(key).substring(0, 200);
|
||||
var v = String(props[key]).substring(0, 2000);
|
||||
sanitized[k] = v;
|
||||
count++;
|
||||
}
|
||||
if (count > 0) payload.props = sanitized;
|
||||
}
|
||||
fetch(apiUrl + '/api/v1/events', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -346,4 +368,95 @@
|
||||
window.pulse = window.pulse || {};
|
||||
window.pulse.track = trackCustomEvent;
|
||||
|
||||
// * Auto-track 404 error pages (on by default)
|
||||
// * Detects pages where document.title contains "404" or "not found"
|
||||
// * Opt-out: add data-no-404 to the script tag
|
||||
var track404 = !script.hasAttribute('data-no-404');
|
||||
var sent404ForUrl = '';
|
||||
|
||||
function check404() {
|
||||
if (!track404) return;
|
||||
// * Only fire once per URL
|
||||
var currentUrl = location.href;
|
||||
if (sent404ForUrl === currentUrl) return;
|
||||
if (/404|not found/i.test(document.title)) {
|
||||
sent404ForUrl = currentUrl;
|
||||
trackCustomEvent('404');
|
||||
}
|
||||
}
|
||||
|
||||
// * Check on initial load (deferred so SPAs can set title)
|
||||
setTimeout(check404, 0);
|
||||
|
||||
// * Auto-track scroll depth at 25%, 50%, 75%, and 100% (on by default)
|
||||
// * Each threshold fires once per pageview; resets on SPA navigation
|
||||
// * Opt-out: add data-no-scroll to the script tag
|
||||
var trackScroll = !script.hasAttribute('data-no-scroll');
|
||||
|
||||
if (trackScroll) {
|
||||
var scrollThresholds = [25, 50, 75, 100];
|
||||
var scrollFired = {};
|
||||
var scrollTicking = false;
|
||||
|
||||
function checkScroll() {
|
||||
var docHeight = document.documentElement.scrollHeight;
|
||||
var viewHeight = window.innerHeight;
|
||||
// * Page fits in viewport — nothing to scroll
|
||||
if (docHeight <= viewHeight) return;
|
||||
var scrollTop = window.scrollY;
|
||||
var scrollPercent = Math.round((scrollTop + viewHeight) / docHeight * 100);
|
||||
|
||||
for (var i = 0; i < scrollThresholds.length; i++) {
|
||||
var t = scrollThresholds[i];
|
||||
if (!scrollFired[t] && scrollPercent >= t) {
|
||||
scrollFired[t] = true;
|
||||
trackCustomEvent('scroll_' + t);
|
||||
}
|
||||
}
|
||||
scrollTicking = false;
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', function() {
|
||||
if (!scrollTicking) {
|
||||
scrollTicking = true;
|
||||
requestAnimationFrame(checkScroll);
|
||||
}
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
// * Auto-track outbound link clicks and file downloads (on by default)
|
||||
// * Opt-out: add data-no-outbound or data-no-downloads to the script tag
|
||||
var trackOutbound = !script.hasAttribute('data-no-outbound');
|
||||
var trackDownloads = !script.hasAttribute('data-no-downloads');
|
||||
|
||||
if (trackOutbound || trackDownloads) {
|
||||
var FILE_EXT_REGEX = /\.(pdf|zip|gz|tar|xlsx|xls|csv|docx|doc|pptx|ppt|mp4|mp3|wav|avi|mov|exe|dmg|pkg|deb|rpm|iso|7z|rar)($|\?|#)/i;
|
||||
|
||||
document.addEventListener('click', function(e) {
|
||||
var el = e.target;
|
||||
// * Walk up from clicked element to find nearest <a> tag
|
||||
while (el && el.tagName !== 'A') el = el.parentElement;
|
||||
if (!el || !el.href) return;
|
||||
|
||||
try {
|
||||
var url = new URL(el.href, location.href);
|
||||
// * Skip non-http links (mailto:, tel:, javascript:, etc.)
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return;
|
||||
|
||||
// * Check file download first (download attribute or known file extension)
|
||||
if (trackDownloads && (el.hasAttribute('download') || FILE_EXT_REGEX.test(url.pathname))) {
|
||||
trackCustomEvent('file_download');
|
||||
return;
|
||||
}
|
||||
|
||||
// * Check outbound link (different hostname)
|
||||
if (trackOutbound && url.hostname && url.hostname !== location.hostname) {
|
||||
trackCustomEvent('outbound_link');
|
||||
}
|
||||
} catch (err) {
|
||||
// * Invalid URL - skip silently
|
||||
}
|
||||
}, true); // * Capture phase: fires before default navigation
|
||||
}
|
||||
|
||||
})();
|
||||
|
||||
File diff suppressed because one or more lines are too long
18
vitest.config.ts
Normal file
18
vitest.config.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./vitest.setup.ts'],
|
||||
include: ['**/__tests__/**/*.test.{ts,tsx}', '**/*.test.{ts,tsx}'],
|
||||
globals: true,
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, '.'),
|
||||
},
|
||||
},
|
||||
})
|
||||
1
vitest.setup.ts
Normal file
1
vitest.setup.ts
Normal file
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
Reference in New Issue
Block a user