Compare commits
375 Commits
0.3.0-alph
...
0.15.0-alp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
484300c307 | ||
|
|
9fb19c18e8 | ||
|
|
0112004457 | ||
|
|
063a21adeb | ||
|
|
90de83ad6d | ||
|
|
a3fa48732a | ||
|
|
a637d32446 | ||
|
|
df394b85ef | ||
|
|
4e7c495160 | ||
|
|
9c8943d1e3 | ||
|
|
e7debdeb41 | ||
|
|
3df93bb227 | ||
|
|
3bde3fd4e1 | ||
|
|
5cdf353233 | ||
|
|
683bbce817 | ||
|
|
828e930a69 | ||
|
|
54daf14c6a | ||
|
|
281a9f237a | ||
|
|
4b10f8c1fc | ||
|
|
31286c45f4 | ||
|
|
908606ade2 | ||
|
|
4cd9544672 | ||
|
|
49aa8aae60 | ||
|
|
b3e335ec6c | ||
|
|
e7e76bb3db | ||
|
|
dc1030036c | ||
|
|
0fa6c4aaf4 | ||
|
|
c669035718 | ||
|
|
7336f9126e | ||
|
|
6964be9610 | ||
|
|
bae492e8d9 | ||
|
|
03e3f41e48 | ||
|
|
eb17e8e8d6 | ||
|
|
540c774100 | ||
|
|
3bf832af92 | ||
|
|
5050422a60 | ||
|
|
13f6f53868 | ||
|
|
bf7fe87120 | ||
|
|
d4dc45e82b | ||
|
|
0889079372 | ||
|
|
2f01be1c67 | ||
|
|
585f37f444 | ||
|
|
1f64bec46d | ||
|
|
9179e058f7 | ||
|
|
d5aafdc48a | ||
|
|
062d0a2b44 | ||
|
|
46084b71a6 | ||
|
|
a00042c557 | ||
|
|
c17a856224 | ||
|
|
953762075b | ||
|
|
fb47716711 | ||
|
|
247a0b3460 | ||
|
|
9e6e2a2214 | ||
|
|
b05f7bbcf6 | ||
|
|
1417c952c6 | ||
|
|
a22333bbc2 | ||
|
|
27a9836d5a | ||
|
|
c6ec4671a4 | ||
|
|
acf7b16dde | ||
|
|
31aff95552 | ||
|
|
d728b49f67 | ||
|
|
eeb46affda | ||
|
|
cf5fbb6f8e | ||
|
|
bb9e907a50 | ||
|
|
7fe8c3818f | ||
|
|
3fc0dec9d9 | ||
|
|
7bd922a012 | ||
|
|
7e91e08532 | ||
|
|
cb6c03432c | ||
|
|
bc299fe9a0 | ||
|
|
632530af7f | ||
|
|
ffbfcf342f | ||
|
|
602f7350b8 | ||
|
|
c15737b9c6 | ||
|
|
a189952fad | ||
|
|
428a6fd18d | ||
|
|
136ceff962 | ||
|
|
eb872dbc5a | ||
|
|
956cfbcf35 | ||
|
|
b5dd5e7082 | ||
|
|
34eca64967 | ||
|
|
1c5ca7fa54 | ||
|
|
275503ae8f | ||
|
|
73db65c0b2 | ||
|
|
0754cb0e4f | ||
|
|
1ba6bf6a84 | ||
|
|
72011dea5c | ||
|
|
7431f2b78d | ||
|
|
bf37add366 | ||
|
|
ca60379e5e | ||
|
|
b30619e6b4 | ||
|
|
0f5d5338f3 | ||
|
|
faa2f50d6e | ||
|
|
55bf20c58d | ||
|
|
2fa3540a48 | ||
|
|
c2d5935394 | ||
|
|
8136268988 | ||
|
|
15d41f5bd9 | ||
|
|
37eb49eb37 | ||
|
|
3d12f35331 | ||
|
|
205cdf314c | ||
|
|
502f4952fc | ||
|
|
f10b903a80 | ||
|
|
848bde237f | ||
|
|
835c284a6b | ||
|
|
beee87bd2e | ||
|
|
bcaa5c25f8 | ||
|
|
d863004d5f | ||
|
|
00d8656ad2 | ||
|
|
64a8652423 | ||
|
|
a99d13309f | ||
|
|
7aa809c8a0 | ||
|
|
ca71c1646d | ||
|
|
3587f93645 | ||
|
|
e07fd3f0e8 | ||
|
|
05d13bff81 | ||
|
|
a9f42acbf6 | ||
|
|
a60efeb6a7 | ||
|
|
88f02a244b | ||
|
|
8c5b452f73 | ||
|
|
5f797112ec | ||
|
|
ae0f6b8ffa | ||
|
|
4babbc7555 | ||
|
|
01f6d8d065 | ||
|
|
628749a416 | ||
|
|
b88f4d438b | ||
|
|
2776c803f1 | ||
|
|
c46d463533 | ||
|
|
6f964f38f3 | ||
|
|
330cc134aa | ||
|
|
92fae83772 | ||
|
|
242c76b763 | ||
|
|
9f2032fc32 | ||
|
|
cc4f924fb8 | ||
|
|
5625703168 | ||
|
|
7175de44af | ||
|
|
033d735c3a | ||
|
|
5721d25291 | ||
|
|
536aebc086 | ||
|
|
8c9c711296 | ||
|
|
652c93cbd0 | ||
|
|
2d7e13b098 | ||
|
|
58c151e2b0 | ||
|
|
1a75b44c68 | ||
|
|
9629a5788c | ||
|
|
464a361094 | ||
|
|
12ae1a9175 | ||
|
|
3268a70baa | ||
|
|
9dba2cf2e2 | ||
|
|
efd647d856 | ||
|
|
df2f38eb83 | ||
|
|
c065853800 | ||
|
|
f58154f18d | ||
|
|
31416f0eb2 | ||
|
|
6ccc26ab48 | ||
|
|
cbf48318ce | ||
|
|
874ff61a46 | ||
|
|
0dfd0ccb3c | ||
|
|
56225bb1ad | ||
|
|
ad747b1772 | ||
|
|
3f81cb0e48 | ||
|
|
86c11dc16f | ||
|
|
5fc6f183db | ||
|
|
4d99334bcf | ||
|
|
3002c4f58c | ||
|
|
a05e2e94b8 | ||
|
|
7ff5be7c4e | ||
|
|
f516c59d32 | ||
|
|
b6199e8a3a | ||
|
|
7f9ad0e977 | ||
|
|
397a5afef9 | ||
|
|
6f1956b740 | ||
|
|
831fd86f67 | ||
|
|
2f5bcf479a | ||
|
|
ad806e0427 | ||
|
|
6338d1dfe7 | ||
|
|
d2dfe62993 | ||
|
|
cc268c320e | ||
|
|
985978dd8f | ||
|
|
8ebd8ba9e1 | ||
|
|
dd8e101f69 | ||
|
|
ece8cda334 | ||
|
|
74ee64a560 | ||
|
|
641a3deebb | ||
|
|
77dc61e7d0 | ||
|
|
dee7089925 | ||
|
|
2acfd90bbd | ||
|
|
34e59894af | ||
|
|
7fc40f2a83 | ||
|
|
068943974e | ||
|
|
2c82c1a52a | ||
|
|
b046978256 | ||
|
|
7be30b57b5 | ||
|
|
386b4a8c44 | ||
|
|
34053004c0 | ||
|
|
0809c37067 | ||
|
|
ec96fa8a0d | ||
|
|
0865774686 | ||
|
|
5677f30f3b | ||
|
|
8b1d196812 | ||
|
|
53a0341925 | ||
|
|
e72e6f2ec5 | ||
|
|
acede8ca54 | ||
|
|
6d360cf1ac | ||
|
|
7865b41722 | ||
|
|
48cf9a1f62 | ||
|
|
f469d0d755 | ||
|
|
88956879de | ||
|
|
564c853f7f | ||
|
|
c9fd949ae1 | ||
|
|
70f46ba63c | ||
|
|
7d3f1cb10a | ||
|
|
fd1386b80d | ||
|
|
501932849b | ||
|
|
b7426d6128 | ||
|
|
dfa887147a | ||
|
|
4de4e05ccb | ||
|
|
d7eb10e815 | ||
|
|
8a7076ee1b | ||
|
|
67c9bdd3e0 | ||
|
|
3ecd2abf63 | ||
|
|
baceb6e8a8 | ||
|
|
fba1fd99c2 | ||
|
|
c9123832a5 | ||
|
|
95920e4724 | ||
|
|
67f6690258 | ||
|
|
5b388808b6 | ||
|
|
27158f7bfc | ||
|
|
bc5e20ab7b | ||
|
|
6bb23bc22a | ||
|
|
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 | ||
|
|
91ec37be53 | ||
|
|
31de661888 | ||
|
|
43a0954e5f | ||
|
|
93028efa0d | ||
|
|
414908b6ce | ||
|
|
14ca762305 | ||
|
|
6545b006de | ||
|
|
19df3c6c75 | ||
|
|
c1325bc573 | ||
|
|
7215eb17b0 | ||
|
|
e53d37a388 | ||
|
|
bd19288f52 | ||
|
|
270b970f43 | ||
|
|
65e5c727de | ||
|
|
a1e9a6b8f7 | ||
|
|
19be64c43a | ||
|
|
39eac4100e | ||
|
|
b88a31c612 | ||
|
|
2d0307d328 | ||
|
|
837c677b51 | ||
|
|
c73c300620 | ||
|
|
8007900940 | ||
|
|
06f54176f1 | ||
|
|
1947c6a886 | ||
|
|
18d9f59e5d | ||
|
|
acac536590 | ||
|
|
da0366603e | ||
|
|
5d234b30d6 | ||
|
|
e0bae5a728 | ||
|
|
ca805c9790 | ||
|
|
5c148a0547 | ||
|
|
94fb7c60e0 | ||
|
|
156d9986df | ||
|
|
ac6a9429d4 | ||
|
|
d571b6156f | ||
|
|
c100277955 | ||
|
|
574462a275 | ||
|
|
afa0cec88b | ||
|
|
b124fa49ef | ||
|
|
a2419d681c | ||
|
|
ccefdcc384 | ||
|
|
2aedc656d7 | ||
|
|
20959683e5 | ||
|
|
1a970279b5 | ||
|
|
ee25d87097 | ||
|
|
4dead4b399 | ||
|
|
aada06c207 | ||
|
|
947e37168d | ||
|
|
d08c8f00a0 | ||
|
|
0b68db58be | ||
|
|
fb47cb0c86 | ||
|
|
8f8761ed3d | ||
|
|
fb3490feb9 | ||
|
|
65ba7ccba2 | ||
|
|
f1e6d5a48e | ||
|
|
72c06816fe | ||
|
|
23ba5f77a9 | ||
|
|
e8e304e238 | ||
|
|
4ffd61963c | ||
|
|
d1d82f5b3c | ||
|
|
98eef9c366 | ||
|
|
5c0babe273 | ||
|
|
22b2c036ac | ||
|
|
1e41bedc86 | ||
|
|
1ae20dba4c | ||
|
|
42ed7d91dd | ||
|
|
b8cb7e177e | ||
|
|
fa3982001d | ||
|
|
6817f0c9fa | ||
|
|
5b1d3d8f0e | ||
|
|
12975f671d | ||
|
|
cc89a27972 | ||
|
|
99e9235f1f | ||
|
|
53ed7493c6 | ||
|
|
a4f2bebd10 | ||
|
|
2d37d065c0 | ||
|
|
17106517d9 | ||
|
|
96b3919e52 | ||
|
|
0bbbb8a1af | ||
|
|
6d277b126e | ||
|
|
4410366ccf | ||
|
|
826dbdbe63 |
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
|
# PWA
|
||||||
public/sw.js
|
public/sw.js
|
||||||
|
public/sw 2.js
|
||||||
public/workbox-*.js
|
public/workbox-*.js
|
||||||
public/swe-worker-*.js
|
public/swe-worker-*.js
|
||||||
|
|||||||
294
CHANGELOG.md
294
CHANGELOG.md
@@ -4,7 +4,290 @@ All notable changes to Pulse (frontend and product) are documented in this file.
|
|||||||
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and Pulse uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) with a **0.x.y** version scheme while in initial development. The leading `0` indicates that the public API and behaviour may change until we release **1.0.0**.
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and Pulse uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) with a **0.x.y** version scheme while in initial development. The leading `0` indicates that the public API and behaviour may change until we release **1.0.0**.
|
||||||
|
|
||||||
## [Unreleased]
|
## [0.15.0-alpha] - 2026-03-13
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **User Journeys tab.** A new "Journeys" tab on your site dashboard visualizes how visitors navigate through your site. A Sankey flow diagram shows the most common paths users take — from landing page through to exit — so you can see where traffic flows and where it drops off. Filter by entry page, adjust the depth (2-10 steps), and click any page in the diagram to drill into paths through it. Below the diagram, a "Top Paths" table ranks the most common full navigation sequences with session counts and average duration.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- **Realtime visitors detail page.** The page that showed individual active visitors and their page-by-page session journey has been removed. The live visitor count on your dashboard still works — it just no longer links to a separate page.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Rage click detection.** Pulse now detects when visitors rapidly click the same element 3 or more times — a strong signal of UI frustration. Rage clicks are tracked automatically (no setup required) and surfaced in the new Behavior tab with the element, page, click count, and number of affected sessions.
|
||||||
|
- **Dead click detection.** Clicks on buttons, links, and other interactive elements that produce no visible result (no navigation, no DOM change, no network request) are now detected and reported. This helps you find broken buttons, disabled links, and unresponsive UI elements your visitors are struggling with.
|
||||||
|
- **Behavior tab.** A new tab in your site dashboard — alongside Dashboard, Uptime, and Funnels — dedicated to user behavior signals. Houses rage clicks, dead clicks, a by-page frustration breakdown, and scroll depth (moved from the main dashboard for a cleaner layout).
|
||||||
|
- **Frustration summary cards.** The Behavior tab opens with three at-a-glance cards: total rage clicks, total dead clicks, and total frustration signals with the most affected page — each with a percentage change compared to the previous period.
|
||||||
|
- **Scheduled Reports.** You can now get your analytics delivered automatically — set up daily, weekly, or monthly reports sent straight to your email, Slack, Discord, or any webhook. Each report includes your key stats (visitors, pageviews, bounce rate), top pages, and traffic sources, all in a clean branded format. Set them up in your site settings under the new "Reports" tab, and hit "Test" to preview before going live. You can create up to 10 schedules per site.
|
||||||
|
- **Time-of-day report scheduling.** Choose when your reports arrive — pick the hour, day of week (for weekly), or day of month (for monthly). Schedule cards show a human-readable description like "Every Monday at 9:00 AM (UTC)."
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Scroll depth moved to Behavior tab.** The scroll depth radar chart has been relocated from the main dashboard to the new Behavior tab, where it fits more naturally alongside other user behavior metrics.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Region names now display correctly.** Some regions were showing as cryptic codes like "14" (Poland), "KKC" (Thailand), or "IDF" (France) instead of their actual names. The Locations panel now shows proper region names like "Masovian", "Khon Kaen", and "Île-de-France."
|
||||||
|
|
||||||
|
## [0.14.0-alpha] - 2026-03-12
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
|
||||||
|
- **Smarter referrer attribution.** Traffic that arrives without a referrer on a deep page (like a blog post) is now shown as "Shared Link" instead of "Direct." Real direct traffic — visitors who land on your homepage — still shows as "Direct." This gives you a much clearer picture of where your traffic actually comes from, since most unattributed deep-page visits are people clicking links shared in messaging apps or AI chatbots that strip the referrer header.
|
||||||
|
- **More in-app browsers detected.** Pulse now recognises visits from WhatsApp, Telegram, Snapchat, Pinterest, Reddit, and Threads in-app browsers and attributes them correctly instead of lumping them into "Direct."
|
||||||
|
- **Dashboard blocks are now consistent in height.** The Goals & Events and Scroll Depth panels now match the height of every other block on the dashboard.
|
||||||
|
- **Cleaner period picker.** The date range dropdown now has visual separators between the rolling windows (Today, Last 7 days, Last 30 days), the calendar periods (This week, This month), and Custom — so it's easy to tell them apart at a glance.
|
||||||
|
- **New date range options.** The period selector now includes "This week" (Monday to today) and "This month" (1st to today) alongside the existing rolling windows. Your selection is remembered between sessions.
|
||||||
|
- **Smarter comparison labels.** The "vs …" label under each stat now matches the period you're viewing — "vs yesterday" for today, "vs last week" for this week, "vs last month" for this month, and "vs previous N days" for rolling windows.
|
||||||
|
- **Refreshed stat headers.** The Unique Visitors, Total Pageviews, Bounce Rate, and Visit Duration stats at the top of the chart have a new look — uppercase labels, the percentage change shown inline next to the number, and an orange underline on whichever metric you're currently graphing.
|
||||||
|
- **Consistent green and red colors.** The up/down percentage indicators now use the same green and red as the rest of the app, instead of slightly different shades.
|
||||||
|
- **Scroll Depth is now a radar chart.** The Scroll Depth panel has been redesigned from a bar chart into a radar chart. The four scroll milestones (25%, 50%, 75%, 100%) are plotted as axes, with the filled shape showing how far visitors are getting through your pages at a glance.
|
||||||
|
- **Polished Goals & Events panel.** The Goals & Events block on your dashboard got a visual refresh to match the style of the Pages, Referrers, and Locations panels. Counts are shown in a consistent style, and hovering any row reveals what percentage of total events that action accounts for — sliding in smoothly from the right.
|
||||||
|
- **Smarter bot protection.** The security checks on shared dashboard access and organization settings now use action-specific tokens tied to each page. A token earned on one page can't be reused on another, making it harder for automated tools to bypass the captcha.
|
||||||
|
- **More resilient under Redis outages.** If the caching layer goes down temporarily, Pulse now continues enforcing rate limits using an in-memory fallback instead of letting all traffic through unchecked. This prevents one infrastructure hiccup from snowballing into a bigger problem.
|
||||||
|
- **Better handling of traffic bursts.** The system can now absorb 5x larger spikes of incoming events before applying backpressure. When events are dropped during extreme bursts, the system now tracks and logs exactly how many — so we can detect and respond to sustained overload before it affects your data.
|
||||||
|
- **Faster map and globe loading.** The interactive 3D globe and dotted map in the Locations panel now only load when you scroll down to them, instead of rendering immediately on page load. This makes the initial dashboard load faster and saves battery on mobile devices.
|
||||||
|
- **Real-time updates work across all servers.** If Pulse runs on multiple servers behind a load balancer, real-time visitor updates now stay in sync no matter which server you're connected to. Previously, you might miss live visitor changes if your connection landed on a different server than the one fetching data.
|
||||||
|
- **Lighter memory usage in long sessions.** If you manage many sites and keep Pulse open for hours, the app now automatically clears out old cached data for sites you're no longer viewing. This keeps the tab responsive and prevents it from slowly using more and more memory over time.
|
||||||
|
- **Cleaner login storage.** Temporary data left behind by abandoned sign-in attempts is now cleaned up automatically when the app loads. This prevents clutter from building up in your browser's storage over time.
|
||||||
|
- **Tidier annotation display.** If you've added a lot of annotations to your chart, only the 20 most recent are shown as lines on the chart to keep it readable. A "+N more" label lets you know there are additional annotations.
|
||||||
|
- **Even faster dashboard loading.** Your dashboard now fetches all its data — pages, locations, devices, referrers, performance, and goals — in a single request instead of seven separate ones. This means the entire dashboard appears at once rather than sections loading one by one, and puts much less strain on the server when many people are viewing their analytics at the same time.
|
||||||
|
- **Smoother real-time updates.** The real-time visitors page now streams updates instantly from the server instead of checking for new data every few seconds. Visitors appear and disappear in real-time with no delay, and the page uses far fewer server resources — especially when many people are watching their live traffic at the same time.
|
||||||
|
- **More reliable under heavy load.** Database queries now have automatic time limits so a single slow query can never lock up the system. If your dashboard or stats take too long to load, the request is gracefully cancelled instead of hanging forever — keeping everything responsive even during traffic spikes.
|
||||||
|
- **Smarter caching for dashboard data.** Your dashboard stats are now cached for longer and shared more efficiently between requests. When the cache refreshes, only one request does the work while others wait for the result — so your dashboard loads consistently fast even when lots of people are viewing their analytics at the same time.
|
||||||
|
- **Faster filtered views.** When you filter your dashboard by country, browser, page, or any other dimension, the results are now cached so repeat views load instantly. If multiple people apply the same filter, only one lookup runs and the result is shared — making filtered views much snappier under heavy use.
|
||||||
|
- **Faster entry and exit page stats.** The queries that figure out which pages visitors land on and leave from have been rewritten to be much more efficient. Instead of sorting through every single event, they now look up just the first and last page per visit — so your Entry Pages and Exit Pages panels load noticeably faster, especially on high-traffic sites.
|
||||||
|
- **Faster goal stats.** The Goals panel on your dashboard now loads faster, especially for sites with many custom events. Goal names are now looked up in a single step instead of one at a time.
|
||||||
|
- **Fairer performance under heavy traffic.** One busy site can no longer slow down dashboards for everyone else. Each site now gets its own dedicated share of server resources, so your analytics stay fast and responsive even when other sites on the platform are experiencing traffic spikes.
|
||||||
|
- **Smoother exports.** Exporting your data to PDF, Excel, or CSV no longer freezes the page. You'll see a clear "Exporting..." indicator while your file is being prepared, and the rest of the dashboard stays fully interactive.
|
||||||
|
- **Smoother "View All" popups.** Opening the expanded view for Pages, Locations, Technology, Referrers, or Campaigns now scrolls smoothly even with hundreds of items. Only the rows you can see are rendered, so the popup opens instantly on any device.
|
||||||
|
- **Faster daily stats processing.** Behind the scenes, the system that calculates your daily visitor stats now automatically scales up when there are more sites to process — so your dashboard numbers stay accurate and up to date even as the platform grows.
|
||||||
|
- **More reliable background processing.** When multiple servers are running, long-running background tasks like daily stats calculations no longer risk being interrupted or duplicated. The system now keeps its coordination lock active for as long as the task is running.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Peak Hours heatmap.** A new panel on your dashboard shows a 7×24 grid of when your visitors are most active — every day of the week against every hour of the day. Cells glow brighter in brand orange the busier that hour is. Hover any cell to see the exact pageview count. No other indie analytics tool surfaces this on the main dashboard.
|
||||||
|
- **Interactive 3D Globe.** The Locations panel now has a "Globe" tab showing your visitor locations on a beautiful, interactive 3D globe. Drag to rotate, and orange markers highlight where your visitors are — sized by how much traffic each country sends. The globe slowly auto-rotates and adapts to light and dark mode.
|
||||||
|
- **Dotted world map.** The "Map" tab in Locations now uses a sleek dotted map style instead of the old filled map. Country markers glow in brand orange and show a tooltip with the country name and pageview count when you hover.
|
||||||
|
- **Hide unknown locations.** New toggle in Site Settings under Data & Privacy to hide "Unknown" entries from your Locations panel. When geographic data can't be determined for a visitor, it normally shows as "Unknown" in countries, cities, and regions. Turn this on to keep your location stats clean and only show resolved locations.
|
||||||
|
- **Chart annotations.** Mark events on your dashboard timeline — like deploys, campaigns, or incidents — so you always know why traffic changed. Click the + button on the chart to add a note on any date. Annotations appear as colored markers on the chart: blue for deploys, green for campaigns, red for incidents. Hover to see the details. Team owners and admins can add, edit, and delete annotations; everyone else (including public dashboard viewers) can see them.
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
|
||||||
|
- **Beautiful funnel visualization.** Funnel reports now show a smooth, animated funnel shape instead of a plain bar chart. Each step flows into the next with curved segments, hover effects, and labels showing visitor counts and conversion percentages at a glance.
|
||||||
|
- **Tidier dashboard layout.** The tab navigation (Dashboard, Uptime, Funnels, Settings) now sits above your site name and controls, keeping the tabs front and center.
|
||||||
|
- **Instant tab switching.** Clicking between Dashboard, Uptime, Funnels, and Settings now feels instant — the tab bar stays in place while the page content loads below it, instead of the whole screen flashing with a loading skeleton.
|
||||||
|
- **Smooth tab animations.** Switching tabs now plays a sliding indicator animation on the active tab and a subtle crossfade on the page content, making navigation feel polished and responsive.
|
||||||
|
- **Cleaner focus styles.** Buttons, tabs, and links no longer show an orange outline when you click them — the focus ring now only appears when navigating with the keyboard, keeping the interface clean.
|
||||||
|
- **Faster dashboard loading.** Switching to the Dashboard and Map tabs is now instant — no more brief lag or delay when navigating between sections.
|
||||||
|
- **Expand icon for data panels.** Pages, Referrers, Locations, Technology, and Campaigns panels now show a small expand icon next to the title when there's more data to see, replacing the old "View all" button at the bottom.
|
||||||
|
- **Better expanded views.** When you expand a data panel, the popup is now wider and taller so you can see more at once. Each row shows a percentage on hover, clicking a row filters your dashboard, and there's a search bar at the top to quickly find what you're looking for.
|
||||||
|
- **Smoother theme switching.** Toggling between light and dark mode now plays a satisfying circular reveal animation that expands from the toggle button, instead of everything just flipping instantly.
|
||||||
|
- **Cleaner site navigation.** Dashboard, Uptime, Funnels, and Settings now use an underline tab bar instead of floating buttons. The active section is highlighted with an orange underline, making it easy to see where you are and switch between views.
|
||||||
|
- **Consistent icon style.** All dashboard icons now use a single, unified icon set for a cleaner look across Technology, Locations, Campaigns, and Referrers panels.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Correct Instagram attribution.** Visits from Instagram's in-app browser were showing as "Facebook" because Instagram routes shared links through Facebook's URL redirector. Pulse now checks the User-Agent to detect the real source app.
|
||||||
|
- **Android and iOS now show up in OS stats.** A bug in the User-Agent parsing order meant Android was always classified as "Linux" (because Android UAs contain "Linux") and iOS as "macOS" (because iPhone UAs contain "like Mac OS X"). Both are now detected correctly.
|
||||||
|
- **Charts no longer show tomorrow's date.** The visitor chart on 7-day and 30-day views could display the next day with zero traffic, making it look like a sudden drop. The chart now ends on today.
|
||||||
|
- **Capitalized technology labels.** Device types, browsers, and OS names in the Technology panel now display with a capital first letter (e.g. "Desktop" instead of "desktop").
|
||||||
|
- **Login no longer gets stuck after updates.** If you happened to have Pulse open when a new version was deployed, logging back in could get stuck on a loading screen. The app now automatically refreshes itself to pick up the latest version.
|
||||||
|
- **City and region data is now accurate.** Location data was incorrectly showing the CDN server's location (e.g. Paris, Villeurbanne) instead of the visitor's actual city. Fixed by reading the correct visitor IP header from Bunny CDN.
|
||||||
|
- **"Reset Data" now clears everything.** Previously, resetting a site's data in Settings only removed pageviews and daily stats. Uptime check history, uptime daily stats, and cached dashboard data were left behind. All collected data is now properly cleared when you reset, while your site configuration, goals, funnels, and uptime monitors are kept.
|
||||||
|
|
||||||
|
## [0.13.0-alpha] - 2026-03-07
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Dashboard filtering.** Filter your entire dashboard by any dimension — browser, country, page, device, OS, referrer, or UTM parameters. A single "Filter" button lets you browse dimensions, see real values from your data with visitor counts, search or type a custom value, and apply — all in a quick dropdown. Active filters appear as removable pills above your charts. Stack multiple filters to narrow things down. Filters are saved in the URL so you can bookmark or share a filtered view.
|
||||||
|
- **Click any item to filter.** Click a referrer, browser, country, page, or any other item in your dashboard panels to instantly filter the entire dashboard to just that traffic.
|
||||||
|
- **Hover percentages.** Hover over any item in Pages, Locations, Technology, or Referrers to see what percentage of total traffic it represents.
|
||||||
|
- **Custom event properties.** Your custom events can now carry extra context — for example, `pulse.track('signup', { plan: 'pro', source: 'landing' })`. Click any event in Goals & Events to see a breakdown of its properties and values, no setup needed.
|
||||||
|
- **AI traffic source identification.** Pulse recognizes visitors from ChatGPT, Perplexity, Claude, Gemini, Copilot, DeepSeek, Grok, Meta AI, You.com, and Phind. These appear in Referrers with proper icons and names instead of raw URLs.
|
||||||
|
- **Automatic outbound link tracking.** Tracks when visitors click links to other websites. Shows up as "outbound link" events in Goals & Events — no setup needed.
|
||||||
|
- **Automatic file download tracking.** Downloads of PDFs, ZIPs, Excel, Word, MP3s, and 20+ other formats are recorded as "file download" events automatically.
|
||||||
|
- **Automatic 404 detection.** Detects when visitors land on pages that don't exist and records "404" events so you can find and fix broken links.
|
||||||
|
- **Automatic scroll depth tracking.** Tracks how far visitors scroll — at 25%, 50%, 75%, and 100% — helping you understand which content keeps people reading.
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
|
||||||
|
- **Chart rebuilt from scratch.** Cleaner stat cards, wider Y-axis that no longer clips labels, whole-number ticks for visitor and pageview counts, lighter grid lines, streamlined toolbar, and a properly positioned live indicator.
|
||||||
|
- **Campaigns panel redesigned.** Clean row-based layout with UTM medium and campaign always visible below the source name. Now sits in a half-width grid next to Goals & Events.
|
||||||
|
- **Better filter design.** Solid brand-colored filter pills that are easy to spot in light and dark mode. A funnel icon on the filter button. Click any pill to remove it.
|
||||||
|
- **Underline tab switchers.** Pages, Locations, and Technology panels now use clean underline tabs instead of pill-style switchers.
|
||||||
|
- **"View all" at the bottom.** The expand action on each panel is now a subtle "View all" link at the bottom of the list instead of an icon in the header.
|
||||||
|
- **Faster dashboard loading.** Each section loads independently with smart caching. Data refreshes in the background, and switching tabs pauses updates to save resources — resuming when you return.
|
||||||
|
- **Smoother navigation.** Switching pages, changing organizations, or signing in no longer triggers unnecessary background requests.
|
||||||
|
- **Loading screen while redirecting to sign-in.** The login page now shows the Pulse logo and a message instead of a blank white screen.
|
||||||
|
- **More reliable billing.** Plan changes, cancellations, and invoice views now handle session expiry and errors gracefully.
|
||||||
|
- **Stronger browser security.** Your browser now only loads scripts, styles, and images from trusted sources, adding protection against cross-site scripting.
|
||||||
|
- **More resilient analytics processing.** The system that processes your events now recovers automatically from unexpected errors instead of stopping silently.
|
||||||
|
- **Dashboard stays responsive under heavy traffic.** Parallel queries are limited during peak usage, and in-progress queries are cancelled when you navigate away.
|
||||||
|
- **Cleaner error messages.** Invalid form submissions show a simple message instead of exposing internal details.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Tracking script now works on all tracked websites.** Page views were silently failing due to two separate issues. Both are fixed — your dashboard receives visits from all registered domains as expected.
|
||||||
|
- **Rate limiting works correctly.** A bug was treating all visitors as the same person, so one heavy user could block everyone. Each visitor is now identified individually.
|
||||||
|
- **Real-time visitor count no longer stops updating.** The live counter would hit a rate limit and stop refreshing. It now has enough headroom for normal usage.
|
||||||
|
- **Team members can view real-time data.** Previously only the site creator could see live visitors. Now any team member in the same organization has access.
|
||||||
|
- **Funnel details load correctly.** Opening a funnel previously showed an error. Funnels now display step-by-step conversion data as expected.
|
||||||
|
- **Consistent date handling.** Funnels now use the same date format as the rest of Pulse, so date pickers and bookmarked links work correctly everywhere.
|
||||||
|
- **Deleting a site cleans up all data.** Orphaned analytics events are now removed automatically before the site is deleted.
|
||||||
|
- **App switcher and site icons load correctly.** Logos and favicons were blocked by a security policy. Fixed by allowing images from Ciphera and Google's favicon service.
|
||||||
|
- **Current session highlighted in settings.** The active session marker now works correctly.
|
||||||
|
- **Notifications load on sign-in.** The notification bell no longer errors briefly after signing in.
|
||||||
|
- **Duplicate filters no longer stack.** Clicking the same item twice no longer adds the same filter again.
|
||||||
|
- **Campaigns respect active filters.** The Campaigns panel now filters along with everything else instead of always showing all campaigns.
|
||||||
|
- **No duplicate "Direct" in referrer filter.** The referrer suggestions no longer show "Direct" twice.
|
||||||
|
- **Filter dropdowns show all your data.** Previously limited to 10 items — now loads up to 100 values.
|
||||||
|
- **Chart Y-axis shows whole numbers.** Visitor and pageview counts no longer show fractional values like "0.75 visitors".
|
||||||
|
- **Duplicate goal names detected reliably.** Goal name uniqueness checks now work correctly regardless of your setup.
|
||||||
|
- **Health checks stay accurate.** The backend health check no longer falsely reports the service as unhealthy after sustained traffic.
|
||||||
|
|
||||||
|
## [0.12.0-alpha] - 2026-03-01
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
- **Better page titles.** Browser tabs now show which site and page you're on (e.g. "Uptime · example.com | Pulse") instead of the same generic title everywhere.
|
||||||
|
- **Link previews for public dashboards.** Sharing a public dashboard link on social media now shows a proper preview with the site name and description.
|
||||||
|
- **Faster login redirects.** If you're not signed in and try to open a dashboard or settings page, you're redirected to login immediately instead of seeing a blank page first. Already-signed-in users who visit the login page are sent straight to the dashboard.
|
||||||
|
- **Graceful error recovery.** If a page crashes, you now see a friendly error screen with a "Try again" button instead of a blank white page. Each section of the app has its own error message so you know exactly what went wrong.
|
||||||
|
- **Security headers.** All pages now include clickjacking protection, MIME-sniffing prevention, a strict referrer policy, and HSTS. Browser APIs like camera and microphone are explicitly disabled.
|
||||||
|
- **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, 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
|
||||||
|
|
||||||
|
- **Smoother loading experience.** Pages now show a subtle preview of the layout while data loads instead of a blank screen or spinner. This applies everywhere — dashboards, settings, uptime, funnels, notifications, billing, and detail modals.
|
||||||
|
- **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 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
|
||||||
|
|
||||||
|
- **Landing page dashboard preview.** The homepage now shows a realistic preview of the Pulse dashboard instead of an empty placeholder.
|
||||||
|
- **Logout redirect loop.** Signing out no longer bounces you straight to Ciphera Auth. You now land on the Pulse homepage where you can choose to sign back in.
|
||||||
|
- **No more loading flicker.** Fast-loading pages no longer flash a loading state for a split second before showing content.
|
||||||
|
- **Organization context switch.** Switching away from a deleted organization now stores the session correctly instead of using an insecure fallback.
|
||||||
|
- **Dark mode uptime chart.** The response time chart on the uptime page now correctly follows your dark mode preference instead of always showing a white tooltip background.
|
||||||
|
- **Onboarding form limits.** The welcome page now enforces the same character limits as the rest of the app.
|
||||||
|
- **Audit log reliability.** Failed audit log writes are now logged to the server instead of being silently ignored, so gaps in the audit trail are detectable.
|
||||||
|
- **Safer error messages.** Server errors no longer expose internal details (database errors, stack traces) to the browser. You see a clear message like "Failed to create site" while the full error is logged server-side for debugging.
|
||||||
|
- **Content Security Policy.** The backend CSP header was being overwritten by a duplicate, and the captcha service was incorrectly whitelisted under image sources instead of connection sources. Both are now fixed.
|
||||||
|
- **Logout redirect loop.** Signing out no longer bounces you straight to Ciphera Auth. You now land on the Pulse homepage where you can choose to sign back in.
|
||||||
|
- **Date range edge case.** The maximum date range check could be off by a day due to an internal time adjustment. It now compares calendar days accurately.
|
||||||
|
|
||||||
|
## [0.10.0-alpha] - 2026-02-21
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Design consistency (PULSE-59).** Pulse now feels more cohesive across all pages — headings, buttons, and layout are consistent.
|
||||||
|
- **Headings.** Marketing and integration pages use the same heading sizes for a clearer visual hierarchy.
|
||||||
|
- **Buttons.** Settings pages and the verification modal use consistent button styles. The Enterprise "Contact us" button on pricing now matches the rest.
|
||||||
|
- **Settings layout.** Profile settings, Organization Settings, and Site Settings now span the full width of the page, matching the dashboard.
|
||||||
|
- **Charts and maps.** Analytics charts, funnel views, and the uptime map now use Pulse's brand colors correctly in both light and dark mode.
|
||||||
|
- **Integration guides.** Code examples in the integration and installation guides look cleaner and work better in dark mode.
|
||||||
|
- **Dark mode.** Text and backgrounds across settings, pricing, and funnels are easier to read when you switch themes.
|
||||||
|
- **Cards and panels.** All cards use consistent padding for a more even layout.
|
||||||
|
- **Integration pages.** Integration setup guides have more comfortable spacing at the top.
|
||||||
|
- **Org slug.** The organization URL prefix correctly shows `pulse.ciphera.net/` instead of the wrong domain.
|
||||||
|
|
||||||
|
## [0.9.0-alpha] - 2026-02-21
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Data retention settings (PULSE-58).** Site owners can choose how long raw event data is kept (1 month to 3 years depending on plan). Events older than the retention period are automatically deleted every 24 hours. Aggregated daily stats are preserved so historical charts remain intact.
|
||||||
|
- **Data Retention section in Site Settings.** Under Data & Privacy, a dropdown lets you set retention; options are capped by your plan (free: up to 6 months, solo: 1 year, team: 2 years, business: 3 years).
|
||||||
|
- **Privacy snippet includes retention.** The generated privacy policy text now mentions when raw data is automatically deleted.
|
||||||
|
|
||||||
|
## [0.8.0-alpha] - 2026-02-20
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Renewal date and amount.** The dashboard and billing tab now show when your subscription renews and how much you'll be charged.
|
||||||
|
- **Invoice preview when changing plans.** Before you switch plans, you can see exactly what your next invoice will be (including prorations).
|
||||||
|
- **Pay now for open invoices.** Unpaid invoices show a clear "Pay now" button so you can settle them quickly.
|
||||||
|
- **Enterprise contact.** The pricing page Enterprise plan now links to email us directly instead of checkout.
|
||||||
|
- **Past due alert.** If your payment fails, a red banner appears with a link to update your payment method.
|
||||||
|
- **Pageview usage bar.** Your billing card shows a color-coded bar so you can see at a glance how close you are to your limit (green, then amber, then red).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Change plan flow.** Cleaner plan selector with Solo, Team, and Business options. Shows which plan you're on and a preview of your next invoice. If the preview can't be calculated, you'll see a friendly message instead of a blank screen.
|
||||||
|
- **Billing tab layout.** Improved spacing, clearer headings, and better focus when using keyboard navigation.
|
||||||
|
- **Pricing page layout.** Updated spacing and typography. Slider and billing toggle are more accessible.
|
||||||
|
- **Billing Portal return.** After updating your payment method in Stripe's portal, you're taken back to the billing tab instead of the general settings page.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Theme toggle crash.** Fixed a crash that could occur when switching between light and dark mode on the pricing page and then opening organization settings.
|
||||||
|
|
||||||
## [0.7.0-alpha] - 2026-02-17
|
## [0.7.0-alpha] - 2026-02-17
|
||||||
|
|
||||||
@@ -82,7 +365,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
[Unreleased]: https://github.com/ciphera-net/pulse/compare/v0.7.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
|
||||||
|
[0.8.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.7.0-alpha...v0.8.0-alpha
|
||||||
[0.7.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.6.0-alpha...v0.7.0-alpha
|
[0.7.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.6.0-alpha...v0.7.0-alpha
|
||||||
[0.6.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.5.1-alpha...v0.6.0-alpha
|
[0.6.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.5.1-alpha...v0.6.0-alpha
|
||||||
[0.5.1-alpha]: https://github.com/ciphera-net/pulse/compare/v0.5.0-alpha...v0.5.1-alpha
|
[0.5.1-alpha]: https://github.com/ciphera-net/pulse/compare/v0.5.0-alpha...v0.5.1-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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
19
app/about/layout.tsx
Normal file
19
app/about/layout.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'About | Pulse',
|
||||||
|
description: 'Pulse is built by Ciphera — privacy-first web analytics made in Switzerland.',
|
||||||
|
openGraph: {
|
||||||
|
title: 'About | Pulse',
|
||||||
|
description: 'Pulse is built by Ciphera — privacy-first web analytics made in Switzerland.',
|
||||||
|
siteName: 'Pulse by Ciphera',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AboutLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return children
|
||||||
|
}
|
||||||
@@ -1,19 +1,11 @@
|
|||||||
'use server'
|
'use server'
|
||||||
|
|
||||||
import { cookies } from 'next/headers'
|
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'
|
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 {
|
interface AuthResponse {
|
||||||
access_token: string
|
access_token: string
|
||||||
refresh_token: string
|
refresh_token: string
|
||||||
@@ -32,19 +24,23 @@ interface UserPayload {
|
|||||||
/** Error type returned to client for mapping to user-facing copy (no sensitive details). */
|
/** Error type returned to client for mapping to user-facing copy (no sensitive details). */
|
||||||
export type AuthExchangeErrorType = 'network' | 'expired' | 'invalid' | 'server'
|
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 {
|
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`, {
|
const res = await fetch(`${AUTH_API_URL}/oauth/token`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
|
credentials: 'include', // * Critical: receives httpOnly cookies from Auth API
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
grant_type: 'authorization_code',
|
grant_type: 'authorization_code',
|
||||||
code,
|
code,
|
||||||
client_id: 'pulse-app',
|
client_id: 'pulse-app',
|
||||||
redirect_uri: redirectUri,
|
redirect_uri: redirectUri,
|
||||||
code_verifier: codeVerifier,
|
code_verifier: codeVerifier || '',
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -90,6 +86,50 @@ export async function exchangeAuthCode(code: string, codeVerifier: string, redir
|
|||||||
maxAge: 60 * 60 * 24 * 30 // 30 days
|
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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
user: {
|
user: {
|
||||||
@@ -102,7 +142,7 @@ export async function exchangeAuthCode(code: string, codeVerifier: string, redir
|
|||||||
}
|
}
|
||||||
|
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
console.error('Auth Exchange Error:', error)
|
logger.error('Auth Exchange Error:', error)
|
||||||
const isNetwork =
|
const isNetwork =
|
||||||
error instanceof TypeError ||
|
error instanceof TypeError ||
|
||||||
(error instanceof Error && (error.name === 'AbortError' || /fetch|network|ECONNREFUSED|ETIMEDOUT/i.test(error.message)))
|
(error instanceof Error && (error.name === 'AbortError' || /fetch|network|ECONNREFUSED|ETIMEDOUT/i.test(error.message)))
|
||||||
@@ -112,18 +152,13 @@ export async function exchangeAuthCode(code: string, codeVerifier: string, redir
|
|||||||
|
|
||||||
export async function setSessionAction(accessToken: string, refreshToken?: string) {
|
export async function setSessionAction(accessToken: string, refreshToken?: string) {
|
||||||
try {
|
try {
|
||||||
console.log('[setSessionAction] Decoding token...')
|
|
||||||
if (!accessToken) throw new Error('Access token is missing')
|
if (!accessToken) throw new Error('Access token is missing')
|
||||||
|
|
||||||
const payloadPart = accessToken.split('.')[1]
|
const payloadPart = accessToken.split('.')[1]
|
||||||
const payload: UserPayload = JSON.parse(Buffer.from(payloadPart, 'base64').toString())
|
const payload: UserPayload = JSON.parse(Buffer.from(payloadPart, 'base64').toString())
|
||||||
|
|
||||||
console.log('[setSessionAction] Token Payload:', { sub: payload.sub, org_id: payload.org_id })
|
|
||||||
|
|
||||||
const cookieStore = await cookies()
|
const cookieStore = await cookies()
|
||||||
const cookieDomain = getCookieDomain()
|
const cookieDomain = getCookieDomain()
|
||||||
|
|
||||||
console.log('[setSessionAction] Setting cookies with domain:', cookieDomain)
|
|
||||||
|
|
||||||
cookieStore.set('access_token', accessToken, {
|
cookieStore.set('access_token', accessToken, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
@@ -146,8 +181,6 @@ export async function setSessionAction(accessToken: string, refreshToken?: strin
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[setSessionAction] Cookies set successfully')
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
user: {
|
user: {
|
||||||
@@ -159,7 +192,7 @@ export async function setSessionAction(accessToken: string, refreshToken?: strin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[setSessionAction] Error:', e)
|
logger.error('[setSessionAction] Error:', e)
|
||||||
return { success: false as const, error: 'invalid' }
|
return { success: false as const, error: 'invalid' }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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 { cookies } from 'next/headers'
|
||||||
import { NextResponse } from 'next/server'
|
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'
|
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() {
|
export async function POST() {
|
||||||
const cookieStore = await cookies()
|
const cookieStore = await cookies()
|
||||||
const refreshToken = cookieStore.get('refresh_token')?.value
|
const refreshToken = cookieStore.get('refresh_token')?.value
|
||||||
@@ -37,6 +30,9 @@ export async function POST() {
|
|||||||
|
|
||||||
const data = await res.json()
|
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, {
|
cookieStore.set('access_token', data.access_token, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: process.env.NODE_ENV === 'production',
|
secure: process.env.NODE_ENV === 'production',
|
||||||
@@ -55,6 +51,18 @@ export async function POST() {
|
|||||||
maxAge: 60 * 60 * 24 * 30
|
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 })
|
return NextResponse.json({ success: true, access_token: data.access_token })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return NextResponse.json({ error: 'Internal error' }, { status: 500 })
|
return NextResponse.json({ error: 'Internal error' }, { status: 500 })
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState, Suspense, useRef, useCallback } from 'react'
|
import { useEffect, useState, Suspense, useRef, useCallback } from 'react'
|
||||||
|
import { logger } from '@/lib/utils/logger'
|
||||||
import { useRouter, useSearchParams } from 'next/navigation'
|
import { useRouter, useSearchParams } from 'next/navigation'
|
||||||
import { useAuth } from '@/lib/auth/context'
|
import { useAuth } from '@/lib/auth/context'
|
||||||
import { AUTH_URL, default as apiRequest } from '@/lib/api/client'
|
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 { authMessageFromErrorType, type AuthErrorType } from '@ciphera-net/ui'
|
||||||
import { LoadingOverlay } from '@ciphera-net/ui'
|
import { LoadingOverlay } from '@ciphera-net/ui'
|
||||||
|
|
||||||
@@ -20,8 +21,15 @@ function AuthCallbackContent() {
|
|||||||
const code = searchParams.get('code')
|
const code = searchParams.get('code')
|
||||||
const codeVerifier = localStorage.getItem('oauth_code_verifier')
|
const codeVerifier = localStorage.getItem('oauth_code_verifier')
|
||||||
const redirectUri = typeof window !== 'undefined' ? window.location.origin + '/auth/callback' : ''
|
const redirectUri = typeof window !== 'undefined' ? window.location.origin + '/auth/callback' : ''
|
||||||
if (!code || !codeVerifier) return
|
if (!code) return
|
||||||
const result = await exchangeAuthCode(code, codeVerifier, redirectUri)
|
let result: Awaited<ReturnType<typeof exchangeAuthCode>>
|
||||||
|
try {
|
||||||
|
result = await exchangeAuthCode(code, codeVerifier, redirectUri)
|
||||||
|
} catch {
|
||||||
|
// * Stale build or network error — show error so user can retry via full navigation
|
||||||
|
setError('Something went wrong. Please try logging in again.')
|
||||||
|
return
|
||||||
|
}
|
||||||
if (result.success && result.user) {
|
if (result.success && result.user) {
|
||||||
// * Fetch full profile (including display_name) before navigating so header shows correct name on first paint
|
// * Fetch full profile (including display_name) before navigating so header shows correct name on first paint
|
||||||
try {
|
try {
|
||||||
@@ -46,59 +54,28 @@ function AuthCallbackContent() {
|
|||||||
}, [searchParams, login, router])
|
}, [searchParams, login, router])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// * Prevent double execution (React Strict Mode or fast re-renders)
|
|
||||||
if (processedRef.current && !isRetrying) return
|
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')
|
const code = searchParams.get('code')
|
||||||
|
if (!code) return
|
||||||
|
|
||||||
const state = searchParams.get('state')
|
const state = searchParams.get('state')
|
||||||
|
|
||||||
if (!code || !state) return
|
|
||||||
|
|
||||||
const storedState = localStorage.getItem('oauth_state')
|
const storedState = localStorage.getItem('oauth_state')
|
||||||
const codeVerifier = localStorage.getItem('oauth_code_verifier')
|
const codeVerifier = localStorage.getItem('oauth_code_verifier')
|
||||||
|
|
||||||
if (!codeVerifier) {
|
// * Session flow (from auth hub): redirect has code but no state. Clear stale PKCE
|
||||||
setError('Missing code verifier')
|
// * data from any previous app-initiated OAuth so exchange proceeds without validation.
|
||||||
return
|
if (!state) {
|
||||||
}
|
localStorage.removeItem('oauth_state')
|
||||||
if (state !== storedState) {
|
localStorage.removeItem('oauth_code_verifier')
|
||||||
console.error('State mismatch', { received: state, stored: storedState })
|
} else {
|
||||||
setError('Invalid state')
|
// * Full OAuth flow (app-initiated): validate state + use PKCE
|
||||||
return
|
const isFullOAuth = !!storedState && !!codeVerifier
|
||||||
|
if (isFullOAuth && state !== storedState) {
|
||||||
|
logger.error('State mismatch', { received: state, stored: storedState })
|
||||||
|
setError('Invalid state')
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
processedRef.current = true
|
processedRef.current = true
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export default function ChangelogPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-3xl px-4 sm:px-6 py-8">
|
<div className="mx-auto max-w-3xl px-4 sm:px-6 py-8">
|
||||||
<h1 className="text-3xl font-bold text-neutral-900 dark:text-white mb-2">
|
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-neutral-900 dark:text-white mb-2">
|
||||||
Changelog
|
Changelog
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-neutral-600 dark:text-neutral-400 mb-8 text-sm">
|
<p className="text-neutral-600 dark:text-neutral-400 mb-8 text-sm">
|
||||||
|
|||||||
13
app/error.tsx
Normal file
13
app/error.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import ErrorDisplay from '@/components/ErrorDisplay'
|
||||||
|
|
||||||
|
export default function GlobalError({ reset }: { error: Error; reset: () => void }) {
|
||||||
|
return (
|
||||||
|
<ErrorDisplay
|
||||||
|
title="Something went wrong"
|
||||||
|
message="An unexpected error occurred. Please try again or go back to the dashboard."
|
||||||
|
onRetry={reset}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
19
app/faq/layout.tsx
Normal file
19
app/faq/layout.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'FAQ | Pulse',
|
||||||
|
description: 'Frequently asked questions about Pulse, privacy, GDPR compliance, and how it works.',
|
||||||
|
openGraph: {
|
||||||
|
title: 'FAQ | Pulse',
|
||||||
|
description: 'Frequently asked questions about Pulse, privacy, GDPR compliance, and how it works.',
|
||||||
|
siteName: 'Pulse by Ciphera',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FaqLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return children
|
||||||
|
}
|
||||||
@@ -58,7 +58,7 @@ function FAQItem({ faq, index }: { faq: typeof faqs[0]; index: number }) {
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
className="w-full py-6 flex items-center justify-between text-left hover:text-brand-orange transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-offset-2"
|
className="w-full py-6 flex items-center justify-between text-left hover:text-brand-orange transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:ring-offset-2"
|
||||||
>
|
>
|
||||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white pr-4">
|
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white pr-4">
|
||||||
{faq.question}
|
{faq.question}
|
||||||
@@ -114,7 +114,7 @@ export default function FAQPage() {
|
|||||||
|
|
||||||
<div className="max-w-3xl mx-auto">
|
<div className="max-w-3xl mx-auto">
|
||||||
{faqs.map((faq, index) => (
|
{faqs.map((faq, index) => (
|
||||||
<FAQItem key={index} faq={faq} index={index} />
|
<FAQItem key={faq.question} faq={faq} index={index} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
19
app/features/layout.tsx
Normal file
19
app/features/layout.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Features | Pulse',
|
||||||
|
description: 'Dashboards, funnels, uptime monitoring, realtime visitors, and more — all without cookies.',
|
||||||
|
openGraph: {
|
||||||
|
title: 'Features | Pulse',
|
||||||
|
description: 'Dashboards, funnels, uptime monitoring, realtime visitors, and more — all without cookies.',
|
||||||
|
siteName: 'Pulse by Ciphera',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FeaturesLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return children
|
||||||
|
}
|
||||||
@@ -83,12 +83,12 @@ const capabilities = [
|
|||||||
description: 'Automatically parse UTM parameters. Built-in link builder for campaigns, sources, and mediums.',
|
description: 'Automatically parse UTM parameters. Built-in link builder for campaigns, sources, and mediums.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: Share2Icon,
|
icon: <Share2Icon className="w-5 h-5" />,
|
||||||
title: 'Shared Dashboards',
|
title: 'Shared Dashboards',
|
||||||
description: 'Generate a public link to share analytics with clients or teammates — no login required.',
|
description: 'Generate a public link to share analytics with clients or teammates — no login required.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: GlobeIcon,
|
icon: <GlobeIcon className="w-5 h-5" />,
|
||||||
title: 'Geographic Insights',
|
title: 'Geographic Insights',
|
||||||
description: 'Country, region, and city-level breakdowns. IPs are never stored — derived at request time only.',
|
description: 'Country, region, and city-level breakdowns. IPs are never stored — derived at request time only.',
|
||||||
},
|
},
|
||||||
@@ -129,7 +129,7 @@ export default function FeaturesPage() {
|
|||||||
<span className="w-1.5 h-1.5 rounded-full bg-brand-orange animate-pulse" />
|
<span className="w-1.5 h-1.5 rounded-full bg-brand-orange animate-pulse" />
|
||||||
Product Tour
|
Product Tour
|
||||||
</span>
|
</span>
|
||||||
<h1 className="text-4xl md:text-6xl font-bold tracking-tight text-neutral-900 dark:text-white mb-6">
|
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-neutral-900 dark:text-white mb-6">
|
||||||
Everything you need. <br />
|
Everything you need. <br />
|
||||||
<span className="gradient-text">Nothing you don't.</span>
|
<span className="gradient-text">Nothing you don't.</span>
|
||||||
</h1>
|
</h1>
|
||||||
@@ -147,7 +147,7 @@ export default function FeaturesPage() {
|
|||||||
whileInView={{ opacity: 1, y: 0 }}
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
viewport={{ once: true }}
|
viewport={{ once: true }}
|
||||||
transition={{ duration: 0.5, delay: i * 0.1 }}
|
transition={{ duration: 0.5, delay: i * 0.1 }}
|
||||||
className="card-glass p-8 hover:-translate-y-1 hover:shadow-xl transition-all duration-300 group"
|
className="card-glass p-6 hover:-translate-y-1 hover:shadow-xl transition-all duration-300 group"
|
||||||
>
|
>
|
||||||
<div className="w-12 h-12 rounded-xl bg-brand-orange/10 flex items-center justify-center mb-6 text-brand-orange group-hover:scale-110 transition-transform duration-300">
|
<div className="w-12 h-12 rounded-xl bg-brand-orange/10 flex items-center justify-center mb-6 text-brand-orange group-hover:scale-110 transition-transform duration-300">
|
||||||
<feature.icon className="w-6 h-6" />
|
<feature.icon className="w-6 h-6" />
|
||||||
@@ -171,7 +171,7 @@ export default function FeaturesPage() {
|
|||||||
className="mb-28"
|
className="mb-28"
|
||||||
>
|
>
|
||||||
<div className="text-center mb-14">
|
<div className="text-center mb-14">
|
||||||
<h2 className="text-3xl md:text-4xl font-bold text-neutral-900 dark:text-white mb-4">
|
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-4">
|
||||||
Powerful analytics, <span className="gradient-text">simplified</span>
|
Powerful analytics, <span className="gradient-text">simplified</span>
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-lg text-neutral-600 dark:text-neutral-400 max-w-2xl mx-auto">
|
<p className="text-lg text-neutral-600 dark:text-neutral-400 max-w-2xl mx-auto">
|
||||||
@@ -190,7 +190,7 @@ export default function FeaturesPage() {
|
|||||||
className="flex gap-4"
|
className="flex gap-4"
|
||||||
>
|
>
|
||||||
<div className="w-10 h-10 rounded-lg bg-brand-orange/10 flex items-center justify-center shrink-0 text-brand-orange mt-0.5">
|
<div className="w-10 h-10 rounded-lg bg-brand-orange/10 flex items-center justify-center shrink-0 text-brand-orange mt-0.5">
|
||||||
{typeof cap.icon === 'object' ? cap.icon : <cap.icon className="w-5 h-5" />}
|
{cap.icon}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-bold text-neutral-900 dark:text-white mb-1">
|
<h3 className="font-bold text-neutral-900 dark:text-white mb-1">
|
||||||
@@ -215,7 +215,7 @@ export default function FeaturesPage() {
|
|||||||
>
|
>
|
||||||
<div className="grid md:grid-cols-2 gap-10 items-center">
|
<div className="grid md:grid-cols-2 gap-10 items-center">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-3xl font-bold text-neutral-900 dark:text-white mb-4">
|
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-4">
|
||||||
Content that <span className="gradient-text">performs</span>
|
Content that <span className="gradient-text">performs</span>
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-neutral-600 dark:text-neutral-400 leading-relaxed mb-6">
|
<p className="text-neutral-600 dark:text-neutral-400 leading-relaxed mb-6">
|
||||||
@@ -285,7 +285,7 @@ export default function FeaturesPage() {
|
|||||||
className="mb-28"
|
className="mb-28"
|
||||||
>
|
>
|
||||||
<div className="text-center mb-12">
|
<div className="text-center mb-12">
|
||||||
<h2 className="text-3xl md:text-4xl font-bold text-neutral-900 dark:text-white mb-4">
|
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-4">
|
||||||
Built for trust
|
Built for trust
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-lg text-neutral-600 dark:text-neutral-400 max-w-2xl mx-auto">
|
<p className="text-lg text-neutral-600 dark:text-neutral-400 max-w-2xl mx-auto">
|
||||||
@@ -341,7 +341,7 @@ export default function FeaturesPage() {
|
|||||||
className="mb-28"
|
className="mb-28"
|
||||||
>
|
>
|
||||||
<div className="text-center mb-14">
|
<div className="text-center mb-14">
|
||||||
<h2 className="text-3xl md:text-4xl font-bold text-neutral-900 dark:text-white mb-4">
|
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-4">
|
||||||
Up and running in <span className="gradient-text">3 minutes</span>
|
Up and running in <span className="gradient-text">3 minutes</span>
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-lg text-neutral-600 dark:text-neutral-400 max-w-2xl mx-auto">
|
<p className="text-lg text-neutral-600 dark:text-neutral-400 max-w-2xl mx-auto">
|
||||||
@@ -390,7 +390,7 @@ export default function FeaturesPage() {
|
|||||||
transition={{ duration: 0.5 }}
|
transition={{ duration: 0.5 }}
|
||||||
className="text-center mb-20"
|
className="text-center mb-20"
|
||||||
>
|
>
|
||||||
<h2 className="text-3xl md:text-4xl font-bold text-neutral-900 dark:text-white mb-4">
|
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-4">
|
||||||
Ready to see it in action?
|
Ready to see it in action?
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-neutral-600 dark:text-neutral-400 mb-8 max-w-lg mx-auto">
|
<p className="text-neutral-600 dark:text-neutral-400 mb-8 max-w-lg mx-auto">
|
||||||
|
|||||||
@@ -33,8 +33,8 @@ export default function InstallationPage() {
|
|||||||
<h2 className="text-2xl font-bold mb-8 text-neutral-900 dark:text-white">Add the snippet</h2>
|
<h2 className="text-2xl font-bold mb-8 text-neutral-900 dark:text-white">Add the snippet</h2>
|
||||||
<p className="text-neutral-500 mb-8">Just add this snippet to your <head> tag in your layout or index file.</p>
|
<p className="text-neutral-500 mb-8">Just add this snippet to your <head> tag in your layout or index file.</p>
|
||||||
|
|
||||||
<div className="max-w-2xl mx-auto bg-[#1e1e1e] rounded-xl overflow-hidden shadow-2xl text-left border border-neutral-800">
|
<div className="max-w-2xl mx-auto bg-neutral-900 rounded-xl overflow-hidden shadow-2xl text-left border border-neutral-800">
|
||||||
<div className="flex items-center px-4 py-3 bg-[#252526] border-b border-neutral-800">
|
<div className="flex items-center px-4 py-3 bg-neutral-800 border-b border-neutral-800">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<div className="w-3 h-3 rounded-full bg-red-500/20" />
|
<div className="w-3 h-3 rounded-full bg-red-500/20" />
|
||||||
<div className="w-3 h-3 rounded-full bg-yellow-500/20" />
|
<div className="w-3 h-3 rounded-full bg-yellow-500/20" />
|
||||||
@@ -63,8 +63,8 @@ export default function InstallationPage() {
|
|||||||
<p className="text-neutral-500 mb-6 max-w-xl mx-auto">
|
<p className="text-neutral-500 mb-6 max-w-xl mx-auto">
|
||||||
Track custom events (e.g. signup, purchase) with <code className="px-1.5 py-0.5 rounded bg-neutral-200 dark:bg-neutral-700 text-sm font-mono">pulse.track('event_name')</code>. Use letters, numbers, and underscores only. Define goals in your site Settings → Goals & Events to see counts in the dashboard.
|
Track custom events (e.g. signup, purchase) with <code className="px-1.5 py-0.5 rounded bg-neutral-200 dark:bg-neutral-700 text-sm font-mono">pulse.track('event_name')</code>. Use letters, numbers, and underscores only. Define goals in your site Settings → Goals & Events to see counts in the dashboard.
|
||||||
</p>
|
</p>
|
||||||
<div className="max-w-2xl mx-auto bg-[#1e1e1e] rounded-xl overflow-hidden shadow-2xl text-left border border-neutral-800">
|
<div className="max-w-2xl mx-auto bg-neutral-900 rounded-xl overflow-hidden shadow-2xl text-left border border-neutral-800">
|
||||||
<div className="flex items-center px-4 py-3 bg-[#252526] border-b border-neutral-800">
|
<div className="flex items-center px-4 py-3 bg-neutral-800 border-b border-neutral-800">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<div className="w-3 h-3 rounded-full bg-red-500/20" />
|
<div className="w-3 h-3 rounded-full bg-red-500/20" />
|
||||||
<div className="w-3 h-3 rounded-full bg-yellow-500/20" />
|
<div className="w-3 h-3 rounded-full bg-yellow-500/20" />
|
||||||
|
|||||||
19
app/integrations/layout.tsx
Normal file
19
app/integrations/layout.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Integrations | Pulse',
|
||||||
|
description: 'Add Pulse analytics to Next.js, React, Vue, WordPress, and more in under a minute.',
|
||||||
|
openGraph: {
|
||||||
|
title: 'Integrations | Pulse',
|
||||||
|
description: 'Add Pulse analytics to Next.js, React, Vue, WordPress, and more in under a minute.',
|
||||||
|
siteName: 'Pulse by Ciphera',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function IntegrationsLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return children
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ export default function NextJsIntegrationPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-grow w-full max-w-4xl mx-auto px-4 pt-12 pb-10 z-10">
|
<div className="flex-grow w-full max-w-4xl mx-auto px-4 pt-20 pb-10 z-10">
|
||||||
<Link
|
<Link
|
||||||
href="/integrations"
|
href="/integrations"
|
||||||
className="inline-flex items-center text-sm text-neutral-500 hover:text-brand-orange mb-8 transition-colors"
|
className="inline-flex items-center text-sm text-neutral-500 hover:text-brand-orange mb-8 transition-colors"
|
||||||
@@ -31,7 +31,7 @@ export default function NextJsIntegrationPage() {
|
|||||||
<path d="M64 0C28.7 0 0 28.7 0 64s28.7 64 64 64 64-28.7 64-64S99.3 0 64 0zm27.6 93.9c-.8.9-2.2 1-3.1.2L42.8 52.8V88c0 1.3-1.1 2.3-2.3 2.3h-7.4c-1.3 0-2.3-1.1-2.3-2.3V40c0-1.3 1.1-2.3 2.3-2.3h7.4c1 0 1.9.6 2.2 1.5l48.6 44.8V40c0-1.3 1.1-2.3 2.3-2.3h7.4c1.3 0 2.3 1.1 2.3 2.3v48c0 1.3-1.1 2.3-2.3 2.3h-6.8c-.9 0-1.7-.5-2.1-1.3z" />
|
<path d="M64 0C28.7 0 0 28.7 0 64s28.7 64 64 64 64-28.7 64-64S99.3 0 64 0zm27.6 93.9c-.8.9-2.2 1-3.1.2L42.8 52.8V88c0 1.3-1.1 2.3-2.3 2.3h-7.4c-1.3 0-2.3-1.1-2.3-2.3V40c0-1.3 1.1-2.3 2.3-2.3h7.4c1 0 1.9.6 2.2 1.5l48.6 44.8V40c0-1.3 1.1-2.3 2.3-2.3h7.4c1.3 0 2.3 1.1 2.3 2.3v48c0 1.3-1.1 2.3-2.3 2.3h-6.8c-.9 0-1.7-.5-2.1-1.3z" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-3xl md:text-4xl font-bold text-neutral-900 dark:text-white">
|
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-neutral-900 dark:text-white">
|
||||||
Next.js Integration
|
Next.js Integration
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
@@ -48,8 +48,8 @@ export default function NextJsIntegrationPage() {
|
|||||||
Add the script to your root layout file (usually <code>app/layout.tsx</code> or <code>app/layout.js</code>).
|
Add the script to your root layout file (usually <code>app/layout.tsx</code> or <code>app/layout.js</code>).
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="bg-[#1e1e1e] rounded-xl overflow-hidden border border-neutral-800 my-6">
|
<div className="bg-neutral-900 rounded-xl overflow-hidden border border-neutral-800 my-6">
|
||||||
<div className="flex items-center px-4 py-2 bg-[#252526] border-b border-neutral-800">
|
<div className="flex items-center px-4 py-2 bg-neutral-800 border-b border-neutral-800">
|
||||||
<span className="text-xs text-neutral-400 font-mono">app/layout.tsx</span>
|
<span className="text-xs text-neutral-400 font-mono">app/layout.tsx</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 overflow-x-auto">
|
<div className="p-4 overflow-x-auto">
|
||||||
@@ -84,8 +84,8 @@ export default function RootLayout({
|
|||||||
If you are using the older Pages Router, add the script to your custom <code>_app.tsx</code> or <code>_document.tsx</code>.
|
If you are using the older Pages Router, add the script to your custom <code>_app.tsx</code> or <code>_document.tsx</code>.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="bg-[#1e1e1e] rounded-xl overflow-hidden border border-neutral-800 my-6">
|
<div className="bg-neutral-900 rounded-xl overflow-hidden border border-neutral-800 my-6">
|
||||||
<div className="flex items-center px-4 py-2 bg-[#252526] border-b border-neutral-800">
|
<div className="flex items-center px-4 py-2 bg-neutral-800 border-b border-neutral-800">
|
||||||
<span className="text-xs text-neutral-400 font-mono">pages/_app.tsx</span>
|
<span className="text-xs text-neutral-400 font-mono">pages/_app.tsx</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 overflow-x-auto">
|
<div className="p-4 overflow-x-auto">
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ export default function IntegrationsPage() {
|
|||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<div className="absolute inset-y-0 right-0 flex items-center pr-4 pointer-events-none">
|
<div className="absolute inset-y-0 right-0 flex items-center pr-4 pointer-events-none">
|
||||||
<kbd className="hidden sm:inline-flex items-center px-1.5 py-0.5 rounded text-[11px] font-mono font-medium bg-neutral-200/80 dark:bg-neutral-700/80 text-neutral-500 dark:text-neutral-400 border border-neutral-300 dark:border-neutral-600">
|
<kbd className="hidden sm:inline-flex items-center px-1.5 py-0.5 rounded text-xs font-mono font-medium bg-neutral-200/80 dark:bg-neutral-700/80 text-neutral-500 dark:text-neutral-400 border border-neutral-300 dark:border-neutral-600">
|
||||||
/
|
/
|
||||||
</kbd>
|
</kbd>
|
||||||
</div>
|
</div>
|
||||||
@@ -285,7 +285,7 @@ export default function IntegrationsPage() {
|
|||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
href={`/integrations/${integration.id}`}
|
href={`/integrations/${integration.id}`}
|
||||||
className="group relative p-8 bg-white/50 dark:bg-neutral-900/50 backdrop-blur-sm border border-neutral-200 dark:border-neutral-800 rounded-2xl hover:border-brand-orange/50 dark:hover:border-brand-orange/50 transition-all duration-300 hover:-translate-y-1 hover:shadow-xl block h-full focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-offset-2"
|
className="group relative p-6 bg-white/50 dark:bg-neutral-900/50 backdrop-blur-sm border border-neutral-200 dark:border-neutral-800 rounded-2xl hover:border-brand-orange/50 dark:hover:border-brand-orange/50 transition-all duration-300 hover:-translate-y-1 hover:shadow-xl block h-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:ring-offset-2"
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between mb-6">
|
<div className="flex items-start justify-between mb-6">
|
||||||
<div className="p-3 bg-neutral-100 dark:bg-neutral-800 rounded-xl group-hover:scale-110 transition-transform duration-300">
|
<div className="p-3 bg-neutral-100 dark:bg-neutral-800 rounded-xl group-hover:scale-110 transition-transform duration-300">
|
||||||
@@ -336,7 +336,7 @@ export default function IntegrationsPage() {
|
|||||||
</p>
|
</p>
|
||||||
<a
|
<a
|
||||||
href="mailto:support@ciphera.net"
|
href="mailto:support@ciphera.net"
|
||||||
className="inline-flex items-center gap-2 px-5 py-2.5 bg-brand-orange text-white font-medium rounded-lg hover:bg-brand-orange/90 transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-offset-2"
|
className="inline-flex items-center gap-2 px-5 py-2.5 bg-brand-orange text-white font-medium rounded-lg hover:bg-brand-orange/90 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:ring-offset-2"
|
||||||
>
|
>
|
||||||
Request Integration
|
Request Integration
|
||||||
</a>
|
</a>
|
||||||
@@ -351,7 +351,7 @@ export default function IntegrationsPage() {
|
|||||||
whileInView={{ opacity: 1, y: 0 }}
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
viewport={{ once: true }}
|
viewport={{ once: true }}
|
||||||
transition={{ duration: 0.5 }}
|
transition={{ duration: 0.5 }}
|
||||||
className="max-w-md mx-auto mt-12 p-8 border border-dashed border-neutral-300 dark:border-neutral-700 rounded-2xl flex flex-col items-center justify-center text-center"
|
className="max-w-md mx-auto mt-12 p-6 border border-dashed border-neutral-300 dark:border-neutral-700 rounded-2xl flex flex-col items-center justify-center text-center"
|
||||||
>
|
>
|
||||||
<h3 className="text-xl font-bold text-neutral-900 dark:text-white mb-2">
|
<h3 className="text-xl font-bold text-neutral-900 dark:text-white mb-2">
|
||||||
Missing something?
|
Missing something?
|
||||||
@@ -361,7 +361,7 @@ export default function IntegrationsPage() {
|
|||||||
</p>
|
</p>
|
||||||
<a
|
<a
|
||||||
href="mailto:support@ciphera.net"
|
href="mailto:support@ciphera.net"
|
||||||
className="text-sm font-medium text-brand-orange hover:underline focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded"
|
className="text-sm font-medium text-brand-orange hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:rounded"
|
||||||
>
|
>
|
||||||
Request Integration
|
Request Integration
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export default function ReactIntegrationPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-grow w-full max-w-4xl mx-auto px-4 pt-12 pb-10 z-10">
|
<div className="flex-grow w-full max-w-4xl mx-auto px-4 pt-20 pb-10 z-10">
|
||||||
<Link
|
<Link
|
||||||
href="/integrations"
|
href="/integrations"
|
||||||
className="inline-flex items-center text-sm text-neutral-500 hover:text-brand-orange mb-8 transition-colors"
|
className="inline-flex items-center text-sm text-neutral-500 hover:text-brand-orange mb-8 transition-colors"
|
||||||
@@ -32,7 +32,7 @@ export default function ReactIntegrationPage() {
|
|||||||
<circle cx="64" cy="64" r="10.6" />
|
<circle cx="64" cy="64" r="10.6" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-3xl md:text-4xl font-bold text-neutral-900 dark:text-white">
|
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-neutral-900 dark:text-white">
|
||||||
React Integration
|
React Integration
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
@@ -49,8 +49,8 @@ export default function ReactIntegrationPage() {
|
|||||||
The simplest way is to add the script tag directly to the <code><head></code> of your <code>index.html</code> file.
|
The simplest way is to add the script tag directly to the <code><head></code> of your <code>index.html</code> file.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="bg-[#1e1e1e] rounded-xl overflow-hidden border border-neutral-800 my-6">
|
<div className="bg-neutral-900 rounded-xl overflow-hidden border border-neutral-800 my-6">
|
||||||
<div className="flex items-center px-4 py-2 bg-[#252526] border-b border-neutral-800">
|
<div className="flex items-center px-4 py-2 bg-neutral-800 border-b border-neutral-800">
|
||||||
<span className="text-xs text-neutral-400 font-mono">public/index.html</span>
|
<span className="text-xs text-neutral-400 font-mono">public/index.html</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 overflow-x-auto">
|
<div className="p-4 overflow-x-auto">
|
||||||
@@ -83,8 +83,8 @@ export default function ReactIntegrationPage() {
|
|||||||
If you need to load the script dynamically (e.g., only in production), you can use a <code>useEffect</code> hook in your main App component.
|
If you need to load the script dynamically (e.g., only in production), you can use a <code>useEffect</code> hook in your main App component.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="bg-[#1e1e1e] rounded-xl overflow-hidden border border-neutral-800 my-6">
|
<div className="bg-neutral-900 rounded-xl overflow-hidden border border-neutral-800 my-6">
|
||||||
<div className="flex items-center px-4 py-2 bg-[#252526] border-b border-neutral-800">
|
<div className="flex items-center px-4 py-2 bg-neutral-800 border-b border-neutral-800">
|
||||||
<span className="text-xs text-neutral-400 font-mono">src/App.tsx</span>
|
<span className="text-xs text-neutral-400 font-mono">src/App.tsx</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 overflow-x-auto">
|
<div className="p-4 overflow-x-auto">
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export default function VueIntegrationPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-grow w-full max-w-4xl mx-auto px-4 pt-12 pb-10 z-10">
|
<div className="flex-grow w-full max-w-4xl mx-auto px-4 pt-20 pb-10 z-10">
|
||||||
<Link
|
<Link
|
||||||
href="/integrations"
|
href="/integrations"
|
||||||
className="inline-flex items-center text-sm text-neutral-500 hover:text-brand-orange mb-8 transition-colors"
|
className="inline-flex items-center text-sm text-neutral-500 hover:text-brand-orange mb-8 transition-colors"
|
||||||
@@ -32,7 +32,7 @@ export default function VueIntegrationPage() {
|
|||||||
<path d="M64 24.6H39L64 67.4l25-42.8H64z" fill="#35495E" />
|
<path d="M64 24.6H39L64 67.4l25-42.8H64z" fill="#35495E" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-3xl md:text-4xl font-bold text-neutral-900 dark:text-white">
|
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-neutral-900 dark:text-white">
|
||||||
Vue.js Integration
|
Vue.js Integration
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
@@ -49,8 +49,8 @@ export default function VueIntegrationPage() {
|
|||||||
Add the script tag to the <code><head></code> section of your <code>index.html</code> file. This works for both Vue 2 and Vue 3 projects created with Vue CLI or Vite.
|
Add the script tag to the <code><head></code> section of your <code>index.html</code> file. This works for both Vue 2 and Vue 3 projects created with Vue CLI or Vite.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="bg-[#1e1e1e] rounded-xl overflow-hidden border border-neutral-800 my-6">
|
<div className="bg-neutral-900 rounded-xl overflow-hidden border border-neutral-800 my-6">
|
||||||
<div className="flex items-center px-4 py-2 bg-[#252526] border-b border-neutral-800">
|
<div className="flex items-center px-4 py-2 bg-neutral-800 border-b border-neutral-800">
|
||||||
<span className="text-xs text-neutral-400 font-mono">index.html</span>
|
<span className="text-xs text-neutral-400 font-mono">index.html</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 overflow-x-auto">
|
<div className="p-4 overflow-x-auto">
|
||||||
@@ -84,8 +84,8 @@ export default function VueIntegrationPage() {
|
|||||||
For Nuxt.js applications, you should add the script to your <code>nuxt.config.js</code> or <code>nuxt.config.ts</code> file.
|
For Nuxt.js applications, you should add the script to your <code>nuxt.config.js</code> or <code>nuxt.config.ts</code> file.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="bg-[#1e1e1e] rounded-xl overflow-hidden border border-neutral-800 my-6">
|
<div className="bg-neutral-900 rounded-xl overflow-hidden border border-neutral-800 my-6">
|
||||||
<div className="flex items-center px-4 py-2 bg-[#252526] border-b border-neutral-800">
|
<div className="flex items-center px-4 py-2 bg-neutral-800 border-b border-neutral-800">
|
||||||
<span className="text-xs text-neutral-400 font-mono">nuxt.config.ts</span>
|
<span className="text-xs text-neutral-400 font-mono">nuxt.config.ts</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 overflow-x-auto">
|
<div className="p-4 overflow-x-auto">
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export default function WordPressIntegrationPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-grow w-full max-w-4xl mx-auto px-4 pt-12 pb-10 z-10">
|
<div className="flex-grow w-full max-w-4xl mx-auto px-4 pt-20 pb-10 z-10">
|
||||||
<Link
|
<Link
|
||||||
href="/integrations"
|
href="/integrations"
|
||||||
className="inline-flex items-center text-sm text-neutral-500 hover:text-brand-orange mb-8 transition-colors"
|
className="inline-flex items-center text-sm text-neutral-500 hover:text-brand-orange mb-8 transition-colors"
|
||||||
@@ -31,7 +31,7 @@ export default function WordPressIntegrationPage() {
|
|||||||
<path d="M116.6 64c0-19.2-10.4-36-26-45.2l28.6 78.4c-1 3.2-2.2 6.2-3.6 9.2-11.4 12.4-27.8 20.2-46 20.2-6.2 0-12.2-.8-17.8-2.4l26.2-76.4c1.2.2 2.4.4 3.6.4 5.4 0 13.8-.8 13.8-.8 2.8-.2 3.2 4 .4 4.2 0 0-2.8.2-6 .4l19 56.6 5.4-18c2.4-7.4 4.2-12.8 4.2-17.4 0-6-2.2-10.2-7.6-12.6-2.8-1.2-2.2-5.4 1.4-5.4h4.4zM64 121.2c-15.8 0-30.2-6.4-40.8-16.8L46.6 36.8c-2.8-.2-5.8-.4-5.8-.4-2.8-.2-2.4-4.4.4-4.2 0 0 8.4.8 13.6.8 5.4 0 13.6-.8 13.6-.8 2.8-.2 3.2 4 .4 4.2 0 0-2.8.2-5.8.4l18.2 54.4 10.6-31.8L64 121.2zM11.4 64c0 17 8.2 32.2 20.8 41.8L18.8 66.8c-.8-3.4-1.2-6.6-1.2-9.2 0-6.8 2.6-13 6.2-17.8C15.6 47.4 11.4 55.2 11.4 64zM64 6.8c16.2 0 30.8 6.8 41.4 17.6-1.4-.2-2.8-.2-4.2-.2-7.8 0-14.2 1.4-14.2 1.4-2.8.6-2.2 4.8.6 4.2 0 0 5-1 10.6-1 2.2 0 4.6.2 6.6.4L88.2 53 71.4 6.8h-7.4z" />
|
<path d="M116.6 64c0-19.2-10.4-36-26-45.2l28.6 78.4c-1 3.2-2.2 6.2-3.6 9.2-11.4 12.4-27.8 20.2-46 20.2-6.2 0-12.2-.8-17.8-2.4l26.2-76.4c1.2.2 2.4.4 3.6.4 5.4 0 13.8-.8 13.8-.8 2.8-.2 3.2 4 .4 4.2 0 0-2.8.2-6 .4l19 56.6 5.4-18c2.4-7.4 4.2-12.8 4.2-17.4 0-6-2.2-10.2-7.6-12.6-2.8-1.2-2.2-5.4 1.4-5.4h4.4zM64 121.2c-15.8 0-30.2-6.4-40.8-16.8L46.6 36.8c-2.8-.2-5.8-.4-5.8-.4-2.8-.2-2.4-4.4.4-4.2 0 0 8.4.8 13.6.8 5.4 0 13.6-.8 13.6-.8 2.8-.2 3.2 4 .4 4.2 0 0-2.8.2-5.8.4l18.2 54.4 10.6-31.8L64 121.2zM11.4 64c0 17 8.2 32.2 20.8 41.8L18.8 66.8c-.8-3.4-1.2-6.6-1.2-9.2 0-6.8 2.6-13 6.2-17.8C15.6 47.4 11.4 55.2 11.4 64zM64 6.8c16.2 0 30.8 6.8 41.4 17.6-1.4-.2-2.8-.2-4.2-.2-7.8 0-14.2 1.4-14.2 1.4-2.8.6-2.2 4.8.6 4.2 0 0 5-1 10.6-1 2.2 0 4.6.2 6.6.4L88.2 53 71.4 6.8h-7.4z" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-3xl md:text-4xl font-bold text-neutral-900 dark:text-white">
|
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-neutral-900 dark:text-white">
|
||||||
WordPress Integration
|
WordPress Integration
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
@@ -50,8 +50,8 @@ export default function WordPressIntegrationPage() {
|
|||||||
<li>Paste the following code snippet:</li>
|
<li>Paste the following code snippet:</li>
|
||||||
</ol>
|
</ol>
|
||||||
|
|
||||||
<div className="bg-[#1e1e1e] rounded-xl overflow-hidden border border-neutral-800 my-6">
|
<div className="bg-neutral-900 rounded-xl overflow-hidden border border-neutral-800 my-6">
|
||||||
<div className="flex items-center px-4 py-2 bg-[#252526] border-b border-neutral-800">
|
<div className="flex items-center px-4 py-2 bg-neutral-800 border-b border-neutral-800">
|
||||||
<span className="text-xs text-neutral-400 font-mono">Header Script</span>
|
<span className="text-xs text-neutral-400 font-mono">Header Script</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 overflow-x-auto">
|
<div className="p-4 overflow-x-auto">
|
||||||
|
|||||||
@@ -2,28 +2,76 @@
|
|||||||
|
|
||||||
import { OfflineBanner } from '@/components/OfflineBanner'
|
import { OfflineBanner } from '@/components/OfflineBanner'
|
||||||
import { Footer } from '@/components/Footer'
|
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 NotificationCenter from '@/components/notifications/NotificationCenter'
|
||||||
import { useAuth } from '@/lib/auth/context'
|
import { useAuth } from '@/lib/auth/context'
|
||||||
import { useOnlineStatus } from '@/lib/hooks/useOnlineStatus'
|
import { useOnlineStatus } from '@/lib/hooks/useOnlineStatus'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { getUserOrganizations, switchContext } from '@/lib/api/organization'
|
import { logger } from '@/lib/utils/logger'
|
||||||
|
import { getUserOrganizations, switchContext, type OrganizationMember } from '@/lib/api/organization'
|
||||||
import { setSessionAction } from '@/app/actions/auth'
|
import { setSessionAction } from '@/app/actions/auth'
|
||||||
|
import { LoadingOverlay } from '@ciphera-net/ui'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { SettingsModalProvider, useSettingsModal } from '@/lib/settings-modal-context'
|
||||||
|
import SettingsModalWrapper from '@/components/settings/SettingsModalWrapper'
|
||||||
|
|
||||||
export default function LayoutContent({ children }: { children: React.ReactNode }) {
|
const ORG_SWITCH_KEY = 'pulse_switching_org'
|
||||||
|
|
||||||
|
// * 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 auth = useAuth()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const isOnline = useOnlineStatus()
|
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'
|
||||||
|
})
|
||||||
|
|
||||||
|
// * Clear the switching flag once the page has settled after reload
|
||||||
|
useEffect(() => {
|
||||||
|
if (isSwitchingOrg) {
|
||||||
|
sessionStorage.removeItem(ORG_SWITCH_KEY)
|
||||||
|
const timer = setTimeout(() => setIsSwitchingOrg(false), 600)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [isSwitchingOrg])
|
||||||
|
|
||||||
// * Fetch organizations for the header organization switcher
|
// * Fetch organizations for the header organization switcher
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (auth.user) {
|
if (auth.user) {
|
||||||
getUserOrganizations()
|
getUserOrganizations()
|
||||||
.then((organizations) => setOrgs(Array.isArray(organizations) ? organizations : []))
|
.then((organizations) => setOrgs(Array.isArray(organizations) ? organizations : []))
|
||||||
.catch(err => console.error('Failed to fetch orgs for header', err))
|
.catch(err => logger.error('Failed to fetch orgs for header', err))
|
||||||
}
|
}
|
||||||
}, [auth.user])
|
}, [auth.user])
|
||||||
|
|
||||||
@@ -32,27 +80,32 @@ export default function LayoutContent({ children }: { children: React.ReactNode
|
|||||||
try {
|
try {
|
||||||
const { access_token } = await switchContext(orgId)
|
const { access_token } = await switchContext(orgId)
|
||||||
await setSessionAction(access_token)
|
await setSessionAction(access_token)
|
||||||
|
sessionStorage.setItem(ORG_SWITCH_KEY, 'true')
|
||||||
window.location.reload()
|
window.location.reload()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to switch organization', err)
|
logger.error('Failed to switch organization', err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCreateOrganization = () => {
|
const handleCreateOrganization = () => {
|
||||||
router.push('/onboarding')
|
router.push('/onboarding')
|
||||||
}
|
}
|
||||||
|
|
||||||
const showOfflineBar = Boolean(auth.user && !isOnline);
|
const showOfflineBar = Boolean(auth.user && !isOnline);
|
||||||
const barHeightRem = 2.5;
|
const barHeightRem = 2.5;
|
||||||
const headerHeightRem = 6;
|
const headerHeightRem = 6;
|
||||||
const mainTopPaddingRem = barHeightRem + headerHeightRem;
|
const mainTopPaddingRem = barHeightRem + headerHeightRem;
|
||||||
|
|
||||||
|
if (isSwitchingOrg) {
|
||||||
|
return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Pulse" portal={false} />
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{auth.user && <OfflineBanner isOnline={isOnline} />}
|
{auth.user && <OfflineBanner isOnline={isOnline} />}
|
||||||
<Header
|
<Header
|
||||||
auth={auth}
|
auth={auth}
|
||||||
LinkComponent={Link}
|
LinkComponent={Link}
|
||||||
logoSrc="/pulse_icon_no_margins.png"
|
logoSrc="/pulse_icon_no_margins.png"
|
||||||
appName="Pulse"
|
appName="Pulse"
|
||||||
orgs={orgs}
|
orgs={orgs}
|
||||||
@@ -65,6 +118,9 @@ export default function LayoutContent({ children }: { children: React.ReactNode
|
|||||||
showPricing={true}
|
showPricing={true}
|
||||||
topOffset={showOfflineBar ? `${barHeightRem}rem` : undefined}
|
topOffset={showOfflineBar ? `${barHeightRem}rem` : undefined}
|
||||||
rightSideActions={auth.user ? <NotificationCenter /> : null}
|
rightSideActions={auth.user ? <NotificationCenter /> : null}
|
||||||
|
apps={CIPHERA_APPS}
|
||||||
|
currentAppId="pulse"
|
||||||
|
onOpenSettings={openSettings}
|
||||||
customNavItems={
|
customNavItems={
|
||||||
<>
|
<>
|
||||||
{!auth.user && (
|
{!auth.user && (
|
||||||
@@ -84,11 +140,20 @@ export default function LayoutContent({ children }: { children: React.ReactNode
|
|||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
<Footer
|
<Footer
|
||||||
LinkComponent={Link}
|
LinkComponent={Link}
|
||||||
appName="Pulse"
|
appName="Pulse"
|
||||||
isAuthenticated={!!auth.user}
|
isAuthenticated={!!auth.user}
|
||||||
/>
|
/>
|
||||||
|
<SettingsModalWrapper />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function LayoutContent({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<SettingsModalProvider>
|
||||||
|
<LayoutInner>{children}</LayoutInner>
|
||||||
|
</SettingsModalProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ThemeProviders, Toaster } from '@ciphera-net/ui'
|
import { ThemeProviders, Toaster } from '@ciphera-net/ui'
|
||||||
import { AuthProvider } from '@/lib/auth/context'
|
import { AuthProvider } from '@/lib/auth/context'
|
||||||
|
import SWRProvider from '@/components/SWRProvider'
|
||||||
import type { Metadata, Viewport } from 'next'
|
import type { Metadata, Viewport } from 'next'
|
||||||
import { Plus_Jakarta_Sans } from 'next/font/google'
|
import { Plus_Jakarta_Sans } from 'next/font/google'
|
||||||
import LayoutContent from './layout-content'
|
import LayoutContent from './layout-content'
|
||||||
@@ -46,12 +47,14 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang="en" className={plusJakartaSans.variable} suppressHydrationWarning>
|
<html lang="en" className={plusJakartaSans.variable} suppressHydrationWarning>
|
||||||
<body className="antialiased min-h-screen flex flex-col bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-50">
|
<body className="antialiased min-h-screen flex flex-col bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-50">
|
||||||
<ThemeProviders>
|
<SWRProvider>
|
||||||
<AuthProvider>
|
<ThemeProviders>
|
||||||
<LayoutContent>{children}</LayoutContent>
|
<AuthProvider>
|
||||||
<Toaster />
|
<LayoutContent>{children}</LayoutContent>
|
||||||
</AuthProvider>
|
<Toaster />
|
||||||
</ThemeProviders>
|
</AuthProvider>
|
||||||
|
</ThemeProviders>
|
||||||
|
</SWRProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { initiateOAuthFlow } from '@/lib/api/oauth'
|
import { initiateOAuthFlow } from '@/lib/api/oauth'
|
||||||
|
import { LoadingOverlay } from '@ciphera-net/ui'
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -9,5 +10,10 @@ export default function LoginPage() {
|
|||||||
initiateOAuthFlow()
|
initiateOAuthFlow()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return null
|
return (
|
||||||
|
<LoadingOverlay
|
||||||
|
logoSrc="/pulse_icon_no_margins.png"
|
||||||
|
title="Redirecting to log in..."
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
13
app/notifications/error.tsx
Normal file
13
app/notifications/error.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import ErrorDisplay from '@/components/ErrorDisplay'
|
||||||
|
|
||||||
|
export default function NotificationsError({ reset }: { error: Error; reset: () => void }) {
|
||||||
|
return (
|
||||||
|
<ErrorDisplay
|
||||||
|
title="Notifications failed to load"
|
||||||
|
message="We couldn't load your notifications. Please try again."
|
||||||
|
onRetry={reset}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
15
app/notifications/layout.tsx
Normal file
15
app/notifications/layout.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Notifications | Pulse',
|
||||||
|
description: 'View your alerts and activity updates.',
|
||||||
|
robots: { index: false, follow: false },
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NotificationsLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return children
|
||||||
|
}
|
||||||
@@ -15,7 +15,8 @@ import {
|
|||||||
} from '@/lib/api/notifications'
|
} from '@/lib/api/notifications'
|
||||||
import { getAuthErrorMessage } from '@ciphera-net/ui'
|
import { getAuthErrorMessage } from '@ciphera-net/ui'
|
||||||
import { formatTimeAgo, getTypeIcon } from '@/lib/utils/notifications'
|
import { formatTimeAgo, getTypeIcon } from '@/lib/utils/notifications'
|
||||||
import { Button, ArrowLeftIcon, Spinner } from '@ciphera-net/ui'
|
import { Button, ArrowLeftIcon } from '@ciphera-net/ui'
|
||||||
|
import { NotificationsListSkeleton, useMinimumLoading } from '@/components/skeletons'
|
||||||
import { toast } from '@ciphera-net/ui'
|
import { toast } from '@ciphera-net/ui'
|
||||||
|
|
||||||
const PAGE_SIZE = 50
|
const PAGE_SIZE = 50
|
||||||
@@ -29,6 +30,7 @@ export default function NotificationsPage() {
|
|||||||
const [offset, setOffset] = useState(0)
|
const [offset, setOffset] = useState(0)
|
||||||
const [hasMore, setHasMore] = useState(true)
|
const [hasMore, setHasMore] = useState(true)
|
||||||
const [loadingMore, setLoadingMore] = useState(false)
|
const [loadingMore, setLoadingMore] = useState(false)
|
||||||
|
const showSkeleton = useMinimumLoading(loading)
|
||||||
|
|
||||||
const fetchPage = async (pageOffset: number, append: boolean) => {
|
const fetchPage = async (pageOffset: number, append: boolean) => {
|
||||||
if (append) setLoadingMore(true)
|
if (append) setLoadingMore(true)
|
||||||
@@ -90,7 +92,7 @@ export default function NotificationsPage() {
|
|||||||
|
|
||||||
if (!user?.org_id) {
|
if (!user?.org_id) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen pt-24 pb-12 px-4 sm:px-6">
|
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
|
||||||
<div className="max-w-2xl mx-auto text-center py-12">
|
<div className="max-w-2xl mx-auto text-center py-12">
|
||||||
<p className="text-neutral-500">Switch to an organization to view notifications.</p>
|
<p className="text-neutral-500">Switch to an organization to view notifications.</p>
|
||||||
<Link href="/welcome" className="text-brand-orange hover:underline mt-4 inline-block">
|
<Link href="/welcome" className="text-brand-orange hover:underline mt-4 inline-block">
|
||||||
@@ -102,7 +104,7 @@ export default function NotificationsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen pt-24 pb-12 px-4 sm:px-6">
|
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
|
||||||
<div className="max-w-2xl mx-auto">
|
<div className="max-w-2xl mx-auto">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<Link
|
<Link
|
||||||
@@ -127,16 +129,14 @@ export default function NotificationsPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{loading ? (
|
{showSkeleton ? (
|
||||||
<div className="flex justify-center py-12">
|
<NotificationsListSkeleton />
|
||||||
<Spinner />
|
|
||||||
</div>
|
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<div className="p-8 text-center text-red-500 bg-red-50 dark:bg-red-900/10 rounded-2xl border border-red-200 dark:border-red-800">
|
<div className="p-6 text-center text-red-500 bg-red-50 dark:bg-red-900/10 rounded-2xl border border-red-200 dark:border-red-800">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
) : notifications.length === 0 ? (
|
) : notifications.length === 0 ? (
|
||||||
<div className="p-8 text-center text-neutral-500 dark:text-neutral-400 rounded-2xl border border-neutral-200 dark:border-neutral-800">
|
<div className="p-6 text-center text-neutral-500 dark:text-neutral-400 rounded-2xl border border-neutral-200 dark:border-neutral-800">
|
||||||
<p>No notifications yet</p>
|
<p>No notifications yet</p>
|
||||||
<p className="text-sm mt-2">
|
<p className="text-sm mt-2">
|
||||||
Manage which notifications you receive in{' '}
|
Manage which notifications you receive in{' '}
|
||||||
|
|||||||
13
app/org-settings/error.tsx
Normal file
13
app/org-settings/error.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import ErrorDisplay from '@/components/ErrorDisplay'
|
||||||
|
|
||||||
|
export default function OrgSettingsError({ reset }: { error: Error; reset: () => void }) {
|
||||||
|
return (
|
||||||
|
<ErrorDisplay
|
||||||
|
title="Organization settings failed to load"
|
||||||
|
message="We couldn't load your organization settings. Please try again."
|
||||||
|
onRetry={reset}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Suspense } from 'react'
|
import { Suspense } from 'react'
|
||||||
import OrganizationSettings from '@/components/settings/OrganizationSettings'
|
import OrganizationSettings from '@/components/settings/OrganizationSettings'
|
||||||
|
import { SettingsFormSkeleton } from '@/components/skeletons'
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: 'Organization Settings - Pulse',
|
title: 'Organization Settings - Pulse',
|
||||||
@@ -8,9 +9,19 @@ export const metadata = {
|
|||||||
|
|
||||||
export default function OrgSettingsPage() {
|
export default function OrgSettingsPage() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen pt-24 pb-12 px-4 sm:px-6">
|
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
|
||||||
<div className="max-w-4xl mx-auto">
|
<div>
|
||||||
<Suspense fallback={<div className="p-8 text-center text-neutral-500">Loading...</div>}>
|
<Suspense fallback={
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div>
|
||||||
|
<div className="h-8 w-56 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800 mb-2" />
|
||||||
|
<div className="h-4 w-80 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800" />
|
||||||
|
</div>
|
||||||
|
<div className="bg-white dark:bg-neutral-900 rounded-2xl border border-neutral-200 dark:border-neutral-800 p-6 md:p-8">
|
||||||
|
<SettingsFormSkeleton />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
<OrganizationSettings />
|
<OrganizationSettings />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
163
app/page.tsx
163
app/page.tsx
@@ -6,39 +6,50 @@ import { motion } from 'framer-motion'
|
|||||||
import { useAuth } from '@/lib/auth/context'
|
import { useAuth } from '@/lib/auth/context'
|
||||||
import { initiateOAuthFlow, initiateSignupFlow } from '@/lib/api/oauth'
|
import { initiateOAuthFlow, initiateSignupFlow } from '@/lib/api/oauth'
|
||||||
import { listSites, deleteSite, type Site } from '@/lib/api/sites'
|
import { listSites, deleteSite, type Site } from '@/lib/api/sites'
|
||||||
|
import { getStats } from '@/lib/api/stats'
|
||||||
|
import type { Stats } from '@/lib/api/stats'
|
||||||
import { getSubscription, type SubscriptionDetails } from '@/lib/api/billing'
|
import { getSubscription, type SubscriptionDetails } from '@/lib/api/billing'
|
||||||
import { LoadingOverlay } from '@ciphera-net/ui'
|
import { LoadingOverlay } from '@ciphera-net/ui'
|
||||||
import SiteList from '@/components/sites/SiteList'
|
import SiteList from '@/components/sites/SiteList'
|
||||||
import { Button } from '@ciphera-net/ui'
|
import { Button } from '@ciphera-net/ui'
|
||||||
|
import Image from 'next/image'
|
||||||
import { BarChartIcon, LockIcon, ZapIcon, CheckCircleIcon, XIcon, GlobeIcon } from '@ciphera-net/ui'
|
import { BarChartIcon, LockIcon, ZapIcon, CheckCircleIcon, XIcon, GlobeIcon } from '@ciphera-net/ui'
|
||||||
import { toast } from '@ciphera-net/ui'
|
import { toast } from '@ciphera-net/ui'
|
||||||
import { getAuthErrorMessage } from '@ciphera-net/ui'
|
import { getAuthErrorMessage } from '@ciphera-net/ui'
|
||||||
|
import { getSitesLimitForPlan } from '@/lib/plans'
|
||||||
|
|
||||||
function DashboardPreview() {
|
function DashboardPreview() {
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full max-w-7xl mx-auto mt-20 mb-32 h-[600px] flex items-center justify-center">
|
<div className="relative w-full max-w-7xl mx-auto mt-20 mb-32">
|
||||||
{/* * Glow behind the image */}
|
|
||||||
<div className="absolute inset-0 bg-brand-orange/20 blur-[100px] -z-10 rounded-full opacity-50" />
|
<div className="absolute inset-0 bg-brand-orange/20 blur-[100px] -z-10 rounded-full opacity-50" />
|
||||||
|
|
||||||
{/* * Static Container */}
|
<motion.div
|
||||||
<div
|
initial={{ opacity: 0, y: 40 }}
|
||||||
className="relative w-full h-full rounded-xl border border-neutral-200/50 dark:border-neutral-800/50 bg-neutral-900/50 backdrop-blur-sm shadow-2xl overflow-hidden"
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.7, delay: 0.4 }}
|
||||||
|
className="relative rounded-xl border border-neutral-200/50 dark:border-neutral-800/50 shadow-2xl overflow-hidden"
|
||||||
>
|
>
|
||||||
{/* * Header of the fake browser window */}
|
{/* * Browser chrome */}
|
||||||
<div className="h-8 bg-neutral-800/50 border-b border-white/5 flex items-center px-4 gap-2">
|
<div className="h-8 bg-neutral-100 dark:bg-neutral-800/80 border-b border-neutral-200 dark:border-white/5 flex items-center px-4 gap-2">
|
||||||
<div className="w-3 h-3 rounded-full bg-red-500/50" />
|
<div className="w-3 h-3 rounded-full bg-red-400/60" />
|
||||||
<div className="w-3 h-3 rounded-full bg-yellow-500/50" />
|
<div className="w-3 h-3 rounded-full bg-yellow-400/60" />
|
||||||
<div className="w-3 h-3 rounded-full bg-green-500/50" />
|
<div className="w-3 h-3 rounded-full bg-green-400/60" />
|
||||||
|
<div className="ml-4 flex-1 max-w-xs h-5 rounded bg-neutral-200 dark:bg-neutral-700/50" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* * Placeholder for actual dashboard screenshot - replace src with real image later */}
|
{/* * Screenshot with bottom fade */}
|
||||||
<div className="w-full h-[calc(100%-2rem)] bg-neutral-900 flex items-center justify-center text-neutral-700">
|
<div className="relative max-h-[900px] overflow-hidden">
|
||||||
<div className="text-center">
|
<Image
|
||||||
<BarChartIcon className="w-16 h-16 mx-auto mb-4 opacity-20" />
|
src="/dashboard-preview-v2.png"
|
||||||
<p>Dashboard Preview</p>
|
alt="Pulse analytics dashboard showing visitor stats, charts, top pages, referrers, locations, and technology breakdown"
|
||||||
</div>
|
width={1920}
|
||||||
|
height={3000}
|
||||||
|
className="w-full h-auto object-cover object-top"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 pointer-events-none bg-gradient-to-b from-transparent from-60% to-white dark:to-neutral-950" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -67,8 +78,8 @@ function ComparisonSection() {
|
|||||||
{ feature: "GDPR Compliant", pulse: true, ga: "Complex" },
|
{ feature: "GDPR Compliant", pulse: true, ga: "Complex" },
|
||||||
{ feature: "Script Size", pulse: "< 1 KB", ga: "45 KB+" },
|
{ feature: "Script Size", pulse: "< 1 KB", ga: "45 KB+" },
|
||||||
{ feature: "Data Ownership", pulse: "Yours", ga: "Google's" },
|
{ feature: "Data Ownership", pulse: "Yours", ga: "Google's" },
|
||||||
].map((row, i) => (
|
].map((row) => (
|
||||||
<tr key={i} className="hover:bg-neutral-50/50 dark:hover:bg-neutral-800/50 transition-colors">
|
<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 text-neutral-900 dark:text-white font-medium">{row.feature}</td>
|
||||||
<td className="p-6">
|
<td className="p-6">
|
||||||
{row.pulse === true ? (
|
{row.pulse === true ? (
|
||||||
@@ -96,10 +107,13 @@ function ComparisonSection() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type SiteStatsMap = Record<string, { stats: Stats }>
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const { user, loading: authLoading } = useAuth()
|
const { user, loading: authLoading } = useAuth()
|
||||||
const [sites, setSites] = useState<Site[]>([])
|
const [sites, setSites] = useState<Site[]>([])
|
||||||
const [sitesLoading, setSitesLoading] = useState(true)
|
const [sitesLoading, setSitesLoading] = useState(true)
|
||||||
|
const [siteStats, setSiteStats] = useState<SiteStatsMap>({})
|
||||||
const [subscription, setSubscription] = useState<SubscriptionDetails | null>(null)
|
const [subscription, setSubscription] = useState<SubscriptionDetails | null>(null)
|
||||||
const [subscriptionLoading, setSubscriptionLoading] = useState(false)
|
const [subscriptionLoading, setSubscriptionLoading] = useState(false)
|
||||||
const [showFinishSetupBanner, setShowFinishSetupBanner] = useState(true)
|
const [showFinishSetupBanner, setShowFinishSetupBanner] = useState(true)
|
||||||
@@ -111,6 +125,37 @@ export default function HomePage() {
|
|||||||
}
|
}
|
||||||
}, [user])
|
}, [user])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (sites.length === 0) {
|
||||||
|
setSiteStats({})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let cancelled = false
|
||||||
|
const today = new Date().toISOString().split('T')[0]
|
||||||
|
const emptyStats: Stats = { pageviews: 0, visitors: 0, bounce_rate: 0, avg_duration: 0 }
|
||||||
|
const load = async () => {
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
sites.map(async (site) => {
|
||||||
|
const statsRes = await getStats(site.id, today, today)
|
||||||
|
return { siteId: site.id, stats: statsRes }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
if (cancelled) return
|
||||||
|
const map: SiteStatsMap = {}
|
||||||
|
results.forEach((r, i) => {
|
||||||
|
const site = sites[i]
|
||||||
|
if (r.status === 'fulfilled') {
|
||||||
|
map[site.id] = { stats: r.value.stats }
|
||||||
|
} else {
|
||||||
|
map[site.id] = { stats: emptyStats }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
setSiteStats(map)
|
||||||
|
}
|
||||||
|
load()
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [sites])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') return
|
if (typeof window === 'undefined') return
|
||||||
if (localStorage.getItem('pulse_welcome_completed') === 'true') setShowFinishSetupBanner(false)
|
if (localStorage.getItem('pulse_welcome_completed') === 'true') setShowFinishSetupBanner(false)
|
||||||
@@ -132,8 +177,8 @@ export default function HomePage() {
|
|||||||
setSitesLoading(true)
|
setSitesLoading(true)
|
||||||
const data = await listSites()
|
const data = await listSites()
|
||||||
setSites(Array.isArray(data) ? data : [])
|
setSites(Array.isArray(data) ? data : [])
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
toast.error(getAuthErrorMessage(error) || 'Failed to load sites: ' + ((error as Error)?.message || 'Unknown error'))
|
toast.error(getAuthErrorMessage(error) || 'Failed to load your sites')
|
||||||
setSites([])
|
setSites([])
|
||||||
} finally {
|
} finally {
|
||||||
setSitesLoading(false)
|
setSitesLoading(false)
|
||||||
@@ -161,8 +206,8 @@ export default function HomePage() {
|
|||||||
await deleteSite(id)
|
await deleteSite(id)
|
||||||
toast.success('Site deleted successfully')
|
toast.success('Site deleted successfully')
|
||||||
loadSites()
|
loadSites()
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
toast.error(getAuthErrorMessage(error) || 'Failed to delete site: ' + ((error as Error)?.message || 'Unknown error'))
|
toast.error(getAuthErrorMessage(error) || 'Failed to delete site')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,12 +303,12 @@ 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." }
|
{ 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) => (
|
].map((feature, i) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={i}
|
key={feature.title}
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
viewport={{ once: true }}
|
viewport={{ once: true }}
|
||||||
transition={{ duration: 0.5, delay: i * 0.1 }}
|
transition={{ duration: 0.5, delay: i * 0.1 }}
|
||||||
className="card-glass p-8 hover:-translate-y-1 hover:shadow-xl transition-all duration-300 group"
|
className="card-glass p-6 hover:-translate-y-1 hover:shadow-xl transition-all duration-300 group"
|
||||||
>
|
>
|
||||||
<div className="w-12 h-12 rounded-xl bg-brand-orange/10 flex items-center justify-center mb-6 text-brand-orange group-hover:scale-110 transition-transform duration-300">
|
<div className="w-12 h-12 rounded-xl bg-brand-orange/10 flex items-center justify-center mb-6 text-brand-orange group-hover:scale-110 transition-transform duration-300">
|
||||||
<feature.icon className="w-6 h-6" />
|
<feature.icon className="w-6 h-6" />
|
||||||
@@ -337,10 +382,13 @@ export default function HomePage() {
|
|||||||
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white">Your Sites</h1>
|
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white">Your Sites</h1>
|
||||||
<p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">Manage your analytics sites and view insights.</p>
|
<p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">Manage your analytics sites and view insights.</p>
|
||||||
</div>
|
</div>
|
||||||
{subscription?.plan_id === 'solo' && sites.length >= 1 ? (
|
{(() => {
|
||||||
|
const siteLimit = getSitesLimitForPlan(subscription?.plan_id)
|
||||||
|
const atLimit = siteLimit != null && sites.length >= siteLimit
|
||||||
|
return atLimit ? (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-sm font-medium text-neutral-500 dark:text-neutral-400 bg-neutral-100 dark:bg-neutral-800 px-3 py-1.5 rounded-lg border border-neutral-200 dark:border-neutral-700">
|
<span className="text-sm font-medium text-neutral-500 dark:text-neutral-400 bg-neutral-100 dark:bg-neutral-800 px-3 py-1.5 rounded-lg border border-neutral-200 dark:border-neutral-700">
|
||||||
Limit reached (1/1)
|
Limit reached ({sites.length}/{siteLimit})
|
||||||
</span>
|
</span>
|
||||||
<Link href="/pricing">
|
<Link href="/pricing">
|
||||||
<Button variant="primary" className="text-sm">
|
<Button variant="primary" className="text-sm">
|
||||||
@@ -348,7 +396,8 @@ export default function HomePage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : null
|
||||||
|
})() ?? (
|
||||||
<Link href="/sites/new">
|
<Link href="/sites/new">
|
||||||
<Button variant="primary" className="text-sm">
|
<Button variant="primary" className="text-sm">
|
||||||
Add New Site
|
Add New Site
|
||||||
@@ -357,20 +406,29 @@ export default function HomePage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* * Global Overview */}
|
{/* * Global Overview - min-h ensures no layout shift when Plan & usage loads */}
|
||||||
<div className="mb-8 grid grid-cols-1 gap-4 sm:grid-cols-3">
|
<div className="mb-8 grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
<div className="rounded-2xl border border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900">
|
<div className="flex min-h-[160px] flex-col rounded-2xl border border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900">
|
||||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">Total Sites</p>
|
<p className="text-sm text-neutral-500 dark:text-neutral-400">Total Sites</p>
|
||||||
<p className="text-2xl font-bold text-neutral-900 dark:text-white">{sites.length}</p>
|
<p className="text-2xl font-bold text-neutral-900 dark:text-white">{sites.length}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-2xl border border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900">
|
<div className="flex min-h-[160px] flex-col rounded-2xl border border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900">
|
||||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">Total Visitors (24h)</p>
|
<p className="text-sm text-neutral-500 dark:text-neutral-400">Total Visitors (24h)</p>
|
||||||
<p className="text-2xl font-bold text-neutral-900 dark:text-white">--</p>
|
<p className="text-2xl font-bold text-neutral-900 dark:text-white">
|
||||||
|
{sites.length === 0 || Object.keys(siteStats).length < sites.length
|
||||||
|
? '--'
|
||||||
|
: Object.values(siteStats).reduce((sum, { stats }) => sum + (stats?.visitors ?? 0), 0).toLocaleString()}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-2xl border border-neutral-200 bg-brand-orange/10 p-4 dark:border-neutral-800">
|
<div className="flex min-h-[160px] flex-col rounded-2xl border border-neutral-200 bg-brand-orange/10 p-4 dark:border-neutral-800">
|
||||||
<p className="text-sm text-brand-orange">Plan & usage</p>
|
<p className="text-sm text-brand-orange">Plan & usage</p>
|
||||||
{subscriptionLoading ? (
|
{subscriptionLoading ? (
|
||||||
<p className="text-lg font-bold text-brand-orange">...</p>
|
<div className="animate-pulse space-y-2">
|
||||||
|
<div className="h-6 w-24 rounded bg-brand-orange/25 dark:bg-brand-orange/20" />
|
||||||
|
<div className="h-4 w-full rounded bg-brand-orange/25 dark:bg-brand-orange/20" />
|
||||||
|
<div className="h-4 w-3/4 rounded bg-brand-orange/25 dark:bg-brand-orange/20" />
|
||||||
|
<div className="h-4 w-20 rounded bg-brand-orange/25 dark:bg-brand-orange/20 pt-2" />
|
||||||
|
</div>
|
||||||
) : subscription ? (
|
) : subscription ? (
|
||||||
<>
|
<>
|
||||||
<p className="text-lg font-bold text-brand-orange">
|
<p className="text-lg font-bold text-brand-orange">
|
||||||
@@ -385,24 +443,43 @@ export default function HomePage() {
|
|||||||
return `${label} Plan`
|
return `${label} Plan`
|
||||||
})()}
|
})()}
|
||||||
</p>
|
</p>
|
||||||
{(typeof subscription.sites_count === 'number' || (subscription.pageview_limit > 0 && typeof subscription.pageview_usage === 'number')) && (
|
{(typeof subscription.sites_count === 'number' || (subscription.pageview_limit > 0 && typeof subscription.pageview_usage === 'number') || (subscription.next_invoice_amount_due != null && subscription.next_invoice_currency && !subscription.cancel_at_period_end && (subscription.subscription_status === 'active' || subscription.subscription_status === 'trialing'))) && (
|
||||||
<p className="text-sm text-neutral-600 dark:text-neutral-400 mt-1">
|
<p className="text-sm text-neutral-600 dark:text-neutral-400 mt-1">
|
||||||
{typeof subscription.sites_count === 'number' && (
|
{typeof subscription.sites_count === 'number' && (
|
||||||
<span>Sites: {subscription.plan_id === 'solo' && subscription.sites_count > 0 ? `${subscription.sites_count}/1` : subscription.sites_count}</span>
|
<span>Sites: {(() => {
|
||||||
|
const limit = getSitesLimitForPlan(subscription.plan_id)
|
||||||
|
return limit != null && typeof subscription.sites_count === 'number' ? `${subscription.sites_count}/${limit}` : subscription.sites_count
|
||||||
|
})()}</span>
|
||||||
)}
|
)}
|
||||||
{typeof subscription.sites_count === 'number' && subscription.pageview_limit > 0 && typeof subscription.pageview_usage === 'number' && ' · '}
|
{typeof subscription.sites_count === 'number' && (subscription.pageview_limit > 0 && typeof subscription.pageview_usage === 'number') && ' · '}
|
||||||
{subscription.pageview_limit > 0 && typeof subscription.pageview_usage === 'number' && (
|
{subscription.pageview_limit > 0 && typeof subscription.pageview_usage === 'number' && (
|
||||||
<span>Pageviews: {subscription.pageview_usage.toLocaleString()}/{subscription.pageview_limit.toLocaleString()}</span>
|
<span>Pageviews: {subscription.pageview_usage.toLocaleString()}/{subscription.pageview_limit.toLocaleString()}</span>
|
||||||
)}
|
)}
|
||||||
|
{subscription.next_invoice_amount_due != null && subscription.next_invoice_currency && !subscription.cancel_at_period_end && (subscription.subscription_status === 'active' || subscription.subscription_status === 'trialing') && (
|
||||||
|
<span className="block mt-1">
|
||||||
|
Renews {(() => {
|
||||||
|
const ts = subscription.next_invoice_period_end ?? subscription.current_period_end
|
||||||
|
const d = ts ? new Date(typeof ts === 'number' ? ts * 1000 : ts) : null
|
||||||
|
const dateStr = d && !Number.isNaN(d.getTime()) && d.getTime() !== 0
|
||||||
|
? d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||||
|
: null
|
||||||
|
const amount = (subscription.next_invoice_amount_due / 100).toLocaleString('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: subscription.next_invoice_currency.toUpperCase(),
|
||||||
|
})
|
||||||
|
return dateStr ? `${dateStr} for ${amount}` : amount
|
||||||
|
})()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="mt-2 flex gap-2">
|
<div className="mt-2 flex gap-2">
|
||||||
{subscription.has_payment_method ? (
|
{subscription.has_payment_method ? (
|
||||||
<Link href="/org-settings?tab=billing" className="text-sm font-medium text-brand-orange hover:underline focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded">
|
<Link href="/org-settings?tab=billing" className="text-sm font-medium text-brand-orange hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:rounded">
|
||||||
Manage billing
|
Manage billing
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<Link href="/pricing" className="text-sm font-medium text-brand-orange hover:underline focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded">
|
<Link href="/pricing" className="text-sm font-medium text-brand-orange hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:rounded">
|
||||||
Upgrade
|
Upgrade
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
@@ -415,7 +492,7 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!sitesLoading && sites.length === 0 && (
|
{!sitesLoading && sites.length === 0 && (
|
||||||
<div className="mb-8 rounded-2xl border-2 border-dashed border-brand-orange/30 bg-brand-orange/5 p-8 text-center dark:bg-brand-orange/10">
|
<div className="mb-8 rounded-2xl border-2 border-dashed border-brand-orange/30 bg-brand-orange/5 p-6 text-center dark:bg-brand-orange/10">
|
||||||
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-brand-orange/20 text-brand-orange mb-4">
|
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-brand-orange/20 text-brand-orange mb-4">
|
||||||
<GlobeIcon className="h-7 w-7" />
|
<GlobeIcon className="h-7 w-7" />
|
||||||
</div>
|
</div>
|
||||||
@@ -432,7 +509,7 @@ export default function HomePage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{(sitesLoading || sites.length > 0) && (
|
{(sitesLoading || sites.length > 0) && (
|
||||||
<SiteList sites={sites} loading={sitesLoading} onDelete={handleDelete} />
|
<SiteList sites={sites} siteStats={siteStats} loading={sitesLoading} onDelete={handleDelete} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,10 +1,30 @@
|
|||||||
import { Suspense } from 'react'
|
import { Suspense } from 'react'
|
||||||
|
import type { Metadata } from 'next'
|
||||||
import PricingSection from '@/components/PricingSection'
|
import PricingSection from '@/components/PricingSection'
|
||||||
|
import { PricingCardsSkeleton } from '@/components/skeletons'
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Pricing | Pulse',
|
||||||
|
description: 'Simple, transparent pricing for privacy-first web analytics. Free tier included.',
|
||||||
|
openGraph: {
|
||||||
|
title: 'Pricing | Pulse',
|
||||||
|
description: 'Simple, transparent pricing for privacy-first web analytics. Free tier included.',
|
||||||
|
siteName: 'Pulse by Ciphera',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
export default function PricingPage() {
|
export default function PricingPage() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen pt-20">
|
<div className="min-h-screen pt-20">
|
||||||
<Suspense fallback={<div className="min-h-screen pt-20 flex items-center justify-center">Loading...</div>}>
|
<Suspense fallback={
|
||||||
|
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-16">
|
||||||
|
<div className="text-center mb-12">
|
||||||
|
<div className="h-10 w-64 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800 mx-auto mb-4" />
|
||||||
|
<div className="h-5 w-96 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800 mx-auto" />
|
||||||
|
</div>
|
||||||
|
<PricingCardsSkeleton />
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
<PricingSection />
|
<PricingSection />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
import { Suspense } from 'react'
|
|
||||||
import ProfileSettings from '@/components/settings/ProfileSettings'
|
|
||||||
import CheckoutSuccessToast from '@/components/checkout/CheckoutSuccessToast'
|
|
||||||
|
|
||||||
export const metadata = {
|
|
||||||
title: 'Settings - Pulse',
|
|
||||||
description: 'Manage your account settings',
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SettingsPage() {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen pt-12 pb-12 px-4 sm:px-6">
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<CheckoutSuccessToast />
|
|
||||||
</Suspense>
|
|
||||||
<ProfileSettings />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
13
app/share/[id]/error.tsx
Normal file
13
app/share/[id]/error.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import ErrorDisplay from '@/components/ErrorDisplay'
|
||||||
|
|
||||||
|
export default function ShareError({ reset }: { error: Error; reset: () => void }) {
|
||||||
|
return (
|
||||||
|
<ErrorDisplay
|
||||||
|
title="Dashboard failed to load"
|
||||||
|
message="We couldn't load this public dashboard. It may be temporarily unavailable — try again."
|
||||||
|
onRetry={reset}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
73
app/share/[id]/layout.tsx
Normal file
73
app/share/[id]/layout.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import { FAVICON_SERVICE_URL } from '@/lib/utils/icons'
|
||||||
|
|
||||||
|
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8082'
|
||||||
|
|
||||||
|
interface SharePageParams {
|
||||||
|
params: Promise<{ id: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: SharePageParams): Promise<Metadata> {
|
||||||
|
const { id } = await params
|
||||||
|
const fallback: Metadata = {
|
||||||
|
title: 'Public Dashboard | Pulse',
|
||||||
|
description: 'Privacy-first web analytics — view this site\'s public stats.',
|
||||||
|
openGraph: {
|
||||||
|
title: 'Public Dashboard | Pulse',
|
||||||
|
description: 'Privacy-first web analytics — view this site\'s public stats.',
|
||||||
|
siteName: 'Pulse by Ciphera',
|
||||||
|
type: 'website',
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: 'summary',
|
||||||
|
title: 'Public Dashboard | Pulse',
|
||||||
|
description: 'Privacy-first web analytics — view this site\'s public stats.',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/public/sites/${id}/dashboard?limit=1`, {
|
||||||
|
next: { revalidate: 3600 },
|
||||||
|
})
|
||||||
|
if (!res.ok) return fallback
|
||||||
|
|
||||||
|
const data = await res.json()
|
||||||
|
const domain = data?.site?.domain
|
||||||
|
if (!domain) return fallback
|
||||||
|
|
||||||
|
const title = `${domain} analytics | Pulse`
|
||||||
|
const description = `Live, privacy-first analytics for ${domain} — powered by Pulse.`
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
openGraph: {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
siteName: 'Pulse by Ciphera',
|
||||||
|
type: 'website',
|
||||||
|
images: [{
|
||||||
|
url: `${FAVICON_SERVICE_URL}?domain=${domain}&sz=128`,
|
||||||
|
width: 128,
|
||||||
|
height: 128,
|
||||||
|
alt: `${domain} favicon`,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: 'summary',
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ShareLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return children
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import Image from 'next/image'
|
||||||
import { useParams, useSearchParams, useRouter } from 'next/navigation'
|
import { useParams, useSearchParams, useRouter } from 'next/navigation'
|
||||||
import { getPublicDashboard, getPublicStats, getPublicDailyStats, getPublicRealtime, getPublicPerformanceByPage, type DashboardData, type Stats, type DailyStat, type PerformanceByPageStat } from '@/lib/api/stats'
|
import { getPublicDashboard, getPublicStats, getPublicDailyStats, getPublicRealtime, getPublicPerformanceByPage, type DashboardData, type Stats, type DailyStat, type PerformanceByPageStat } from '@/lib/api/stats'
|
||||||
import { toast } from '@ciphera-net/ui'
|
import { toast } from '@ciphera-net/ui'
|
||||||
import { getAuthErrorMessage } from '@ciphera-net/ui'
|
import { getAuthErrorMessage } from '@ciphera-net/ui'
|
||||||
|
import { ApiError } from '@/lib/api/client'
|
||||||
import { LoadingOverlay, Button } from '@ciphera-net/ui'
|
import { LoadingOverlay, Button } from '@ciphera-net/ui'
|
||||||
import Chart from '@/components/dashboard/Chart'
|
import Chart from '@/components/dashboard/Chart'
|
||||||
import TopPages from '@/components/dashboard/ContentStats'
|
import TopPages from '@/components/dashboard/ContentStats'
|
||||||
@@ -13,7 +15,9 @@ import Locations from '@/components/dashboard/Locations'
|
|||||||
import TechSpecs from '@/components/dashboard/TechSpecs'
|
import TechSpecs from '@/components/dashboard/TechSpecs'
|
||||||
import PerformanceStats from '@/components/dashboard/PerformanceStats'
|
import PerformanceStats from '@/components/dashboard/PerformanceStats'
|
||||||
import { Select, DatePicker as DatePickerModal, Captcha, DownloadIcon, ZapIcon } from '@ciphera-net/ui'
|
import { Select, DatePicker as DatePickerModal, Captcha, DownloadIcon, ZapIcon } from '@ciphera-net/ui'
|
||||||
|
import { DashboardSkeleton, useMinimumLoading } from '@/components/skeletons'
|
||||||
import ExportModal from '@/components/dashboard/ExportModal'
|
import ExportModal from '@/components/dashboard/ExportModal'
|
||||||
|
import { FAVICON_SERVICE_URL } from '@/lib/utils/icons'
|
||||||
|
|
||||||
// Helper to get date ranges
|
// Helper to get date ranges
|
||||||
const getDateRange = (days: number) => {
|
const getDateRange = (days: number) => {
|
||||||
@@ -152,8 +156,9 @@ export default function PublicDashboardPage() {
|
|||||||
setCaptchaId('')
|
setCaptchaId('')
|
||||||
setCaptchaSolution('')
|
setCaptchaSolution('')
|
||||||
setCaptchaToken('')
|
setCaptchaToken('')
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
if ((error.status === 401 || error.response?.status === 401) && (error.data?.is_protected || error.response?.data?.is_protected)) {
|
const apiErr = error instanceof ApiError ? error : null
|
||||||
|
if (apiErr?.status === 401 && (apiErr.data as Record<string, unknown>)?.is_protected) {
|
||||||
setIsPasswordProtected(true)
|
setIsPasswordProtected(true)
|
||||||
if (password) {
|
if (password) {
|
||||||
toast.error('Invalid password or captcha')
|
toast.error('Invalid password or captcha')
|
||||||
@@ -162,10 +167,10 @@ export default function PublicDashboardPage() {
|
|||||||
setCaptchaSolution('')
|
setCaptchaSolution('')
|
||||||
setCaptchaToken('')
|
setCaptchaToken('')
|
||||||
}
|
}
|
||||||
} else if (error.status === 404 || error.response?.status === 404) {
|
} else if (apiErr?.status === 404) {
|
||||||
toast.error('Site not found')
|
toast.error('Site not found')
|
||||||
} else if (!silent) {
|
} else if (!silent) {
|
||||||
toast.error(getAuthErrorMessage(error) || 'Failed to load dashboard: ' + ((error as Error)?.message || 'Unknown error'))
|
toast.error(getAuthErrorMessage(error) || 'Failed to load public dashboard')
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (!silent) setLoading(false)
|
if (!silent) setLoading(false)
|
||||||
@@ -192,14 +197,16 @@ export default function PublicDashboardPage() {
|
|||||||
loadDashboard()
|
loadDashboard()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading && !data && !isPasswordProtected) {
|
const showSkeleton = useMinimumLoading(loading && !data && !isPasswordProtected)
|
||||||
return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Pulse" />
|
|
||||||
|
if (showSkeleton) {
|
||||||
|
return <DashboardSkeleton />
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isPasswordProtected && !data) {
|
if (isPasswordProtected && !data) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center px-4">
|
<div className="min-h-screen flex items-center justify-center px-4">
|
||||||
<div className="max-w-md w-full bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-8 shadow-lg">
|
<div className="max-w-md w-full bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 shadow-lg transition-shadow duration-300">
|
||||||
<div className="text-center mb-6">
|
<div className="text-center mb-6">
|
||||||
<div className="w-12 h-12 bg-brand-orange/10 rounded-xl flex items-center justify-center mx-auto mb-4 text-brand-orange">
|
<div className="w-12 h-12 bg-brand-orange/10 rounded-xl flex items-center justify-center mx-auto mb-4 text-brand-orange">
|
||||||
<ZapIcon className="w-6 h-6" />
|
<ZapIcon className="w-6 h-6" />
|
||||||
@@ -231,6 +238,7 @@ export default function PublicDashboardPage() {
|
|||||||
setCaptchaToken(token || '')
|
setCaptchaToken(token || '')
|
||||||
}}
|
}}
|
||||||
apiUrl={process.env.NEXT_PUBLIC_CAPTCHA_API_URL}
|
apiUrl={process.env.NEXT_PUBLIC_CAPTCHA_API_URL}
|
||||||
|
action="share-access"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@@ -279,13 +287,16 @@ export default function PublicDashboardPage() {
|
|||||||
<span className="text-sm font-medium text-brand-orange uppercase tracking-wider">Public Dashboard</span>
|
<span className="text-sm font-medium text-brand-orange uppercase tracking-wider">Public Dashboard</span>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white flex items-center gap-3">
|
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white flex items-center gap-3">
|
||||||
<img
|
<Image
|
||||||
src={`https://www.google.com/s2/favicons?domain=${site.domain}&sz=64`}
|
src={`${FAVICON_SERVICE_URL}?domain=${site.domain}&sz=64`}
|
||||||
alt={site.name}
|
alt={site.name}
|
||||||
|
width={32}
|
||||||
|
height={32}
|
||||||
className="w-8 h-8 rounded-lg"
|
className="w-8 h-8 rounded-lg"
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
(e.target as HTMLImageElement).src = '/globe.svg'
|
(e.target as HTMLImageElement).src = '/globe.svg'
|
||||||
}}
|
}}
|
||||||
|
unoptimized
|
||||||
/>
|
/>
|
||||||
{site.domain}
|
{site.domain}
|
||||||
</h1>
|
</h1>
|
||||||
|
|||||||
20
app/sites/[id]/SiteLayoutShell.tsx
Normal file
20
app/sites/[id]/SiteLayoutShell.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import SiteNav from '@/components/dashboard/SiteNav'
|
||||||
|
|
||||||
|
export default function SiteLayoutShell({
|
||||||
|
siteId,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
siteId: string
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pt-8">
|
||||||
|
<SiteNav siteId={siteId} />
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
168
app/sites/[id]/behavior/page.tsx
Normal file
168
app/sites/[id]/behavior/page.tsx
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { useParams } from 'next/navigation'
|
||||||
|
import { getDateRange, formatDate } from '@ciphera-net/ui'
|
||||||
|
import { Select, DatePicker } from '@ciphera-net/ui'
|
||||||
|
import dynamic from 'next/dynamic'
|
||||||
|
import { getRageClicks, getDeadClicks } from '@/lib/api/stats'
|
||||||
|
import FrustrationSummaryCards from '@/components/behavior/FrustrationSummaryCards'
|
||||||
|
import FrustrationTable from '@/components/behavior/FrustrationTable'
|
||||||
|
import FrustrationByPageTable from '@/components/behavior/FrustrationByPageTable'
|
||||||
|
import FrustrationTrend from '@/components/behavior/FrustrationTrend'
|
||||||
|
import { useDashboard, useBehavior } from '@/lib/swr/dashboard'
|
||||||
|
|
||||||
|
const ScrollDepth = dynamic(() => import('@/components/dashboard/ScrollDepth'))
|
||||||
|
|
||||||
|
function getThisWeekRange(): { start: string; end: string } {
|
||||||
|
const today = new Date()
|
||||||
|
const dayOfWeek = today.getDay()
|
||||||
|
const monday = new Date(today)
|
||||||
|
monday.setDate(today.getDate() - (dayOfWeek === 0 ? 6 : dayOfWeek - 1))
|
||||||
|
return { start: formatDate(monday), end: formatDate(today) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getThisMonthRange(): { start: string; end: string } {
|
||||||
|
const today = new Date()
|
||||||
|
const firstOfMonth = new Date(today.getFullYear(), today.getMonth(), 1)
|
||||||
|
return { start: formatDate(firstOfMonth), end: formatDate(today) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BehaviorPage() {
|
||||||
|
const params = useParams()
|
||||||
|
const siteId = params.id as string
|
||||||
|
|
||||||
|
const [period, setPeriod] = useState('30')
|
||||||
|
const [dateRange, setDateRange] = useState(() => getDateRange(30))
|
||||||
|
const [isDatePickerOpen, setIsDatePickerOpen] = useState(false)
|
||||||
|
|
||||||
|
// Single request for all frustration data
|
||||||
|
const { data: behavior, isLoading: loading, error: behaviorError } = useBehavior(siteId, dateRange.start, dateRange.end)
|
||||||
|
|
||||||
|
// Fetch dashboard data for scroll depth (goal_counts + stats)
|
||||||
|
const { data: dashboard } = useDashboard(siteId, dateRange.start, dateRange.end)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const domain = dashboard?.site?.domain
|
||||||
|
document.title = domain ? `Behavior · ${domain} | Pulse` : 'Behavior | Pulse'
|
||||||
|
}, [dashboard?.site?.domain])
|
||||||
|
|
||||||
|
// On-demand fetchers for modal "view all"
|
||||||
|
const fetchAllRage = useCallback(
|
||||||
|
() => getRageClicks(siteId, dateRange.start, dateRange.end, 100),
|
||||||
|
[siteId, dateRange.start, dateRange.end]
|
||||||
|
)
|
||||||
|
|
||||||
|
const fetchAllDead = useCallback(
|
||||||
|
() => getDeadClicks(siteId, dateRange.start, dateRange.end, 100),
|
||||||
|
[siteId, dateRange.start, dateRange.end]
|
||||||
|
)
|
||||||
|
|
||||||
|
const summary = behavior?.summary ?? null
|
||||||
|
const rageClicks = behavior?.rage_clicks ?? { items: [], total: 0 }
|
||||||
|
const deadClicks = behavior?.dead_clicks ?? { items: [], total: 0 }
|
||||||
|
const byPage = behavior?.by_page ?? []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-1">
|
||||||
|
Behavior
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||||
|
Frustration signals and user engagement patterns
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
variant="input"
|
||||||
|
className="min-w-[140px]"
|
||||||
|
value={period}
|
||||||
|
onChange={(value) => {
|
||||||
|
if (value === 'today') {
|
||||||
|
const today = formatDate(new Date())
|
||||||
|
setDateRange({ start: today, end: today })
|
||||||
|
setPeriod('today')
|
||||||
|
} else if (value === '7') {
|
||||||
|
setDateRange(getDateRange(7))
|
||||||
|
setPeriod('7')
|
||||||
|
} else if (value === 'week') {
|
||||||
|
setDateRange(getThisWeekRange())
|
||||||
|
setPeriod('week')
|
||||||
|
} else if (value === '30') {
|
||||||
|
setDateRange(getDateRange(30))
|
||||||
|
setPeriod('30')
|
||||||
|
} else if (value === 'month') {
|
||||||
|
setDateRange(getThisMonthRange())
|
||||||
|
setPeriod('month')
|
||||||
|
} else if (value === 'custom') {
|
||||||
|
setIsDatePickerOpen(true)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
options={[
|
||||||
|
{ value: 'today', label: 'Today' },
|
||||||
|
{ value: '7', label: 'Last 7 days' },
|
||||||
|
{ value: '30', label: 'Last 30 days' },
|
||||||
|
{ value: 'divider-1', label: '', divider: true },
|
||||||
|
{ value: 'week', label: 'This week' },
|
||||||
|
{ value: 'month', label: 'This month' },
|
||||||
|
{ value: 'divider-2', label: '', divider: true },
|
||||||
|
{ value: 'custom', label: 'Custom' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary cards */}
|
||||||
|
<FrustrationSummaryCards data={summary} loading={loading} />
|
||||||
|
|
||||||
|
{/* Rage clicks + Dead clicks side by side */}
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2 mb-8">
|
||||||
|
<FrustrationTable
|
||||||
|
title="Rage Clicks"
|
||||||
|
description="Elements users clicked repeatedly in frustration"
|
||||||
|
items={rageClicks.items}
|
||||||
|
total={rageClicks.total}
|
||||||
|
totalSignals={summary?.rage_clicks ?? 0}
|
||||||
|
showAvgClicks
|
||||||
|
loading={loading}
|
||||||
|
fetchAll={fetchAllRage}
|
||||||
|
/>
|
||||||
|
<FrustrationTable
|
||||||
|
title="Dead Clicks"
|
||||||
|
description="Elements users clicked that produced no response"
|
||||||
|
items={deadClicks.items}
|
||||||
|
total={deadClicks.total}
|
||||||
|
totalSignals={summary?.dead_clicks ?? 0}
|
||||||
|
loading={loading}
|
||||||
|
fetchAll={fetchAllDead}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* By page breakdown */}
|
||||||
|
<FrustrationByPageTable pages={byPage} loading={loading} />
|
||||||
|
|
||||||
|
{/* Scroll depth + Frustration trend — hide when data failed to load */}
|
||||||
|
{!behaviorError && (
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2 mb-8">
|
||||||
|
<ScrollDepth
|
||||||
|
goalCounts={dashboard?.goal_counts ?? []}
|
||||||
|
totalPageviews={dashboard?.stats?.pageviews ?? 0}
|
||||||
|
/>
|
||||||
|
<FrustrationTrend summary={summary} loading={loading} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DatePicker
|
||||||
|
isOpen={isDatePickerOpen}
|
||||||
|
onClose={() => setIsDatePickerOpen(false)}
|
||||||
|
onApply={(range) => {
|
||||||
|
setDateRange(range)
|
||||||
|
setPeriod('custom')
|
||||||
|
setIsDatePickerOpen(false)
|
||||||
|
}}
|
||||||
|
initialRange={dateRange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
13
app/sites/[id]/error.tsx
Normal file
13
app/sites/[id]/error.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import ErrorDisplay from '@/components/ErrorDisplay'
|
||||||
|
|
||||||
|
export default function DashboardError({ reset }: { error: Error; reset: () => void }) {
|
||||||
|
return (
|
||||||
|
<ErrorDisplay
|
||||||
|
title="Dashboard failed to load"
|
||||||
|
message="We couldn't load your site analytics. This might be a temporary issue — try again."
|
||||||
|
onRetry={reset}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,39 +1,15 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { useParams, useRouter } from 'next/navigation'
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
import { ApiError } from '@/lib/api/client'
|
import { ApiError } from '@/lib/api/client'
|
||||||
import { getFunnel, getFunnelStats, deleteFunnel, type Funnel, type FunnelStats } from '@/lib/api/funnels'
|
import { getFunnel, getFunnelStats, deleteFunnel, type Funnel, type FunnelStats } from '@/lib/api/funnels'
|
||||||
import { toast, LoadingOverlay, Select, DatePicker, ChevronLeftIcon, ArrowRightIcon, TrashIcon, useTheme, Button } from '@ciphera-net/ui'
|
import { toast, Select, DatePicker, ChevronLeftIcon, ArrowRightIcon, TrashIcon, Button } from '@ciphera-net/ui'
|
||||||
|
import { FunnelDetailSkeleton, useMinimumLoading } from '@/components/skeletons'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import {
|
import { FunnelChart } from '@/components/ui/funnel-chart'
|
||||||
BarChart,
|
|
||||||
Bar,
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
CartesianGrid,
|
|
||||||
Tooltip,
|
|
||||||
ResponsiveContainer,
|
|
||||||
Cell
|
|
||||||
} from 'recharts'
|
|
||||||
import { getDateRange } from '@ciphera-net/ui'
|
import { getDateRange } from '@ciphera-net/ui'
|
||||||
|
|
||||||
const CHART_COLORS_LIGHT = {
|
|
||||||
border: '#E5E5E5',
|
|
||||||
axis: '#A3A3A3',
|
|
||||||
tooltipBg: '#ffffff',
|
|
||||||
tooltipBorder: '#E5E5E5',
|
|
||||||
}
|
|
||||||
|
|
||||||
const CHART_COLORS_DARK = {
|
|
||||||
border: '#404040',
|
|
||||||
axis: '#737373',
|
|
||||||
tooltipBg: '#262626',
|
|
||||||
tooltipBorder: '#404040',
|
|
||||||
}
|
|
||||||
|
|
||||||
const BRAND_ORANGE = '#FD5E0F'
|
|
||||||
|
|
||||||
export default function FunnelReportPage() {
|
export default function FunnelReportPage() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -63,7 +39,7 @@ export default function FunnelReportPage() {
|
|||||||
if (status === 404) setLoadError('not_found')
|
if (status === 404) setLoadError('not_found')
|
||||||
else if (status === 403) setLoadError('forbidden')
|
else if (status === 403) setLoadError('forbidden')
|
||||||
else setLoadError('error')
|
else setLoadError('error')
|
||||||
if (status !== 404 && status !== 403) toast.error('Failed to load funnel data')
|
if (status !== 404 && status !== 403) toast.error('Failed to load funnel details')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -73,12 +49,6 @@ export default function FunnelReportPage() {
|
|||||||
loadData()
|
loadData()
|
||||||
}, [loadData])
|
}, [loadData])
|
||||||
|
|
||||||
const { resolvedTheme } = useTheme()
|
|
||||||
const chartColors = useMemo(
|
|
||||||
() => (resolvedTheme === 'dark' ? CHART_COLORS_DARK : CHART_COLORS_LIGHT),
|
|
||||||
[resolvedTheme]
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
if (!confirm('Are you sure you want to delete this funnel?')) return
|
if (!confirm('Are you sure you want to delete this funnel?')) return
|
||||||
|
|
||||||
@@ -91,13 +61,15 @@ export default function FunnelReportPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading && !funnel) {
|
const showSkeleton = useMinimumLoading(loading && !funnel)
|
||||||
return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Pulse" />
|
|
||||||
|
if (showSkeleton) {
|
||||||
|
return <FunnelDetailSkeleton />
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loadError === 'not_found' || (!funnel && !stats && !loadError)) {
|
if (loadError === 'not_found' || (!funnel && !stats && !loadError)) {
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
|
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
|
||||||
<p className="text-neutral-600 dark:text-neutral-400">Funnel not found</p>
|
<p className="text-neutral-600 dark:text-neutral-400">Funnel not found</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -105,7 +77,7 @@ export default function FunnelReportPage() {
|
|||||||
|
|
||||||
if (loadError === 'forbidden') {
|
if (loadError === 'forbidden') {
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
|
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
|
||||||
<p className="text-neutral-600 dark:text-neutral-400">Access denied</p>
|
<p className="text-neutral-600 dark:text-neutral-400">Access denied</p>
|
||||||
<Link href={`/sites/${siteId}/funnels`}>
|
<Link href={`/sites/${siteId}/funnels`}>
|
||||||
<Button variant="primary" className="mt-4">
|
<Button variant="primary" className="mt-4">
|
||||||
@@ -118,7 +90,7 @@ export default function FunnelReportPage() {
|
|||||||
|
|
||||||
if (loadError === 'error') {
|
if (loadError === 'error') {
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
|
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
|
||||||
<p className="text-neutral-600 dark:text-neutral-400 mb-4">Unable to load funnel</p>
|
<p className="text-neutral-600 dark:text-neutral-400 mb-4">Unable to load funnel</p>
|
||||||
<Button type="button" onClick={() => loadData()} variant="primary">
|
<Button type="button" onClick={() => loadData()} variant="primary">
|
||||||
Try again
|
Try again
|
||||||
@@ -129,21 +101,19 @@ export default function FunnelReportPage() {
|
|||||||
|
|
||||||
if (!funnel || !stats) {
|
if (!funnel || !stats) {
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
|
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
|
||||||
<p className="text-neutral-600 dark:text-neutral-400">Funnel not found</p>
|
<p className="text-neutral-600 dark:text-neutral-400">Funnel not found</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const chartData = stats.steps.map(s => ({
|
const chartData = stats.steps.map(s => ({
|
||||||
name: s.step.name,
|
label: s.step.name,
|
||||||
visitors: s.visitors,
|
value: s.visitors,
|
||||||
dropoff: s.dropoff,
|
|
||||||
conversion: s.conversion
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
|
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
@@ -201,64 +171,13 @@ export default function FunnelReportPage() {
|
|||||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white mb-6">
|
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white mb-6">
|
||||||
Funnel Visualization
|
Funnel Visualization
|
||||||
</h3>
|
</h3>
|
||||||
<div className="h-[400px] w-full">
|
<FunnelChart
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
data={chartData}
|
||||||
<BarChart data={chartData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
orientation="vertical"
|
||||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={chartColors.border} />
|
color="var(--chart-1)"
|
||||||
<XAxis
|
layers={3}
|
||||||
dataKey="name"
|
className="mx-auto max-w-md"
|
||||||
stroke={chartColors.axis}
|
/>
|
||||||
fontSize={12}
|
|
||||||
tickLine={false}
|
|
||||||
axisLine={false}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
stroke={chartColors.axis}
|
|
||||||
fontSize={12}
|
|
||||||
tickLine={false}
|
|
||||||
axisLine={false}
|
|
||||||
/>
|
|
||||||
<Tooltip
|
|
||||||
cursor={{ fill: 'transparent' }}
|
|
||||||
content={({ active, payload, label }) => {
|
|
||||||
if (active && payload && payload.length) {
|
|
||||||
const data = payload[0].payload;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="p-3 rounded-xl shadow-lg border"
|
|
||||||
style={{
|
|
||||||
backgroundColor: chartColors.tooltipBg,
|
|
||||||
borderColor: chartColors.tooltipBorder,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<p className="font-medium text-neutral-900 dark:text-white mb-1">{label}</p>
|
|
||||||
<p className="text-brand-orange font-bold text-lg">
|
|
||||||
{data.visitors.toLocaleString()} visitors
|
|
||||||
</p>
|
|
||||||
{data.dropoff > 0 && (
|
|
||||||
<p className="text-red-500 text-sm">
|
|
||||||
{Math.round(data.dropoff)}% drop-off
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{data.conversion > 0 && (
|
|
||||||
<p className="text-green-500 text-sm">
|
|
||||||
{Math.round(data.conversion)}% conversion (overall)
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Bar dataKey="visitors" radius={[4, 4, 0, 0]} barSize={60}>
|
|
||||||
{chartData.map((entry, index) => (
|
|
||||||
<Cell key={`cell-${index}`} fill={BRAND_ORANGE} fillOpacity={Math.max(0.1, 1 - index * 0.15)} />
|
|
||||||
))}
|
|
||||||
</Bar>
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Detailed Stats Table */}
|
{/* Detailed Stats Table */}
|
||||||
@@ -267,15 +186,15 @@ export default function FunnelReportPage() {
|
|||||||
<table className="w-full text-left text-sm">
|
<table className="w-full text-left text-sm">
|
||||||
<thead className="bg-neutral-50 dark:bg-neutral-800/50 border-b border-neutral-200 dark:border-neutral-800">
|
<thead className="bg-neutral-50 dark:bg-neutral-800/50 border-b border-neutral-200 dark:border-neutral-800">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-4 font-medium text-neutral-500 uppercase tracking-wider">Step</th>
|
<th className="px-6 py-4 font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Step</th>
|
||||||
<th className="px-6 py-4 font-medium text-neutral-500 uppercase tracking-wider text-right">Visitors</th>
|
<th className="px-6 py-4 font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider text-right">Visitors</th>
|
||||||
<th className="px-6 py-4 font-medium text-neutral-500 uppercase tracking-wider text-right">Drop-off</th>
|
<th className="px-6 py-4 font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider text-right">Drop-off</th>
|
||||||
<th className="px-6 py-4 font-medium text-neutral-500 uppercase tracking-wider text-right">Conversion</th>
|
<th className="px-6 py-4 font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider text-right">Conversion</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-800">
|
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-800">
|
||||||
{stats.steps.map((step, i) => (
|
{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">
|
<td className="px-6 py-4">
|
||||||
<div className="flex items-center gap-3">
|
<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">
|
<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">
|
||||||
@@ -283,7 +202,7 @@ export default function FunnelReportPage() {
|
|||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-neutral-900 dark:text-white">{step.step.name}</p>
|
<p className="font-medium text-neutral-900 dark:text-white">{step.step.name}</p>
|
||||||
<p className="text-neutral-500 text-xs font-mono mt-0.5">{step.step.value}</p>
|
<p className="text-neutral-500 dark:text-neutral-400 text-xs font-mono mt-0.5">{step.step.value}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
13
app/sites/[id]/funnels/error.tsx
Normal file
13
app/sites/[id]/funnels/error.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import ErrorDisplay from '@/components/ErrorDisplay'
|
||||||
|
|
||||||
|
export default function FunnelsError({ reset }: { error: Error; reset: () => void }) {
|
||||||
|
return (
|
||||||
|
<ErrorDisplay
|
||||||
|
title="Funnels failed to load"
|
||||||
|
message="We couldn't load your funnels. This might be a temporary issue — try again."
|
||||||
|
onRetry={reset}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
15
app/sites/[id]/funnels/layout.tsx
Normal file
15
app/sites/[id]/funnels/layout.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Funnels | Pulse',
|
||||||
|
description: 'Track conversion funnels and user journeys.',
|
||||||
|
robots: { index: false, follow: false },
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FunnelsLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return children
|
||||||
|
}
|
||||||
@@ -84,14 +84,14 @@ export default function CreateFunnelPage() {
|
|||||||
toast.success('Funnel created')
|
toast.success('Funnel created')
|
||||||
router.push(`/sites/${siteId}/funnels`)
|
router.push(`/sites/${siteId}/funnels`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error('Failed to create funnel')
|
toast.error('Failed to create funnel. Please try again.')
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-3xl mx-auto px-4 sm:px-6 py-8">
|
<div className="w-full max-w-3xl mx-auto px-4 sm:px-6 pb-8">
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<Link
|
<Link
|
||||||
href={`/sites/${siteId}/funnels`}
|
href={`/sites/${siteId}/funnels`}
|
||||||
@@ -120,8 +120,13 @@ export default function CreateFunnelPage() {
|
|||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
placeholder="e.g. Signup Flow"
|
placeholder="e.g. Signup Flow"
|
||||||
|
autoFocus
|
||||||
required
|
required
|
||||||
|
maxLength={100}
|
||||||
/>
|
/>
|
||||||
|
{name.length > 80 && (
|
||||||
|
<span className={`text-xs tabular-nums mt-1 ${name.length > 90 ? 'text-amber-500' : 'text-neutral-400'}`}>{name.length}/100</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||||
@@ -144,7 +149,7 @@ export default function CreateFunnelPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{steps.map((step, index) => (
|
{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="flex items-start gap-4">
|
||||||
<div className="mt-3 text-neutral-400">
|
<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">
|
<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">
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { useParams, useRouter } from 'next/navigation'
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
import { listFunnels, deleteFunnel, type Funnel } from '@/lib/api/funnels'
|
import { listFunnels, deleteFunnel, type Funnel } from '@/lib/api/funnels'
|
||||||
import { toast, LoadingOverlay, PlusIcon, ArrowRightIcon, ChevronLeftIcon, TrashIcon, Button } from '@ciphera-net/ui'
|
import { toast, PlusIcon, ArrowRightIcon, ChevronLeftIcon, TrashIcon, Button } from '@ciphera-net/ui'
|
||||||
|
import { FunnelsListSkeleton, useMinimumLoading } from '@/components/skeletons'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
|
||||||
export default function FunnelsPage() {
|
export default function FunnelsPage() {
|
||||||
@@ -20,7 +21,7 @@ export default function FunnelsPage() {
|
|||||||
const data = await listFunnels(siteId)
|
const data = await listFunnels(siteId)
|
||||||
setFunnels(data)
|
setFunnels(data)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error('Failed to load funnels')
|
toast.error('Failed to load your funnels')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -43,20 +44,16 @@ export default function FunnelsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
const showSkeleton = useMinimumLoading(loading)
|
||||||
return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Pulse" />
|
|
||||||
|
if (showSkeleton) {
|
||||||
|
return <FunnelsListSkeleton />
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
|
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="flex items-center gap-4 mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<Link
|
|
||||||
href={`/sites/${siteId}`}
|
|
||||||
className="p-2 -ml-2 text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white rounded-xl hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
|
|
||||||
>
|
|
||||||
<ChevronLeftIcon className="w-5 h-5" />
|
|
||||||
</Link>
|
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white">
|
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white">
|
||||||
Funnels
|
Funnels
|
||||||
@@ -65,14 +62,12 @@ export default function FunnelsPage() {
|
|||||||
Track user journeys and identify drop-off points
|
Track user journeys and identify drop-off points
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-auto">
|
<Link href={`/sites/${siteId}/funnels/new`}>
|
||||||
<Link href={`/sites/${siteId}/funnels/new`}>
|
<Button variant="primary" className="inline-flex items-center gap-2">
|
||||||
<Button variant="primary" className="inline-flex items-center gap-2">
|
<PlusIcon className="w-4 h-4" />
|
||||||
<PlusIcon className="w-4 h-4" />
|
<span>Create Funnel</span>
|
||||||
<span>Create Funnel</span>
|
</Button>
|
||||||
</Button>
|
</Link>
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{funnels.length === 0 ? (
|
{funnels.length === 0 ? (
|
||||||
@@ -114,7 +109,7 @@ export default function FunnelsPage() {
|
|||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-2 mt-4">
|
<div className="flex items-center gap-2 mt-4">
|
||||||
{funnel.steps.map((step, i) => (
|
{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">
|
<span className="px-2 py-1 bg-neutral-100 dark:bg-neutral-800 rounded-lg text-neutral-700 dark:text-neutral-300">
|
||||||
{step.name}
|
{step.name}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
13
app/sites/[id]/journeys/error.tsx
Normal file
13
app/sites/[id]/journeys/error.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import ErrorDisplay from '@/components/ErrorDisplay'
|
||||||
|
|
||||||
|
export default function JourneysError({ reset }: { error: Error; reset: () => void }) {
|
||||||
|
return (
|
||||||
|
<ErrorDisplay
|
||||||
|
title="Journeys failed to load"
|
||||||
|
message="We couldn't load the journey data. This might be a temporary issue — try again."
|
||||||
|
onRetry={reset}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
9
app/sites/[id]/journeys/layout.tsx
Normal file
9
app/sites/[id]/journeys/layout.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Journeys | Pulse',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function JourneysLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return children
|
||||||
|
}
|
||||||
179
app/sites/[id]/journeys/page.tsx
Normal file
179
app/sites/[id]/journeys/page.tsx
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useParams } from 'next/navigation'
|
||||||
|
import { getDateRange, formatDate } from '@ciphera-net/ui'
|
||||||
|
import { Select, DatePicker } from '@ciphera-net/ui'
|
||||||
|
import SankeyDiagram from '@/components/journeys/SankeyDiagram'
|
||||||
|
import TopPathsTable from '@/components/journeys/TopPathsTable'
|
||||||
|
import { SkeletonCard } from '@/components/skeletons'
|
||||||
|
import {
|
||||||
|
useDashboard,
|
||||||
|
useJourneyTransitions,
|
||||||
|
useJourneyTopPaths,
|
||||||
|
useJourneyEntryPoints,
|
||||||
|
} from '@/lib/swr/dashboard'
|
||||||
|
|
||||||
|
function getThisWeekRange(): { start: string; end: string } {
|
||||||
|
const today = new Date()
|
||||||
|
const dayOfWeek = today.getDay()
|
||||||
|
const monday = new Date(today)
|
||||||
|
monday.setDate(today.getDate() - (dayOfWeek === 0 ? 6 : dayOfWeek - 1))
|
||||||
|
return { start: formatDate(monday), end: formatDate(today) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getThisMonthRange(): { start: string; end: string } {
|
||||||
|
const today = new Date()
|
||||||
|
const firstOfMonth = new Date(today.getFullYear(), today.getMonth(), 1)
|
||||||
|
return { start: formatDate(firstOfMonth), end: formatDate(today) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function JourneysPage() {
|
||||||
|
const params = useParams()
|
||||||
|
const siteId = params.id as string
|
||||||
|
|
||||||
|
const [period, setPeriod] = useState('30')
|
||||||
|
const [dateRange, setDateRange] = useState(() => getDateRange(30))
|
||||||
|
const [isDatePickerOpen, setIsDatePickerOpen] = useState(false)
|
||||||
|
const [depth, setDepth] = useState(3)
|
||||||
|
const [entryPath, setEntryPath] = useState('')
|
||||||
|
|
||||||
|
const { data: transitionsData, isLoading: transitionsLoading } = useJourneyTransitions(
|
||||||
|
siteId, dateRange.start, dateRange.end, depth, 2, entryPath || undefined
|
||||||
|
)
|
||||||
|
const { data: topPaths, isLoading: topPathsLoading } = useJourneyTopPaths(
|
||||||
|
siteId, dateRange.start, dateRange.end, 20, 2, entryPath || undefined
|
||||||
|
)
|
||||||
|
const { data: entryPoints } = useJourneyEntryPoints(siteId, dateRange.start, dateRange.end)
|
||||||
|
const { data: dashboard } = useDashboard(siteId, dateRange.start, dateRange.end)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const domain = dashboard?.site?.domain
|
||||||
|
document.title = domain ? `Journeys \u00b7 ${domain} | Pulse` : 'Journeys | Pulse'
|
||||||
|
}, [dashboard?.site?.domain])
|
||||||
|
|
||||||
|
const entryPointOptions = [
|
||||||
|
{ value: '', label: 'All entry points' },
|
||||||
|
...(entryPoints ?? []).map((ep) => ({
|
||||||
|
value: ep.path,
|
||||||
|
label: `${ep.path} (${ep.session_count.toLocaleString()})`,
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-1">
|
||||||
|
Journeys
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||||
|
How visitors navigate through your site
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
variant="input"
|
||||||
|
className="min-w-[140px]"
|
||||||
|
value={period}
|
||||||
|
onChange={(value) => {
|
||||||
|
if (value === 'today') {
|
||||||
|
const today = formatDate(new Date())
|
||||||
|
setDateRange({ start: today, end: today })
|
||||||
|
setPeriod('today')
|
||||||
|
} else if (value === '7') {
|
||||||
|
setDateRange(getDateRange(7))
|
||||||
|
setPeriod('7')
|
||||||
|
} else if (value === 'week') {
|
||||||
|
setDateRange(getThisWeekRange())
|
||||||
|
setPeriod('week')
|
||||||
|
} else if (value === '30') {
|
||||||
|
setDateRange(getDateRange(30))
|
||||||
|
setPeriod('30')
|
||||||
|
} else if (value === 'month') {
|
||||||
|
setDateRange(getThisMonthRange())
|
||||||
|
setPeriod('month')
|
||||||
|
} else if (value === 'custom') {
|
||||||
|
setIsDatePickerOpen(true)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
options={[
|
||||||
|
{ value: 'today', label: 'Today' },
|
||||||
|
{ value: '7', label: 'Last 7 days' },
|
||||||
|
{ value: '30', label: 'Last 30 days' },
|
||||||
|
{ value: 'divider-1', label: '', divider: true },
|
||||||
|
{ value: 'week', label: 'This week' },
|
||||||
|
{ value: 'month', label: 'This month' },
|
||||||
|
{ value: 'divider-2', label: '', divider: true },
|
||||||
|
{ value: 'custom', label: 'Custom' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="flex flex-wrap items-center gap-4 mb-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="text-sm text-neutral-500 dark:text-neutral-400">Depth</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={2}
|
||||||
|
max={5}
|
||||||
|
step={1}
|
||||||
|
value={depth}
|
||||||
|
onChange={(e) => setDepth(Number(e.target.value))}
|
||||||
|
className="w-32 accent-brand-orange"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium text-neutral-900 dark:text-white w-4">{depth}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
variant="input"
|
||||||
|
className="min-w-[180px]"
|
||||||
|
value={entryPath}
|
||||||
|
onChange={(value) => setEntryPath(value)}
|
||||||
|
options={entryPointOptions}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(depth !== 3 || entryPath) && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setDepth(3); setEntryPath('') }}
|
||||||
|
className="text-sm text-neutral-500 hover:text-neutral-900 dark:hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sankey Diagram */}
|
||||||
|
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 mb-6">
|
||||||
|
{transitionsLoading ? (
|
||||||
|
<div className="h-[400px] flex items-center justify-center">
|
||||||
|
<SkeletonCard className="w-full h-full" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<SankeyDiagram
|
||||||
|
transitions={transitionsData?.transitions ?? []}
|
||||||
|
totalSessions={transitionsData?.total_sessions ?? 0}
|
||||||
|
depth={depth}
|
||||||
|
onNodeClick={(path) => setEntryPath(path)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Top Paths */}
|
||||||
|
<TopPathsTable paths={topPaths ?? []} loading={topPathsLoading} />
|
||||||
|
|
||||||
|
{/* Date Picker Modal */}
|
||||||
|
<DatePicker
|
||||||
|
isOpen={isDatePickerOpen}
|
||||||
|
onClose={() => setIsDatePickerOpen(false)}
|
||||||
|
onApply={(range) => {
|
||||||
|
setDateRange(range)
|
||||||
|
setPeriod('custom')
|
||||||
|
setIsDatePickerOpen(false)
|
||||||
|
}}
|
||||||
|
initialRange={dateRange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
19
app/sites/[id]/layout.tsx
Normal file
19
app/sites/[id]/layout.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import SiteLayoutShell from './SiteLayoutShell'
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Dashboard | Pulse',
|
||||||
|
description: 'View your site analytics, traffic, and performance.',
|
||||||
|
robots: { index: false, follow: false },
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function SiteLayout({
|
||||||
|
children,
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
params: Promise<{ id: string }>
|
||||||
|
}) {
|
||||||
|
const { id } = await params
|
||||||
|
return <SiteLayoutShell siteId={id}>{children}</SiteLayoutShell>
|
||||||
|
}
|
||||||
@@ -1,97 +1,389 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useAuth } from '@/lib/auth/context'
|
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { logger } from '@/lib/utils/logger'
|
||||||
import { useParams, useRouter } from 'next/navigation'
|
import { useCallback, useEffect, useRef, useState, useMemo } from 'react'
|
||||||
import { motion } from 'framer-motion'
|
import { useParams, useRouter, useSearchParams } from 'next/navigation'
|
||||||
import { getSite, type Site } from '@/lib/api/sites'
|
import {
|
||||||
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'
|
getPerformanceByPage,
|
||||||
import { formatNumber, formatDuration, getDateRange } from '@ciphera-net/ui'
|
getTopPages,
|
||||||
|
getTopReferrers,
|
||||||
|
getCountries,
|
||||||
|
getCities,
|
||||||
|
getRegions,
|
||||||
|
getBrowsers,
|
||||||
|
getOS,
|
||||||
|
getDevices,
|
||||||
|
getCampaigns,
|
||||||
|
type Stats,
|
||||||
|
type DailyStat,
|
||||||
|
} from '@/lib/api/stats'
|
||||||
|
import { getDateRange, formatDate } from '@ciphera-net/ui'
|
||||||
import { toast } from '@ciphera-net/ui'
|
import { toast } from '@ciphera-net/ui'
|
||||||
import { getAuthErrorMessage } from '@ciphera-net/ui'
|
import { Button } from '@ciphera-net/ui'
|
||||||
import { LoadingOverlay, Button } from '@ciphera-net/ui'
|
|
||||||
import { Select, DatePicker, DownloadIcon } from '@ciphera-net/ui'
|
import { Select, DatePicker, DownloadIcon } from '@ciphera-net/ui'
|
||||||
import ExportModal from '@/components/dashboard/ExportModal'
|
import dynamic from 'next/dynamic'
|
||||||
|
import { DashboardSkeleton, useMinimumLoading } from '@/components/skeletons'
|
||||||
|
import FilterBar from '@/components/dashboard/FilterBar'
|
||||||
|
import AddFilterDropdown, { type FilterSuggestion, type FilterSuggestions } from '@/components/dashboard/AddFilterDropdown'
|
||||||
|
import Chart from '@/components/dashboard/Chart'
|
||||||
import ContentStats from '@/components/dashboard/ContentStats'
|
import ContentStats from '@/components/dashboard/ContentStats'
|
||||||
import TopReferrers from '@/components/dashboard/TopReferrers'
|
import TopReferrers from '@/components/dashboard/TopReferrers'
|
||||||
import Locations from '@/components/dashboard/Locations'
|
import Locations from '@/components/dashboard/Locations'
|
||||||
import TechSpecs from '@/components/dashboard/TechSpecs'
|
import TechSpecs from '@/components/dashboard/TechSpecs'
|
||||||
import Chart from '@/components/dashboard/Chart'
|
|
||||||
import PerformanceStats from '@/components/dashboard/PerformanceStats'
|
const PerformanceStats = dynamic(() => import('@/components/dashboard/PerformanceStats'))
|
||||||
import GoalStats from '@/components/dashboard/GoalStats'
|
const GoalStats = dynamic(() => import('@/components/dashboard/GoalStats'))
|
||||||
import Campaigns from '@/components/dashboard/Campaigns'
|
const Campaigns = dynamic(() => import('@/components/dashboard/Campaigns'))
|
||||||
|
const PeakHours = dynamic(() => import('@/components/dashboard/PeakHours'))
|
||||||
|
const EventProperties = dynamic(() => import('@/components/dashboard/EventProperties'))
|
||||||
|
const ExportModal = dynamic(() => import('@/components/dashboard/ExportModal'))
|
||||||
|
import { type DimensionFilter, serializeFilters, parseFiltersFromURL } from '@/lib/filters'
|
||||||
|
import {
|
||||||
|
useDashboard,
|
||||||
|
useRealtime,
|
||||||
|
useStats,
|
||||||
|
useDailyStats,
|
||||||
|
useCampaigns,
|
||||||
|
useAnnotations,
|
||||||
|
} from '@/lib/swr/dashboard'
|
||||||
|
import { createAnnotation, updateAnnotation, deleteAnnotation, type AnnotationCategory } from '@/lib/api/annotations'
|
||||||
|
|
||||||
|
function loadSavedSettings(): {
|
||||||
|
type?: string
|
||||||
|
dateRange?: { start: string; end: string }
|
||||||
|
todayInterval?: 'minute' | 'hour'
|
||||||
|
multiDayInterval?: 'hour' | 'day'
|
||||||
|
} | null {
|
||||||
|
if (typeof window === 'undefined') return null
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('pulse_dashboard_settings')
|
||||||
|
return saved ? JSON.parse(saved) : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getThisWeekRange(): { start: string; end: string } {
|
||||||
|
const today = new Date()
|
||||||
|
const dayOfWeek = today.getDay()
|
||||||
|
const monday = new Date(today)
|
||||||
|
monday.setDate(today.getDate() - (dayOfWeek === 0 ? 6 : dayOfWeek - 1))
|
||||||
|
return { start: formatDate(monday), end: formatDate(today) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getThisMonthRange(): { start: string; end: string } {
|
||||||
|
const today = new Date()
|
||||||
|
const firstOfMonth = new Date(today.getFullYear(), today.getMonth(), 1)
|
||||||
|
return { start: formatDate(firstOfMonth), end: formatDate(today) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInitialDateRange(): { start: string; end: string } {
|
||||||
|
const settings = loadSavedSettings()
|
||||||
|
if (settings?.type === 'today') {
|
||||||
|
const today = formatDate(new Date())
|
||||||
|
return { start: today, end: today }
|
||||||
|
}
|
||||||
|
if (settings?.type === '7') return getDateRange(7)
|
||||||
|
if (settings?.type === 'week') return getThisWeekRange()
|
||||||
|
if (settings?.type === 'month') return getThisMonthRange()
|
||||||
|
if (settings?.type === 'custom' && settings.dateRange) return settings.dateRange
|
||||||
|
return getDateRange(30)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInitialPeriod(): string {
|
||||||
|
return loadSavedSettings()?.type || '30'
|
||||||
|
}
|
||||||
|
|
||||||
export default function SiteDashboardPage() {
|
export default function SiteDashboardPage() {
|
||||||
const { user } = useAuth()
|
|
||||||
const canEdit = user?.role === 'owner' || user?.role === 'admin'
|
|
||||||
|
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const siteId = params.id as string
|
const siteId = params.id as string
|
||||||
|
|
||||||
const [site, setSite] = useState<Site | null>(null)
|
// UI state - initialized from localStorage synchronously to avoid double-fetch
|
||||||
const [loading, setLoading] = useState(true)
|
const [period, setPeriod] = useState(getInitialPeriod)
|
||||||
const [stats, setStats] = useState<Stats>({ pageviews: 0, visitors: 0, bounce_rate: 0, avg_duration: 0 })
|
const [dateRange, setDateRange] = useState(getInitialDateRange)
|
||||||
const [prevStats, setPrevStats] = useState<Stats | undefined>(undefined)
|
const [todayInterval, setTodayInterval] = useState<'minute' | 'hour'>(
|
||||||
const [realtime, setRealtime] = useState(0)
|
() => loadSavedSettings()?.todayInterval || 'hour'
|
||||||
const [dailyStats, setDailyStats] = useState<DailyStat[]>([])
|
)
|
||||||
const [prevDailyStats, setPrevDailyStats] = useState<DailyStat[] | undefined>(undefined)
|
const [multiDayInterval, setMultiDayInterval] = useState<'hour' | 'day'>(
|
||||||
const [topPages, setTopPages] = useState<any[]>([])
|
() => loadSavedSettings()?.multiDayInterval || 'day'
|
||||||
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))
|
|
||||||
const [isDatePickerOpen, setIsDatePickerOpen] = useState(false)
|
const [isDatePickerOpen, setIsDatePickerOpen] = useState(false)
|
||||||
const [isExportModalOpen, setIsExportModalOpen] = useState(false)
|
const [isExportModalOpen, setIsExportModalOpen] = useState(false)
|
||||||
const [todayInterval, setTodayInterval] = useState<'minute' | 'hour'>('hour')
|
const lastUpdatedAtRef = useRef<number | null>(null)
|
||||||
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
|
// Dimension filters state
|
||||||
useEffect(() => {
|
const searchParams = useSearchParams()
|
||||||
try {
|
const [filters, setFilters] = useState<DimensionFilter[]>(() => {
|
||||||
const savedSettings = localStorage.getItem('pulse_dashboard_settings')
|
const raw = searchParams.get('filters')
|
||||||
if (savedSettings) {
|
return raw ? parseFiltersFromURL(raw) : []
|
||||||
const settings = JSON.parse(savedSettings)
|
})
|
||||||
|
const filtersParam = useMemo(() => serializeFilters(filters), [filters])
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Restore intervals
|
// Selected event for property breakdown
|
||||||
if (settings.todayInterval) setTodayInterval(settings.todayInterval)
|
const [selectedEvent, setSelectedEvent] = useState<string | null>(null)
|
||||||
if (settings.multiDayInterval) setMultiDayInterval(settings.multiDayInterval)
|
|
||||||
}
|
const handleAddFilter = useCallback((filter: DimensionFilter) => {
|
||||||
} catch (e) {
|
setFilters(prev => {
|
||||||
console.error('Failed to load dashboard settings', e)
|
const isDuplicate = prev.some(
|
||||||
} finally {
|
f => f.dimension === filter.dimension && f.operator === filter.operator && f.values.join(';') === filter.values.join(';')
|
||||||
setIsSettingsLoaded(true)
|
)
|
||||||
}
|
if (isDuplicate) return prev
|
||||||
|
return [...prev, filter]
|
||||||
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const handleRemoveFilter = useCallback((index: number) => {
|
||||||
|
setFilters(prev => prev.filter((_, i) => i !== index))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleClearFilters = useCallback(() => {
|
||||||
|
setFilters([])
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Fetch full suggestion list (up to 100) when a dimension is selected in the filter dropdown
|
||||||
|
const handleFetchSuggestions = useCallback(async (dimension: string): Promise<FilterSuggestion[]> => {
|
||||||
|
const start = dateRange.start
|
||||||
|
const end = dateRange.end
|
||||||
|
const f = filtersParam || undefined
|
||||||
|
const limit = 100
|
||||||
|
|
||||||
|
try {
|
||||||
|
const regionNames = (() => { try { return new Intl.DisplayNames(['en'], { type: 'region' }) } catch { return null } })()
|
||||||
|
|
||||||
|
switch (dimension) {
|
||||||
|
case 'page': {
|
||||||
|
const data = await getTopPages(siteId, start, end, limit, f)
|
||||||
|
return data.map(p => ({ value: p.path, label: p.path, count: p.pageviews }))
|
||||||
|
}
|
||||||
|
case 'referrer': {
|
||||||
|
const data = await getTopReferrers(siteId, start, end, limit, f)
|
||||||
|
return data.filter(r => r.referrer && r.referrer !== '').map(r => ({ value: r.referrer, label: r.referrer, count: r.pageviews }))
|
||||||
|
}
|
||||||
|
case 'country': {
|
||||||
|
const data = await getCountries(siteId, start, end, limit, f)
|
||||||
|
return data.filter(c => c.country && c.country !== 'Unknown').map(c => ({ value: c.country, label: regionNames?.of(c.country) ?? c.country, count: c.pageviews }))
|
||||||
|
}
|
||||||
|
case 'city': {
|
||||||
|
const data = await getCities(siteId, start, end, limit, f)
|
||||||
|
return data.filter(c => c.city && c.city !== 'Unknown').map(c => ({ value: c.city, label: c.city, count: c.pageviews }))
|
||||||
|
}
|
||||||
|
case 'region': {
|
||||||
|
const data = await getRegions(siteId, start, end, limit, f)
|
||||||
|
return data.filter(r => r.region && r.region !== 'Unknown').map(r => ({ value: r.region, label: r.region, count: r.pageviews }))
|
||||||
|
}
|
||||||
|
case 'browser': {
|
||||||
|
const data = await getBrowsers(siteId, start, end, limit, f)
|
||||||
|
return data.filter(b => b.browser && b.browser !== 'Unknown').map(b => ({ value: b.browser, label: b.browser, count: b.pageviews }))
|
||||||
|
}
|
||||||
|
case 'os': {
|
||||||
|
const data = await getOS(siteId, start, end, limit, f)
|
||||||
|
return data.filter(o => o.os && o.os !== 'Unknown').map(o => ({ value: o.os, label: o.os, count: o.pageviews }))
|
||||||
|
}
|
||||||
|
case 'device': {
|
||||||
|
const data = await getDevices(siteId, start, end, limit, f)
|
||||||
|
return data.filter(d => d.device && d.device !== 'Unknown').map(d => ({ value: d.device, label: d.device, count: d.pageviews }))
|
||||||
|
}
|
||||||
|
case 'utm_source':
|
||||||
|
case 'utm_medium':
|
||||||
|
case 'utm_campaign': {
|
||||||
|
const data = await getCampaigns(siteId, start, end, limit, f)
|
||||||
|
const map = new Map<string, number>()
|
||||||
|
const field = dimension === 'utm_source' ? 'source' : dimension === 'utm_medium' ? 'medium' : 'campaign'
|
||||||
|
data.forEach(c => {
|
||||||
|
const val = c[field]
|
||||||
|
if (val) map.set(val, (map.get(val) ?? 0) + c.pageviews)
|
||||||
|
})
|
||||||
|
return [...map.entries()].map(([v, count]) => ({ value: v, label: v, count }))
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}, [siteId, dateRange.start, dateRange.end, filtersParam])
|
||||||
|
|
||||||
|
// Sync filters to URL
|
||||||
|
useEffect(() => {
|
||||||
|
const url = new URL(window.location.href)
|
||||||
|
if (filtersParam) {
|
||||||
|
url.searchParams.set('filters', filtersParam)
|
||||||
|
} else {
|
||||||
|
url.searchParams.delete('filters')
|
||||||
|
}
|
||||||
|
window.history.replaceState({}, '', url.toString())
|
||||||
|
}, [filtersParam])
|
||||||
|
|
||||||
|
const interval = dateRange.start === dateRange.end ? todayInterval : multiDayInterval
|
||||||
|
|
||||||
|
// Previous period date range for comparison
|
||||||
|
const prevRange = useMemo(() => {
|
||||||
|
const startDate = new Date(dateRange.start)
|
||||||
|
const endDate = new Date(dateRange.end)
|
||||||
|
const duration = endDate.getTime() - startDate.getTime()
|
||||||
|
if (duration === 0) {
|
||||||
|
const prevEnd = new Date(startDate.getTime() - 24 * 60 * 60 * 1000)
|
||||||
|
return { start: prevEnd.toISOString().split('T')[0], end: prevEnd.toISOString().split('T')[0] }
|
||||||
|
}
|
||||||
|
const prevEnd = new Date(startDate.getTime() - 24 * 60 * 60 * 1000)
|
||||||
|
const prevStart = new Date(prevEnd.getTime() - duration)
|
||||||
|
return { start: prevStart.toISOString().split('T')[0], end: prevEnd.toISOString().split('T')[0] }
|
||||||
|
}, [dateRange])
|
||||||
|
|
||||||
|
// Single dashboard request replaces 7 focused hooks (overview, pages, locations,
|
||||||
|
// devices, referrers, performance, goals). The backend runs all queries in parallel
|
||||||
|
// and caches the result in Redis, reducing requests from 12 to 6 per refresh cycle.
|
||||||
|
const { data: dashboard, isLoading: dashboardLoading, error: dashboardError } = useDashboard(siteId, dateRange.start, dateRange.end, interval, filtersParam || undefined)
|
||||||
|
const { data: realtimeData } = useRealtime(siteId)
|
||||||
|
const { data: prevStats } = useStats(siteId, prevRange.start, prevRange.end)
|
||||||
|
const { data: prevDailyStats } = useDailyStats(siteId, prevRange.start, prevRange.end, interval)
|
||||||
|
const { data: campaigns } = useCampaigns(siteId, dateRange.start, dateRange.end)
|
||||||
|
const { data: annotations, mutate: mutateAnnotations } = useAnnotations(siteId, dateRange.start, dateRange.end)
|
||||||
|
|
||||||
|
// Annotation mutation handlers
|
||||||
|
const handleCreateAnnotation = async (data: { date: string; time?: string; text: string; category: string }) => {
|
||||||
|
await createAnnotation(siteId, { ...data, category: data.category as AnnotationCategory })
|
||||||
|
mutateAnnotations()
|
||||||
|
toast.success('Annotation added')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdateAnnotation = async (id: string, data: { date: string; time?: string; text: string; category: string }) => {
|
||||||
|
await updateAnnotation(siteId, id, { ...data, category: data.category as AnnotationCategory })
|
||||||
|
mutateAnnotations()
|
||||||
|
toast.success('Annotation updated')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteAnnotation = async (id: string) => {
|
||||||
|
await deleteAnnotation(siteId, id)
|
||||||
|
mutateAnnotations()
|
||||||
|
toast.success('Annotation deleted')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive typed values from single dashboard response
|
||||||
|
const site = dashboard?.site ?? null
|
||||||
|
const stats: Stats = dashboard?.stats ?? { pageviews: 0, visitors: 0, bounce_rate: 0, avg_duration: 0 }
|
||||||
|
const realtime = realtimeData?.visitors ?? dashboard?.realtime_visitors ?? 0
|
||||||
|
const dailyStats: DailyStat[] = dashboard?.daily_stats ?? []
|
||||||
|
|
||||||
|
// Build filter suggestions from current dashboard data
|
||||||
|
const filterSuggestions = useMemo<FilterSuggestions>(() => {
|
||||||
|
const s: FilterSuggestions = {}
|
||||||
|
|
||||||
|
// Pages
|
||||||
|
const topPages = dashboard?.top_pages ?? []
|
||||||
|
if (topPages.length > 0) {
|
||||||
|
s.page = topPages.map(p => ({ value: p.path, label: p.path, count: p.pageviews }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Referrers
|
||||||
|
const refs = dashboard?.top_referrers ?? []
|
||||||
|
if (refs.length > 0) {
|
||||||
|
s.referrer = refs.filter(r => r.referrer && r.referrer !== '').map(r => ({
|
||||||
|
value: r.referrer,
|
||||||
|
label: r.referrer,
|
||||||
|
count: r.pageviews,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Countries
|
||||||
|
const ctrs = dashboard?.countries ?? []
|
||||||
|
if (ctrs.length > 0) {
|
||||||
|
const regionNames = (() => { try { return new Intl.DisplayNames(['en'], { type: 'region' }) } catch { return null } })()
|
||||||
|
s.country = ctrs.filter(c => c.country && c.country !== 'Unknown').map(c => ({
|
||||||
|
value: c.country,
|
||||||
|
label: regionNames?.of(c.country) ?? c.country,
|
||||||
|
count: c.pageviews,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regions
|
||||||
|
const regs = dashboard?.regions ?? []
|
||||||
|
if (regs.length > 0) {
|
||||||
|
s.region = regs.filter(r => r.region && r.region !== 'Unknown').map(r => ({
|
||||||
|
value: r.region,
|
||||||
|
label: r.region,
|
||||||
|
count: r.pageviews,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cities
|
||||||
|
const cts = dashboard?.cities ?? []
|
||||||
|
if (cts.length > 0) {
|
||||||
|
s.city = cts.filter(c => c.city && c.city !== 'Unknown').map(c => ({
|
||||||
|
value: c.city,
|
||||||
|
label: c.city,
|
||||||
|
count: c.pageviews,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Browsers
|
||||||
|
const brs = dashboard?.browsers ?? []
|
||||||
|
if (brs.length > 0) {
|
||||||
|
s.browser = brs.filter(b => b.browser && b.browser !== 'Unknown').map(b => ({
|
||||||
|
value: b.browser,
|
||||||
|
label: b.browser,
|
||||||
|
count: b.pageviews,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// OS
|
||||||
|
const oses = dashboard?.os ?? []
|
||||||
|
if (oses.length > 0) {
|
||||||
|
s.os = oses.filter(o => o.os && o.os !== 'Unknown').map(o => ({
|
||||||
|
value: o.os,
|
||||||
|
label: o.os,
|
||||||
|
count: o.pageviews,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Devices
|
||||||
|
const devs = dashboard?.devices ?? []
|
||||||
|
if (devs.length > 0) {
|
||||||
|
s.device = devs.filter(d => d.device && d.device !== 'Unknown').map(d => ({
|
||||||
|
value: d.device,
|
||||||
|
label: d.device,
|
||||||
|
count: d.pageviews,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// UTM from campaigns
|
||||||
|
const camps = campaigns ?? []
|
||||||
|
if (camps.length > 0) {
|
||||||
|
const sources = new Map<string, number>()
|
||||||
|
const mediums = new Map<string, number>()
|
||||||
|
const campNames = new Map<string, number>()
|
||||||
|
camps.forEach(c => {
|
||||||
|
if (c.source) sources.set(c.source, (sources.get(c.source) ?? 0) + c.pageviews)
|
||||||
|
if (c.medium) mediums.set(c.medium, (mediums.get(c.medium) ?? 0) + c.pageviews)
|
||||||
|
if (c.campaign) campNames.set(c.campaign, (campNames.get(c.campaign) ?? 0) + c.pageviews)
|
||||||
|
})
|
||||||
|
if (sources.size > 0) s.utm_source = [...sources.entries()].map(([v, c]) => ({ value: v, label: v, count: c }))
|
||||||
|
if (mediums.size > 0) s.utm_medium = [...mediums.entries()].map(([v, c]) => ({ value: v, label: v, count: c }))
|
||||||
|
if (campNames.size > 0) s.utm_campaign = [...campNames.entries()].map(([v, c]) => ({ value: v, label: v, count: c }))
|
||||||
|
}
|
||||||
|
|
||||||
|
return s
|
||||||
|
}, [dashboard, campaigns])
|
||||||
|
|
||||||
|
// Show error toast on fetch failure
|
||||||
|
useEffect(() => {
|
||||||
|
if (dashboardError) {
|
||||||
|
toast.error('Failed to load dashboard analytics')
|
||||||
|
}
|
||||||
|
}, [dashboardError])
|
||||||
|
|
||||||
|
// Track when data was last updated (for "Live · Xs ago" display)
|
||||||
|
useEffect(() => {
|
||||||
|
if (dashboard) lastUpdatedAtRef.current = Date.now()
|
||||||
|
}, [dashboard])
|
||||||
|
|
||||||
// Save settings to localStorage
|
// Save settings to localStorage
|
||||||
const saveSettings = (type: string, newDateRange?: { start: string, end: string }) => {
|
const saveSettings = (type: string, newDateRange?: { start: string; end: string }) => {
|
||||||
try {
|
try {
|
||||||
const settings = {
|
const settings = {
|
||||||
type,
|
type,
|
||||||
@@ -102,17 +394,14 @@ export default function SiteDashboardPage() {
|
|||||||
}
|
}
|
||||||
localStorage.setItem('pulse_dashboard_settings', JSON.stringify(settings))
|
localStorage.setItem('pulse_dashboard_settings', JSON.stringify(settings))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to save dashboard settings', e)
|
logger.error('Failed to save dashboard settings', e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save intervals when they change
|
// Save intervals when they change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isSettingsLoaded) return
|
|
||||||
|
|
||||||
// Determine current type
|
|
||||||
let type = 'custom'
|
let type = 'custom'
|
||||||
const today = new Date().toISOString().split('T')[0]
|
const today = formatDate(new Date())
|
||||||
if (dateRange.start === today && dateRange.end === today) type = 'today'
|
if (dateRange.start === today && dateRange.end === today) type = 'today'
|
||||||
else if (dateRange.start === getDateRange(7).start) type = '7'
|
else if (dateRange.start === getDateRange(7).start) type = '7'
|
||||||
else if (dateRange.start === getDateRange(30).start) type = '30'
|
else if (dateRange.start === getDateRange(30).start) type = '30'
|
||||||
@@ -125,115 +414,30 @@ export default function SiteDashboardPage() {
|
|||||||
lastUpdated: Date.now()
|
lastUpdated: Date.now()
|
||||||
}
|
}
|
||||||
localStorage.setItem('pulse_dashboard_settings', JSON.stringify(settings))
|
localStorage.setItem('pulse_dashboard_settings', JSON.stringify(settings))
|
||||||
}, [todayInterval, multiDayInterval, isSettingsLoaded]) // dateRange is handled in saveSettings/onChange
|
}, [todayInterval, multiDayInterval]) // eslint-disable-line react-hooks/exhaustive-deps -- dateRange saved via saveSettings
|
||||||
|
|
||||||
// * 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 data: ' + ((error as Error)?.message || 'Unknown error'))
|
|
||||||
}
|
|
||||||
} 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(() => {
|
useEffect(() => {
|
||||||
if (isSettingsLoaded) loadData()
|
if (site?.domain) document.title = `${site.domain} | Pulse`
|
||||||
const interval = setInterval(() => {
|
}, [site?.domain])
|
||||||
loadData(true)
|
|
||||||
loadRealtime()
|
|
||||||
}, 30000)
|
|
||||||
return () => clearInterval(interval)
|
|
||||||
}, [siteId, dateRange, todayInterval, multiDayInterval, isSettingsLoaded, loadData, loadRealtime])
|
|
||||||
|
|
||||||
if (loading) {
|
// Skip the minimum-loading skeleton when SWR already has cached data
|
||||||
return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Pulse" />
|
// (prevents the 300ms flash when navigating back to the dashboard)
|
||||||
|
const showSkeleton = useMinimumLoading(dashboardLoading && !dashboard)
|
||||||
|
|
||||||
|
if (showSkeleton) {
|
||||||
|
return <DashboardSkeleton />
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!site) {
|
if (!site) {
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4 py-8">
|
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
|
||||||
<p className="text-neutral-600 dark:text-neutral-400">Site not found</p>
|
<p className="text-neutral-600 dark:text-neutral-400">Site not found</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
transition={{ duration: 0.2 }}
|
|
||||||
className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8"
|
|
||||||
>
|
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
@@ -245,11 +449,10 @@ export default function SiteDashboardPage() {
|
|||||||
{site.domain}
|
{site.domain}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Realtime Indicator */}
|
{/* Realtime Indicator */}
|
||||||
<button
|
<div
|
||||||
onClick={() => router.push(`/sites/${siteId}/realtime`)}
|
className="flex items-center gap-2 px-3 py-1 bg-green-500/10 rounded-full border border-green-500/20"
|
||||||
className="flex items-center gap-2 px-3 py-1 bg-green-500/10 rounded-full border border-green-500/20 hover:bg-green-500/20 transition-colors cursor-pointer"
|
|
||||||
>
|
>
|
||||||
<span className="relative flex h-2 w-2">
|
<span className="relative flex h-2 w-2">
|
||||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-500 opacity-75"></span>
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-500 opacity-75"></span>
|
||||||
@@ -258,7 +461,7 @@ export default function SiteDashboardPage() {
|
|||||||
<span className="text-sm font-medium text-green-700 dark:text-green-400">
|
<span className="text-sm font-medium text-green-700 dark:text-green-400">
|
||||||
{realtime} current visitors
|
{realtime} current visitors
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -274,33 +477,35 @@ export default function SiteDashboardPage() {
|
|||||||
<Select
|
<Select
|
||||||
variant="input"
|
variant="input"
|
||||||
className="min-w-[140px]"
|
className="min-w-[140px]"
|
||||||
value={
|
value={period}
|
||||||
dateRange.start === new Date().toISOString().split('T')[0] && dateRange.end === new Date().toISOString().split('T')[0]
|
|
||||||
? 'today'
|
|
||||||
: dateRange.start === getDateRange(7).start
|
|
||||||
? '7'
|
|
||||||
: dateRange.start === getDateRange(30).start
|
|
||||||
? '30'
|
|
||||||
: 'custom'
|
|
||||||
}
|
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
if (value === '7') {
|
if (value === 'today') {
|
||||||
const range = getDateRange(7)
|
const today = formatDate(new Date())
|
||||||
setDateRange(range)
|
|
||||||
saveSettings('7', range)
|
|
||||||
}
|
|
||||||
else if (value === '30') {
|
|
||||||
const range = getDateRange(30)
|
|
||||||
setDateRange(range)
|
|
||||||
saveSettings('30', range)
|
|
||||||
}
|
|
||||||
else if (value === 'today') {
|
|
||||||
const today = new Date().toISOString().split('T')[0]
|
|
||||||
const range = { start: today, end: today }
|
const range = { start: today, end: today }
|
||||||
setDateRange(range)
|
setDateRange(range)
|
||||||
|
setPeriod('today')
|
||||||
saveSettings('today', range)
|
saveSettings('today', range)
|
||||||
}
|
} else if (value === '7') {
|
||||||
else if (value === 'custom') {
|
const range = getDateRange(7)
|
||||||
|
setDateRange(range)
|
||||||
|
setPeriod('7')
|
||||||
|
saveSettings('7', range)
|
||||||
|
} else if (value === 'week') {
|
||||||
|
const range = getThisWeekRange()
|
||||||
|
setDateRange(range)
|
||||||
|
setPeriod('week')
|
||||||
|
saveSettings('week', range)
|
||||||
|
} else if (value === '30') {
|
||||||
|
const range = getDateRange(30)
|
||||||
|
setDateRange(range)
|
||||||
|
setPeriod('30')
|
||||||
|
saveSettings('30', range)
|
||||||
|
} else if (value === 'month') {
|
||||||
|
const range = getThisMonthRange()
|
||||||
|
setDateRange(range)
|
||||||
|
setPeriod('month')
|
||||||
|
saveSettings('month', range)
|
||||||
|
} else if (value === 'custom') {
|
||||||
setIsDatePickerOpen(true)
|
setIsDatePickerOpen(true)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -308,57 +513,44 @@ export default function SiteDashboardPage() {
|
|||||||
{ value: 'today', label: 'Today' },
|
{ value: 'today', label: 'Today' },
|
||||||
{ value: '7', label: 'Last 7 days' },
|
{ value: '7', label: 'Last 7 days' },
|
||||||
{ value: '30', label: 'Last 30 days' },
|
{ value: '30', label: 'Last 30 days' },
|
||||||
|
{ value: 'divider-1', label: '', divider: true },
|
||||||
|
{ value: 'week', label: 'This week' },
|
||||||
|
{ value: 'month', label: 'This month' },
|
||||||
|
{ value: 'divider-2', label: '', divider: true },
|
||||||
{ value: 'custom', label: 'Custom' },
|
{ value: 'custom', label: 'Custom' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
className="h-6 w-px bg-neutral-200 dark:bg-neutral-700 flex-shrink-0"
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Button
|
|
||||||
onClick={() => router.push(`/sites/${siteId}/uptime`)}
|
|
||||||
variant="ghost"
|
|
||||||
className="text-sm"
|
|
||||||
>
|
|
||||||
Uptime
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => router.push(`/sites/${siteId}/funnels`)}
|
|
||||||
variant="ghost"
|
|
||||||
className="text-sm"
|
|
||||||
>
|
|
||||||
Funnels
|
|
||||||
</Button>
|
|
||||||
{canEdit && (
|
|
||||||
<Button
|
|
||||||
onClick={() => router.push(`/sites/${siteId}/settings`)}
|
|
||||||
variant="ghost"
|
|
||||||
className="text-sm"
|
|
||||||
>
|
|
||||||
Settings
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Dimension Filters */}
|
||||||
|
<div className="flex items-center gap-2 flex-wrap mb-2">
|
||||||
|
<AddFilterDropdown onAdd={handleAddFilter} suggestions={filterSuggestions} onFetchSuggestions={handleFetchSuggestions} />
|
||||||
|
<FilterBar filters={filters} onRemove={handleRemoveFilter} onClear={handleClearFilters} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Advanced Chart with Integrated Stats */}
|
{/* Advanced Chart with Integrated Stats */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<Chart
|
<Chart
|
||||||
data={dailyStats}
|
data={dailyStats}
|
||||||
prevData={prevDailyStats}
|
prevData={prevDailyStats}
|
||||||
stats={stats}
|
stats={stats}
|
||||||
prevStats={prevStats}
|
prevStats={prevStats}
|
||||||
interval={dateRange.start === dateRange.end ? todayInterval : multiDayInterval}
|
interval={dateRange.start === dateRange.end ? todayInterval : multiDayInterval}
|
||||||
dateRange={dateRange}
|
dateRange={dateRange}
|
||||||
|
period={period}
|
||||||
todayInterval={todayInterval}
|
todayInterval={todayInterval}
|
||||||
setTodayInterval={setTodayInterval}
|
setTodayInterval={setTodayInterval}
|
||||||
multiDayInterval={multiDayInterval}
|
multiDayInterval={multiDayInterval}
|
||||||
setMultiDayInterval={setMultiDayInterval}
|
setMultiDayInterval={setMultiDayInterval}
|
||||||
lastUpdatedAt={lastUpdatedAt}
|
lastUpdatedAt={lastUpdatedAtRef.current}
|
||||||
|
annotations={annotations}
|
||||||
|
canManageAnnotations={true}
|
||||||
|
onCreateAnnotation={handleCreateAnnotation}
|
||||||
|
onUpdateAnnotation={handleUpdateAnnotation}
|
||||||
|
onDeleteAnnotation={handleDeleteAnnotation}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -366,8 +558,8 @@ export default function SiteDashboardPage() {
|
|||||||
{site.enable_performance_insights && (
|
{site.enable_performance_insights && (
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<PerformanceStats
|
<PerformanceStats
|
||||||
stats={performance}
|
stats={dashboard?.performance ?? { lcp: 0, cls: 0, inp: 0 }}
|
||||||
performanceByPage={performanceByPage}
|
performanceByPage={dashboard?.performance_by_page ?? null}
|
||||||
siteId={siteId}
|
siteId={siteId}
|
||||||
startDate={dateRange.start}
|
startDate={dateRange.start}
|
||||||
endDate={dateRange.end}
|
endDate={dateRange.end}
|
||||||
@@ -378,57 +570,77 @@ export default function SiteDashboardPage() {
|
|||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-2 mb-8">
|
<div className="grid gap-6 lg:grid-cols-2 mb-8">
|
||||||
<ContentStats
|
<ContentStats
|
||||||
topPages={topPages}
|
topPages={dashboard?.top_pages ?? []}
|
||||||
entryPages={entryPages}
|
entryPages={dashboard?.entry_pages ?? []}
|
||||||
exitPages={exitPages}
|
exitPages={dashboard?.exit_pages ?? []}
|
||||||
domain={site.domain}
|
domain={site.domain}
|
||||||
collectPagePaths={site.collect_page_paths ?? true}
|
collectPagePaths={site.collect_page_paths ?? true}
|
||||||
siteId={siteId}
|
siteId={siteId}
|
||||||
dateRange={dateRange}
|
dateRange={dateRange}
|
||||||
|
onFilter={handleAddFilter}
|
||||||
/>
|
/>
|
||||||
<TopReferrers
|
<TopReferrers
|
||||||
referrers={topReferrers}
|
referrers={dashboard?.top_referrers ?? []}
|
||||||
collectReferrers={site.collect_referrers ?? true}
|
collectReferrers={site.collect_referrers ?? true}
|
||||||
siteId={siteId}
|
siteId={siteId}
|
||||||
dateRange={dateRange}
|
dateRange={dateRange}
|
||||||
|
onFilter={handleAddFilter}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-2 mb-8">
|
<div className="grid gap-6 lg:grid-cols-2 mb-8">
|
||||||
<Locations
|
<Locations
|
||||||
countries={countries}
|
countries={dashboard?.countries ?? []}
|
||||||
cities={cities}
|
cities={dashboard?.cities ?? []}
|
||||||
regions={regions}
|
regions={dashboard?.regions ?? []}
|
||||||
geoDataLevel={site.collect_geo_data || 'full'}
|
geoDataLevel={site.collect_geo_data || 'full'}
|
||||||
siteId={siteId}
|
siteId={siteId}
|
||||||
dateRange={dateRange}
|
dateRange={dateRange}
|
||||||
|
onFilter={handleAddFilter}
|
||||||
/>
|
/>
|
||||||
<TechSpecs
|
<TechSpecs
|
||||||
browsers={browsers}
|
browsers={dashboard?.browsers ?? []}
|
||||||
os={os}
|
os={dashboard?.os ?? []}
|
||||||
devices={devices}
|
devices={dashboard?.devices ?? []}
|
||||||
screenResolutions={screenResolutions}
|
screenResolutions={dashboard?.screen_resolutions ?? []}
|
||||||
collectDeviceInfo={site.collect_device_info ?? true}
|
collectDeviceInfo={site.collect_device_info ?? true}
|
||||||
collectScreenResolution={site.collect_screen_resolution ?? true}
|
collectScreenResolution={site.collect_screen_resolution ?? true}
|
||||||
siteId={siteId}
|
siteId={siteId}
|
||||||
dateRange={dateRange}
|
dateRange={dateRange}
|
||||||
|
onFilter={handleAddFilter}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Campaigns Report */}
|
<div className="grid gap-6 lg:grid-cols-2 mb-8">
|
||||||
<div className="mb-8">
|
<Campaigns siteId={siteId} dateRange={dateRange} filters={filtersParam || undefined} onFilter={handleAddFilter} />
|
||||||
<Campaigns siteId={siteId} dateRange={dateRange} />
|
<PeakHours siteId={siteId} dateRange={dateRange} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<GoalStats goalCounts={goalCounts} />
|
<GoalStats
|
||||||
|
goalCounts={(dashboard?.goal_counts ?? []).filter(g => !/^scroll_\d+$/.test(g.event_name))}
|
||||||
|
onSelectEvent={setSelectedEvent}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Event Properties Breakdown */}
|
||||||
|
{selectedEvent && (
|
||||||
|
<div className="mb-8">
|
||||||
|
<EventProperties
|
||||||
|
siteId={siteId}
|
||||||
|
eventName={selectedEvent}
|
||||||
|
dateRange={dateRange}
|
||||||
|
onClose={() => setSelectedEvent(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<DatePicker
|
<DatePicker
|
||||||
isOpen={isDatePickerOpen}
|
isOpen={isDatePickerOpen}
|
||||||
onClose={() => setIsDatePickerOpen(false)}
|
onClose={() => setIsDatePickerOpen(false)}
|
||||||
onApply={(range) => {
|
onApply={(range) => {
|
||||||
setDateRange(range)
|
setDateRange(range)
|
||||||
|
setPeriod('custom')
|
||||||
saveSettings('custom', range)
|
saveSettings('custom', range)
|
||||||
setIsDatePickerOpen(false)
|
setIsDatePickerOpen(false)
|
||||||
}}
|
}}
|
||||||
@@ -440,10 +652,10 @@ export default function SiteDashboardPage() {
|
|||||||
onClose={() => setIsExportModalOpen(false)}
|
onClose={() => setIsExportModalOpen(false)}
|
||||||
data={dailyStats}
|
data={dailyStats}
|
||||||
stats={stats}
|
stats={stats}
|
||||||
topPages={topPages}
|
topPages={dashboard?.top_pages}
|
||||||
topReferrers={topReferrers}
|
topReferrers={dashboard?.top_referrers}
|
||||||
campaigns={campaigns}
|
campaigns={campaigns}
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,251 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { useParams, useRouter } from 'next/navigation'
|
|
||||||
import { getSite, type Site } from '@/lib/api/sites'
|
|
||||||
import { getRealtimeVisitors, getSessionDetails, type Visitor, type SessionEvent } from '@/lib/api/realtime'
|
|
||||||
import { toast } from '@ciphera-net/ui'
|
|
||||||
import { getAuthErrorMessage } from '@ciphera-net/ui'
|
|
||||||
import { LoadingOverlay, UserIcon } from '@ciphera-net/ui'
|
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
|
||||||
|
|
||||||
function formatTimeAgo(dateString: string) {
|
|
||||||
const date = new Date(dateString)
|
|
||||||
const now = new Date()
|
|
||||||
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000)
|
|
||||||
|
|
||||||
if (diffInSeconds < 60) return 'just now'
|
|
||||||
if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)}m ago`
|
|
||||||
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)}h ago`
|
|
||||||
return `${Math.floor(diffInSeconds / 86400)}d ago`
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function RealtimePage() {
|
|
||||||
const params = useParams()
|
|
||||||
const router = useRouter()
|
|
||||||
const siteId = params.id as string
|
|
||||||
|
|
||||||
const [site, setSite] = useState<Site | null>(null)
|
|
||||||
const [visitors, setVisitors] = useState<Visitor[]>([])
|
|
||||||
const [selectedVisitor, setSelectedVisitor] = useState<Visitor | null>(null)
|
|
||||||
const [sessionEvents, setSessionEvents] = useState<SessionEvent[]>([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [loadingEvents, setLoadingEvents] = useState(false)
|
|
||||||
|
|
||||||
// Load site info and initial visitors
|
|
||||||
useEffect(() => {
|
|
||||||
const init = async () => {
|
|
||||||
try {
|
|
||||||
const [siteData, visitorsData] = await Promise.all([
|
|
||||||
getSite(siteId),
|
|
||||||
getRealtimeVisitors(siteId)
|
|
||||||
])
|
|
||||||
setSite(siteData)
|
|
||||||
setVisitors(visitorsData || [])
|
|
||||||
// Select first visitor if available
|
|
||||||
if (visitorsData && visitorsData.length > 0) {
|
|
||||||
handleSelectVisitor(visitorsData[0])
|
|
||||||
}
|
|
||||||
} catch (error: unknown) {
|
|
||||||
toast.error(getAuthErrorMessage(error) || 'Failed to load data')
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
init()
|
|
||||||
}, [siteId])
|
|
||||||
|
|
||||||
// Poll for updates
|
|
||||||
useEffect(() => {
|
|
||||||
const interval = setInterval(async () => {
|
|
||||||
try {
|
|
||||||
const data = await getRealtimeVisitors(siteId)
|
|
||||||
setVisitors(data || [])
|
|
||||||
|
|
||||||
// Update selected visitor reference if they are still in the list
|
|
||||||
if (selectedVisitor) {
|
|
||||||
const updatedVisitor = data?.find(v => v.session_id === selectedVisitor.session_id)
|
|
||||||
if (updatedVisitor) {
|
|
||||||
// Don't overwrite the selectedVisitor state directly to avoid flickering details
|
|
||||||
// But we could update "last seen" indicators if we wanted
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Silent fail
|
|
||||||
}
|
|
||||||
}, 5000)
|
|
||||||
return () => clearInterval(interval)
|
|
||||||
}, [siteId, selectedVisitor])
|
|
||||||
|
|
||||||
const handleSelectVisitor = async (visitor: Visitor) => {
|
|
||||||
setSelectedVisitor(visitor)
|
|
||||||
setLoadingEvents(true)
|
|
||||||
try {
|
|
||||||
const events = await getSessionDetails(siteId, visitor.session_id)
|
|
||||||
setSessionEvents(events || [])
|
|
||||||
} catch (error: unknown) {
|
|
||||||
toast.error(getAuthErrorMessage(error) || 'Failed to load session details')
|
|
||||||
} finally {
|
|
||||||
setLoadingEvents(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Realtime" />
|
|
||||||
if (!site) return <div className="p-8">Site not found</div>
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8 h-[calc(100vh-64px)] flex flex-col">
|
|
||||||
<div className="mb-6 flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<button onClick={() => router.push(`/sites/${siteId}`)} className="text-sm text-neutral-500 hover:text-neutral-900 dark:hover:text-white transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded">
|
|
||||||
← Back to Dashboard
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white flex items-center gap-3">
|
|
||||||
Realtime Visitors
|
|
||||||
<span className="relative flex h-3 w-3">
|
|
||||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
|
|
||||||
<span className="relative inline-flex rounded-full h-3 w-3 bg-green-500"></span>
|
|
||||||
</span>
|
|
||||||
<span className="text-lg font-normal text-neutral-500" aria-live="polite" aria-atomic="true">
|
|
||||||
{visitors.length} active now
|
|
||||||
</span>
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col md:flex-row flex-1 gap-6 min-h-0">
|
|
||||||
{/* Visitors List */}
|
|
||||||
<div className="w-full md:w-1/3 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden flex flex-col bg-white dark:bg-neutral-900">
|
|
||||||
<div className="p-4 border-b border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-800/50">
|
|
||||||
<h2 className="text-xl font-semibold text-neutral-900 dark:text-white">Active Sessions</h2>
|
|
||||||
</div>
|
|
||||||
<div className="overflow-y-auto flex-1">
|
|
||||||
{visitors.length === 0 ? (
|
|
||||||
<div className="p-8 flex flex-col items-center justify-center text-center gap-3">
|
|
||||||
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-3">
|
|
||||||
<UserIcon className="w-6 h-6 text-neutral-500 dark:text-neutral-400" />
|
|
||||||
</div>
|
|
||||||
<p className="text-sm font-medium text-neutral-900 dark:text-white">
|
|
||||||
No active visitors right now
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
|
||||||
New visitors will appear here in real-time
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="divide-y divide-neutral-100 dark:divide-neutral-800">
|
|
||||||
<AnimatePresence mode="popLayout">
|
|
||||||
{visitors.map((visitor) => (
|
|
||||||
<motion.button
|
|
||||||
key={visitor.session_id}
|
|
||||||
initial={{ opacity: 0, x: -10 }}
|
|
||||||
animate={{ opacity: 1, x: 0 }}
|
|
||||||
exit={{ opacity: 0, x: -10 }}
|
|
||||||
transition={{ duration: 0.2 }}
|
|
||||||
onClick={() => handleSelectVisitor(visitor)}
|
|
||||||
className={`w-full text-left p-4 hover:bg-neutral-50 dark:hover:bg-neutral-800/50 transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-inset ${
|
|
||||||
selectedVisitor?.session_id === visitor.session_id ? 'bg-neutral-50 dark:bg-neutral-800/50 ring-1 ring-inset ring-neutral-200 dark:ring-neutral-700' : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex justify-between items-start mb-1">
|
|
||||||
<div className="font-medium text-neutral-900 dark:text-white truncate pr-2">
|
|
||||||
{visitor.country ? `${getFlagEmoji(visitor.country)} ${visitor.city || 'Unknown City'}` : 'Unknown Location'}
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-neutral-500 whitespace-nowrap">
|
|
||||||
{formatTimeAgo(visitor.last_seen)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-neutral-600 dark:text-neutral-400 truncate mb-1" title={visitor.current_path}>
|
|
||||||
{visitor.current_path}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-xs text-neutral-400">
|
|
||||||
<span>{visitor.device_type}</span>
|
|
||||||
<span>•</span>
|
|
||||||
<span>{visitor.browser}</span>
|
|
||||||
<span>•</span>
|
|
||||||
<span>{visitor.os}</span>
|
|
||||||
<span className="ml-auto bg-neutral-100 dark:bg-neutral-800 px-1.5 py-0.5 rounded text-neutral-600 dark:text-neutral-400">
|
|
||||||
{visitor.pageviews} views
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</motion.button>
|
|
||||||
))}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Session Details */}
|
|
||||||
<div className="flex-1 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden flex flex-col bg-white dark:bg-neutral-900">
|
|
||||||
<div className="p-4 border-b border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-800/50 flex justify-between items-center">
|
|
||||||
<h2 className="text-xl font-semibold text-neutral-900 dark:text-white">
|
|
||||||
{selectedVisitor ? 'Session Journey' : 'Select a visitor'}
|
|
||||||
</h2>
|
|
||||||
{selectedVisitor && (
|
|
||||||
<span className="text-xs font-mono text-neutral-400">
|
|
||||||
ID: {selectedVisitor.session_id.substring(0, 8)}...
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
|
||||||
{!selectedVisitor ? (
|
|
||||||
<div className="h-full flex items-center justify-center text-neutral-500">
|
|
||||||
Select a visitor on the left to see their activity.
|
|
||||||
</div>
|
|
||||||
) : loadingEvents ? (
|
|
||||||
<div className="h-full flex items-center justify-center">
|
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-neutral-900 dark:border-white"></div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="relative pl-6 border-l-2 border-neutral-100 dark:border-neutral-800 space-y-8">
|
|
||||||
{sessionEvents.map((event, idx) => (
|
|
||||||
<div key={event.id} className="relative">
|
|
||||||
<span className={`absolute -left-[29px] top-1 h-3 w-3 rounded-full border-2 border-white dark:border-neutral-900 ${
|
|
||||||
idx === 0 ? 'bg-green-500 ring-4 ring-green-100 dark:ring-green-900/30' : 'bg-neutral-300 dark:bg-neutral-700'
|
|
||||||
}`}></span>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm font-medium text-neutral-900 dark:text-white">
|
|
||||||
Visited {event.path}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-neutral-500">
|
|
||||||
{new Date(event.timestamp).toLocaleTimeString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{event.referrer && (
|
|
||||||
<div className="text-xs text-neutral-500">
|
|
||||||
Referrer: <span className="text-neutral-700 dark:text-neutral-300">{event.referrer}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<div className="relative">
|
|
||||||
<span className="absolute -left-[29px] top-1 h-3 w-3 rounded-full border-2 border-white dark:border-neutral-900 bg-neutral-300 dark:bg-neutral-700"></span>
|
|
||||||
<div className="text-sm text-neutral-500">
|
|
||||||
Session started {formatTimeAgo(sessionEvents[sessionEvents.length - 1]?.timestamp || new Date().toISOString())}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFlagEmoji(countryCode: string) {
|
|
||||||
if (!countryCode || countryCode.length !== 2) return '🌍'
|
|
||||||
const codePoints = countryCode
|
|
||||||
.toUpperCase()
|
|
||||||
.split('')
|
|
||||||
.map(char => 127397 + char.charCodeAt(0))
|
|
||||||
return String.fromCodePoint(...codePoints)
|
|
||||||
}
|
|
||||||
13
app/sites/[id]/settings/error.tsx
Normal file
13
app/sites/[id]/settings/error.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import ErrorDisplay from '@/components/ErrorDisplay'
|
||||||
|
|
||||||
|
export default function SiteSettingsError({ reset }: { error: Error; reset: () => void }) {
|
||||||
|
return (
|
||||||
|
<ErrorDisplay
|
||||||
|
title="Settings failed to load"
|
||||||
|
message="We couldn't load your site settings. Please try again."
|
||||||
|
onRetry={reset}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
15
app/sites/[id]/settings/layout.tsx
Normal file
15
app/sites/[id]/settings/layout.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Site Settings | Pulse',
|
||||||
|
description: 'Configure your site tracking, privacy, and goals.',
|
||||||
|
robots: { index: false, follow: false },
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SiteSettingsLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return children
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
13
app/sites/[id]/uptime/error.tsx
Normal file
13
app/sites/[id]/uptime/error.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import ErrorDisplay from '@/components/ErrorDisplay'
|
||||||
|
|
||||||
|
export default function UptimeError({ reset }: { error: Error; reset: () => void }) {
|
||||||
|
return (
|
||||||
|
<ErrorDisplay
|
||||||
|
title="Uptime page failed to load"
|
||||||
|
message="We couldn't load your uptime monitors. This might be a temporary issue — try again."
|
||||||
|
onRetry={reset}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
15
app/sites/[id]/uptime/layout.tsx
Normal file
15
app/sites/[id]/uptime/layout.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Uptime | Pulse',
|
||||||
|
description: 'Monitor your site uptime and response times.',
|
||||||
|
robots: { index: false, follow: false },
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UptimeLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return children
|
||||||
|
}
|
||||||
@@ -20,35 +20,23 @@ import {
|
|||||||
import { toast } from '@ciphera-net/ui'
|
import { toast } from '@ciphera-net/ui'
|
||||||
import { useTheme } from '@ciphera-net/ui'
|
import { useTheme } from '@ciphera-net/ui'
|
||||||
import { getAuthErrorMessage } from '@ciphera-net/ui'
|
import { getAuthErrorMessage } from '@ciphera-net/ui'
|
||||||
import { LoadingOverlay, Button, Modal } from '@ciphera-net/ui'
|
import { Button, Modal } from '@ciphera-net/ui'
|
||||||
|
import { UptimeSkeleton, ChecksSkeleton, useMinimumLoading } from '@/components/skeletons'
|
||||||
import {
|
import {
|
||||||
AreaChart,
|
AreaChart,
|
||||||
Area,
|
Area,
|
||||||
XAxis,
|
XAxis,
|
||||||
YAxis,
|
YAxis,
|
||||||
CartesianGrid,
|
CartesianGrid,
|
||||||
Tooltip as RechartsTooltip,
|
|
||||||
ResponsiveContainer,
|
|
||||||
} from 'recharts'
|
} from 'recharts'
|
||||||
import type { TooltipProps } from 'recharts'
|
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@/components/charts'
|
||||||
|
|
||||||
// * Chart theme colors (consistent with main Pulse chart)
|
const responseTimeChartConfig = {
|
||||||
const CHART_COLORS_LIGHT = {
|
ms: {
|
||||||
border: '#E5E5E5',
|
label: 'Response Time',
|
||||||
text: '#171717',
|
color: 'var(--chart-1)',
|
||||||
textMuted: '#737373',
|
},
|
||||||
axis: '#A3A3A3',
|
} satisfies ChartConfig
|
||||||
tooltipBg: '#ffffff',
|
|
||||||
tooltipBorder: '#E5E5E5',
|
|
||||||
}
|
|
||||||
const CHART_COLORS_DARK = {
|
|
||||||
border: '#404040',
|
|
||||||
text: '#fafafa',
|
|
||||||
textMuted: '#a3a3a3',
|
|
||||||
axis: '#737373',
|
|
||||||
tooltipBg: '#262626',
|
|
||||||
tooltipBorder: '#404040',
|
|
||||||
}
|
|
||||||
|
|
||||||
// * Status color mapping
|
// * Status color mapping
|
||||||
function getStatusColor(status: string): string {
|
function getStatusColor(status: string): string {
|
||||||
@@ -189,7 +177,7 @@ function StatusBarTooltip({
|
|||||||
className="fixed z-50 pointer-events-none"
|
className="fixed z-50 pointer-events-none"
|
||||||
style={{ left: position.x, top: position.y - 10, transform: 'translate(-50%, -100%)' }}
|
style={{ left: position.x, top: position.y - 10, transform: 'translate(-50%, -100%)' }}
|
||||||
>
|
>
|
||||||
<div className="bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-xl shadow-lg px-3 py-2.5 text-xs min-w-[160px]">
|
<div className="bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-xl shadow-lg transition-shadow duration-300 px-3 py-2.5 text-xs min-w-40">
|
||||||
<div className="font-semibold text-neutral-900 dark:text-white mb-1.5">{formattedDate}</div>
|
<div className="font-semibold text-neutral-900 dark:text-white mb-1.5">{formattedDate}</div>
|
||||||
{stat && stat.total_checks > 0 ? (
|
{stat && stat.total_checks > 0 ? (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -256,7 +244,7 @@ function UptimeStatusBar({
|
|||||||
className="relative"
|
className="relative"
|
||||||
onMouseLeave={() => setHoveredDay(null)}
|
onMouseLeave={() => setHoveredDay(null)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-[2px] w-full">
|
<div className="flex items-center gap-0.5 w-full">
|
||||||
{dateRange.map((date) => {
|
{dateRange.map((date) => {
|
||||||
const stat = statsMap.get(date)
|
const stat = statsMap.get(date)
|
||||||
const barColor = getDayBarColor(stat)
|
const barColor = getDayBarColor(stat)
|
||||||
@@ -264,7 +252,7 @@ function UptimeStatusBar({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={date}
|
key={date}
|
||||||
className={`flex-1 h-8 rounded-[2px] ${barColor} transition-all duration-150 hover:opacity-80 cursor-pointer min-w-[3px]`}
|
className={`flex-1 h-8 rounded-sm ${barColor} transition-all duration-150 hover:opacity-80 cursor-pointer min-w-[3px]`}
|
||||||
onMouseEnter={(e) => handleMouseEnter(e, date, stat)}
|
onMouseEnter={(e) => handleMouseEnter(e, date, stat)}
|
||||||
onMouseLeave={() => setHoveredDay(null)}
|
onMouseLeave={() => setHoveredDay(null)}
|
||||||
/>
|
/>
|
||||||
@@ -283,9 +271,6 @@ function UptimeStatusBar({
|
|||||||
|
|
||||||
// * Component: Response time chart (Recharts area chart)
|
// * Component: Response time chart (Recharts area chart)
|
||||||
function ResponseTimeChart({ checks }: { checks: UptimeCheck[] }) {
|
function ResponseTimeChart({ checks }: { checks: UptimeCheck[] }) {
|
||||||
const { theme } = useTheme()
|
|
||||||
const colors = theme === 'dark' ? CHART_COLORS_DARK : CHART_COLORS_LIGHT
|
|
||||||
|
|
||||||
// * Prepare data in chronological order (oldest first)
|
// * Prepare data in chronological order (oldest first)
|
||||||
const data = [...checks]
|
const data = [...checks]
|
||||||
.reverse()
|
.reverse()
|
||||||
@@ -301,71 +286,58 @@ function ResponseTimeChart({ checks }: { checks: UptimeCheck[] }) {
|
|||||||
|
|
||||||
if (data.length < 2) return null
|
if (data.length < 2) return null
|
||||||
|
|
||||||
const CustomTooltip = ({ active, payload, label }: TooltipProps<number, string>) => {
|
|
||||||
if (!active || !payload?.length) return null
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="rounded-xl px-3 py-2 text-xs shadow-lg border"
|
|
||||||
style={{
|
|
||||||
background: colors.tooltipBg,
|
|
||||||
borderColor: colors.tooltipBorder,
|
|
||||||
color: colors.text,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="font-medium mb-0.5">{label}</div>
|
|
||||||
<div style={{ color: 'var(--color-brand-orange)' }} className="font-semibold">
|
|
||||||
{payload[0].value}ms
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<h4 className="text-xs font-semibold text-neutral-500 dark:text-neutral-400 uppercase tracking-wider mb-3">
|
<h4 className="text-xs font-semibold text-neutral-500 dark:text-neutral-400 uppercase tracking-wider mb-3">
|
||||||
Response Time
|
Response Time
|
||||||
</h4>
|
</h4>
|
||||||
<div className="h-40">
|
<ChartContainer config={responseTimeChartConfig} className="h-40">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<AreaChart accessibilityLayer data={data} margin={{ top: 5, right: 5, left: -20, bottom: 0 }}>
|
||||||
<AreaChart data={data} margin={{ top: 5, right: 5, left: -20, bottom: 0 }}>
|
<defs>
|
||||||
<defs>
|
<linearGradient id="responseTimeGradient" x1="0" y1="0" x2="0" y2="1">
|
||||||
<linearGradient id="responseTimeGradient" x1="0" y1="0" x2="0" y2="1">
|
<stop offset="0%" stopColor="var(--color-ms)" stopOpacity={0.3} />
|
||||||
<stop offset="0%" stopColor="var(--color-brand-orange)" stopOpacity={0.3} />
|
<stop offset="100%" stopColor="var(--color-ms)" stopOpacity={0.02} />
|
||||||
<stop offset="100%" stopColor="var(--color-brand-orange)" stopOpacity={0.02} />
|
</linearGradient>
|
||||||
</linearGradient>
|
</defs>
|
||||||
</defs>
|
<CartesianGrid
|
||||||
<CartesianGrid
|
strokeDasharray="3 3"
|
||||||
strokeDasharray="3 3"
|
stroke="var(--chart-grid)"
|
||||||
stroke={colors.border}
|
strokeOpacity={0.5}
|
||||||
strokeOpacity={0.5}
|
vertical={false}
|
||||||
vertical={false}
|
/>
|
||||||
/>
|
<XAxis
|
||||||
<XAxis
|
dataKey="time"
|
||||||
dataKey="time"
|
tick={{ fontSize: 10, fill: 'var(--chart-axis)' }}
|
||||||
tick={{ fontSize: 10, fill: colors.axis }}
|
tickLine={false}
|
||||||
tickLine={false}
|
axisLine={false}
|
||||||
axisLine={false}
|
interval="preserveStartEnd"
|
||||||
interval="preserveStartEnd"
|
/>
|
||||||
/>
|
<YAxis
|
||||||
<YAxis
|
tick={{ fontSize: 10, fill: 'var(--chart-axis)' }}
|
||||||
tick={{ fontSize: 10, fill: colors.axis }}
|
tickLine={false}
|
||||||
tickLine={false}
|
axisLine={false}
|
||||||
axisLine={false}
|
tickFormatter={(v: number) => `${v}ms`}
|
||||||
tickFormatter={(v: number) => `${v}ms`}
|
/>
|
||||||
/>
|
<ChartTooltip
|
||||||
<RechartsTooltip content={<CustomTooltip />} />
|
content={
|
||||||
<Area
|
<ChartTooltipContent
|
||||||
type="monotone"
|
className="text-xs"
|
||||||
dataKey="ms"
|
labelKey="time"
|
||||||
stroke="var(--color-brand-orange)"
|
formatter={(value) => <span className="font-semibold">{value}ms</span>}
|
||||||
strokeWidth={2}
|
/>
|
||||||
fill="url(#responseTimeGradient)"
|
}
|
||||||
dot={false}
|
/>
|
||||||
activeDot={{ r: 4, fill: 'var(--color-brand-orange)', strokeWidth: 0 }}
|
<Area
|
||||||
/>
|
type="monotone"
|
||||||
</AreaChart>
|
dataKey="ms"
|
||||||
</ResponsiveContainer>
|
stroke="var(--color-ms)"
|
||||||
</div>
|
strokeWidth={2}
|
||||||
|
fill="url(#responseTimeGradient)"
|
||||||
|
dot={false}
|
||||||
|
activeDot={{ r: 4, fill: 'var(--color-ms)', strokeWidth: 0 }}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ChartContainer>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -473,7 +445,7 @@ function MonitorCard({
|
|||||||
<div className="text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider mb-1">
|
<div className="text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider mb-1">
|
||||||
Status
|
Status
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-2">
|
||||||
<div className={`w-2 h-2 rounded-full ${getStatusDotColor(monitor.last_status)}`} />
|
<div className={`w-2 h-2 rounded-full ${getStatusDotColor(monitor.last_status)}`} />
|
||||||
<span className="text-sm font-medium text-neutral-900 dark:text-white">
|
<span className="text-sm font-medium text-neutral-900 dark:text-white">
|
||||||
{getStatusLabel(monitor.last_status)}
|
{getStatusLabel(monitor.last_status)}
|
||||||
@@ -510,9 +482,7 @@ function MonitorCard({
|
|||||||
|
|
||||||
{/* Response time chart */}
|
{/* Response time chart */}
|
||||||
{loadingChecks ? (
|
{loadingChecks ? (
|
||||||
<div className="text-center py-4 text-neutral-500 dark:text-neutral-400 text-sm">
|
<ChecksSkeleton />
|
||||||
Loading checks...
|
|
||||||
</div>
|
|
||||||
) : checks.length > 0 ? (
|
) : checks.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<ResponseTimeChart checks={checks} />
|
<ResponseTimeChart checks={checks} />
|
||||||
@@ -616,7 +586,7 @@ export default function UptimePage() {
|
|||||||
setSite(siteData)
|
setSite(siteData)
|
||||||
setUptimeData(statusData)
|
setUptimeData(statusData)
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
toast.error(getAuthErrorMessage(error) || 'Failed to load uptime data')
|
toast.error(getAuthErrorMessage(error) || 'Failed to load uptime monitors')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -704,7 +674,13 @@ export default function UptimePage() {
|
|||||||
setShowEditModal(true)
|
setShowEditModal(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Uptime" />
|
useEffect(() => {
|
||||||
|
if (site?.domain) document.title = `Uptime · ${site.domain} | Pulse`
|
||||||
|
}, [site?.domain])
|
||||||
|
|
||||||
|
const showSkeleton = useMinimumLoading(loading)
|
||||||
|
|
||||||
|
if (showSkeleton) return <UptimeSkeleton />
|
||||||
if (!site) return <div className="p-8 text-neutral-500">Site not found</div>
|
if (!site) return <div className="p-8 text-neutral-500">Site not found</div>
|
||||||
|
|
||||||
const monitors = Array.isArray(uptimeData?.monitors) ? uptimeData.monitors : []
|
const monitors = Array.isArray(uptimeData?.monitors) ? uptimeData.monitors : []
|
||||||
@@ -712,27 +688,13 @@ export default function UptimePage() {
|
|||||||
const overallStatus = uptimeData?.status ?? 'operational'
|
const overallStatus = uptimeData?.status ?? 'operational'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
transition={{ duration: 0.2 }}
|
|
||||||
className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8"
|
|
||||||
>
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-1">
|
||||||
<button
|
Uptime
|
||||||
onClick={() => router.push(`/sites/${siteId}`)}
|
</h1>
|
||||||
className="text-sm text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200 transition-colors"
|
|
||||||
>
|
|
||||||
{site.name}
|
|
||||||
</button>
|
|
||||||
<span className="text-neutral-300 dark:text-neutral-600">/</span>
|
|
||||||
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white">
|
|
||||||
Uptime
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||||
Monitor your endpoints and track availability over time
|
Monitor your endpoints and track availability over time
|
||||||
</p>
|
</p>
|
||||||
@@ -846,7 +808,7 @@ export default function UptimePage() {
|
|||||||
siteDomain={site.domain}
|
siteDomain={site.domain}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
</motion.div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -932,8 +894,13 @@ function MonitorForm({
|
|||||||
value={formData.name}
|
value={formData.name}
|
||||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
placeholder="e.g. API, Website, CDN"
|
placeholder="e.g. API, Website, CDN"
|
||||||
|
autoFocus
|
||||||
|
maxLength={100}
|
||||||
className="w-full px-3 py-2 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-white placeholder-neutral-400 focus:outline-none focus:ring-2 focus:ring-brand-orange focus:border-transparent text-sm"
|
className="w-full px-3 py-2 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-white placeholder-neutral-400 focus:outline-none focus:ring-2 focus:ring-brand-orange focus:border-transparent text-sm"
|
||||||
/>
|
/>
|
||||||
|
{formData.name.length > 80 && (
|
||||||
|
<span className={`text-xs tabular-nums mt-1 ${formData.name.length > 90 ? 'text-amber-500' : 'text-neutral-400'}`}>{formData.name.length}/100</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* URL with protocol dropdown + domain prefix */}
|
{/* URL with protocol dropdown + domain prefix */}
|
||||||
@@ -955,7 +922,7 @@ function MonitorForm({
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{showProtocolDropdown && (
|
{showProtocolDropdown && (
|
||||||
<div className="absolute top-full left-0 mt-1 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg shadow-lg z-10 min-w-[100px]">
|
<div className="absolute top-full left-0 mt-1 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg shadow-lg transition-shadow duration-300 z-10 min-w-[100px]">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleProtocolChange('https://')}
|
onClick={() => handleProtocolChange('https://')}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
|
import { logger } from '@/lib/utils/logger'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { createSite, listSites, getSite, type Site } from '@/lib/api/sites'
|
import { createSite, listSites, getSite, type Site } from '@/lib/api/sites'
|
||||||
import { getSubscription } from '@/lib/api/billing'
|
import { getSubscription } from '@/lib/api/billing'
|
||||||
|
import { getSitesLimitForPlan } from '@/lib/plans'
|
||||||
import { trackSiteCreatedFromDashboard, trackSiteCreatedScriptCopied } from '@/lib/welcomeAnalytics'
|
import { trackSiteCreatedFromDashboard, trackSiteCreatedScriptCopied } from '@/lib/welcomeAnalytics'
|
||||||
import { toast } from '@ciphera-net/ui'
|
import { toast } from '@ciphera-net/ui'
|
||||||
import { getAuthErrorMessage } from '@ciphera-net/ui'
|
import { getAuthErrorMessage } from '@ciphera-net/ui'
|
||||||
@@ -57,13 +59,14 @@ export default function NewSitePage() {
|
|||||||
getSubscription()
|
getSubscription()
|
||||||
])
|
])
|
||||||
|
|
||||||
if (subscription?.plan_id === 'solo' && sites.length >= 1) {
|
const siteLimit = subscription?.plan_id ? getSitesLimitForPlan(subscription.plan_id) : null
|
||||||
|
if (siteLimit != null && sites.length >= siteLimit) {
|
||||||
setAtLimit(true)
|
setAtLimit(true)
|
||||||
toast.error('Solo plan limit reached (1 site). Please upgrade to add more sites.')
|
toast.error(`${subscription.plan_id} plan limit reached (${siteLimit} site${siteLimit === 1 ? '' : 's'}). Please upgrade to add more sites.`)
|
||||||
router.replace('/')
|
router.replace('/')
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to check limits', error)
|
logger.error('Failed to check limits', error)
|
||||||
} finally {
|
} finally {
|
||||||
setLimitsChecked(true)
|
setLimitsChecked(true)
|
||||||
}
|
}
|
||||||
@@ -85,7 +88,7 @@ export default function NewSitePage() {
|
|||||||
sessionStorage.setItem(LAST_CREATED_SITE_KEY, JSON.stringify({ id: site.id }))
|
sessionStorage.setItem(LAST_CREATED_SITE_KEY, JSON.stringify({ id: site.id }))
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
toast.error(getAuthErrorMessage(error) || 'Failed to create site: ' + ((error as Error)?.message || 'Unknown error'))
|
toast.error(getAuthErrorMessage(error) || 'Failed to create site. Please try again.')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -104,8 +107,8 @@ export default function NewSitePage() {
|
|||||||
// * Step 2: Framework picker + script (same as /welcome after adding first site)
|
// * Step 2: Framework picker + script (same as /welcome after adding first site)
|
||||||
if (createdSite) {
|
if (createdSite) {
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4 py-8 max-w-2xl">
|
<div className="w-full max-w-2xl mx-auto px-4 sm:px-6 py-8">
|
||||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-8">
|
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-green-500/10 text-green-600 dark:text-green-400 mb-6">
|
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-green-500/10 text-green-600 dark:text-green-400 mb-6">
|
||||||
<CheckCircleIcon className="h-7 w-7" />
|
<CheckCircleIcon className="h-7 w-7" />
|
||||||
@@ -130,7 +133,7 @@ export default function NewSitePage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowVerificationModal(true)}
|
onClick={() => setShowVerificationModal(true)}
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 text-neutral-700 dark:text-neutral-300 rounded-xl hover:bg-neutral-50 dark:hover:bg-neutral-700 transition-all text-sm font-medium focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-offset-2"
|
className="flex items-center gap-2 px-4 py-2 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 text-neutral-700 dark:text-neutral-300 rounded-xl hover:bg-neutral-50 dark:hover:bg-neutral-700 transition-all text-sm font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:ring-offset-2"
|
||||||
>
|
>
|
||||||
<span className="text-brand-orange">Verify installation</span>
|
<span className="text-brand-orange">Verify installation</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -150,10 +153,10 @@ export default function NewSitePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8 flex flex-col sm:flex-row gap-3 justify-center">
|
<div className="mt-8 flex flex-col sm:flex-row gap-3 justify-center">
|
||||||
<Button variant="primary" onClick={goToDashboard} className="min-w-[160px]">
|
<Button variant="primary" onClick={goToDashboard} className="min-w-40">
|
||||||
Back to dashboard
|
Back to dashboard
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="secondary" onClick={() => router.push(`/sites/${createdSite.id}`)} className="min-w-[160px]">
|
<Button variant="secondary" onClick={() => router.push(`/sites/${createdSite.id}`)} className="min-w-40">
|
||||||
View {createdSite.name}
|
View {createdSite.name}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -170,7 +173,7 @@ export default function NewSitePage() {
|
|||||||
|
|
||||||
// * Step 1: Name & domain form
|
// * Step 1: Name & domain form
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4 py-8 max-w-2xl">
|
<div className="w-full max-w-2xl mx-auto px-4 sm:px-6 py-8">
|
||||||
<h1 className="text-2xl font-bold mb-8 text-neutral-900 dark:text-white">
|
<h1 className="text-2xl font-bold mb-8 text-neutral-900 dark:text-white">
|
||||||
Create New Site
|
Create New Site
|
||||||
</h1>
|
</h1>
|
||||||
@@ -189,6 +192,8 @@ export default function NewSitePage() {
|
|||||||
<Input
|
<Input
|
||||||
id="name"
|
id="name"
|
||||||
required
|
required
|
||||||
|
autoFocus
|
||||||
|
maxLength={100}
|
||||||
value={formData.name}
|
value={formData.name}
|
||||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
placeholder="My Website"
|
placeholder="My Website"
|
||||||
@@ -202,6 +207,7 @@ export default function NewSitePage() {
|
|||||||
<Input
|
<Input
|
||||||
id="domain"
|
id="domain"
|
||||||
required
|
required
|
||||||
|
maxLength={253}
|
||||||
value={formData.domain}
|
value={formData.domain}
|
||||||
onChange={(e) => setFormData({ ...formData, domain: e.target.value.toLowerCase().trim() })}
|
onChange={(e) => setFormData({ ...formData, domain: e.target.value.toLowerCase().trim() })}
|
||||||
placeholder="example.com"
|
placeholder="example.com"
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ function WelcomeContent() {
|
|||||||
setStep(3)
|
setStep(3)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(getAuthErrorMessage(err) || 'Failed to switch organization')
|
toast.error(getAuthErrorMessage(err) || 'Failed to switch workspace')
|
||||||
} finally {
|
} finally {
|
||||||
setSwitchingOrgId(null)
|
setSwitchingOrgId(null)
|
||||||
}
|
}
|
||||||
@@ -332,13 +332,13 @@ function WelcomeContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cardClass =
|
const cardClass =
|
||||||
'bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl shadow-sm p-8 max-w-lg mx-auto'
|
'bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl shadow-sm p-6 max-w-lg mx-auto'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col items-center justify-center bg-neutral-50 dark:bg-neutral-950 px-4 py-12">
|
<div className="flex-1 flex flex-col items-center justify-center bg-neutral-50 dark:bg-neutral-950 px-4 py-12">
|
||||||
<div className="w-full max-w-lg">
|
<div className="w-full max-w-lg">
|
||||||
<div
|
<div
|
||||||
className="flex justify-center gap-1.5 mb-8"
|
className="flex justify-center gap-2 mb-8"
|
||||||
role="progressbar"
|
role="progressbar"
|
||||||
aria-valuenow={step}
|
aria-valuenow={step}
|
||||||
aria-valuemin={1}
|
aria-valuemin={1}
|
||||||
@@ -475,7 +475,7 @@ function WelcomeContent() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setStep(1)}
|
onClick={() => setStep(1)}
|
||||||
className="flex items-center gap-1.5 text-sm text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300 mb-6 focus:outline-none focus:ring-2 focus:ring-brand-orange rounded"
|
className="flex items-center gap-2 text-sm text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300 mb-6 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange rounded"
|
||||||
aria-label="Back to welcome"
|
aria-label="Back to welcome"
|
||||||
>
|
>
|
||||||
<ArrowLeftIcon className="h-4 w-4" />
|
<ArrowLeftIcon className="h-4 w-4" />
|
||||||
@@ -485,7 +485,7 @@ function WelcomeContent() {
|
|||||||
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-brand-orange/10 text-brand-orange mb-4">
|
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-brand-orange/10 text-brand-orange mb-4">
|
||||||
<BarChartIcon className="h-7 w-7" />
|
<BarChartIcon className="h-7 w-7" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-xl font-bold text-neutral-900 dark:text-white">
|
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white">
|
||||||
Name your organization
|
Name your organization
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
|
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
|
||||||
@@ -546,7 +546,7 @@ function WelcomeContent() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setStep(2)}
|
onClick={() => setStep(2)}
|
||||||
className="flex items-center gap-1.5 text-sm text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300 mb-6 focus:outline-none focus:ring-2 focus:ring-brand-orange rounded"
|
className="flex items-center gap-2 text-sm text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300 mb-6 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange rounded"
|
||||||
aria-label="Back to organization"
|
aria-label="Back to organization"
|
||||||
>
|
>
|
||||||
<ArrowLeftIcon className="h-4 w-4" />
|
<ArrowLeftIcon className="h-4 w-4" />
|
||||||
@@ -556,7 +556,7 @@ function WelcomeContent() {
|
|||||||
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-green-500/10 text-green-600 dark:text-green-400 mb-4">
|
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-green-500/10 text-green-600 dark:text-green-400 mb-4">
|
||||||
<CheckCircleIcon className="h-7 w-7" />
|
<CheckCircleIcon className="h-7 w-7" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-xl font-bold text-neutral-900 dark:text-white">
|
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white">
|
||||||
{showPendingCheckoutInStep3 ? 'Complete your plan' : "You're on the free plan"}
|
{showPendingCheckoutInStep3 ? 'Complete your plan' : "You're on the free plan"}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
|
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
|
||||||
@@ -604,14 +604,14 @@ function WelcomeContent() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => router.push('/pricing')}
|
onClick={() => router.push('/pricing')}
|
||||||
className="text-sm text-brand-orange hover:underline focus:outline-none focus:ring-2 focus:ring-brand-orange rounded"
|
className="text-sm text-brand-orange hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange rounded"
|
||||||
>
|
>
|
||||||
Choose a different plan
|
Choose a different plan
|
||||||
</button>
|
</button>
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="mt-4 text-center">
|
<p className="mt-4 text-center">
|
||||||
<Link href="/pricing" className="text-sm text-brand-orange hover:underline focus:outline-none focus:ring-2 focus:ring-brand-orange rounded">
|
<Link href="/pricing" className="text-sm text-brand-orange hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange rounded">
|
||||||
View pricing
|
View pricing
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
@@ -631,7 +631,7 @@ function WelcomeContent() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setStep(3)}
|
onClick={() => setStep(3)}
|
||||||
className="flex items-center gap-1.5 text-sm text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300 mb-6 focus:outline-none focus:ring-2 focus:ring-brand-orange rounded"
|
className="flex items-center gap-2 text-sm text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300 mb-6 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange rounded"
|
||||||
aria-label="Back to plan"
|
aria-label="Back to plan"
|
||||||
>
|
>
|
||||||
<ArrowLeftIcon className="h-4 w-4" />
|
<ArrowLeftIcon className="h-4 w-4" />
|
||||||
@@ -641,7 +641,7 @@ function WelcomeContent() {
|
|||||||
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-brand-orange/10 text-brand-orange mb-4">
|
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-brand-orange/10 text-brand-orange mb-4">
|
||||||
<GlobeIcon className="h-7 w-7" />
|
<GlobeIcon className="h-7 w-7" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-xl font-bold text-neutral-900 dark:text-white">
|
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white">
|
||||||
Add your first site
|
Add your first site
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
|
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
|
||||||
@@ -659,6 +659,7 @@ function WelcomeContent() {
|
|||||||
placeholder="My Website"
|
placeholder="My Website"
|
||||||
value={siteName}
|
value={siteName}
|
||||||
onChange={(e) => setSiteName(e.target.value)}
|
onChange={(e) => setSiteName(e.target.value)}
|
||||||
|
maxLength={100}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -672,6 +673,7 @@ function WelcomeContent() {
|
|||||||
placeholder="example.com"
|
placeholder="example.com"
|
||||||
value={siteDomain}
|
value={siteDomain}
|
||||||
onChange={(e) => setSiteDomain(e.target.value.toLowerCase().trim())}
|
onChange={(e) => setSiteDomain(e.target.value.toLowerCase().trim())}
|
||||||
|
maxLength={253}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
|
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
|
||||||
@@ -748,7 +750,7 @@ function WelcomeContent() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowVerificationModal(true)}
|
onClick={() => setShowVerificationModal(true)}
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 text-neutral-700 dark:text-neutral-300 rounded-xl hover:bg-neutral-50 dark:hover:bg-neutral-700 transition-all text-sm font-medium focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-offset-2"
|
className="flex items-center gap-2 px-4 py-2 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 text-neutral-700 dark:text-neutral-300 rounded-xl hover:bg-neutral-50 dark:hover:bg-neutral-700 transition-all text-sm font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:ring-offset-2"
|
||||||
>
|
>
|
||||||
<span className="text-brand-orange">Verify installation</span>
|
<span className="text-brand-orange">Verify installation</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -759,11 +761,11 @@ function WelcomeContent() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-8 flex flex-col sm:flex-row gap-3 justify-center">
|
<div className="mt-8 flex flex-col sm:flex-row gap-3 justify-center">
|
||||||
<Button variant="primary" onClick={goToDashboard} className="min-w-[160px]">
|
<Button variant="primary" onClick={goToDashboard} className="min-w-40">
|
||||||
Go to dashboard
|
Go to dashboard
|
||||||
</Button>
|
</Button>
|
||||||
{createdSite && (
|
{createdSite && (
|
||||||
<Button variant="secondary" onClick={goToSite} className="min-w-[160px]">
|
<Button variant="secondary" onClick={goToSite} className="min-w-40">
|
||||||
View {createdSite.name}
|
View {createdSite.name}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
63
components/ErrorDisplay.tsx
Normal file
63
components/ErrorDisplay.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Button } from '@ciphera-net/ui'
|
||||||
|
|
||||||
|
interface ErrorDisplayProps {
|
||||||
|
title?: string
|
||||||
|
message?: string
|
||||||
|
onRetry?: () => void
|
||||||
|
onGoHome?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared error UI for route-level error.tsx boundaries.
|
||||||
|
* Matches the visual style of the 404 page.
|
||||||
|
*/
|
||||||
|
export default function ErrorDisplay({
|
||||||
|
title = 'Something went wrong',
|
||||||
|
message = 'An unexpected error occurred. Please try again or go back to the dashboard.',
|
||||||
|
onRetry,
|
||||||
|
onGoHome = true,
|
||||||
|
}: ErrorDisplayProps) {
|
||||||
|
return (
|
||||||
|
<div className="relative min-h-[80vh] flex flex-col items-center justify-center overflow-hidden">
|
||||||
|
<div className="absolute inset-0 -z-10 pointer-events-none">
|
||||||
|
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[500px] h-[500px] bg-red-500/10 rounded-full blur-[128px] opacity-60" />
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-grid-pattern opacity-[0.02] dark:opacity-[0.05]"
|
||||||
|
style={{ maskImage: 'radial-gradient(ellipse at center, black 0%, transparent 70%)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center px-4 z-10">
|
||||||
|
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-red-100 dark:bg-red-900/30 mb-6">
|
||||||
|
<svg className="w-8 h-8 text-red-500" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-4">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<p className="text-lg text-neutral-600 dark:text-neutral-400 max-w-md mx-auto mb-10 leading-relaxed">
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
|
||||||
|
{onRetry && (
|
||||||
|
<Button variant="primary" onClick={onRetry} className="px-8 py-3">
|
||||||
|
Try again
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{onGoHome && (
|
||||||
|
<a href="/">
|
||||||
|
<Button variant="secondary" className="px-8 py-3">
|
||||||
|
Go to dashboard
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import Image from 'next/image'
|
|||||||
import { GithubIcon, TwitterIcon, SwissFlagIcon } from '@ciphera-net/ui'
|
import { GithubIcon, TwitterIcon, SwissFlagIcon } from '@ciphera-net/ui'
|
||||||
|
|
||||||
interface FooterProps {
|
interface FooterProps {
|
||||||
LinkComponent?: any
|
LinkComponent?: React.ElementType
|
||||||
appName?: string
|
appName?: string
|
||||||
isAuthenticated?: boolean
|
isAuthenticated?: boolean
|
||||||
}
|
}
|
||||||
@@ -52,16 +52,16 @@ export function Footer({ LinkComponent = Link, appName = 'Pulse', isAuthenticate
|
|||||||
© 2024-{year} Ciphera. All rights reserved.
|
© 2024-{year} Ciphera. All rights reserved.
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-6 text-sm font-medium text-neutral-600 dark:text-neutral-300">
|
<div className="flex gap-6 text-sm font-medium text-neutral-600 dark:text-neutral-300">
|
||||||
<Component href="/about" className="hover:text-brand-orange transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded">
|
<Component href="/about" className="hover:text-brand-orange transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus:rounded">
|
||||||
Why {appName}
|
Why {appName}
|
||||||
</Component>
|
</Component>
|
||||||
<Component href="/changelog" className="hover:text-brand-orange transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded">
|
<Component href="/changelog" className="hover:text-brand-orange transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus:rounded">
|
||||||
Changelog
|
Changelog
|
||||||
</Component>
|
</Component>
|
||||||
<Component href="/pricing" className="hover:text-brand-orange transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded">
|
<Component href="/pricing" className="hover:text-brand-orange transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus:rounded">
|
||||||
Pricing
|
Pricing
|
||||||
</Component>
|
</Component>
|
||||||
<Component href="/faq" className="hover:text-brand-orange transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded">
|
<Component href="/faq" className="hover:text-brand-orange transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus:rounded">
|
||||||
FAQ
|
FAQ
|
||||||
</Component>
|
</Component>
|
||||||
</div>
|
</div>
|
||||||
@@ -95,7 +95,7 @@ export function Footer({ LinkComponent = Link, appName = 'Pulse', isAuthenticate
|
|||||||
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4 leading-relaxed">
|
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4 leading-relaxed">
|
||||||
Simple analytics for privacy-conscious apps.
|
Simple analytics for privacy-conscious apps.
|
||||||
</p>
|
</p>
|
||||||
<div className="inline-flex items-center gap-2.5 text-sm text-neutral-600 dark:text-neutral-400 mb-4">
|
<div className="inline-flex items-center gap-3 text-sm text-neutral-600 dark:text-neutral-400 mb-4">
|
||||||
<span className="flex items-center justify-center w-8 h-8 rounded-lg bg-neutral-100 dark:bg-neutral-800 shrink-0 overflow-hidden ring-1 ring-neutral-200 dark:ring-neutral-700" aria-hidden>
|
<span className="flex items-center justify-center w-8 h-8 rounded-lg bg-neutral-100 dark:bg-neutral-800 shrink-0 overflow-hidden ring-1 ring-neutral-200 dark:ring-neutral-700" aria-hidden>
|
||||||
<SwissFlagIcon className="w-5 h-5" />
|
<SwissFlagIcon className="w-5 h-5" />
|
||||||
</span>
|
</span>
|
||||||
@@ -106,7 +106,7 @@ export function Footer({ LinkComponent = Link, appName = 'Pulse', isAuthenticate
|
|||||||
href="https://github.com/ciphera-net"
|
href="https://github.com/ciphera-net"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="w-9 h-9 rounded-lg bg-neutral-100 dark:bg-neutral-800 flex items-center justify-center text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange"
|
className="w-9 h-9 rounded-lg bg-neutral-100 dark:bg-neutral-800 flex items-center justify-center text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange"
|
||||||
aria-label="GitHub"
|
aria-label="GitHub"
|
||||||
>
|
>
|
||||||
<GithubIcon className="w-5 h-5" />
|
<GithubIcon className="w-5 h-5" />
|
||||||
@@ -115,7 +115,7 @@ export function Footer({ LinkComponent = Link, appName = 'Pulse', isAuthenticate
|
|||||||
href="https://x.com/cipheranet"
|
href="https://x.com/cipheranet"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="w-9 h-9 rounded-lg bg-neutral-100 dark:bg-neutral-800 flex items-center justify-center text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange"
|
className="w-9 h-9 rounded-lg bg-neutral-100 dark:bg-neutral-800 flex items-center justify-center text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange"
|
||||||
aria-label="X (Twitter)"
|
aria-label="X (Twitter)"
|
||||||
>
|
>
|
||||||
<TwitterIcon className="w-5 h-5" />
|
<TwitterIcon className="w-5 h-5" />
|
||||||
@@ -134,14 +134,14 @@ export function Footer({ LinkComponent = Link, appName = 'Pulse', isAuthenticate
|
|||||||
href={link.href}
|
href={link.href}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-sm text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded"
|
className="text-sm text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus:rounded"
|
||||||
>
|
>
|
||||||
{link.name}
|
{link.name}
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
<Component
|
<Component
|
||||||
href={link.href}
|
href={link.href}
|
||||||
className="text-sm text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded"
|
className="text-sm text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus:rounded"
|
||||||
>
|
>
|
||||||
{link.name}
|
{link.name}
|
||||||
</Component>
|
</Component>
|
||||||
@@ -162,14 +162,14 @@ export function Footer({ LinkComponent = Link, appName = 'Pulse', isAuthenticate
|
|||||||
href={link.href}
|
href={link.href}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-sm text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded"
|
className="text-sm text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus:rounded"
|
||||||
>
|
>
|
||||||
{link.name}
|
{link.name}
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
<Component
|
<Component
|
||||||
href={link.href}
|
href={link.href}
|
||||||
className="text-sm text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded"
|
className="text-sm text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus:rounded"
|
||||||
>
|
>
|
||||||
{link.name}
|
{link.name}
|
||||||
</Component>
|
</Component>
|
||||||
@@ -190,14 +190,14 @@ export function Footer({ LinkComponent = Link, appName = 'Pulse', isAuthenticate
|
|||||||
href={link.href}
|
href={link.href}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-sm text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded"
|
className="text-sm text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus:rounded"
|
||||||
>
|
>
|
||||||
{link.name}
|
{link.name}
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
<Component
|
<Component
|
||||||
href={link.href}
|
href={link.href}
|
||||||
className="text-sm text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded"
|
className="text-sm text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus:rounded"
|
||||||
>
|
>
|
||||||
{link.name}
|
{link.name}
|
||||||
</Component>
|
</Component>
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export function IntegrationGuide({ integration, children }: IntegrationGuideProp
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-grow w-full max-w-4xl mx-auto px-4 pt-12 pb-10 z-10">
|
<div className="flex-grow w-full max-w-4xl mx-auto px-4 pt-20 pb-10 z-10">
|
||||||
<Link
|
<Link
|
||||||
href="/integrations"
|
href="/integrations"
|
||||||
className="inline-flex items-center text-sm text-neutral-500 hover:text-brand-orange mb-8 transition-colors"
|
className="inline-flex items-center text-sm text-neutral-500 hover:text-brand-orange mb-8 transition-colors"
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { FiWifiOff } from 'react-icons/fi';
|
import { WifiSlash } from '@phosphor-icons/react';
|
||||||
|
|
||||||
export function OfflineBanner({ isOnline }: { isOnline: boolean }) {
|
export function OfflineBanner({ isOnline }: { isOnline: boolean }) {
|
||||||
if (isOnline) return null;
|
if (isOnline) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed top-0 left-0 right-0 z-[100] rounded-b-xl bg-yellow-500/15 dark:bg-yellow-500/25 border-b border-yellow-500/30 dark:border-yellow-500/40 text-yellow-700 dark:text-yellow-300 px-4 sm:px-8 py-2.5 text-sm flex items-center justify-center gap-2 font-medium shadow-md">
|
<div className="fixed top-0 left-0 right-0 z-[100] rounded-b-xl bg-yellow-500/15 dark:bg-yellow-500/25 border-b border-yellow-500/30 dark:border-yellow-500/40 text-yellow-700 dark:text-yellow-300 px-4 sm:px-8 py-2.5 text-sm flex items-center justify-center gap-2 font-medium shadow-md transition-shadow duration-300">
|
||||||
<FiWifiOff className="w-4 h-4 shrink-0" />
|
<WifiSlash className="w-4 h-4 shrink-0" />
|
||||||
<span>You are currently offline. Changes may not be saved.</span>
|
<span>You are currently offline. Changes may not be saved.</span>
|
||||||
</div>
|
</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,6 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
|
import { logger } from '@/lib/utils/logger'
|
||||||
import { useSearchParams } from 'next/navigation'
|
import { useSearchParams } from 'next/navigation'
|
||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
import { Button, CheckCircleIcon } from '@ciphera-net/ui'
|
import { Button, CheckCircleIcon } from '@ciphera-net/ui'
|
||||||
@@ -140,7 +141,7 @@ export default function PricingSection() {
|
|||||||
// Clear intent
|
// Clear intent
|
||||||
localStorage.removeItem('pulse_pending_checkout')
|
localStorage.removeItem('pulse_pending_checkout')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to parse pending checkout', e)
|
logger.error('Failed to parse pending checkout', e)
|
||||||
localStorage.removeItem('pulse_pending_checkout')
|
localStorage.removeItem('pulse_pending_checkout')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -150,8 +151,7 @@ export default function PricingSection() {
|
|||||||
|
|
||||||
// Helper to get all price details
|
// Helper to get all price details
|
||||||
const getPriceDetails = (planId: string) => {
|
const getPriceDetails = (planId: string) => {
|
||||||
// @ts-ignore
|
const basePrice = currentTraffic.prices[planId as keyof typeof currentTraffic.prices]
|
||||||
const basePrice = currentTraffic.prices[planId]
|
|
||||||
|
|
||||||
// Handle "Custom"
|
// Handle "Custom"
|
||||||
if (basePrice === null || basePrice === undefined) return null
|
if (basePrice === null || basePrice === undefined) return null
|
||||||
@@ -203,9 +203,9 @@ export default function PricingSection() {
|
|||||||
throw new Error('No checkout URL returned')
|
throw new Error('No checkout URL returned')
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
console.error('Checkout error:', error)
|
logger.error('Checkout error:', error)
|
||||||
toast.error('Failed to start checkout. Please try again.')
|
toast.error('Failed to start checkout — please try again')
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingPlan(null)
|
setLoadingPlan(null)
|
||||||
}
|
}
|
||||||
@@ -219,10 +219,10 @@ export default function PricingSection() {
|
|||||||
transition={{ duration: 0.5 }}
|
transition={{ duration: 0.5 }}
|
||||||
className="text-center mb-12"
|
className="text-center mb-12"
|
||||||
>
|
>
|
||||||
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-6">
|
<h2 className="text-3xl font-bold text-neutral-900 dark:text-white mb-4">
|
||||||
Transparent Pricing
|
Transparent Pricing
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xl text-neutral-600 dark:text-neutral-400">
|
<p className="text-lg text-neutral-600 dark:text-neutral-400">
|
||||||
Scale with your traffic. No hidden fees.
|
Scale with your traffic. No hidden fees.
|
||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -232,13 +232,13 @@ export default function PricingSection() {
|
|||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.5, delay: 0.1 }}
|
transition={{ duration: 0.5, delay: 0.1 }}
|
||||||
className="max-w-6xl mx-auto border border-neutral-200 dark:border-neutral-800 rounded-3xl bg-white/50 dark:bg-neutral-900/50 backdrop-blur-xl shadow-sm overflow-hidden mb-20"
|
className="max-w-6xl mx-auto border border-neutral-200 dark:border-neutral-800 rounded-2xl bg-white/50 dark:bg-neutral-900/50 backdrop-blur-xl shadow-sm overflow-hidden mb-20"
|
||||||
>
|
>
|
||||||
|
|
||||||
{/* Top Toolbar */}
|
{/* Top Toolbar */}
|
||||||
<div className="p-8 border-b border-neutral-200 dark:border-neutral-800 flex flex-col md:flex-row items-center justify-between gap-8 bg-neutral-50/50 dark:bg-neutral-900/50">
|
<div className="p-6 border-b border-neutral-200 dark:border-neutral-800 flex flex-col md:flex-row items-center justify-between gap-8 bg-neutral-50/50 dark:bg-neutral-900/50">
|
||||||
<div className="w-full md:w-2/3">
|
<div className="w-full md:w-2/3">
|
||||||
<div className="flex justify-between text-sm font-medium text-neutral-500 mb-4">
|
<div className="flex justify-between text-sm font-medium text-neutral-500 dark:text-neutral-400 mb-4">
|
||||||
<span>10k</span>
|
<span>10k</span>
|
||||||
<span className="text-brand-orange font-bold text-lg">
|
<span className="text-brand-orange font-bold text-lg">
|
||||||
Up to {currentTraffic.label} monthly pageviews
|
Up to {currentTraffic.label} monthly pageviews
|
||||||
@@ -252,18 +252,22 @@ export default function PricingSection() {
|
|||||||
step="1"
|
step="1"
|
||||||
value={sliderIndex}
|
value={sliderIndex}
|
||||||
onChange={(e) => setSliderIndex(parseInt(e.target.value))}
|
onChange={(e) => setSliderIndex(parseInt(e.target.value))}
|
||||||
className="w-full h-2 bg-neutral-200 rounded-lg appearance-none cursor-pointer dark:bg-neutral-700 accent-brand-orange"
|
aria-label="Monthly pageview limit"
|
||||||
|
aria-valuetext={`${currentTraffic.label} pageviews per month`}
|
||||||
|
className="w-full h-2 bg-neutral-200 rounded-lg appearance-none cursor-pointer dark:bg-neutral-700 accent-brand-orange focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-offset-2"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col items-end gap-2 shrink-0">
|
<div className="flex flex-col items-end gap-2 shrink-0">
|
||||||
<span className="text-[10px] text-neutral-500 dark:text-neutral-400 font-medium uppercase tracking-wide">
|
<span className="text-xs text-neutral-500 dark:text-neutral-400 font-medium uppercase tracking-wide">
|
||||||
Get 1 month free with yearly
|
Get 1 month free with yearly
|
||||||
</span>
|
</span>
|
||||||
<div className="bg-neutral-200 dark:bg-neutral-800 p-1 rounded-lg flex">
|
<div className="bg-neutral-200 dark:bg-neutral-800 p-1 rounded-lg flex" role="radiogroup" aria-label="Billing interval">
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsYearly(false)}
|
onClick={() => setIsYearly(false)}
|
||||||
className={`min-w-[88px] px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
role="radio"
|
||||||
|
aria-checked={!isYearly}
|
||||||
|
className={`min-w-[88px] px-4 py-2 rounded-lg text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange ${
|
||||||
!isYearly
|
!isYearly
|
||||||
? 'bg-white dark:bg-neutral-700 text-neutral-900 dark:text-white shadow-sm'
|
? 'bg-white dark:bg-neutral-700 text-neutral-900 dark:text-white shadow-sm'
|
||||||
: 'text-neutral-500 hover:text-neutral-900 dark:hover:text-white'
|
: 'text-neutral-500 hover:text-neutral-900 dark:hover:text-white'
|
||||||
@@ -273,7 +277,9 @@ export default function PricingSection() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsYearly(true)}
|
onClick={() => setIsYearly(true)}
|
||||||
className={`min-w-[88px] px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
role="radio"
|
||||||
|
aria-checked={isYearly}
|
||||||
|
className={`min-w-[88px] px-4 py-2 rounded-lg text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange ${
|
||||||
isYearly
|
isYearly
|
||||||
? 'bg-white dark:bg-neutral-700 text-neutral-900 dark:text-white shadow-sm'
|
? 'bg-white dark:bg-neutral-700 text-neutral-900 dark:text-white shadow-sm'
|
||||||
: 'text-neutral-500 hover:text-neutral-900 dark:hover:text-white'
|
: 'text-neutral-500 hover:text-neutral-900 dark:hover:text-white'
|
||||||
@@ -292,7 +298,7 @@ export default function PricingSection() {
|
|||||||
const isTeam = plan.id === 'team'
|
const isTeam = plan.id === 'team'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={plan.id} className={`p-8 flex flex-col relative transition-colors ${isTeam ? 'bg-brand-orange/[0.02]' : 'hover:bg-neutral-50/50 dark:hover:bg-neutral-800/50'}`}>
|
<div key={plan.id} className={`p-6 flex flex-col relative transition-colors ${isTeam ? 'bg-brand-orange/[0.02]' : 'hover:bg-neutral-50/50 dark:hover:bg-neutral-800/50'}`}>
|
||||||
{isTeam && (
|
{isTeam && (
|
||||||
<>
|
<>
|
||||||
<div className="absolute top-0 left-0 w-full h-1 bg-brand-orange" />
|
<div className="absolute top-0 left-0 w-full h-1 bg-brand-orange" />
|
||||||
@@ -304,7 +310,7 @@ export default function PricingSection() {
|
|||||||
|
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h3 className="text-lg font-bold text-neutral-900 dark:text-white mb-2">{plan.name}</h3>
|
<h3 className="text-lg font-bold text-neutral-900 dark:text-white mb-2">{plan.name}</h3>
|
||||||
<p className="text-sm text-neutral-500 min-h-[40px] mb-4">{plan.description}</p>
|
<p className="text-sm text-neutral-500 dark:text-neutral-400 min-h-[40px] mb-4">{plan.description}</p>
|
||||||
|
|
||||||
{priceDetails ? (
|
{priceDetails ? (
|
||||||
isYearly ? (
|
isYearly ? (
|
||||||
@@ -313,7 +319,7 @@ export default function PricingSection() {
|
|||||||
<span className="text-4xl font-bold text-neutral-900 dark:text-white">
|
<span className="text-4xl font-bold text-neutral-900 dark:text-white">
|
||||||
€{priceDetails.yearlyTotal}
|
€{priceDetails.yearlyTotal}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-neutral-500 font-medium">/year</span>
|
<span className="text-neutral-500 dark:text-neutral-400 font-medium">/year</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 mt-2 text-sm font-medium">
|
<div className="flex items-center gap-2 mt-2 text-sm font-medium">
|
||||||
<span className="text-neutral-400 line-through decoration-neutral-400">
|
<span className="text-neutral-400 line-through decoration-neutral-400">
|
||||||
@@ -329,7 +335,7 @@ export default function PricingSection() {
|
|||||||
<span className="text-4xl font-bold text-neutral-900 dark:text-white">
|
<span className="text-4xl font-bold text-neutral-900 dark:text-white">
|
||||||
€{priceDetails.baseMonthly}
|
€{priceDetails.baseMonthly}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-neutral-500 font-medium">/mo</span>
|
<span className="text-neutral-500 dark:text-neutral-400 font-medium">/mo</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
@@ -361,16 +367,20 @@ export default function PricingSection() {
|
|||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Enterprise Section */}
|
{/* Enterprise Section */}
|
||||||
<div className="p-8 bg-neutral-50/50 dark:bg-neutral-900/50 flex flex-col">
|
<div className="p-6 bg-neutral-50/50 dark:bg-neutral-900/50 flex flex-col">
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h3 className="text-lg font-bold text-neutral-900 dark:text-white mb-2">Enterprise</h3>
|
<h3 className="text-lg font-bold text-neutral-900 dark:text-white mb-2">Enterprise</h3>
|
||||||
<p className="text-sm text-neutral-500 min-h-[40px] mb-4">For high volume sites and custom needs</p>
|
<p className="text-sm text-neutral-500 dark:text-neutral-400 min-h-[40px] mb-4">For high volume sites and custom needs</p>
|
||||||
<div className="text-4xl font-bold text-neutral-900 dark:text-white">
|
<div className="text-4xl font-bold text-neutral-900 dark:text-white">
|
||||||
Custom
|
Custom
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button variant="secondary" className="w-full mb-8 border-neutral-200 dark:border-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-800">
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
className="w-full mb-8"
|
||||||
|
onClick={() => { window.location.href = 'mailto:business@ciphera.net?subject=Enterprise%20Plan%20Inquiry' }}
|
||||||
|
>
|
||||||
Contact us
|
Contact us
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
|||||||
12
components/SWRProvider.tsx
Normal file
12
components/SWRProvider.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { SWRConfig } from 'swr'
|
||||||
|
import { boundedCacheProvider } from '@/lib/swr/cache-provider'
|
||||||
|
|
||||||
|
export default function SWRProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<SWRConfig value={{ provider: boundedCacheProvider }}>
|
||||||
|
{children}
|
||||||
|
</SWRConfig>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,109 +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 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) => {
|
|
||||||
console.log('Switching to organization:', orgId)
|
|
||||||
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)
|
|
||||||
|
|
||||||
// Force reload to pick up new permissions
|
|
||||||
window.location.reload()
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to switch organization', err)
|
|
||||||
setSwitching(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="border-b border-neutral-100 dark:border-neutral-800 pb-2 mb-2">
|
|
||||||
<div className="px-3 py-2 text-xs font-medium text-neutral-500 uppercase tracking-wider">
|
|
||||||
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)}
|
|
||||||
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" />
|
|
||||||
</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">Loading...</span>}
|
|
||||||
{activeOrgId === org.organization_id && !switching && <CheckIcon className="h-4 w-4 text-neutral-600 dark:text-neutral-400" />}
|
|
||||||
</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">
|
|
||||||
<PlusIcon className="h-3 w-3" />
|
|
||||||
</div>
|
|
||||||
<span>Create Organization</span>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
114
components/behavior/FrustrationByPageTable.tsx
Normal file
114
components/behavior/FrustrationByPageTable.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { formatNumber } from '@ciphera-net/ui'
|
||||||
|
import { Files } from '@phosphor-icons/react'
|
||||||
|
import type { FrustrationByPage } from '@/lib/api/stats'
|
||||||
|
|
||||||
|
interface FrustrationByPageTableProps {
|
||||||
|
pages: FrustrationByPage[]
|
||||||
|
loading: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function SkeletonRows() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<div key={i} className="animate-pulse flex items-center justify-between h-9 px-2">
|
||||||
|
<div className="h-4 w-40 bg-neutral-200 dark:bg-neutral-700 rounded" />
|
||||||
|
<div className="flex gap-6">
|
||||||
|
<div className="h-4 w-10 bg-neutral-200 dark:bg-neutral-700 rounded" />
|
||||||
|
<div className="h-4 w-10 bg-neutral-200 dark:bg-neutral-700 rounded" />
|
||||||
|
<div className="h-4 w-10 bg-neutral-200 dark:bg-neutral-700 rounded" />
|
||||||
|
<div className="h-4 w-10 bg-neutral-200 dark:bg-neutral-700 rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FrustrationByPageTable({ pages, loading }: FrustrationByPageTableProps) {
|
||||||
|
const hasData = pages.length > 0
|
||||||
|
const maxTotal = Math.max(...pages.map(p => p.total), 1)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 mb-8">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
|
||||||
|
Frustration by Page
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-4">
|
||||||
|
Pages with the most frustration signals
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<SkeletonRows />
|
||||||
|
) : hasData ? (
|
||||||
|
<div>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-2 -mx-2 mb-2 text-xs font-medium text-neutral-400 dark:text-neutral-500 uppercase tracking-wider">
|
||||||
|
<span>Page</span>
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<span className="w-12 text-right">Rage</span>
|
||||||
|
<span className="w-12 text-right">Dead</span>
|
||||||
|
<span className="w-12 text-right">Total</span>
|
||||||
|
<span className="w-16 text-right">Elements</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rows */}
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{pages.map((page) => {
|
||||||
|
const barWidth = (page.total / maxTotal) * 100
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={page.page_path}
|
||||||
|
className="relative flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors"
|
||||||
|
>
|
||||||
|
{/* Background bar */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-y-0 left-0 bg-brand-orange/5 dark:bg-brand-orange/10 rounded-lg transition-all"
|
||||||
|
style={{ width: `${barWidth}%` }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="relative text-sm text-neutral-900 dark:text-white truncate max-w-[300px]"
|
||||||
|
title={page.page_path}
|
||||||
|
>
|
||||||
|
{page.page_path}
|
||||||
|
</span>
|
||||||
|
<div className="relative flex items-center gap-6">
|
||||||
|
<span className="w-12 text-right text-sm tabular-nums text-neutral-600 dark:text-neutral-400">
|
||||||
|
{formatNumber(page.rage_clicks)}
|
||||||
|
</span>
|
||||||
|
<span className="w-12 text-right text-sm tabular-nums text-neutral-600 dark:text-neutral-400">
|
||||||
|
{formatNumber(page.dead_clicks)}
|
||||||
|
</span>
|
||||||
|
<span className="w-12 text-right text-sm font-semibold tabular-nums text-neutral-900 dark:text-white">
|
||||||
|
{formatNumber(page.total)}
|
||||||
|
</span>
|
||||||
|
<span className="w-16 text-right text-sm tabular-nums text-neutral-600 dark:text-neutral-400">
|
||||||
|
{page.unique_elements}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center text-center px-6 py-8 gap-4 min-h-[200px]">
|
||||||
|
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
|
||||||
|
<Files className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
|
||||||
|
</div>
|
||||||
|
<h4 className="font-semibold text-neutral-900 dark:text-white">
|
||||||
|
No frustration signals detected
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-md">
|
||||||
|
Page-level frustration data will appear here once rage clicks or dead clicks are detected on your site.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
124
components/behavior/FrustrationSummaryCards.tsx
Normal file
124
components/behavior/FrustrationSummaryCards.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { FrustrationSummary } from '@/lib/api/stats'
|
||||||
|
|
||||||
|
interface FrustrationSummaryCardsProps {
|
||||||
|
data: FrustrationSummary | null
|
||||||
|
loading: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function pctChange(current: number, previous: number): { type: 'pct'; value: number } | { type: 'new' } | null {
|
||||||
|
if (previous === 0 && current === 0) return null
|
||||||
|
if (previous === 0) return { type: 'new' }
|
||||||
|
return { type: 'pct', value: Math.round(((current - previous) / previous) * 100) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChangeIndicator({ change }: { change: ReturnType<typeof pctChange> }) {
|
||||||
|
if (change === null) return null
|
||||||
|
if (change.type === 'new') {
|
||||||
|
return (
|
||||||
|
<span className="text-xs font-medium bg-brand-orange/10 text-brand-orange px-1.5 py-0.5 rounded">
|
||||||
|
New
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const isUp = change.value > 0
|
||||||
|
const isDown = change.value < 0
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`text-xs font-medium ${
|
||||||
|
isUp
|
||||||
|
? 'text-red-600 dark:text-red-400'
|
||||||
|
: isDown
|
||||||
|
? 'text-green-600 dark:text-green-400'
|
||||||
|
: 'text-neutral-500 dark:text-neutral-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isUp ? '+' : ''}{change.value}%
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SkeletonCard() {
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
|
||||||
|
<div className="animate-pulse space-y-3">
|
||||||
|
<div className="h-4 w-24 bg-neutral-200 dark:bg-neutral-700 rounded" />
|
||||||
|
<div className="h-8 w-16 bg-neutral-200 dark:bg-neutral-700 rounded" />
|
||||||
|
<div className="h-3 w-32 bg-neutral-200 dark:bg-neutral-700 rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FrustrationSummaryCards({ data, loading }: FrustrationSummaryCardsProps) {
|
||||||
|
if (loading || !data) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-8">
|
||||||
|
<SkeletonCard />
|
||||||
|
<SkeletonCard />
|
||||||
|
<SkeletonCard />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rageChange = pctChange(data.rage_clicks, data.prev_rage_clicks)
|
||||||
|
const deadChange = pctChange(data.dead_clicks, data.prev_dead_clicks)
|
||||||
|
const topPage = data.rage_top_page || data.dead_top_page
|
||||||
|
const totalSignals = data.rage_clicks + data.dead_clicks
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-8">
|
||||||
|
{/* Rage Clicks */}
|
||||||
|
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
|
||||||
|
<p className="text-sm font-medium text-neutral-500 dark:text-neutral-400 mb-1">
|
||||||
|
Rage Clicks
|
||||||
|
</p>
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<span className="text-2xl font-bold text-neutral-900 dark:text-white tabular-nums">
|
||||||
|
{data.rage_clicks.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
<ChangeIndicator change={rageChange} />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-neutral-400 dark:text-neutral-500 mt-1">
|
||||||
|
{data.rage_unique_elements} unique elements
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dead Clicks */}
|
||||||
|
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
|
||||||
|
<p className="text-sm font-medium text-neutral-500 dark:text-neutral-400 mb-1">
|
||||||
|
Dead Clicks
|
||||||
|
</p>
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<span className="text-2xl font-bold text-neutral-900 dark:text-white tabular-nums">
|
||||||
|
{data.dead_clicks.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
<ChangeIndicator change={deadChange} />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-neutral-400 dark:text-neutral-500 mt-1">
|
||||||
|
{data.dead_unique_elements} unique elements
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Total Frustration Signals */}
|
||||||
|
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
|
||||||
|
<p className="text-sm font-medium text-neutral-500 dark:text-neutral-400 mb-1">
|
||||||
|
Total Signals
|
||||||
|
</p>
|
||||||
|
<span className="text-2xl font-bold text-neutral-900 dark:text-white tabular-nums">
|
||||||
|
{totalSignals.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
{topPage ? (
|
||||||
|
<p className="text-xs text-neutral-400 dark:text-neutral-500 mt-1">
|
||||||
|
Top page: {topPage}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-neutral-400 dark:text-neutral-500 mt-1">
|
||||||
|
No data in this period
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
220
components/behavior/FrustrationTable.tsx
Normal file
220
components/behavior/FrustrationTable.tsx
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { formatNumber, Modal } from '@ciphera-net/ui'
|
||||||
|
import { FrameCornersIcon, Copy, Check, CursorClick } from '@phosphor-icons/react'
|
||||||
|
import { toast } from '@ciphera-net/ui'
|
||||||
|
import type { FrustrationElement } from '@/lib/api/stats'
|
||||||
|
import { ListSkeleton } from '@/components/skeletons'
|
||||||
|
|
||||||
|
const DISPLAY_LIMIT = 7
|
||||||
|
|
||||||
|
interface FrustrationTableProps {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
items: FrustrationElement[]
|
||||||
|
total: number
|
||||||
|
totalSignals: number
|
||||||
|
showAvgClicks?: boolean
|
||||||
|
loading: boolean
|
||||||
|
fetchAll?: () => Promise<{ items: FrustrationElement[]; total: number }>
|
||||||
|
}
|
||||||
|
|
||||||
|
function SkeletonRows() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Array.from({ length: DISPLAY_LIMIT }).map((_, i) => (
|
||||||
|
<div key={i} className="animate-pulse flex items-center justify-between h-9 px-2">
|
||||||
|
<div className="flex items-center gap-3 flex-1">
|
||||||
|
<div className="h-4 w-32 bg-neutral-200 dark:bg-neutral-700 rounded" />
|
||||||
|
<div className="h-3 w-20 bg-neutral-200 dark:bg-neutral-700 rounded" />
|
||||||
|
</div>
|
||||||
|
<div className="h-4 w-10 bg-neutral-200 dark:bg-neutral-700 rounded" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectorCell({ selector }: { selector: string }) {
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
|
const handleCopy = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
navigator.clipboard.writeText(selector)
|
||||||
|
setCopied(true)
|
||||||
|
toast.success('Selector copied')
|
||||||
|
setTimeout(() => setCopied(false), 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleCopy}
|
||||||
|
className="flex items-center gap-1 min-w-0 group/copy cursor-pointer"
|
||||||
|
title={selector}
|
||||||
|
>
|
||||||
|
<span className="text-sm font-mono text-neutral-900 dark:text-white truncate">
|
||||||
|
{selector}
|
||||||
|
</span>
|
||||||
|
<span className="opacity-0 group-hover/copy:opacity-100 transition-opacity shrink-0">
|
||||||
|
{copied ? (
|
||||||
|
<Check className="w-3 h-3 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<Copy className="w-3 h-3 text-neutral-400" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({
|
||||||
|
item,
|
||||||
|
showAvgClicks,
|
||||||
|
totalSignals,
|
||||||
|
}: {
|
||||||
|
item: FrustrationElement
|
||||||
|
showAvgClicks?: boolean
|
||||||
|
totalSignals: number
|
||||||
|
}) {
|
||||||
|
const pct = totalSignals > 0 ? `${Math.round((item.count / totalSignals) * 100)}%` : ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<SelectorCell selector={item.selector} />
|
||||||
|
<span
|
||||||
|
className="text-xs text-neutral-400 dark:text-neutral-500 truncate shrink-0"
|
||||||
|
title={item.page_path}
|
||||||
|
>
|
||||||
|
{item.page_path}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 ml-4 shrink-0">
|
||||||
|
{/* Percentage badge: slides in on hover */}
|
||||||
|
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200 tabular-nums">
|
||||||
|
{pct}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400 tabular-nums">
|
||||||
|
{formatNumber(item.count)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FrustrationTable({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
items,
|
||||||
|
total,
|
||||||
|
totalSignals,
|
||||||
|
showAvgClicks,
|
||||||
|
loading,
|
||||||
|
fetchAll,
|
||||||
|
}: FrustrationTableProps) {
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||||
|
const [fullData, setFullData] = useState<FrustrationElement[]>([])
|
||||||
|
const [isLoadingFull, setIsLoadingFull] = useState(false)
|
||||||
|
const hasData = items.length > 0
|
||||||
|
const showViewAll = hasData && total > items.length
|
||||||
|
const emptySlots = Math.max(0, DISPLAY_LIMIT - items.length)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isModalOpen && fetchAll) {
|
||||||
|
const load = async () => {
|
||||||
|
setIsLoadingFull(true)
|
||||||
|
try {
|
||||||
|
const result = await fetchAll()
|
||||||
|
setFullData(result.items)
|
||||||
|
} catch {
|
||||||
|
// silent
|
||||||
|
} finally {
|
||||||
|
setIsLoadingFull(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
load()
|
||||||
|
} else {
|
||||||
|
setFullData([])
|
||||||
|
}
|
||||||
|
}, [isModalOpen, fetchAll])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
{showViewAll && (
|
||||||
|
<button
|
||||||
|
onClick={() => setIsModalOpen(true)}
|
||||||
|
className="p-1.5 text-neutral-400 dark:text-neutral-500 hover:text-brand-orange dark:hover:text-brand-orange hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-all cursor-pointer rounded-lg"
|
||||||
|
aria-label={`View all ${title.toLowerCase()}`}
|
||||||
|
>
|
||||||
|
<FrameCornersIcon className="w-4 h-4" weight="bold" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-4">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex-1 min-h-[270px]">
|
||||||
|
{loading ? (
|
||||||
|
<SkeletonRows />
|
||||||
|
) : hasData ? (
|
||||||
|
<>
|
||||||
|
{items.map((item, i) => (
|
||||||
|
<Row key={`${item.selector}-${item.page_path}-${i}`} item={item} showAvgClicks={showAvgClicks} totalSignals={totalSignals} />
|
||||||
|
))}
|
||||||
|
{Array.from({ length: emptySlots }).map((_, i) => (
|
||||||
|
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="h-full flex flex-col items-center justify-center text-center px-6 py-8 gap-4">
|
||||||
|
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
|
||||||
|
<CursorClick className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
|
||||||
|
</div>
|
||||||
|
<h4 className="font-semibold text-neutral-900 dark:text-white">
|
||||||
|
No {title.toLowerCase()} detected
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-md">
|
||||||
|
{description}. Data will appear here once frustration signals are detected on your site.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={isModalOpen}
|
||||||
|
onClose={() => setIsModalOpen(false)}
|
||||||
|
title={title}
|
||||||
|
className="max-w-2xl"
|
||||||
|
>
|
||||||
|
<div className="max-h-[80vh] overflow-y-auto">
|
||||||
|
{isLoadingFull ? (
|
||||||
|
<div className="py-4">
|
||||||
|
<ListSkeleton rows={10} />
|
||||||
|
</div>
|
||||||
|
) : fullData.length > 0 ? (
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{fullData.map((item, i) => (
|
||||||
|
<Row key={`${item.selector}-${item.page_path}-${i}`} item={item} showAvgClicks={showAvgClicks} totalSignals={totalSignals} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-neutral-500 dark:text-neutral-400 py-8 text-center">
|
||||||
|
No data available
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
166
components/behavior/FrustrationTrend.tsx
Normal file
166
components/behavior/FrustrationTrend.tsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { TrendUp } from '@phosphor-icons/react'
|
||||||
|
import { Pie, PieChart, Tooltip } from 'recharts'
|
||||||
|
|
||||||
|
import {
|
||||||
|
ChartContainer,
|
||||||
|
type ChartConfig,
|
||||||
|
} from '@/components/charts'
|
||||||
|
import type { FrustrationSummary } from '@/lib/api/stats'
|
||||||
|
|
||||||
|
interface FrustrationTrendProps {
|
||||||
|
summary: FrustrationSummary | null
|
||||||
|
loading: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function SkeletonCard() {
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
|
||||||
|
<div className="animate-pulse space-y-3 mb-4">
|
||||||
|
<div className="h-5 w-36 bg-neutral-200 dark:bg-neutral-700 rounded" />
|
||||||
|
<div className="h-4 w-48 bg-neutral-200 dark:bg-neutral-700 rounded" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-h-[270px] animate-pulse flex items-center justify-center">
|
||||||
|
<div className="w-[200px] h-[200px] rounded-full bg-neutral-200 dark:bg-neutral-700" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const LABELS: Record<string, string> = {
|
||||||
|
rage_clicks: 'Rage Clicks',
|
||||||
|
dead_clicks: 'Dead Clicks',
|
||||||
|
prev_rage_clicks: 'Prev Rage Clicks',
|
||||||
|
prev_dead_clicks: 'Prev Dead Clicks',
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLORS = {
|
||||||
|
rage_clicks: 'rgba(253, 94, 15, 0.7)',
|
||||||
|
dead_clicks: 'rgba(180, 83, 9, 0.7)',
|
||||||
|
prev_rage_clicks: 'rgba(253, 94, 15, 0.35)',
|
||||||
|
prev_dead_clicks: 'rgba(180, 83, 9, 0.35)',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const chartConfig = {
|
||||||
|
count: { label: 'Count' },
|
||||||
|
rage_clicks: { label: 'Rage Clicks', color: COLORS.rage_clicks },
|
||||||
|
dead_clicks: { label: 'Dead Clicks', color: COLORS.dead_clicks },
|
||||||
|
prev_rage_clicks: { label: 'Prev Rage Clicks', color: COLORS.prev_rage_clicks },
|
||||||
|
prev_dead_clicks: { label: 'Prev Dead Clicks', color: COLORS.prev_dead_clicks },
|
||||||
|
} satisfies ChartConfig
|
||||||
|
|
||||||
|
function CustomTooltip({ active, payload }: { active?: boolean; payload?: Array<{ payload: { type: string; count: number; fill: string } }> }) {
|
||||||
|
if (!active || !payload?.length) return null
|
||||||
|
const item = payload[0].payload
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-2.5 py-1.5 text-xs shadow-xl">
|
||||||
|
<div
|
||||||
|
className="h-2.5 w-2.5 shrink-0 rounded-full"
|
||||||
|
style={{ backgroundColor: item.fill }}
|
||||||
|
/>
|
||||||
|
<span className="text-neutral-500 dark:text-neutral-400">
|
||||||
|
{LABELS[item.type] ?? item.type}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono font-medium tabular-nums text-neutral-900 dark:text-neutral-50">
|
||||||
|
{item.count.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FrustrationTrend({ summary, loading }: FrustrationTrendProps) {
|
||||||
|
if (loading || !summary) return <SkeletonCard />
|
||||||
|
|
||||||
|
const hasData = summary.rage_clicks > 0 || summary.dead_clicks > 0 ||
|
||||||
|
summary.prev_rage_clicks > 0 || summary.prev_dead_clicks > 0
|
||||||
|
|
||||||
|
const totalCurrent = summary.rage_clicks + summary.dead_clicks
|
||||||
|
const totalPrevious = summary.prev_rage_clicks + summary.prev_dead_clicks
|
||||||
|
const totalChange = totalPrevious > 0
|
||||||
|
? Math.round(((totalCurrent - totalPrevious) / totalPrevious) * 100)
|
||||||
|
: null
|
||||||
|
const hasPrevious = totalPrevious > 0
|
||||||
|
|
||||||
|
const chartData = [
|
||||||
|
{ type: 'rage_clicks', count: summary.rage_clicks, fill: COLORS.rage_clicks },
|
||||||
|
{ type: 'dead_clicks', count: summary.dead_clicks, fill: COLORS.dead_clicks },
|
||||||
|
{ type: 'prev_rage_clicks', count: summary.prev_rage_clicks, fill: COLORS.prev_rage_clicks },
|
||||||
|
{ type: 'prev_dead_clicks', count: summary.prev_dead_clicks, fill: COLORS.prev_dead_clicks },
|
||||||
|
].filter(d => d.count > 0)
|
||||||
|
|
||||||
|
if (!hasData) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
|
||||||
|
Frustration Trend
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-4">
|
||||||
|
Rage vs. dead click breakdown
|
||||||
|
</p>
|
||||||
|
<div className="flex-1 min-h-[270px] flex flex-col items-center justify-center text-center px-6 py-8 gap-4">
|
||||||
|
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
|
||||||
|
<TrendUp className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
|
||||||
|
</div>
|
||||||
|
<h4 className="font-semibold text-neutral-900 dark:text-white">
|
||||||
|
No trend data yet
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-md">
|
||||||
|
Frustration trend data will appear here once rage clicks or dead clicks are detected on your site.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
|
||||||
|
Frustration Trend
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-4">
|
||||||
|
{hasPrevious
|
||||||
|
? 'Rage and dead clicks split across current and previous period'
|
||||||
|
: 'Rage vs. dead click breakdown'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<ChartContainer
|
||||||
|
config={chartConfig}
|
||||||
|
className="mx-auto aspect-square max-h-[250px]"
|
||||||
|
>
|
||||||
|
<PieChart>
|
||||||
|
<Tooltip
|
||||||
|
cursor={false}
|
||||||
|
content={<CustomTooltip />}
|
||||||
|
/>
|
||||||
|
<Pie
|
||||||
|
data={chartData}
|
||||||
|
dataKey="count"
|
||||||
|
nameKey="type"
|
||||||
|
stroke="0"
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-center gap-2 text-sm font-medium pt-2">
|
||||||
|
{totalChange !== null ? (
|
||||||
|
<>
|
||||||
|
{totalChange > 0 ? 'Up' : totalChange < 0 ? 'Down' : 'No change'} by {Math.abs(totalChange)}% vs previous period <TrendUp className="h-4 w-4" />
|
||||||
|
</>
|
||||||
|
) : totalCurrent > 0 ? (
|
||||||
|
<>
|
||||||
|
{totalCurrent.toLocaleString()} new signals this period <TrendUp className="h-4 w-4" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'No frustration signals detected'
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
323
components/charts/chart.tsx
Normal file
323
components/charts/chart.tsx
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import { Tooltip, Legend, ResponsiveContainer } from 'recharts'
|
||||||
|
import { cn } from '@ciphera-net/ui'
|
||||||
|
|
||||||
|
// ─── ChartConfig ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type ChartConfig = Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
label?: React.ReactNode
|
||||||
|
icon?: React.ComponentType
|
||||||
|
color?: string
|
||||||
|
theme?: { light: string; dark: string }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
|
||||||
|
// ─── ChartContext ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type ChartContextProps = {
|
||||||
|
config: ChartConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartContext = React.createContext<ChartContextProps | null>(null)
|
||||||
|
|
||||||
|
function useChart() {
|
||||||
|
const context = React.useContext(ChartContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useChart must be used within a <ChartContainer />')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── ChartContainer ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const ChartContainer = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.ComponentProps<'div'> & {
|
||||||
|
config: ChartConfig
|
||||||
|
children: React.ComponentProps<typeof ResponsiveContainer>['children']
|
||||||
|
}
|
||||||
|
>(({ id, className, children, config, ...props }, ref) => {
|
||||||
|
const uniqueId = React.useId()
|
||||||
|
const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`
|
||||||
|
|
||||||
|
// Build CSS variables from config
|
||||||
|
const colorVars = React.useMemo(() => {
|
||||||
|
const vars: Record<string, string> = {}
|
||||||
|
for (const [key, value] of Object.entries(config)) {
|
||||||
|
if (value.color) {
|
||||||
|
vars[`--color-${key}`] = value.color
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return vars
|
||||||
|
}, [config])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartContext.Provider value={{ config }}>
|
||||||
|
<div
|
||||||
|
data-chart={chartId}
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"[&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-[var(--chart-grid)]",
|
||||||
|
"[&_.recharts-curve.recharts-tooltip-cursor]:stroke-[var(--chart-grid)]",
|
||||||
|
"[&_.recharts-rectangle.recharts-tooltip-cursor]:fill-[var(--chart-grid)]",
|
||||||
|
"[&_.recharts-reference-line_[stroke='#ccc']]:stroke-[var(--chart-grid)]",
|
||||||
|
'[&_.recharts-sector]:outline-none',
|
||||||
|
'[&_.recharts-surface]:outline-none',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
style={colorVars as React.CSSProperties}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
{children}
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</ChartContext.Provider>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
ChartContainer.displayName = 'ChartContainer'
|
||||||
|
|
||||||
|
// ─── ChartTooltip ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const ChartTooltip = Tooltip
|
||||||
|
|
||||||
|
// ─── ChartTooltipContent ───────────────────────────────────────────
|
||||||
|
|
||||||
|
const ChartTooltipContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.ComponentProps<typeof Tooltip> &
|
||||||
|
React.ComponentProps<'div'> & {
|
||||||
|
hideLabel?: boolean
|
||||||
|
hideIndicator?: boolean
|
||||||
|
indicator?: 'line' | 'dot' | 'dashed'
|
||||||
|
nameKey?: string
|
||||||
|
labelKey?: string
|
||||||
|
labelFormatter?: (value: string, payload: Record<string, unknown>[]) => React.ReactNode
|
||||||
|
}
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
active,
|
||||||
|
payload,
|
||||||
|
className,
|
||||||
|
indicator = 'dot',
|
||||||
|
hideLabel = false,
|
||||||
|
hideIndicator = false,
|
||||||
|
label,
|
||||||
|
labelFormatter,
|
||||||
|
labelKey,
|
||||||
|
nameKey,
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
|
const { config } = useChart()
|
||||||
|
|
||||||
|
const tooltipLabel = React.useMemo(() => {
|
||||||
|
if (hideLabel || !payload?.length) return null
|
||||||
|
|
||||||
|
const item = payload[0]
|
||||||
|
const key = `${labelKey || item?.dataKey || item?.name || 'value'}`
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||||
|
const value =
|
||||||
|
!labelKey && typeof label === 'string'
|
||||||
|
? config[label as keyof typeof config]?.label || label
|
||||||
|
: itemConfig?.label
|
||||||
|
|
||||||
|
if (labelFormatter) {
|
||||||
|
return labelFormatter(
|
||||||
|
value as string,
|
||||||
|
payload as Record<string, unknown>[],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
}, [label, labelFormatter, payload, hideLabel, config, labelKey])
|
||||||
|
|
||||||
|
if (!active || !payload?.length) return null
|
||||||
|
|
||||||
|
const nestLabel = payload.length === 1 && indicator !== 'dot'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-2.5 py-1.5 text-xs shadow-xl',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{!nestLabel ? tooltipLabel ? (
|
||||||
|
<div className="font-medium text-neutral-900 dark:text-neutral-50">
|
||||||
|
{tooltipLabel}
|
||||||
|
</div>
|
||||||
|
) : null : null}
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
{payload.map((item, index) => {
|
||||||
|
const key = `${nameKey || item.name || item.dataKey || 'value'}`
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||||
|
const indicatorColor = item.fill || item.color
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.dataKey || index}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground',
|
||||||
|
indicator === 'dot' && 'items-center',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{itemConfig?.icon ? (
|
||||||
|
<itemConfig.icon />
|
||||||
|
) : (
|
||||||
|
!hideIndicator && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'shrink-0 rounded-[2px] border-[var(--color-border)] bg-[var(--color-bg)]',
|
||||||
|
indicator === 'dot' && 'h-2.5 w-2.5 rounded-full',
|
||||||
|
indicator === 'line' && 'w-1',
|
||||||
|
indicator === 'dashed' &&
|
||||||
|
'w-0 border-[1.5px] border-dashed bg-transparent',
|
||||||
|
nestLabel && indicator === 'dashed'
|
||||||
|
? 'my-0.5'
|
||||||
|
: 'my-0.5',
|
||||||
|
)}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
'--color-bg': indicatorColor,
|
||||||
|
'--color-border': indicatorColor,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-1 justify-between leading-none',
|
||||||
|
nestLabel ? 'items-end' : 'items-center',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
{nestLabel ? tooltipLabel : null}
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{itemConfig?.label || item.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{item.value != null && (
|
||||||
|
<span className="font-mono font-medium tabular-nums text-neutral-900 dark:text-neutral-50">
|
||||||
|
{typeof item.value === 'number'
|
||||||
|
? item.value.toLocaleString()
|
||||||
|
: item.value}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
ChartTooltipContent.displayName = 'ChartTooltipContent'
|
||||||
|
|
||||||
|
// ─── ChartLegend ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const ChartLegend = Legend
|
||||||
|
|
||||||
|
const ChartLegendContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.ComponentProps<'div'> &
|
||||||
|
Pick<React.ComponentProps<typeof Legend>, 'payload' | 'verticalAlign'> & {
|
||||||
|
hideIcon?: boolean
|
||||||
|
nameKey?: string
|
||||||
|
}
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{ className, hideIcon = false, payload, verticalAlign = 'bottom', nameKey },
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
|
const { config } = useChart()
|
||||||
|
|
||||||
|
if (!payload?.length) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-center gap-4',
|
||||||
|
verticalAlign === 'top' ? 'pb-3' : 'pt-3',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{payload.map((item) => {
|
||||||
|
const key = `${nameKey || item.dataKey || 'value'}`
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.value}
|
||||||
|
className="flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
|
||||||
|
>
|
||||||
|
{itemConfig?.icon && !hideIcon ? (
|
||||||
|
<itemConfig.icon />
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||||
|
style={{ backgroundColor: item.color }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{itemConfig?.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
ChartLegendContent.displayName = 'ChartLegendContent'
|
||||||
|
|
||||||
|
// ─── Helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function getPayloadConfigFromPayload(
|
||||||
|
config: ChartConfig,
|
||||||
|
payload: unknown,
|
||||||
|
key: string,
|
||||||
|
) {
|
||||||
|
if (typeof payload !== 'object' || payload === null) return undefined
|
||||||
|
|
||||||
|
const payloadPayload =
|
||||||
|
'payload' in payload &&
|
||||||
|
typeof (payload as Record<string, unknown>).payload === 'object' &&
|
||||||
|
(payload as Record<string, unknown>).payload !== null
|
||||||
|
? ((payload as Record<string, unknown>).payload as Record<string, unknown>)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
let configLabelKey = key
|
||||||
|
|
||||||
|
if (
|
||||||
|
key in config
|
||||||
|
) {
|
||||||
|
configLabelKey = key
|
||||||
|
} else if (payloadPayload) {
|
||||||
|
const payloadKey = Object.keys(payloadPayload).find(
|
||||||
|
(k) => payloadPayload[k] === key && k in config,
|
||||||
|
)
|
||||||
|
if (payloadKey) configLabelKey = payloadKey
|
||||||
|
}
|
||||||
|
|
||||||
|
return configLabelKey in config ? config[configLabelKey] : config[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
ChartContainer,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
ChartLegend,
|
||||||
|
ChartLegendContent,
|
||||||
|
ChartContext,
|
||||||
|
useChart,
|
||||||
|
}
|
||||||
8
components/charts/index.ts
Normal file
8
components/charts/index.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export {
|
||||||
|
ChartContainer,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
ChartLegend,
|
||||||
|
ChartLegendContent,
|
||||||
|
type ChartConfig,
|
||||||
|
} from './chart'
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useEffect } from 'react'
|
|
||||||
import { useSearchParams } from 'next/navigation'
|
|
||||||
import { toast } from '@ciphera-net/ui'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shows a success toast when redirected from Stripe Checkout with success=true,
|
|
||||||
* then clears the query params from the URL.
|
|
||||||
*/
|
|
||||||
export default function CheckoutSuccessToast() {
|
|
||||||
const searchParams = useSearchParams()
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const success = searchParams.get('success')
|
|
||||||
if (success === 'true') {
|
|
||||||
toast.success('Thank you for subscribing! Your subscription is now active.')
|
|
||||||
const url = new URL(window.location.href)
|
|
||||||
url.searchParams.delete('success')
|
|
||||||
url.searchParams.delete('session_id')
|
|
||||||
window.history.replaceState({}, '', url.pathname + url.search)
|
|
||||||
}
|
|
||||||
}, [searchParams])
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,78 +1,62 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect, useMemo } from 'react'
|
import { useState, useEffect, useMemo } from 'react'
|
||||||
|
import { logger } from '@/lib/utils/logger'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
import Image from 'next/image'
|
||||||
import { formatNumber } from '@ciphera-net/ui'
|
import { formatNumber } from '@ciphera-net/ui'
|
||||||
import { Modal, ArrowRightIcon, Button, Spinner } from '@ciphera-net/ui'
|
import { Modal, ArrowRightIcon } from '@ciphera-net/ui'
|
||||||
import { ChevronDownIcon, DownloadIcon } from '@ciphera-net/ui'
|
import { ListSkeleton } from '@/components/skeletons'
|
||||||
|
import VirtualList from './VirtualList'
|
||||||
import { getCampaigns, CampaignStat } from '@/lib/api/stats'
|
import { getCampaigns, CampaignStat } from '@/lib/api/stats'
|
||||||
import { getReferrerFavicon, getReferrerIcon, getReferrerDisplayName } from '@/lib/utils/icons'
|
import { getReferrerFavicon, getReferrerIcon, getReferrerDisplayName } from '@/lib/utils/icons'
|
||||||
import { FaBullhorn } from 'react-icons/fa'
|
import { Megaphone, FrameCornersIcon } from '@phosphor-icons/react'
|
||||||
import { PlusIcon } from '@radix-ui/react-icons'
|
|
||||||
import UtmBuilder from '@/components/tools/UtmBuilder'
|
import UtmBuilder from '@/components/tools/UtmBuilder'
|
||||||
|
import { type DimensionFilter } from '@/lib/filters'
|
||||||
|
|
||||||
interface CampaignsProps {
|
interface CampaignsProps {
|
||||||
siteId: string
|
siteId: string
|
||||||
dateRange: { start: string, end: string }
|
dateRange: { start: string, end: string }
|
||||||
|
filters?: string
|
||||||
|
onFilter?: (filter: DimensionFilter) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const LIMIT = 7
|
const LIMIT = 7
|
||||||
const EMPTY_LABEL = '—'
|
|
||||||
|
|
||||||
type SortKey = 'source' | 'medium' | 'campaign' | 'visitors' | 'pageviews'
|
export default function Campaigns({ siteId, dateRange, filters, onFilter }: CampaignsProps) {
|
||||||
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) {
|
|
||||||
const [data, setData] = useState<CampaignStat[]>([])
|
const [data, setData] = useState<CampaignStat[]>([])
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||||
|
const [modalSearch, setModalSearch] = useState('')
|
||||||
const [isBuilderOpen, setIsBuilderOpen] = useState(false)
|
const [isBuilderOpen, setIsBuilderOpen] = useState(false)
|
||||||
const [fullData, setFullData] = useState<CampaignStat[]>([])
|
const [fullData, setFullData] = useState<CampaignStat[]>([])
|
||||||
const [isLoadingFull, setIsLoadingFull] = useState(false)
|
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())
|
const [faviconFailed, setFaviconFailed] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
try {
|
try {
|
||||||
const result = await getCampaigns(siteId, dateRange.start, dateRange.end, 10)
|
const result = await getCampaigns(siteId, dateRange.start, dateRange.end, 10, filters)
|
||||||
setData(result)
|
setData(result)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
logger.error(e)
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fetchData()
|
fetchData()
|
||||||
}, [siteId, dateRange])
|
}, [siteId, dateRange, filters])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isModalOpen) {
|
if (isModalOpen) {
|
||||||
const fetchFullData = async () => {
|
const fetchFullData = async () => {
|
||||||
setIsLoadingFull(true)
|
setIsLoadingFull(true)
|
||||||
try {
|
try {
|
||||||
const result = await getCampaigns(siteId, dateRange.start, dateRange.end, 100)
|
const result = await getCampaigns(siteId, dateRange.start, dateRange.end, 100, filters)
|
||||||
setFullData(result)
|
setFullData(result)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
logger.error(e)
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoadingFull(false)
|
setIsLoadingFull(false)
|
||||||
}
|
}
|
||||||
@@ -81,40 +65,36 @@ export default function Campaigns({ siteId, dateRange }: CampaignsProps) {
|
|||||||
} else {
|
} else {
|
||||||
setFullData([])
|
setFullData([])
|
||||||
}
|
}
|
||||||
}, [isModalOpen, siteId, dateRange])
|
}, [isModalOpen, siteId, dateRange, filters])
|
||||||
|
|
||||||
const sortedData = useMemo(
|
const sortedData = useMemo(
|
||||||
() => sortCampaigns(data, sortKey, sortDir),
|
() => [...data].sort((a, b) => b.visitors - a.visitors),
|
||||||
[data, sortKey, sortDir]
|
[data]
|
||||||
)
|
)
|
||||||
const sortedFullData = useMemo(
|
const sortedFullData = useMemo(
|
||||||
() => sortCampaigns(fullData.length > 0 ? fullData : data, sortKey, sortDir),
|
() => [...(fullData.length > 0 ? fullData : data)].sort((a, b) => b.visitors - a.visitors),
|
||||||
[fullData, data, sortKey, sortDir]
|
[fullData, data]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const totalVisitors = sortedData.reduce((sum, c) => sum + c.visitors, 0)
|
||||||
const hasData = data.length > 0
|
const hasData = data.length > 0
|
||||||
const displayedData = hasData ? sortedData.slice(0, LIMIT) : []
|
const displayedData = hasData ? sortedData.slice(0, LIMIT) : []
|
||||||
const emptySlots = Math.max(0, LIMIT - displayedData.length)
|
|
||||||
const showViewAll = hasData && data.length > LIMIT
|
const showViewAll = hasData && data.length > LIMIT
|
||||||
|
const emptySlots = Math.max(0, LIMIT - displayedData.length)
|
||||||
const handleSort = (key: SortKey) => {
|
|
||||||
if (sortKey === key) {
|
|
||||||
setSortDir(d => d === 'asc' ? 'desc' : 'asc')
|
|
||||||
} else {
|
|
||||||
setSortKey(key)
|
|
||||||
setSortDir(key === 'visitors' || key === 'pageviews' ? 'desc' : 'asc')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderSourceIcon(source: string) {
|
function renderSourceIcon(source: string) {
|
||||||
const faviconUrl = getReferrerFavicon(source)
|
const faviconUrl = getReferrerFavicon(source)
|
||||||
const useFavicon = faviconUrl && !faviconFailed.has(source)
|
const useFavicon = faviconUrl && !faviconFailed.has(source)
|
||||||
if (useFavicon) {
|
if (useFavicon) {
|
||||||
return (
|
return (
|
||||||
<img
|
<Image
|
||||||
src={faviconUrl}
|
src={faviconUrl}
|
||||||
alt=""
|
alt=""
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
className="w-5 h-5 flex-shrink-0 rounded object-contain"
|
className="w-5 h-5 flex-shrink-0 rounded object-contain"
|
||||||
onError={() => setFaviconFailed((prev) => new Set(prev).add(source))}
|
onError={() => setFaviconFailed((prev) => new Set(prev).add(source))}
|
||||||
|
unoptimized
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -122,13 +102,13 @@ export default function Campaigns({ siteId, dateRange }: CampaignsProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleExportCampaigns = () => {
|
const handleExportCampaigns = () => {
|
||||||
const rows = sortedData.length > 0 ? sortedData : data
|
const rows = sortedFullData.length > 0 ? sortedFullData : sortedData
|
||||||
if (rows.length === 0) return
|
if (rows.length === 0) return
|
||||||
const header = ['Source', 'Medium', 'Campaign', 'Visitors', 'Pageviews']
|
const header = ['Source', 'Medium', 'Campaign', 'Visitors', 'Pageviews']
|
||||||
const csvRows = [
|
const csvRows = [
|
||||||
header.join(','),
|
header.join(','),
|
||||||
...rows.map(r =>
|
...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;' })
|
const blob = new Blob([csvRows.join('\n')], { type: 'text/csv;charset=utf-8;' })
|
||||||
@@ -142,196 +122,171 @@ export default function Campaigns({ siteId, dateRange }: CampaignsProps) {
|
|||||||
URL.revokeObjectURL(url)
|
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-0.5 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
|
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
|
|
||||||
Campaigns
|
|
||||||
</h3>
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{hasData && (
|
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
|
||||||
<Button
|
Campaigns
|
||||||
variant="ghost"
|
</h3>
|
||||||
onClick={handleExportCampaigns}
|
|
||||||
className="h-8 px-3 text-xs gap-1.5"
|
|
||||||
>
|
|
||||||
<DownloadIcon className="w-3.5 h-3.5" />
|
|
||||||
Export
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => setIsBuilderOpen(true)}
|
|
||||||
className="h-8 px-3 text-xs gap-1.5"
|
|
||||||
>
|
|
||||||
<PlusIcon className="w-3.5 h-3.5" />
|
|
||||||
Build URL
|
|
||||||
</Button>
|
|
||||||
{showViewAll && (
|
{showViewAll && (
|
||||||
<Button
|
<button
|
||||||
variant="ghost"
|
|
||||||
onClick={() => setIsModalOpen(true)}
|
onClick={() => setIsModalOpen(true)}
|
||||||
className="h-8 px-3 text-xs"
|
className="p-1.5 text-neutral-400 dark:text-neutral-500 hover:text-brand-orange dark:hover:text-brand-orange hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-all cursor-pointer rounded-lg"
|
||||||
|
aria-label="View all campaigns"
|
||||||
>
|
>
|
||||||
View All
|
<FrameCornersIcon className="w-4 h-4" weight="bold" />
|
||||||
</Button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
<div className="space-y-2 flex-1 min-h-[270px]">
|
||||||
<div className="space-y-2 flex-1 min-h-[270px]">
|
{isLoading ? (
|
||||||
<div className="grid grid-cols-12 gap-2 mb-2 px-2">
|
<ListSkeleton rows={LIMIT} />
|
||||||
<div className="col-span-4 h-4 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800" />
|
) : hasData ? (
|
||||||
<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" />
|
{displayedData.map((item) => {
|
||||||
<div className="col-span-2 h-4 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800" />
|
return (
|
||||||
<div className="col-span-2 h-4 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800" />
|
<div
|
||||||
</div>
|
key={`${item.source}|${item.medium}|${item.campaign}`}
|
||||||
{Array.from({ length: 7 }).map((_, i) => (
|
onClick={() => onFilter?.({ dimension: 'utm_source', operator: 'is', values: [item.source] })}
|
||||||
<div key={`skeleton-${i}`} className="grid grid-cols-12 gap-2 h-9 px-2 -mx-2">
|
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="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="flex-1 text-neutral-900 dark:text-white flex items-center gap-3 min-w-0">
|
||||||
<div className="col-span-2 h-4 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800" />
|
{renderSourceIcon(item.source)}
|
||||||
<div className="col-span-2 h-4 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800" />
|
<div className="min-w-0">
|
||||||
<div className="col-span-2 h-4 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800" />
|
<div className="truncate font-medium text-sm" title={item.source}>
|
||||||
|
{getReferrerDisplayName(item.source)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 text-[11px] text-neutral-400 dark:text-neutral-500">
|
||||||
|
<span>{item.medium || '—'}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span className="truncate">{item.campaign || '—'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 ml-4">
|
||||||
|
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
|
||||||
|
{totalVisitors > 0 ? `${Math.round((item.visitors / totalVisitors) * 100)}%` : ''}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
|
||||||
|
{formatNumber(item.visitors)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{Array.from({ length: emptySlots }).map((_, i) => (
|
||||||
|
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="h-full flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
|
||||||
|
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
|
||||||
|
<Megaphone className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
|
||||||
</div>
|
</div>
|
||||||
))}
|
<h4 className="font-semibold text-neutral-900 dark:text-white">
|
||||||
</div>
|
Track your marketing campaigns
|
||||||
) : hasData ? (
|
</h4>
|
||||||
<div className="space-y-2 flex-1 min-h-[270px]">
|
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs">
|
||||||
<div className="grid grid-cols-12 gap-2 text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-2 px-2">
|
Add UTM parameters to your links to see campaign performance here.
|
||||||
<div className="col-span-4">
|
</p>
|
||||||
<SortHeader label="Source" colKey="source" className="text-left" />
|
<Link
|
||||||
</div>
|
href="/installation"
|
||||||
<div className="col-span-2">
|
className="inline-flex items-center gap-2 text-sm font-medium text-brand-orange hover:text-brand-orange/90 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange/20 rounded"
|
||||||
<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"
|
|
||||||
>
|
>
|
||||||
<div className="col-span-4 flex items-center gap-3 truncate">
|
Learn more
|
||||||
{renderSourceIcon(item.source)}
|
<ArrowRightIcon className="w-4 h-4" />
|
||||||
<span className="truncate text-neutral-900 dark:text-white font-medium" title={item.source}>
|
</Link>
|
||||||
{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" />
|
|
||||||
</div>
|
</div>
|
||||||
<h4 className="font-semibold text-neutral-900 dark:text-white">
|
)}
|
||||||
Track your marketing campaigns
|
</div>
|
||||||
</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-1.5 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
|
<Modal
|
||||||
isOpen={isModalOpen}
|
isOpen={isModalOpen}
|
||||||
onClose={() => setIsModalOpen(false)}
|
onClose={() => { setIsModalOpen(false); setModalSearch('') }}
|
||||||
title="All Campaigns"
|
title="Campaigns"
|
||||||
|
className="max-w-2xl"
|
||||||
>
|
>
|
||||||
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-2">
|
<div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={modalSearch}
|
||||||
|
onChange={(e) => setModalSearch(e.target.value)}
|
||||||
|
placeholder="Search campaigns..."
|
||||||
|
className="w-full px-3 py-2 mb-3 text-sm bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg text-neutral-900 dark:text-white placeholder-neutral-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-brand-orange/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[80vh]">
|
||||||
{isLoadingFull ? (
|
{isLoadingFull ? (
|
||||||
<div className="py-8 flex flex-col items-center gap-2">
|
<div className="py-4">
|
||||||
<Spinner />
|
<ListSkeleton rows={10} />
|
||||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">Loading...</p>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (() => {
|
||||||
<>
|
const filteredCampaigns = !modalSearch ? sortedFullData : sortedFullData.filter(item => {
|
||||||
<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">
|
const search = modalSearch.toLowerCase()
|
||||||
<div className="col-span-4">Source</div>
|
return item.source.toLowerCase().includes(search) || (item.medium || '').toLowerCase().includes(search) || (item.campaign || '').toLowerCase().includes(search)
|
||||||
<div className="col-span-2">Medium</div>
|
})
|
||||||
<div className="col-span-2">Campaign</div>
|
const modalTotal = filteredCampaigns.reduce((sum, item) => sum + item.visitors, 0)
|
||||||
<div className="col-span-2 text-right">Visitors</div>
|
return (
|
||||||
<div className="col-span-2 text-right">Pageviews</div>
|
<>
|
||||||
</div>
|
<div className="flex items-center justify-end mb-2">
|
||||||
{sortedFullData.map((item) => (
|
<button
|
||||||
<div
|
onClick={handleExportCampaigns}
|
||||||
key={campaignRowKey(item)}
|
className="text-xs font-medium text-neutral-400 hover:text-brand-orange transition-colors cursor-pointer"
|
||||||
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"
|
>
|
||||||
>
|
Export CSV
|
||||||
<div className="col-span-4 flex items-center gap-3 truncate">
|
</button>
|
||||||
{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>
|
</div>
|
||||||
))}
|
<VirtualList
|
||||||
</>
|
items={filteredCampaigns}
|
||||||
)}
|
estimateSize={36}
|
||||||
|
className="max-h-[80vh] overflow-y-auto pr-2"
|
||||||
|
renderItem={(item) => (
|
||||||
|
<div
|
||||||
|
key={`${item.source}|${item.medium}|${item.campaign}`}
|
||||||
|
onClick={() => { if (onFilter) { onFilter({ dimension: 'utm_source', operator: 'is', values: [item.source] }); setIsModalOpen(false) } }}
|
||||||
|
className={`flex items-center justify-between py-2 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 transition-colors${onFilter ? ' cursor-pointer' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="flex-1 flex items-center gap-3 min-w-0">
|
||||||
|
{renderSourceIcon(item.source)}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-neutral-900 dark:text-white font-medium truncate text-sm" title={item.source}>
|
||||||
|
{getReferrerDisplayName(item.source)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 text-[11px] text-neutral-400 dark:text-neutral-500">
|
||||||
|
<span>{item.medium || '—'}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span className="truncate">{item.campaign || '—'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 ml-4 text-sm">
|
||||||
|
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
|
||||||
|
{modalTotal > 0 ? `${Math.round((item.visitors / modalTotal) * 100)}%` : ''}
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold text-neutral-900 dark:text-white">
|
||||||
|
{formatNumber(item.visitors)}
|
||||||
|
</span>
|
||||||
|
<span className="text-neutral-400 dark:text-neutral-500 w-16 text-right">
|
||||||
|
{formatNumber(item.pageviews)} pv
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,16 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import { logger } from '@/lib/utils/logger'
|
||||||
import { formatNumber } from '@ciphera-net/ui'
|
import { formatNumber } from '@ciphera-net/ui'
|
||||||
|
import { useTabListKeyboard } from '@/lib/hooks/useTabListKeyboard'
|
||||||
import { TopPage, getTopPages, getEntryPages, getExitPages } from '@/lib/api/stats'
|
import { TopPage, getTopPages, getEntryPages, getExitPages } from '@/lib/api/stats'
|
||||||
import { Modal, ArrowUpRightIcon, LayoutDashboardIcon, Spinner } from '@ciphera-net/ui'
|
import { FrameCornersIcon } from '@phosphor-icons/react'
|
||||||
|
import { Modal, ArrowUpRightIcon, LayoutDashboardIcon } from '@ciphera-net/ui'
|
||||||
|
import { ListSkeleton } from '@/components/skeletons'
|
||||||
|
import VirtualList from './VirtualList'
|
||||||
|
import { type DimensionFilter } from '@/lib/filters'
|
||||||
|
|
||||||
interface ContentStatsProps {
|
interface ContentStatsProps {
|
||||||
topPages: TopPage[]
|
topPages: TopPage[]
|
||||||
@@ -13,15 +20,18 @@ interface ContentStatsProps {
|
|||||||
collectPagePaths?: boolean
|
collectPagePaths?: boolean
|
||||||
siteId: string
|
siteId: string
|
||||||
dateRange: { start: string, end: string }
|
dateRange: { start: string, end: string }
|
||||||
|
onFilter?: (filter: DimensionFilter) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type Tab = 'top_pages' | 'entry_pages' | 'exit_pages'
|
type Tab = 'top_pages' | 'entry_pages' | 'exit_pages'
|
||||||
|
|
||||||
const LIMIT = 7
|
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 [activeTab, setActiveTab] = useState<Tab>('top_pages')
|
||||||
|
const handleTabKeyDown = useTabListKeyboard()
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||||
|
const [modalSearch, setModalSearch] = useState('')
|
||||||
const [fullData, setFullData] = useState<TopPage[]>([])
|
const [fullData, setFullData] = useState<TopPage[]>([])
|
||||||
const [isLoadingFull, setIsLoadingFull] = useState(false)
|
const [isLoadingFull, setIsLoadingFull] = useState(false)
|
||||||
|
|
||||||
@@ -47,7 +57,7 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain,
|
|||||||
}
|
}
|
||||||
setFullData(filterGenericPaths(data))
|
setFullData(filterGenericPaths(data))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
logger.error(e)
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoadingFull(false)
|
setIsLoadingFull(false)
|
||||||
}
|
}
|
||||||
@@ -72,6 +82,7 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain,
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = getData()
|
const data = getData()
|
||||||
|
const totalPageviews = data.reduce((sum, p) => sum + p.pageviews, 0)
|
||||||
const hasData = data && data.length > 0
|
const hasData = data && data.length > 0
|
||||||
const displayedData = hasData ? data.slice(0, LIMIT) : []
|
const displayedData = hasData ? data.slice(0, LIMIT) : []
|
||||||
const emptySlots = Math.max(0, LIMIT - displayedData.length)
|
const emptySlots = Math.max(0, LIMIT - displayedData.length)
|
||||||
@@ -89,33 +100,41 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain,
|
|||||||
<>
|
<>
|
||||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
|
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-2">
|
||||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
|
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
|
||||||
Content
|
Pages
|
||||||
</h3>
|
</h3>
|
||||||
{showViewAll && (
|
{showViewAll && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsModalOpen(true)}
|
onClick={() => setIsModalOpen(true)}
|
||||||
className="text-xs font-medium text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded"
|
className="p-1.5 text-neutral-400 dark:text-neutral-500 hover:text-brand-orange dark:hover:text-brand-orange hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-all cursor-pointer rounded-lg"
|
||||||
|
aria-label="View all pages"
|
||||||
>
|
>
|
||||||
View All
|
<FrameCornersIcon className="w-4 h-4" weight="bold" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex p-1 bg-neutral-100 dark:bg-neutral-800 rounded-lg" role="tablist" aria-label="Content view tabs">
|
<div className="flex gap-1" role="tablist" aria-label="Pages view tabs" onKeyDown={handleTabKeyDown}>
|
||||||
{(['top_pages', 'entry_pages', 'exit_pages'] as Tab[]).map((tab) => (
|
{(['top_pages', 'entry_pages', 'exit_pages'] as Tab[]).map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab}
|
key={tab}
|
||||||
onClick={() => setActiveTab(tab)}
|
onClick={() => setActiveTab(tab)}
|
||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={activeTab === tab}
|
aria-selected={activeTab === tab}
|
||||||
className={`px-3 py-1 text-xs font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange ${
|
className={`relative px-2.5 py-1 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange rounded cursor-pointer ${
|
||||||
activeTab === tab
|
activeTab === tab
|
||||||
? 'bg-white dark:bg-neutral-700 text-neutral-900 dark:text-white shadow-sm'
|
? 'text-neutral-900 dark:text-white'
|
||||||
: 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white'
|
: 'text-neutral-400 dark:text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{getTabLabel(tab)}
|
{getTabLabel(tab)}
|
||||||
|
{activeTab === tab && (
|
||||||
|
<motion.div
|
||||||
|
layoutId="contentStatsTab"
|
||||||
|
className="absolute inset-x-0 -bottom-px h-0.5 bg-brand-orange"
|
||||||
|
transition={{ type: 'spring', stiffness: 500, damping: 35 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -128,21 +147,31 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain,
|
|||||||
</div>
|
</div>
|
||||||
) : hasData ? (
|
) : hasData ? (
|
||||||
<>
|
<>
|
||||||
{displayedData.map((page, index) => (
|
{displayedData.map((page) => (
|
||||||
<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
|
||||||
|
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">
|
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center">
|
||||||
|
<span className="truncate">{page.path}</span>
|
||||||
<a
|
<a
|
||||||
href={`https://${domain.replace(/^https?:\/\//, '')}${page.path}`}
|
href={`https://${domain.replace(/^https?:\/\//, '')}${page.path}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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 text-neutral-400 opacity-0 group-hover:opacity-100 transition-opacity hover:text-brand-orange" />
|
||||||
<ArrowUpRightIcon className="w-3 h-3 ml-2 text-neutral-400 opacity-0 group-hover:opacity-100 transition-opacity" />
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm font-semibold text-neutral-600 dark:text-neutral-400 ml-4">
|
<div className="flex items-center gap-2 ml-4">
|
||||||
{formatNumber(page.pageviews)}
|
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
|
||||||
|
{totalPageviews > 0 ? `${Math.round((page.pageviews / totalPageviews) * 100)}%` : ''}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
|
||||||
|
{formatNumber(page.pageviews)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -168,35 +197,57 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain,
|
|||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={isModalOpen}
|
isOpen={isModalOpen}
|
||||||
onClose={() => setIsModalOpen(false)}
|
onClose={() => { setIsModalOpen(false); setModalSearch('') }}
|
||||||
title={`Content - ${getTabLabel(activeTab)}`}
|
title={getTabLabel(activeTab)}
|
||||||
|
className="max-w-2xl"
|
||||||
>
|
>
|
||||||
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-2">
|
<div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={modalSearch}
|
||||||
|
onChange={(e) => setModalSearch(e.target.value)}
|
||||||
|
placeholder="Search pages..."
|
||||||
|
className="w-full px-3 py-2 mb-3 text-sm bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg text-neutral-900 dark:text-white placeholder-neutral-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-brand-orange/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[80vh]">
|
||||||
{isLoadingFull ? (
|
{isLoadingFull ? (
|
||||||
<div className="py-8 flex flex-col items-center gap-2">
|
<div className="py-4">
|
||||||
<Spinner />
|
<ListSkeleton rows={10} />
|
||||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">Loading...</p>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (() => {
|
||||||
(fullData.length > 0 ? fullData : data).map((page, index) => (
|
const modalData = (fullData.length > 0 ? fullData : data).filter(p => !modalSearch || p.path.toLowerCase().includes(modalSearch.toLowerCase()))
|
||||||
<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">
|
const modalTotal = modalData.reduce((sum, p) => sum + p.pageviews, 0)
|
||||||
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center">
|
return (
|
||||||
<a
|
<VirtualList
|
||||||
href={`https://${domain.replace(/^https?:\/\//, '')}${page.path}`}
|
items={modalData}
|
||||||
target="_blank"
|
estimateSize={36}
|
||||||
rel="noopener noreferrer"
|
className="max-h-[80vh] overflow-y-auto pr-2"
|
||||||
className="hover:underline flex items-center"
|
renderItem={(page) => {
|
||||||
>
|
const canFilter = onFilter && page.path
|
||||||
{page.path}
|
return (
|
||||||
<ArrowUpRightIcon className="w-3 h-3 ml-2 text-neutral-400 opacity-0 group-hover:opacity-100 transition-opacity" />
|
<div
|
||||||
</a>
|
key={page.path}
|
||||||
</div>
|
onClick={() => { if (canFilter) { onFilter({ dimension: 'page', operator: 'is', values: [page.path] }); setIsModalOpen(false) } }}
|
||||||
<div className="text-sm font-semibold text-neutral-600 dark:text-neutral-400 ml-4">
|
className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 transition-colors${canFilter ? ' cursor-pointer' : ''}`}
|
||||||
{formatNumber(page.pageviews)}
|
>
|
||||||
</div>
|
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center">
|
||||||
</div>
|
<span className="truncate">{page.path}</span>
|
||||||
))
|
</div>
|
||||||
)}
|
<div className="flex items-center gap-2 ml-4">
|
||||||
|
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
|
||||||
|
{modalTotal > 0 ? `${Math.round((page.pageviews / modalTotal) * 100)}%` : ''}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
|
||||||
|
{formatNumber(page.pageviews)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,140 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { formatNumber } from '@ciphera-net/ui'
|
|
||||||
import * as Flags from 'country-flag-icons/react/3x2'
|
|
||||||
import WorldMap from './WorldMap'
|
|
||||||
import { GlobeIcon } from '@ciphera-net/ui'
|
|
||||||
|
|
||||||
interface LocationProps {
|
|
||||||
countries: Array<{ country: string; pageviews: number }>
|
|
||||||
cities: Array<{ city: string; country: string; pageviews: number }>
|
|
||||||
}
|
|
||||||
|
|
||||||
type Tab = 'countries' | 'cities'
|
|
||||||
|
|
||||||
export default function Locations({ countries, cities }: LocationProps) {
|
|
||||||
const [activeTab, setActiveTab] = useState<Tab>('countries')
|
|
||||||
|
|
||||||
const getFlagComponent = (countryCode: string) => {
|
|
||||||
if (!countryCode || countryCode === 'Unknown') return null
|
|
||||||
// * The API returns 2-letter country codes (e.g. US, DE)
|
|
||||||
// * We cast it to the flag component name
|
|
||||||
const FlagComponent = (Flags as any)[countryCode]
|
|
||||||
return FlagComponent ? <FlagComponent className="w-5 h-5 rounded-sm shadow-sm" /> : null
|
|
||||||
}
|
|
||||||
|
|
||||||
const getCountryName = (code: string) => {
|
|
||||||
if (!code || code === 'Unknown') return 'Unknown'
|
|
||||||
try {
|
|
||||||
const regionNames = new Intl.DisplayNames(['en'], { type: 'region' })
|
|
||||||
return regionNames.of(code) || code
|
|
||||||
} catch (e) {
|
|
||||||
return code
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderContent = () => {
|
|
||||||
if (activeTab === 'countries') {
|
|
||||||
if (!countries || countries.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="h-full flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
|
|
||||||
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
|
|
||||||
<GlobeIcon className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
|
|
||||||
</div>
|
|
||||||
<h4 className="font-semibold text-neutral-900 dark:text-white">
|
|
||||||
No location data yet
|
|
||||||
</h4>
|
|
||||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs">
|
|
||||||
Visitor locations will appear here based on anonymous geographic data.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<WorldMap data={countries} />
|
|
||||||
<div className="space-y-3">
|
|
||||||
{countries.map((country, index) => (
|
|
||||||
<div key={index} className="flex items-center justify-between">
|
|
||||||
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
|
|
||||||
<span className="shrink-0">{getFlagComponent(country.country)}</span>
|
|
||||||
<span className="truncate">{getCountryName(country.country)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm font-semibold text-neutral-600 dark:text-neutral-400 ml-4">
|
|
||||||
{formatNumber(country.pageviews)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeTab === 'cities') {
|
|
||||||
if (!cities || cities.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="h-full flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
|
|
||||||
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
|
|
||||||
<GlobeIcon className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
|
|
||||||
</div>
|
|
||||||
<h4 className="font-semibold text-neutral-900 dark:text-white">
|
|
||||||
No city data yet
|
|
||||||
</h4>
|
|
||||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs">
|
|
||||||
City-level visitor data will appear as traffic grows.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{cities.map((city, index) => (
|
|
||||||
<div key={index} className="flex items-center justify-between">
|
|
||||||
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
|
|
||||||
<span className="shrink-0">{getFlagComponent(city.country)}</span>
|
|
||||||
<span className="truncate">{city.city === 'Unknown' ? 'Unknown' : city.city}</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm font-semibold text-neutral-600 dark:text-neutral-400 ml-4">
|
|
||||||
{formatNumber(city.pageviews)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full">
|
|
||||||
<div className="flex items-center justify-between mb-6">
|
|
||||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
|
|
||||||
Locations
|
|
||||||
</h3>
|
|
||||||
<div className="flex p-1 bg-neutral-100 dark:bg-neutral-800 rounded-lg">
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab('countries')}
|
|
||||||
className={`px-3 py-1 text-xs font-medium rounded-lg transition-colors ${
|
|
||||||
activeTab === 'countries'
|
|
||||||
? 'bg-white dark:bg-neutral-700 text-neutral-900 dark:text-white shadow-sm'
|
|
||||||
: 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Countries
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab('cities')}
|
|
||||||
className={`px-3 py-1 text-xs font-medium rounded-lg transition-colors ${
|
|
||||||
activeTab === 'cities'
|
|
||||||
? 'bg-white dark:bg-neutral-700 text-neutral-900 dark:text-white shadow-sm'
|
|
||||||
: 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Cities
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{renderContent()}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
160
components/dashboard/DottedMap.tsx
Normal file
160
components/dashboard/DottedMap.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { createMap } from 'svg-dotted-map'
|
||||||
|
import { cn, formatNumber } from '@ciphera-net/ui'
|
||||||
|
import { countryCentroids } from '@/lib/country-centroids'
|
||||||
|
|
||||||
|
// ─── Module-level constants ────────────────────────────────────────
|
||||||
|
// Computed once when the module loads, survives component unmount/remount.
|
||||||
|
const MAP_WIDTH = 150
|
||||||
|
const MAP_HEIGHT = 68
|
||||||
|
const DOT_RADIUS = 0.25
|
||||||
|
|
||||||
|
const { points: MAP_POINTS, addMarkers } = createMap({ width: MAP_WIDTH, height: MAP_HEIGHT, mapSamples: 8000 })
|
||||||
|
|
||||||
|
// Pre-compute stagger helpers (row offsets for hex-grid pattern)
|
||||||
|
const _stagger = (() => {
|
||||||
|
const sorted = [...MAP_POINTS].sort((a, b) => a.y - b.y || a.x - b.x)
|
||||||
|
const rowMap = new Map<number, number>()
|
||||||
|
let step = 0
|
||||||
|
let prevY = Number.NaN
|
||||||
|
let prevXInRow = Number.NaN
|
||||||
|
|
||||||
|
for (const p of sorted) {
|
||||||
|
if (p.y !== prevY) {
|
||||||
|
prevY = p.y
|
||||||
|
prevXInRow = Number.NaN
|
||||||
|
if (!rowMap.has(p.y)) rowMap.set(p.y, rowMap.size)
|
||||||
|
}
|
||||||
|
if (!Number.isNaN(prevXInRow)) {
|
||||||
|
const delta = p.x - prevXInRow
|
||||||
|
if (delta > 0) step = step === 0 ? delta : Math.min(step, delta)
|
||||||
|
}
|
||||||
|
prevXInRow = p.x
|
||||||
|
}
|
||||||
|
|
||||||
|
return { xStep: step || 1, yToRowIndex: rowMap }
|
||||||
|
})()
|
||||||
|
|
||||||
|
// Pre-compute the base map dots as a single SVG path string (~8000 circles → 1 path)
|
||||||
|
const BASE_DOTS_PATH = (() => {
|
||||||
|
const r = DOT_RADIUS
|
||||||
|
const d = r * 2
|
||||||
|
const parts: string[] = []
|
||||||
|
for (const point of MAP_POINTS) {
|
||||||
|
const rowIndex = _stagger.yToRowIndex.get(point.y) ?? 0
|
||||||
|
const offsetX = rowIndex % 2 === 1 ? _stagger.xStep / 2 : 0
|
||||||
|
const cx = point.x + offsetX
|
||||||
|
const cy = point.y
|
||||||
|
parts.push(`M${cx - r},${cy}a${r},${r} 0 1,0 ${d},0a${r},${r} 0 1,0 ${-d},0`)
|
||||||
|
}
|
||||||
|
return parts.join('')
|
||||||
|
})()
|
||||||
|
|
||||||
|
// ─── Component ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface DottedMapProps {
|
||||||
|
data: Array<{ country: string; pageviews: number }>
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCountryName(code: string): string {
|
||||||
|
try {
|
||||||
|
const regionNames = new Intl.DisplayNames(['en'], { type: 'region' })
|
||||||
|
return regionNames.of(code) || code
|
||||||
|
} catch {
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DottedMap({ data, className }: DottedMapProps) {
|
||||||
|
const [tooltip, setTooltip] = useState<{ x: number; y: number; country: string; pageviews: number } | null>(null)
|
||||||
|
|
||||||
|
const markerData = useMemo(() => {
|
||||||
|
if (!data.length) return []
|
||||||
|
|
||||||
|
const max = Math.max(...data.map((d) => d.pageviews))
|
||||||
|
if (max === 0) return []
|
||||||
|
|
||||||
|
return data
|
||||||
|
.filter((d) => d.country && d.country !== 'Unknown' && countryCentroids[d.country])
|
||||||
|
.map((d) => ({
|
||||||
|
lat: countryCentroids[d.country].lat,
|
||||||
|
lng: countryCentroids[d.country].lng,
|
||||||
|
size: 0.4 + (d.pageviews / max) * 0.8,
|
||||||
|
country: d.country,
|
||||||
|
pageviews: d.pageviews,
|
||||||
|
}))
|
||||||
|
}, [data])
|
||||||
|
|
||||||
|
const processedMarkers = useMemo(
|
||||||
|
() => addMarkers(markerData.map((d) => ({ lat: d.lat, lng: d.lng, size: d.size }))),
|
||||||
|
[markerData],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative w-full h-full flex items-center justify-center">
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${MAP_WIDTH} ${MAP_HEIGHT}`}
|
||||||
|
className={cn('text-neutral-400 dark:text-neutral-500', className)}
|
||||||
|
style={{ width: '100%', height: '100%' }}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<filter id="marker-glow" x="-50%" y="-50%" width="200%" height="200%">
|
||||||
|
<feGaussianBlur in="SourceGraphic" stdDeviation="0.8" result="blur" />
|
||||||
|
<feColorMatrix in="blur" type="matrix" values="1 0 0 0 0 0 0.4 0 0 0 0 0 0 0 0 0 0 0 0.6 0" />
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode />
|
||||||
|
<feMergeNode in="SourceGraphic" />
|
||||||
|
</feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<path
|
||||||
|
d={BASE_DOTS_PATH}
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
{processedMarkers.map((marker, index) => {
|
||||||
|
const rowIndex = _stagger.yToRowIndex.get(marker.y) ?? 0
|
||||||
|
const offsetX = rowIndex % 2 === 1 ? _stagger.xStep / 2 : 0
|
||||||
|
const info = markerData[index]
|
||||||
|
return (
|
||||||
|
<circle
|
||||||
|
cx={marker.x + offsetX}
|
||||||
|
cy={marker.y}
|
||||||
|
r={marker.size ?? DOT_RADIUS}
|
||||||
|
fill="#FD5E0F"
|
||||||
|
filter="url(#marker-glow)"
|
||||||
|
className="cursor-pointer"
|
||||||
|
key={`marker-${marker.x}-${marker.y}-${index}`}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (info) {
|
||||||
|
const rect = (e.target as SVGCircleElement).closest('svg')!.getBoundingClientRect()
|
||||||
|
const svgX = marker.x + offsetX
|
||||||
|
const svgY = marker.y
|
||||||
|
setTooltip({
|
||||||
|
x: rect.left + (svgX / MAP_WIDTH) * rect.width,
|
||||||
|
y: rect.top + (svgY / MAP_HEIGHT) * rect.height,
|
||||||
|
country: info.country,
|
||||||
|
pageviews: info.pageviews,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => setTooltip(null)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{tooltip && (
|
||||||
|
<div
|
||||||
|
className="fixed z-50 px-2.5 py-1.5 text-xs font-medium text-white bg-neutral-900 dark:bg-neutral-800 border border-neutral-700 rounded-lg shadow-lg pointer-events-none -translate-x-1/2 -translate-y-full -mt-2"
|
||||||
|
style={{ left: tooltip.x, top: tooltip.y }}
|
||||||
|
>
|
||||||
|
<span>{getCountryName(tooltip.country)}</span>
|
||||||
|
<span className="ml-1.5 text-brand-orange font-bold">{formatNumber(tooltip.pageviews)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
108
components/dashboard/EventProperties.tsx
Normal file
108
components/dashboard/EventProperties.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { formatNumber } from '@ciphera-net/ui'
|
||||||
|
import { getEventPropertyKeys, getEventPropertyValues, type EventPropertyKey, type EventPropertyValue } from '@/lib/api/stats'
|
||||||
|
|
||||||
|
interface EventPropertiesProps {
|
||||||
|
siteId: string
|
||||||
|
eventName: string
|
||||||
|
dateRange: { start: string; end: string }
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EventProperties({ siteId, eventName, dateRange, onClose }: EventPropertiesProps) {
|
||||||
|
const [keys, setKeys] = useState<EventPropertyKey[]>([])
|
||||||
|
const [selectedKey, setSelectedKey] = useState<string | null>(null)
|
||||||
|
const [values, setValues] = useState<EventPropertyValue[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true)
|
||||||
|
getEventPropertyKeys(siteId, eventName, dateRange.start, dateRange.end)
|
||||||
|
.then(k => {
|
||||||
|
setKeys(k)
|
||||||
|
if (k.length > 0) setSelectedKey(k[0].key)
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [siteId, eventName, dateRange.start, dateRange.end])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedKey) return
|
||||||
|
getEventPropertyValues(siteId, eventName, selectedKey, dateRange.start, dateRange.end)
|
||||||
|
.then(setValues)
|
||||||
|
}, [siteId, eventName, selectedKey, dateRange.start, dateRange.end])
|
||||||
|
|
||||||
|
const maxCount = values.length > 0 ? values[0].count : 1
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
|
||||||
|
Properties: <span className="text-brand-orange">{eventName.replace(/_/g, ' ')}</span>
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="animate-pulse space-y-3">
|
||||||
|
{[1, 2, 3].map(i => (
|
||||||
|
<div key={i} className="h-8 bg-neutral-100 dark:bg-neutral-800 rounded-lg" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : keys.length === 0 ? (
|
||||||
|
<p className="text-sm text-neutral-500 dark:text-neutral-400 py-4 text-center">
|
||||||
|
No properties recorded for this event yet.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex gap-2 mb-4 flex-wrap">
|
||||||
|
{keys.map(k => (
|
||||||
|
<button
|
||||||
|
key={k.key}
|
||||||
|
onClick={() => setSelectedKey(k.key)}
|
||||||
|
className={`px-3 py-1 text-xs font-medium rounded-full transition-colors cursor-pointer ${
|
||||||
|
selectedKey === k.key
|
||||||
|
? 'bg-brand-orange text-white'
|
||||||
|
: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-200 dark:hover:bg-neutral-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{k.key}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{values.map(v => (
|
||||||
|
<div key={v.value} className="flex items-center gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<span className="text-sm font-medium text-neutral-900 dark:text-white truncate">
|
||||||
|
{v.value}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-semibold text-brand-orange tabular-nums ml-2">
|
||||||
|
{formatNumber(v.count)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-1.5 bg-neutral-100 dark:bg-neutral-800 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-brand-orange/60 rounded-full transition-all"
|
||||||
|
style={{ width: `${(v.count / maxCount) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useCallback } from 'react'
|
||||||
import { Modal, Button, Checkbox, Input, Select } from '@ciphera-net/ui'
|
import { Modal, Button, Checkbox, Input, Select } from '@ciphera-net/ui'
|
||||||
import * as XLSX from 'xlsx'
|
import * as XLSX from 'xlsx'
|
||||||
import jsPDF from 'jspdf'
|
import jsPDF from 'jspdf'
|
||||||
@@ -49,6 +49,7 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to
|
|||||||
const [format, setFormat] = useState<ExportFormat>('csv')
|
const [format, setFormat] = useState<ExportFormat>('csv')
|
||||||
const [filename, setFilename] = useState(`pulse_export_${new Date().toISOString().split('T')[0]}`)
|
const [filename, setFilename] = useState(`pulse_export_${new Date().toISOString().split('T')[0]}`)
|
||||||
const [includeHeader, setIncludeHeader] = useState(true)
|
const [includeHeader, setIncludeHeader] = useState(true)
|
||||||
|
const [isExporting, setIsExporting] = useState(false)
|
||||||
const [selectedFields, setSelectedFields] = useState<Record<keyof DailyStat, boolean>>({
|
const [selectedFields, setSelectedFields] = useState<Record<keyof DailyStat, boolean>>({
|
||||||
date: true,
|
date: true,
|
||||||
pageviews: true,
|
pageviews: true,
|
||||||
@@ -61,300 +62,312 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to
|
|||||||
setSelectedFields((prev) => ({ ...prev, [field]: checked }))
|
setSelectedFields((prev) => ({ ...prev, [field]: checked }))
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleExport = async () => {
|
const handleExport = () => {
|
||||||
// Filter fields
|
setIsExporting(true)
|
||||||
const fields = (Object.keys(selectedFields) as Array<keyof DailyStat>).filter((k) => selectedFields[k])
|
// Let the browser paint the loading state before starting heavy work
|
||||||
|
requestAnimationFrame(() => {
|
||||||
// Prepare data
|
setTimeout(async () => {
|
||||||
const exportData = data.map((item) => {
|
try {
|
||||||
const filteredItem: Partial<DailyStat> = {}
|
// Filter fields
|
||||||
fields.forEach((field) => {
|
const fields = (Object.keys(selectedFields) as Array<keyof DailyStat>).filter((k) => selectedFields[k])
|
||||||
(filteredItem as any)[field] = item[field]
|
|
||||||
})
|
|
||||||
return filteredItem
|
|
||||||
})
|
|
||||||
|
|
||||||
let content = ''
|
// Prepare data
|
||||||
let mimeType = ''
|
const exportData = data.map((item) => {
|
||||||
let extension = ''
|
const filteredItem: Record<string, string | number> = {}
|
||||||
|
fields.forEach((field) => {
|
||||||
|
filteredItem[field] = item[field]
|
||||||
|
})
|
||||||
|
return filteredItem
|
||||||
|
})
|
||||||
|
|
||||||
if (format === 'csv') {
|
let content = ''
|
||||||
const header = fields.join(',')
|
let mimeType = ''
|
||||||
const rows = exportData.map((row) =>
|
let extension = ''
|
||||||
fields.map((field) => {
|
|
||||||
const val = row[field]
|
if (format === 'csv') {
|
||||||
if (field === 'date' && typeof val === 'string') {
|
const header = fields.join(',')
|
||||||
return new Date(val).toISOString()
|
const rows = exportData.map((row) =>
|
||||||
|
fields.map((field) => {
|
||||||
|
const val = row[field]
|
||||||
|
if (field === 'date' && typeof val === 'string') {
|
||||||
|
return new Date(val).toISOString()
|
||||||
|
}
|
||||||
|
return val
|
||||||
|
}).join(',')
|
||||||
|
)
|
||||||
|
content = (includeHeader ? header + '\n' : '') + rows.join('\n')
|
||||||
|
mimeType = 'text/csv;charset=utf-8;'
|
||||||
|
extension = 'csv'
|
||||||
|
} else if (format === 'xlsx') {
|
||||||
|
const ws = XLSX.utils.json_to_sheet(exportData)
|
||||||
|
const wb = XLSX.utils.book_new()
|
||||||
|
XLSX.utils.book_append_sheet(wb, ws, 'Data')
|
||||||
|
if (campaigns && campaigns.length > 0) {
|
||||||
|
const campaignsSheet = XLSX.utils.json_to_sheet(
|
||||||
|
campaigns.map(c => ({
|
||||||
|
Source: getReferrerDisplayName(c.source),
|
||||||
|
Medium: c.medium || '—',
|
||||||
|
Campaign: c.campaign || '—',
|
||||||
|
Visitors: c.visitors,
|
||||||
|
Pageviews: c.pageviews,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
XLSX.utils.book_append_sheet(wb, campaignsSheet, 'Campaigns')
|
||||||
}
|
}
|
||||||
return val
|
const wbout = XLSX.write(wb, { bookType: 'xlsx', type: 'array' })
|
||||||
}).join(',')
|
const blob = new Blob([wbout], { type: 'application/octet-stream' })
|
||||||
)
|
|
||||||
content = (includeHeader ? header + '\n' : '') + rows.join('\n')
|
|
||||||
mimeType = 'text/csv;charset=utf-8;'
|
|
||||||
extension = 'csv'
|
|
||||||
} else if (format === 'xlsx') {
|
|
||||||
const ws = XLSX.utils.json_to_sheet(exportData)
|
|
||||||
const wb = XLSX.utils.book_new()
|
|
||||||
XLSX.utils.book_append_sheet(wb, ws, 'Data')
|
|
||||||
if (campaigns && campaigns.length > 0) {
|
|
||||||
const campaignsSheet = XLSX.utils.json_to_sheet(
|
|
||||||
campaigns.map(c => ({
|
|
||||||
Source: getReferrerDisplayName(c.source),
|
|
||||||
Medium: c.medium || '—',
|
|
||||||
Campaign: c.campaign || '—',
|
|
||||||
Visitors: c.visitors,
|
|
||||||
Pageviews: c.pageviews,
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
XLSX.utils.book_append_sheet(wb, campaignsSheet, 'Campaigns')
|
|
||||||
}
|
|
||||||
const wbout = XLSX.write(wb, { bookType: 'xlsx', type: 'array' })
|
|
||||||
const blob = new Blob([wbout], { type: 'application/octet-stream' })
|
|
||||||
|
|
||||||
const url = URL.createObjectURL(blob)
|
|
||||||
const link = document.createElement('a')
|
|
||||||
link.setAttribute('href', url)
|
|
||||||
link.setAttribute('download', `${filename || 'export'}.${extension || 'xlsx'}`)
|
|
||||||
document.body.appendChild(link)
|
|
||||||
link.click()
|
|
||||||
document.body.removeChild(link)
|
|
||||||
onClose()
|
|
||||||
return
|
|
||||||
} else if (format === 'pdf') {
|
|
||||||
const doc = new jsPDF()
|
|
||||||
|
|
||||||
// Header Section
|
|
||||||
try {
|
|
||||||
// Logo
|
|
||||||
const logoData = await loadImage('/pulse_icon_no_margins.png')
|
|
||||||
doc.addImage(logoData, 'PNG', 14, 12, 12, 12) // x, y, w, h
|
|
||||||
|
|
||||||
// Title
|
|
||||||
doc.setFontSize(22)
|
|
||||||
doc.setTextColor(249, 115, 22) // Brand Orange #F97316
|
|
||||||
doc.text('Pulse', 32, 20)
|
|
||||||
|
|
||||||
doc.setFontSize(12)
|
|
||||||
doc.setTextColor(100, 100, 100)
|
|
||||||
doc.text('Analytics Export', 32, 25)
|
|
||||||
} catch (e) {
|
|
||||||
// Fallback if logo fails
|
|
||||||
doc.setFontSize(22)
|
|
||||||
doc.setTextColor(249, 115, 22)
|
|
||||||
doc.text('Pulse Analytics', 14, 20)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Metadata (Top Right)
|
const url = URL.createObjectURL(blob)
|
||||||
doc.setFontSize(9)
|
const link = document.createElement('a')
|
||||||
doc.setTextColor(150, 150, 150)
|
link.setAttribute('href', url)
|
||||||
const generatedDate = new Date().toLocaleDateString()
|
link.setAttribute('download', `${filename || 'export'}.${extension || 'xlsx'}`)
|
||||||
const dateRange = data.length > 0
|
document.body.appendChild(link)
|
||||||
? `${new Date(data[0].date).toLocaleDateString()} - ${new Date(data[data.length - 1].date).toLocaleDateString()}`
|
link.click()
|
||||||
: generatedDate
|
document.body.removeChild(link)
|
||||||
|
onClose()
|
||||||
const pageWidth = doc.internal.pageSize.width
|
return
|
||||||
doc.text(`Generated: ${generatedDate}`, pageWidth - 14, 18, { align: 'right' })
|
} else if (format === 'pdf') {
|
||||||
doc.text(`Range: ${dateRange}`, pageWidth - 14, 23, { align: 'right' })
|
const doc = new jsPDF()
|
||||||
|
|
||||||
let startY = 35
|
// Header Section
|
||||||
|
try {
|
||||||
|
// Logo
|
||||||
|
const logoData = await loadImage('/pulse_icon_no_margins.png')
|
||||||
|
doc.addImage(logoData, 'PNG', 14, 12, 12, 12) // x, y, w, h
|
||||||
|
|
||||||
// Summary Section
|
// Title
|
||||||
if (stats) {
|
doc.setFontSize(22)
|
||||||
const summaryY = 35
|
doc.setTextColor(249, 115, 22) // Brand Orange #F97316
|
||||||
const cardWidth = (pageWidth - 28 - 15) / 4 // 4 cards with 5mm gap
|
doc.text('Pulse', 32, 20)
|
||||||
const cardHeight = 20
|
|
||||||
|
doc.setFontSize(12)
|
||||||
const drawCard = (x: number, label: string, value: string) => {
|
doc.setTextColor(100, 100, 100)
|
||||||
doc.setFillColor(255, 247, 237) // Very light orange
|
doc.text('Analytics Export', 32, 25)
|
||||||
doc.setDrawColor(254, 215, 170) // Light orange border
|
} catch (e) {
|
||||||
doc.roundedRect(x, summaryY, cardWidth, cardHeight, 2, 2, 'FD')
|
// Fallback if logo fails
|
||||||
|
doc.setFontSize(22)
|
||||||
doc.setFontSize(8)
|
doc.setTextColor(249, 115, 22)
|
||||||
|
doc.text('Pulse Analytics', 14, 20)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metadata (Top Right)
|
||||||
|
doc.setFontSize(9)
|
||||||
doc.setTextColor(150, 150, 150)
|
doc.setTextColor(150, 150, 150)
|
||||||
doc.text(label, x + 3, summaryY + 6)
|
const generatedDate = new Date().toLocaleDateString()
|
||||||
|
const dateRange = data.length > 0
|
||||||
doc.setFontSize(12)
|
? `${new Date(data[0].date).toLocaleDateString()} - ${new Date(data[data.length - 1].date).toLocaleDateString()}`
|
||||||
doc.setTextColor(23, 23, 23) // Neutral 900
|
: generatedDate
|
||||||
doc.setFont('helvetica', 'bold')
|
|
||||||
doc.text(value, x + 3, summaryY + 14)
|
|
||||||
doc.setFont('helvetica', 'normal')
|
|
||||||
}
|
|
||||||
|
|
||||||
drawCard(14, 'Unique Visitors', formatNumber(stats.visitors))
|
const pageWidth = doc.internal.pageSize.width
|
||||||
drawCard(14 + cardWidth + 5, 'Total Pageviews', formatNumber(stats.pageviews))
|
doc.text(`Generated: ${generatedDate}`, pageWidth - 14, 18, { align: 'right' })
|
||||||
drawCard(14 + (cardWidth + 5) * 2, 'Bounce Rate', `${Math.round(stats.bounce_rate)}%`)
|
doc.text(`Range: ${dateRange}`, pageWidth - 14, 23, { align: 'right' })
|
||||||
drawCard(14 + (cardWidth + 5) * 3, 'Avg Duration', formatDuration(stats.avg_duration))
|
|
||||||
|
|
||||||
startY = 65 // Move table down
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if data is hourly (same date for multiple rows)
|
let startY = 35
|
||||||
const isHourly = data.length > 1 && data[0].date.split('T')[0] === data[1].date.split('T')[0]
|
|
||||||
|
|
||||||
const tableData = exportData.map(row =>
|
// Summary Section
|
||||||
fields.map(field => {
|
if (stats) {
|
||||||
const val = row[field]
|
const summaryY = 35
|
||||||
if (field === 'date' && typeof val === 'string') {
|
const cardWidth = (pageWidth - 28 - 15) / 4 // 4 cards with 5mm gap
|
||||||
const date = new Date(val)
|
const cardHeight = 20
|
||||||
return isHourly
|
|
||||||
? date.toLocaleString('en-US', { month: 'numeric', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' })
|
const drawCard = (x: number, label: string, value: string) => {
|
||||||
: date.toLocaleDateString()
|
doc.setFillColor(255, 247, 237) // Very light orange
|
||||||
|
doc.setDrawColor(254, 215, 170) // Light orange border
|
||||||
|
doc.roundedRect(x, summaryY, cardWidth, cardHeight, 2, 2, 'FD')
|
||||||
|
|
||||||
|
doc.setFontSize(8)
|
||||||
|
doc.setTextColor(150, 150, 150)
|
||||||
|
doc.text(label, x + 3, summaryY + 6)
|
||||||
|
|
||||||
|
doc.setFontSize(12)
|
||||||
|
doc.setTextColor(23, 23, 23) // Neutral 900
|
||||||
|
doc.setFont('helvetica', 'bold')
|
||||||
|
doc.text(value, x + 3, summaryY + 14)
|
||||||
|
doc.setFont('helvetica', 'normal')
|
||||||
|
}
|
||||||
|
|
||||||
|
drawCard(14, 'Unique Visitors', formatNumber(stats.visitors))
|
||||||
|
drawCard(14 + cardWidth + 5, 'Total Pageviews', formatNumber(stats.pageviews))
|
||||||
|
drawCard(14 + (cardWidth + 5) * 2, 'Bounce Rate', `${Math.round(stats.bounce_rate)}%`)
|
||||||
|
drawCard(14 + (cardWidth + 5) * 3, 'Avg Duration', formatDuration(stats.avg_duration))
|
||||||
|
|
||||||
|
startY = 65 // Move table down
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if data is hourly (same date for multiple rows)
|
||||||
|
const isHourly = data.length > 1 && data[0].date.split('T')[0] === data[1].date.split('T')[0]
|
||||||
|
|
||||||
|
const tableData = exportData.map(row =>
|
||||||
|
fields.map(field => {
|
||||||
|
const val = row[field]
|
||||||
|
if (field === 'date' && typeof val === 'string') {
|
||||||
|
const date = new Date(val)
|
||||||
|
return isHourly
|
||||||
|
? date.toLocaleString('en-US', { month: 'numeric', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' })
|
||||||
|
: date.toLocaleDateString()
|
||||||
|
}
|
||||||
|
if (typeof val === 'number') {
|
||||||
|
if (field === 'bounce_rate') return `${Math.round(val)}%`
|
||||||
|
if (field === 'avg_duration') return formatDuration(val)
|
||||||
|
if (field === 'pageviews' || field === 'visitors') return formatNumber(val)
|
||||||
|
}
|
||||||
|
return val ?? ''
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
autoTable(doc, {
|
||||||
|
startY: startY,
|
||||||
|
head: [fields.map(f => f.charAt(0).toUpperCase() + f.slice(1).replace('_', ' '))],
|
||||||
|
body: tableData as (string | number)[][],
|
||||||
|
styles: {
|
||||||
|
font: 'helvetica',
|
||||||
|
fontSize: 9,
|
||||||
|
cellPadding: 4,
|
||||||
|
lineColor: [229, 231, 235], // Neutral 200
|
||||||
|
lineWidth: 0.1,
|
||||||
|
},
|
||||||
|
headStyles: {
|
||||||
|
fillColor: [249, 115, 22], // Brand Orange
|
||||||
|
textColor: [255, 255, 255],
|
||||||
|
fontStyle: 'bold',
|
||||||
|
halign: 'left'
|
||||||
|
},
|
||||||
|
columnStyles: {
|
||||||
|
0: { halign: 'left' }, // Date
|
||||||
|
1: { halign: 'right' }, // Pageviews
|
||||||
|
2: { halign: 'right' }, // Visitors
|
||||||
|
3: { halign: 'right' }, // Bounce Rate
|
||||||
|
4: { halign: 'right' }, // Avg Duration
|
||||||
|
},
|
||||||
|
alternateRowStyles: {
|
||||||
|
fillColor: [255, 250, 245], // Very very light orange
|
||||||
|
},
|
||||||
|
didDrawPage: (data) => {
|
||||||
|
// Footer
|
||||||
|
const pageSize = doc.internal.pageSize
|
||||||
|
const pageHeight = pageSize.height ? pageSize.height : pageSize.getHeight()
|
||||||
|
doc.setFontSize(8)
|
||||||
|
doc.setTextColor(150, 150, 150)
|
||||||
|
doc.text('Powered by Ciphera', 14, pageHeight - 10)
|
||||||
|
|
||||||
|
const str = 'Page ' + doc.getNumberOfPages()
|
||||||
|
doc.text(str, pageSize.width - 14, pageHeight - 10, { align: 'right' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
let finalY = doc.lastAutoTable.finalY + 10
|
||||||
|
|
||||||
|
// Top Pages Table
|
||||||
|
if (topPages && topPages.length > 0) {
|
||||||
|
// Check if we need a new page
|
||||||
|
if (finalY + 40 > doc.internal.pageSize.height) {
|
||||||
|
doc.addPage()
|
||||||
|
finalY = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.setFontSize(14)
|
||||||
|
doc.setTextColor(23, 23, 23)
|
||||||
|
doc.text('Top Pages', 14, finalY)
|
||||||
|
finalY += 5
|
||||||
|
|
||||||
|
const pagesData = topPages.slice(0, 10).map(p => [p.path, formatNumber(p.pageviews)])
|
||||||
|
|
||||||
|
autoTable(doc, {
|
||||||
|
startY: finalY,
|
||||||
|
head: [['Path', 'Pageviews']],
|
||||||
|
body: pagesData,
|
||||||
|
styles: { font: 'helvetica', fontSize: 9, cellPadding: 3 },
|
||||||
|
headStyles: { fillColor: [249, 115, 22], textColor: [255, 255, 255], fontStyle: 'bold' },
|
||||||
|
columnStyles: { 1: { halign: 'right' } },
|
||||||
|
alternateRowStyles: { fillColor: [255, 250, 245] },
|
||||||
|
})
|
||||||
|
|
||||||
|
finalY = doc.lastAutoTable.finalY + 10
|
||||||
|
}
|
||||||
|
|
||||||
|
// Top Referrers Table
|
||||||
|
if (topReferrers && topReferrers.length > 0) {
|
||||||
|
// Check if we need a new page
|
||||||
|
if (finalY + 40 > doc.internal.pageSize.height) {
|
||||||
|
doc.addPage()
|
||||||
|
finalY = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.setFontSize(14)
|
||||||
|
doc.setTextColor(23, 23, 23)
|
||||||
|
doc.text('Top Referrers', 14, finalY)
|
||||||
|
finalY += 5
|
||||||
|
|
||||||
|
const mergedReferrers = mergeReferrersByDisplayName(topReferrers)
|
||||||
|
const referrersData = mergedReferrers.slice(0, 10).map(r => [getReferrerDisplayName(r.referrer), formatNumber(r.pageviews)])
|
||||||
|
|
||||||
|
autoTable(doc, {
|
||||||
|
startY: finalY,
|
||||||
|
head: [['Referrer', 'Pageviews']],
|
||||||
|
body: referrersData,
|
||||||
|
styles: { font: 'helvetica', fontSize: 9, cellPadding: 3 },
|
||||||
|
headStyles: { fillColor: [249, 115, 22], textColor: [255, 255, 255], fontStyle: 'bold' },
|
||||||
|
columnStyles: { 1: { halign: 'right' } },
|
||||||
|
alternateRowStyles: { fillColor: [255, 250, 245] },
|
||||||
|
})
|
||||||
|
|
||||||
|
finalY = doc.lastAutoTable.finalY + 10
|
||||||
|
}
|
||||||
|
|
||||||
|
// Campaigns Table
|
||||||
|
if (campaigns && campaigns.length > 0) {
|
||||||
|
if (finalY + 40 > doc.internal.pageSize.height) {
|
||||||
|
doc.addPage()
|
||||||
|
finalY = 20
|
||||||
|
}
|
||||||
|
doc.setFontSize(14)
|
||||||
|
doc.setTextColor(23, 23, 23)
|
||||||
|
doc.text('Campaigns', 14, finalY)
|
||||||
|
finalY += 5
|
||||||
|
const campaignsData = campaigns.slice(0, 10).map(c => [
|
||||||
|
getReferrerDisplayName(c.source),
|
||||||
|
c.medium || '—',
|
||||||
|
c.campaign || '—',
|
||||||
|
formatNumber(c.visitors),
|
||||||
|
formatNumber(c.pageviews),
|
||||||
|
])
|
||||||
|
autoTable(doc, {
|
||||||
|
startY: finalY,
|
||||||
|
head: [['Source', 'Medium', 'Campaign', 'Visitors', 'Pageviews']],
|
||||||
|
body: campaignsData,
|
||||||
|
styles: { font: 'helvetica', fontSize: 9, cellPadding: 3 },
|
||||||
|
headStyles: { fillColor: [249, 115, 22], textColor: [255, 255, 255], fontStyle: 'bold' },
|
||||||
|
columnStyles: { 3: { halign: 'right' }, 4: { halign: 'right' } },
|
||||||
|
alternateRowStyles: { fillColor: [255, 250, 245] },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.save(`${filename || 'export'}.pdf`)
|
||||||
|
onClose()
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
content = JSON.stringify(exportData, null, 2)
|
||||||
|
mimeType = 'application/json;charset=utf-8;'
|
||||||
|
extension = 'json'
|
||||||
}
|
}
|
||||||
if (typeof val === 'number') {
|
|
||||||
if (field === 'bounce_rate') return `${Math.round(val)}%`
|
|
||||||
if (field === 'avg_duration') return formatDuration(val)
|
|
||||||
if (field === 'pageviews' || field === 'visitors') return formatNumber(val)
|
|
||||||
}
|
|
||||||
return val ?? ''
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
autoTable(doc, {
|
const blob = new Blob([content], { type: mimeType })
|
||||||
startY: startY,
|
const url = URL.createObjectURL(blob)
|
||||||
head: [fields.map(f => f.charAt(0).toUpperCase() + f.slice(1).replace('_', ' '))],
|
const link = document.createElement('a')
|
||||||
body: tableData as any[][],
|
link.setAttribute('href', url)
|
||||||
styles: {
|
link.setAttribute('download', `${filename || 'export'}.${extension}`)
|
||||||
font: 'helvetica',
|
document.body.appendChild(link)
|
||||||
fontSize: 9,
|
link.click()
|
||||||
cellPadding: 4,
|
document.body.removeChild(link)
|
||||||
lineColor: [229, 231, 235], // Neutral 200
|
|
||||||
lineWidth: 0.1,
|
onClose()
|
||||||
},
|
} catch (e) {
|
||||||
headStyles: {
|
console.error('Export failed:', e)
|
||||||
fillColor: [249, 115, 22], // Brand Orange
|
} finally {
|
||||||
textColor: [255, 255, 255],
|
setIsExporting(false)
|
||||||
fontStyle: 'bold',
|
|
||||||
halign: 'left'
|
|
||||||
},
|
|
||||||
columnStyles: {
|
|
||||||
0: { halign: 'left' }, // Date
|
|
||||||
1: { halign: 'right' }, // Pageviews
|
|
||||||
2: { halign: 'right' }, // Visitors
|
|
||||||
3: { halign: 'right' }, // Bounce Rate
|
|
||||||
4: { halign: 'right' }, // Avg Duration
|
|
||||||
},
|
|
||||||
alternateRowStyles: {
|
|
||||||
fillColor: [255, 250, 245], // Very very light orange
|
|
||||||
},
|
|
||||||
didDrawPage: (data) => {
|
|
||||||
// Footer
|
|
||||||
const pageSize = doc.internal.pageSize
|
|
||||||
const pageHeight = pageSize.height ? pageSize.height : pageSize.getHeight()
|
|
||||||
doc.setFontSize(8)
|
|
||||||
doc.setTextColor(150, 150, 150)
|
|
||||||
doc.text('Powered by Ciphera', 14, pageHeight - 10)
|
|
||||||
|
|
||||||
const str = 'Page ' + doc.getNumberOfPages()
|
|
||||||
doc.text(str, pageSize.width - 14, pageHeight - 10, { align: 'right' })
|
|
||||||
}
|
}
|
||||||
})
|
}, 0)
|
||||||
|
})
|
||||||
let finalY = (doc as any).lastAutoTable.finalY + 10
|
|
||||||
|
|
||||||
// Top Pages Table
|
|
||||||
if (topPages && topPages.length > 0) {
|
|
||||||
// Check if we need a new page
|
|
||||||
if (finalY + 40 > doc.internal.pageSize.height) {
|
|
||||||
doc.addPage()
|
|
||||||
finalY = 20
|
|
||||||
}
|
|
||||||
|
|
||||||
doc.setFontSize(14)
|
|
||||||
doc.setTextColor(23, 23, 23)
|
|
||||||
doc.text('Top Pages', 14, finalY)
|
|
||||||
finalY += 5
|
|
||||||
|
|
||||||
const pagesData = topPages.slice(0, 10).map(p => [p.path, formatNumber(p.pageviews)])
|
|
||||||
|
|
||||||
autoTable(doc, {
|
|
||||||
startY: finalY,
|
|
||||||
head: [['Path', 'Pageviews']],
|
|
||||||
body: pagesData,
|
|
||||||
styles: { font: 'helvetica', fontSize: 9, cellPadding: 3 },
|
|
||||||
headStyles: { fillColor: [249, 115, 22], textColor: [255, 255, 255], fontStyle: 'bold' },
|
|
||||||
columnStyles: { 1: { halign: 'right' } },
|
|
||||||
alternateRowStyles: { fillColor: [255, 250, 245] },
|
|
||||||
})
|
|
||||||
|
|
||||||
finalY = (doc as any).lastAutoTable.finalY + 10
|
|
||||||
}
|
|
||||||
|
|
||||||
// Top Referrers Table
|
|
||||||
if (topReferrers && topReferrers.length > 0) {
|
|
||||||
// Check if we need a new page
|
|
||||||
if (finalY + 40 > doc.internal.pageSize.height) {
|
|
||||||
doc.addPage()
|
|
||||||
finalY = 20
|
|
||||||
}
|
|
||||||
|
|
||||||
doc.setFontSize(14)
|
|
||||||
doc.setTextColor(23, 23, 23)
|
|
||||||
doc.text('Top Referrers', 14, finalY)
|
|
||||||
finalY += 5
|
|
||||||
|
|
||||||
const mergedReferrers = mergeReferrersByDisplayName(topReferrers)
|
|
||||||
const referrersData = mergedReferrers.slice(0, 10).map(r => [getReferrerDisplayName(r.referrer), formatNumber(r.pageviews)])
|
|
||||||
|
|
||||||
autoTable(doc, {
|
|
||||||
startY: finalY,
|
|
||||||
head: [['Referrer', 'Pageviews']],
|
|
||||||
body: referrersData,
|
|
||||||
styles: { font: 'helvetica', fontSize: 9, cellPadding: 3 },
|
|
||||||
headStyles: { fillColor: [249, 115, 22], textColor: [255, 255, 255], fontStyle: 'bold' },
|
|
||||||
columnStyles: { 1: { halign: 'right' } },
|
|
||||||
alternateRowStyles: { fillColor: [255, 250, 245] },
|
|
||||||
})
|
|
||||||
|
|
||||||
finalY = (doc as any).lastAutoTable.finalY + 10
|
|
||||||
}
|
|
||||||
|
|
||||||
// Campaigns Table
|
|
||||||
if (campaigns && campaigns.length > 0) {
|
|
||||||
if (finalY + 40 > doc.internal.pageSize.height) {
|
|
||||||
doc.addPage()
|
|
||||||
finalY = 20
|
|
||||||
}
|
|
||||||
doc.setFontSize(14)
|
|
||||||
doc.setTextColor(23, 23, 23)
|
|
||||||
doc.text('Campaigns', 14, finalY)
|
|
||||||
finalY += 5
|
|
||||||
const campaignsData = campaigns.slice(0, 10).map(c => [
|
|
||||||
getReferrerDisplayName(c.source),
|
|
||||||
c.medium || '—',
|
|
||||||
c.campaign || '—',
|
|
||||||
formatNumber(c.visitors),
|
|
||||||
formatNumber(c.pageviews),
|
|
||||||
])
|
|
||||||
autoTable(doc, {
|
|
||||||
startY: finalY,
|
|
||||||
head: [['Source', 'Medium', 'Campaign', 'Visitors', 'Pageviews']],
|
|
||||||
body: campaignsData,
|
|
||||||
styles: { font: 'helvetica', fontSize: 9, cellPadding: 3 },
|
|
||||||
headStyles: { fillColor: [249, 115, 22], textColor: [255, 255, 255], fontStyle: 'bold' },
|
|
||||||
columnStyles: { 3: { halign: 'right' }, 4: { halign: 'right' } },
|
|
||||||
alternateRowStyles: { fillColor: [255, 250, 245] },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
doc.save(`${filename || 'export'}.pdf`)
|
|
||||||
onClose()
|
|
||||||
return
|
|
||||||
} else {
|
|
||||||
content = JSON.stringify(exportData, null, 2)
|
|
||||||
mimeType = 'application/json;charset=utf-8;'
|
|
||||||
extension = 'json'
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = new Blob([content], { type: mimeType })
|
|
||||||
const url = URL.createObjectURL(blob)
|
|
||||||
const link = document.createElement('a')
|
|
||||||
link.setAttribute('href', url)
|
|
||||||
link.setAttribute('download', `${filename || 'export'}.${extension}`)
|
|
||||||
document.body.appendChild(link)
|
|
||||||
link.click()
|
|
||||||
document.body.removeChild(link)
|
|
||||||
|
|
||||||
onClose()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -440,11 +453,11 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to
|
|||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex justify-end gap-3 pt-4">
|
<div className="flex justify-end gap-3 pt-4">
|
||||||
<Button variant="secondary" onClick={onClose}>
|
<Button variant="secondary" onClick={onClose} disabled={isExporting}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="primary" onClick={handleExport}>
|
<Button variant="primary" onClick={handleExport} disabled={isExporting}>
|
||||||
Export Data
|
{isExporting ? 'Exporting...' : 'Export Data'}
|
||||||
</Button>
|
</Button>
|
||||||
</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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
113
components/dashboard/Globe.tsx
Normal file
113
components/dashboard/Globe.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef } from 'react'
|
||||||
|
import createGlobe from 'cobe'
|
||||||
|
import { useTheme } from '@ciphera-net/ui'
|
||||||
|
import { countryCentroids } from '@/lib/country-centroids'
|
||||||
|
|
||||||
|
interface GlobeProps {
|
||||||
|
data: Array<{ country: string; pageviews: number }>
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Globe({ data, className }: GlobeProps) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||||
|
const phiRef = useRef(0)
|
||||||
|
const dragRef = useRef(0)
|
||||||
|
const pointerRef = useRef<number | null>(null)
|
||||||
|
const { resolvedTheme } = useTheme()
|
||||||
|
const isDarkRef = useRef(resolvedTheme === 'dark')
|
||||||
|
const markersRef = useRef<Array<{ location: [number, number]; size: number }>>([])
|
||||||
|
|
||||||
|
// Update refs without causing effect re-runs
|
||||||
|
isDarkRef.current = resolvedTheme === 'dark'
|
||||||
|
|
||||||
|
// Compute markers into ref (memoized to avoid recalculating on every render)
|
||||||
|
const markers = useMemo(() => {
|
||||||
|
const max = data.length ? Math.max(...data.map((d) => d.pageviews)) : 0
|
||||||
|
return max > 0
|
||||||
|
? data
|
||||||
|
.filter((d) => d.country && d.country !== 'Unknown' && countryCentroids[d.country])
|
||||||
|
.map((d) => ({
|
||||||
|
location: [countryCentroids[d.country].lat, countryCentroids[d.country].lng] as [number, number],
|
||||||
|
size: 0.03 + (d.pageviews / max) * 0.12,
|
||||||
|
}))
|
||||||
|
: []
|
||||||
|
}, [data])
|
||||||
|
markersRef.current = markers
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canvasRef.current) return
|
||||||
|
|
||||||
|
const size = canvasRef.current.offsetWidth
|
||||||
|
const pixelRatio = Math.min(window.devicePixelRatio, 2)
|
||||||
|
const isDark = isDarkRef.current
|
||||||
|
|
||||||
|
const globe = createGlobe(canvasRef.current, {
|
||||||
|
width: size * pixelRatio,
|
||||||
|
height: size * pixelRatio,
|
||||||
|
devicePixelRatio: pixelRatio,
|
||||||
|
phi: phiRef.current,
|
||||||
|
theta: 0.3,
|
||||||
|
dark: isDark ? 1 : 0,
|
||||||
|
diffuse: isDark ? 2 : 0.4,
|
||||||
|
mapSamples: 16000,
|
||||||
|
mapBrightness: isDark ? 2 : 1.2,
|
||||||
|
baseColor: isDark ? [0.5, 0.5, 0.5] : [1, 1, 1],
|
||||||
|
markerColor: [253 / 255, 94 / 255, 15 / 255],
|
||||||
|
glowColor: isDark ? [0.15, 0.15, 0.15] : [1, 1, 1],
|
||||||
|
markers: markersRef.current,
|
||||||
|
onRender: (state) => {
|
||||||
|
if (!pointerRef.current) phiRef.current += 0.002
|
||||||
|
state.phi = phiRef.current + dragRef.current
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (canvasRef.current) canvasRef.current.style.opacity = '1'
|
||||||
|
}, 0)
|
||||||
|
|
||||||
|
return () => { globe.destroy() }
|
||||||
|
// Only recreate on theme change
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [resolvedTheme])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`relative w-full h-full overflow-hidden ${className ?? ''}`}>
|
||||||
|
<div className="absolute left-1/2 -translate-x-1/2 top-0 aspect-square w-[130%]">
|
||||||
|
<canvas
|
||||||
|
className="size-full opacity-0 transition-opacity duration-500"
|
||||||
|
style={{ contain: 'layout paint size' }}
|
||||||
|
ref={canvasRef}
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
pointerRef.current = e.clientX
|
||||||
|
canvasRef.current!.style.cursor = 'grabbing'
|
||||||
|
}}
|
||||||
|
onPointerUp={() => {
|
||||||
|
pointerRef.current = null
|
||||||
|
canvasRef.current!.style.cursor = 'grab'
|
||||||
|
}}
|
||||||
|
onPointerOut={() => {
|
||||||
|
pointerRef.current = null
|
||||||
|
if (canvasRef.current) canvasRef.current.style.cursor = 'grab'
|
||||||
|
}}
|
||||||
|
onMouseMove={(e) => {
|
||||||
|
if (pointerRef.current !== null) {
|
||||||
|
const delta = e.clientX - pointerRef.current
|
||||||
|
dragRef.current += delta / 800
|
||||||
|
pointerRef.current = e.clientX
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onTouchMove={(e) => {
|
||||||
|
if (pointerRef.current !== null && e.touches[0]) {
|
||||||
|
const delta = e.touches[0].clientX - pointerRef.current
|
||||||
|
dragRef.current += delta / 800
|
||||||
|
pointerRef.current = e.touches[0].clientX
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="pointer-events-none absolute inset-0 h-full bg-[radial-gradient(circle_at_50%_200%,rgba(0,0,0,0.2),rgba(255,255,255,0))]" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,13 +7,16 @@ import type { GoalCountStat } from '@/lib/api/stats'
|
|||||||
|
|
||||||
interface GoalStatsProps {
|
interface GoalStatsProps {
|
||||||
goalCounts: GoalCountStat[]
|
goalCounts: GoalCountStat[]
|
||||||
|
onSelectEvent?: (eventName: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const LIMIT = 10
|
const LIMIT = 10
|
||||||
|
|
||||||
export default function GoalStats({ goalCounts }: GoalStatsProps) {
|
export default function GoalStats({ goalCounts, onSelectEvent }: GoalStatsProps) {
|
||||||
const list = (goalCounts || []).slice(0, LIMIT)
|
const list = (goalCounts || []).slice(0, LIMIT)
|
||||||
const hasData = list.length > 0
|
const hasData = list.length > 0
|
||||||
|
const total = list.reduce((sum, r) => sum + r.count, 0)
|
||||||
|
const emptySlots = Math.max(0, 6 - list.length)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
|
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
|
||||||
@@ -24,23 +27,34 @@ export default function GoalStats({ goalCounts }: GoalStatsProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasData ? (
|
{hasData ? (
|
||||||
<div className="space-y-2 flex-1 min-h-[200px]">
|
<div className="flex-1 min-h-[270px]">
|
||||||
{list.map((row) => (
|
{list.map((row) => (
|
||||||
<div
|
<div
|
||||||
key={row.event_name}
|
key={row.event_name}
|
||||||
className="flex items-center justify-between py-2 px-3 rounded-lg bg-neutral-50 dark:bg-neutral-800/50 hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
|
onClick={() => onSelectEvent?.(row.event_name)}
|
||||||
|
className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors${onSelectEvent ? ' cursor-pointer' : ''}`}
|
||||||
>
|
>
|
||||||
<span className="text-sm font-medium text-neutral-900 dark:text-white truncate">
|
<div className="flex items-center flex-1 min-w-0">
|
||||||
{row.display_name ?? row.event_name.replace(/_/g, ' ')}
|
<span className="text-sm font-medium text-neutral-900 dark:text-white truncate">
|
||||||
</span>
|
{row.display_name ?? row.event_name.replace(/_/g, ' ')}
|
||||||
<span className="text-sm font-semibold text-brand-orange tabular-nums">
|
</span>
|
||||||
{formatNumber(row.count)}
|
</div>
|
||||||
</span>
|
<div className="flex items-center gap-2 ml-4">
|
||||||
|
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
|
||||||
|
{total > 0 ? `${Math.round((row.count / total) * 100)}%` : ''}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400 tabular-nums">
|
||||||
|
{formatNumber(row.count)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
{Array.from({ length: emptySlots }).map((_, i) => (
|
||||||
|
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
|
||||||
|
))}
|
||||||
</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="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">
|
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
|
||||||
<BookOpenIcon className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
|
<BookOpenIcon className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
|
||||||
</div>
|
</div>
|
||||||
@@ -52,7 +66,7 @@ export default function GoalStats({ goalCounts }: GoalStatsProps) {
|
|||||||
</p>
|
</p>
|
||||||
<Link
|
<Link
|
||||||
href="/installation"
|
href="/installation"
|
||||||
className="inline-flex items-center gap-1.5 text-sm font-medium text-brand-orange hover:text-brand-orange/90 hover:underline focus:outline-none focus:ring-2 focus:ring-brand-orange/20 rounded"
|
className="inline-flex items-center gap-2 text-sm font-medium text-brand-orange hover:text-brand-orange/90 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange/20 rounded"
|
||||||
>
|
>
|
||||||
Read documentation
|
Read documentation
|
||||||
<ArrowRightIcon className="w-4 h-4" />
|
<ArrowRightIcon className="w-4 h-4" />
|
||||||
|
|||||||
@@ -1,15 +1,22 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import dynamic from 'next/dynamic'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import { logger } from '@/lib/utils/logger'
|
||||||
import { formatNumber } from '@ciphera-net/ui'
|
import { formatNumber } from '@ciphera-net/ui'
|
||||||
|
import { useTabListKeyboard } from '@/lib/hooks/useTabListKeyboard'
|
||||||
import * as Flags from 'country-flag-icons/react/3x2'
|
import * as Flags from 'country-flag-icons/react/3x2'
|
||||||
// @ts-ignore
|
|
||||||
import iso3166 from 'iso-3166-2'
|
import iso3166 from 'iso-3166-2'
|
||||||
import WorldMap from './WorldMap'
|
|
||||||
import { Modal, GlobeIcon, Spinner } from '@ciphera-net/ui'
|
const DottedMap = dynamic(() => import('./DottedMap'), { ssr: false })
|
||||||
import { SiTorproject } from 'react-icons/si'
|
const Globe = dynamic(() => import('./Globe'), { ssr: false })
|
||||||
import { FaUserSecret, FaSatellite } from 'react-icons/fa'
|
import { Modal, GlobeIcon } from '@ciphera-net/ui'
|
||||||
|
import { ListSkeleton } from '@/components/skeletons'
|
||||||
|
import VirtualList from './VirtualList'
|
||||||
|
import { ShieldCheck, Detective, Broadcast, FrameCornersIcon } from '@phosphor-icons/react'
|
||||||
import { getCountries, getCities, getRegions } from '@/lib/api/stats'
|
import { getCountries, getCities, getRegions } from '@/lib/api/stats'
|
||||||
|
import { type DimensionFilter } from '@/lib/filters'
|
||||||
|
|
||||||
interface LocationProps {
|
interface LocationProps {
|
||||||
countries: Array<{ country: string; pageviews: number }>
|
countries: Array<{ country: string; pageviews: number }>
|
||||||
@@ -18,24 +25,44 @@ interface LocationProps {
|
|||||||
geoDataLevel?: 'full' | 'country' | 'none'
|
geoDataLevel?: 'full' | 'country' | 'none'
|
||||||
siteId: string
|
siteId: string
|
||||||
dateRange: { start: string, end: string }
|
dateRange: { start: string, end: string }
|
||||||
|
onFilter?: (filter: DimensionFilter) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type Tab = 'map' | 'countries' | 'regions' | 'cities'
|
type Tab = 'map' | 'globe' | 'countries' | 'regions' | 'cities'
|
||||||
|
|
||||||
const LIMIT = 7
|
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 [activeTab, setActiveTab] = useState<Tab>('map')
|
||||||
|
const handleTabKeyDown = useTabListKeyboard()
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||||
const [fullData, setFullData] = useState<any[]>([])
|
const [modalSearch, setModalSearch] = useState('')
|
||||||
|
type LocationItem = { country?: string; city?: string; region?: string; pageviews: number }
|
||||||
|
const [fullData, setFullData] = useState<LocationItem[]>([])
|
||||||
const [isLoadingFull, setIsLoadingFull] = useState(false)
|
const [isLoadingFull, setIsLoadingFull] = useState(false)
|
||||||
|
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [inView, setInView] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = containerRef.current
|
||||||
|
if (!el) return
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => { if (entry.isIntersecting) setInView(true) },
|
||||||
|
{ rootMargin: '200px' }
|
||||||
|
)
|
||||||
|
observer.observe(el)
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isModalOpen) {
|
if (isModalOpen) {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
setIsLoadingFull(true)
|
setIsLoadingFull(true)
|
||||||
try {
|
try {
|
||||||
let data: any[] = []
|
let data: LocationItem[] = []
|
||||||
if (activeTab === 'countries') {
|
if (activeTab === 'countries') {
|
||||||
data = await getCountries(siteId, dateRange.start, dateRange.end, 250)
|
data = await getCountries(siteId, dateRange.start, dateRange.end, 250)
|
||||||
} else if (activeTab === 'regions') {
|
} else if (activeTab === 'regions') {
|
||||||
@@ -45,7 +72,7 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
|
|||||||
}
|
}
|
||||||
setFullData(data)
|
setFullData(data)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
logger.error(e)
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoadingFull(false)
|
setIsLoadingFull(false)
|
||||||
}
|
}
|
||||||
@@ -61,18 +88,18 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
|
|||||||
|
|
||||||
switch (countryCode) {
|
switch (countryCode) {
|
||||||
case 'T1':
|
case 'T1':
|
||||||
return <SiTorproject className="w-5 h-5 text-purple-600 dark:text-purple-400" />
|
return <ShieldCheck className="w-5 h-5 text-purple-600 dark:text-purple-400" />
|
||||||
case 'A1':
|
case 'A1':
|
||||||
return <FaUserSecret className="w-5 h-5 text-neutral-600 dark:text-neutral-400" />
|
return <Detective className="w-5 h-5 text-neutral-600 dark:text-neutral-400" />
|
||||||
case 'A2':
|
case 'A2':
|
||||||
return <FaSatellite className="w-5 h-5 text-blue-500 dark:text-blue-400" />
|
return <Broadcast className="w-5 h-5 text-blue-500 dark:text-blue-400" />
|
||||||
case 'O1':
|
case 'O1':
|
||||||
case 'EU':
|
case 'EU':
|
||||||
case 'AP':
|
case 'AP':
|
||||||
return <GlobeIcon className="w-5 h-5 text-neutral-500 dark:text-neutral-400" />
|
return <GlobeIcon className="w-5 h-5 text-neutral-500 dark:text-neutral-400" />
|
||||||
}
|
}
|
||||||
|
|
||||||
const FlagComponent = (Flags as any)[countryCode]
|
const FlagComponent = (Flags as Record<string, React.ComponentType<{ className?: string }>>)[countryCode]
|
||||||
return FlagComponent ? <FlagComponent className="w-5 h-5 rounded-sm shadow-sm" /> : null
|
return FlagComponent ? <FlagComponent className="w-5 h-5 rounded-sm shadow-sm" /> : null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,7 +184,7 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Filter out "Unknown" entries that result from disabled collection
|
// Filter out "Unknown" entries that result from disabled collection
|
||||||
const filterUnknown = (data: any[]) => {
|
const filterUnknown = (data: LocationItem[]) => {
|
||||||
return data.filter(item => {
|
return data.filter(item => {
|
||||||
if (activeTab === 'countries') return item.country && item.country !== 'Unknown' && item.country !== ''
|
if (activeTab === 'countries') return item.country && item.country !== 'Unknown' && item.country !== ''
|
||||||
if (activeTab === 'regions') return item.region && item.region !== 'Unknown' && item.region !== ''
|
if (activeTab === 'regions') return item.region && item.region !== 'Unknown' && item.region !== ''
|
||||||
@@ -166,14 +193,16 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawData = activeTab === 'map' ? [] : getData()
|
const isVisualTab = activeTab === 'map' || activeTab === 'globe'
|
||||||
|
const rawData = isVisualTab ? [] : getData()
|
||||||
const data = filterUnknown(rawData)
|
const data = filterUnknown(rawData)
|
||||||
const hasData = activeTab === 'map'
|
const totalPageviews = data.reduce((sum, item) => sum + item.pageviews, 0)
|
||||||
|
const hasData = isVisualTab
|
||||||
? (countries && filterUnknown(countries).length > 0)
|
? (countries && filterUnknown(countries).length > 0)
|
||||||
: (data && data.length > 0)
|
: (data && data.length > 0)
|
||||||
const displayedData = (activeTab !== 'map' && hasData) ? (data as any[]).slice(0, LIMIT) : []
|
const displayedData = (!isVisualTab && hasData) ? data.slice(0, LIMIT) : []
|
||||||
const emptySlots = Math.max(0, LIMIT - displayedData.length)
|
const emptySlots = Math.max(0, LIMIT - displayedData.length)
|
||||||
const showViewAll = activeTab !== 'map' && hasData && data.length > LIMIT
|
const showViewAll = !isVisualTab && hasData && data.length > LIMIT
|
||||||
|
|
||||||
const getDisabledMessage = () => {
|
const getDisabledMessage = () => {
|
||||||
if (geoDataLevel === 'none') {
|
if (geoDataLevel === 'none') {
|
||||||
@@ -187,35 +216,43 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
|
<div ref={containerRef} className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-2">
|
||||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
|
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
|
||||||
Locations
|
Locations
|
||||||
</h3>
|
</h3>
|
||||||
{showViewAll && (
|
{showViewAll && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsModalOpen(true)}
|
onClick={() => setIsModalOpen(true)}
|
||||||
className="text-xs font-medium text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded"
|
className="p-1.5 text-neutral-400 dark:text-neutral-500 hover:text-brand-orange dark:hover:text-brand-orange hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-all cursor-pointer rounded-lg"
|
||||||
|
aria-label="View all locations"
|
||||||
>
|
>
|
||||||
View All
|
<FrameCornersIcon className="w-4 h-4" weight="bold" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex p-1 bg-neutral-100 dark:bg-neutral-800 rounded-lg" role="tablist" aria-label="Location view tabs">
|
<div className="flex gap-1" role="tablist" aria-label="Location view tabs" onKeyDown={handleTabKeyDown}>
|
||||||
{(['map', 'countries', 'regions', 'cities'] as Tab[]).map((tab) => (
|
{(['map', 'globe', 'countries', 'regions', 'cities'] as Tab[]).map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab}
|
key={tab}
|
||||||
onClick={() => setActiveTab(tab)}
|
onClick={() => setActiveTab(tab)}
|
||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={activeTab === tab}
|
aria-selected={activeTab === tab}
|
||||||
className={`px-3 py-1 text-xs font-medium rounded-lg transition-colors capitalize focus:outline-none focus:ring-2 focus:ring-brand-orange ${
|
className={`relative px-2.5 py-1 text-xs font-medium transition-colors capitalize focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange rounded cursor-pointer ${
|
||||||
activeTab === tab
|
activeTab === tab
|
||||||
? 'bg-white dark:bg-neutral-700 text-neutral-900 dark:text-white shadow-sm'
|
? 'text-neutral-900 dark:text-white'
|
||||||
: 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white'
|
: 'text-neutral-400 dark:text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{tab}
|
{tab}
|
||||||
|
{activeTab === tab && (
|
||||||
|
<motion.div
|
||||||
|
layoutId="locationsTab"
|
||||||
|
className="absolute inset-x-0 -bottom-px h-0.5 bg-brand-orange"
|
||||||
|
transition={{ type: 'spring', stiffness: 500, damping: 35 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -226,8 +263,12 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
|
|||||||
<div className="h-full flex flex-col items-center justify-center text-center px-4">
|
<div className="h-full flex flex-col items-center justify-center text-center px-4">
|
||||||
<p className="text-neutral-500 dark:text-neutral-400 text-sm">{getDisabledMessage()}</p>
|
<p className="text-neutral-500 dark:text-neutral-400 text-sm">{getDisabledMessage()}</p>
|
||||||
</div>
|
</div>
|
||||||
) : activeTab === 'map' ? (
|
) : isVisualTab ? (
|
||||||
hasData ? <WorldMap data={filterUnknown(countries)} /> : (
|
hasData ? (
|
||||||
|
activeTab === 'globe'
|
||||||
|
? (inView ? <Globe data={filterUnknown(countries) as { country: string; pageviews: number }[]} /> : null)
|
||||||
|
: (inView ? <DottedMap data={filterUnknown(countries) as { country: string; pageviews: number }[]} /> : null)
|
||||||
|
) : (
|
||||||
<div className="h-full flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
|
<div className="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">
|
<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" />
|
<GlobeIcon className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
|
||||||
@@ -243,26 +284,38 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
|
|||||||
) : (
|
) : (
|
||||||
hasData ? (
|
hasData ? (
|
||||||
<>
|
<>
|
||||||
{displayedData.map((item, index) => (
|
{displayedData.map((item) => {
|
||||||
<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">
|
const dim = TAB_TO_DIMENSION[activeTab]
|
||||||
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
|
const filterValue = activeTab === 'countries' ? item.country : activeTab === 'regions' ? item.region : item.city
|
||||||
{activeTab === 'countries' && <span className="shrink-0">{getFlagComponent(item.country)}</span>}
|
const canFilter = onFilter && dim && filterValue
|
||||||
{activeTab !== 'countries' && <span className="shrink-0">{getFlagComponent(item.country)}</span>}
|
return (
|
||||||
|
<div
|
||||||
<span className="truncate">
|
key={`${item.country ?? ''}-${item.region ?? ''}-${item.city ?? ''}`}
|
||||||
{activeTab === 'countries' ? getCountryName(item.country) :
|
onClick={() => canFilter && onFilter({ dimension: dim, operator: 'is', values: [filterValue!] })}
|
||||||
activeTab === 'regions' ? getRegionName(item.region, item.country) :
|
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' : ''}`}
|
||||||
getCityName(item.city)}
|
>
|
||||||
</span>
|
<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>
|
||||||
<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) => (
|
{Array.from({ length: emptySlots }).map((_, i) => (
|
||||||
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
|
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-full flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
|
<div className="h-full flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
|
||||||
@@ -283,32 +336,69 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
|
|||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={isModalOpen}
|
isOpen={isModalOpen}
|
||||||
onClose={() => setIsModalOpen(false)}
|
onClose={() => { setIsModalOpen(false); setModalSearch('') }}
|
||||||
title={`Locations - ${activeTab.charAt(0).toUpperCase() + activeTab.slice(1)}`}
|
title={activeTab.charAt(0).toUpperCase() + activeTab.slice(1)}
|
||||||
|
className="max-w-2xl"
|
||||||
>
|
>
|
||||||
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-2">
|
<div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={modalSearch}
|
||||||
|
onChange={(e) => setModalSearch(e.target.value)}
|
||||||
|
placeholder="Search locations..."
|
||||||
|
className="w-full px-3 py-2 mb-3 text-sm bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg text-neutral-900 dark:text-white placeholder-neutral-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-brand-orange/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[80vh]">
|
||||||
{isLoadingFull ? (
|
{isLoadingFull ? (
|
||||||
<div className="py-8 flex flex-col items-center gap-2">
|
<div className="py-4">
|
||||||
<Spinner />
|
<ListSkeleton rows={10} />
|
||||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">Loading...</p>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (() => {
|
||||||
(fullData.length > 0 ? fullData : data as any[]).map((item, index) => (
|
const rawModalData = fullData.length > 0 ? fullData : data
|
||||||
<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">
|
const search = modalSearch.toLowerCase()
|
||||||
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
|
const modalData = !modalSearch ? rawModalData : rawModalData.filter(item => {
|
||||||
<span className="shrink-0">{getFlagComponent(item.country)}</span>
|
const label = activeTab === 'countries' ? getCountryName(item.country ?? '') : activeTab === 'regions' ? getRegionName(item.region ?? '', item.country ?? '') : getCityName(item.city ?? '')
|
||||||
<span className="truncate">
|
return label.toLowerCase().includes(search)
|
||||||
{activeTab === 'countries' ? getCountryName(item.country) :
|
})
|
||||||
activeTab === 'regions' ? getRegionName(item.region, item.country) :
|
const modalTotal = modalData.reduce((sum, item) => sum + item.pageviews, 0)
|
||||||
getCityName(item.city)}
|
return (
|
||||||
</span>
|
<VirtualList
|
||||||
</div>
|
items={modalData}
|
||||||
<div className="text-sm font-semibold text-neutral-600 dark:text-neutral-400 ml-4">
|
estimateSize={36}
|
||||||
{formatNumber(item.pageviews)}
|
className="max-h-[80vh] overflow-y-auto pr-2"
|
||||||
</div>
|
renderItem={(item) => {
|
||||||
</div>
|
const dim = TAB_TO_DIMENSION[activeTab]
|
||||||
))
|
const filterValue = activeTab === 'countries' ? item.country : activeTab === 'regions' ? item.region : item.city
|
||||||
)}
|
const canFilter = onFilter && dim && filterValue
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${item.country ?? ''}-${item.region ?? ''}-${item.city ?? ''}`}
|
||||||
|
onClick={() => { if (canFilter) { onFilter({ dimension: dim, operator: 'is', values: [filterValue!] }); setIsModalOpen(false) } }}
|
||||||
|
className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 transition-colors${canFilter ? ' cursor-pointer' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
|
||||||
|
<span className="shrink-0">{getFlagComponent(item.country ?? '')}</span>
|
||||||
|
<span className="truncate">
|
||||||
|
{activeTab === 'countries' ? getCountryName(item.country ?? '') :
|
||||||
|
activeTab === 'regions' ? getRegionName(item.region ?? '', item.country ?? '') :
|
||||||
|
getCityName(item.city ?? '')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 ml-4">
|
||||||
|
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
|
||||||
|
{modalTotal > 0 ? `${Math.round((item.pageviews / modalTotal) * 100)}%` : ''}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
|
||||||
|
{formatNumber(item.pageviews)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
</>
|
</>
|
||||||
|
|||||||
271
components/dashboard/PeakHours.tsx
Normal file
271
components/dashboard/PeakHours.tsx
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useMemo, useRef, type CSSProperties } from 'react'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import { logger } from '@/lib/utils/logger'
|
||||||
|
import { getDailyStats } from '@/lib/api/stats'
|
||||||
|
import type { DailyStat } from '@/lib/api/stats'
|
||||||
|
|
||||||
|
interface PeakHoursProps {
|
||||||
|
siteId: string
|
||||||
|
dateRange: { start: string, end: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
||||||
|
const BUCKETS = 12 // 2-hour buckets
|
||||||
|
// Label at bucket index 0=12am, 3=6am, 6=12pm, 9=6pm
|
||||||
|
const BUCKET_LABELS: Record<number, string> = { 0: '12am', 3: '6am', 6: '12pm', 9: '6pm' }
|
||||||
|
|
||||||
|
const HIGHLIGHT_COLORS = [
|
||||||
|
'rgba(253,94,15,0.18)',
|
||||||
|
'rgba(253,94,15,0.38)',
|
||||||
|
'rgba(253,94,15,0.62)',
|
||||||
|
'#FD5E0F',
|
||||||
|
]
|
||||||
|
|
||||||
|
function formatBucket(bucket: number): string {
|
||||||
|
const hour = bucket * 2
|
||||||
|
if (hour === 0) return '12am–2am'
|
||||||
|
if (hour === 12) return '12pm–2pm'
|
||||||
|
return hour < 12 ? `${hour}am–${hour + 2}am` : `${hour - 12}pm–${hour - 10}pm`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHour(hour: number): string {
|
||||||
|
if (hour === 0) return '12am'
|
||||||
|
if (hour === 12) return '12pm'
|
||||||
|
return hour < 12 ? `${hour}am` : `${hour - 12}pm`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHighlightColor(value: number, max: number): string {
|
||||||
|
if (value === 0) return HIGHLIGHT_COLORS[0]
|
||||||
|
const ratio = value / max
|
||||||
|
if (ratio < 0.25) return HIGHLIGHT_COLORS[1]
|
||||||
|
if (ratio < 0.6) return HIGHLIGHT_COLORS[2]
|
||||||
|
return HIGHLIGHT_COLORS[3]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PeakHours({ siteId, dateRange }: PeakHoursProps) {
|
||||||
|
const [data, setData] = useState<DailyStat[]>([])
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [animKey, setAnimKey] = useState(0)
|
||||||
|
const [hovered, setHovered] = useState<{ day: number; bucket: number } | null>(null)
|
||||||
|
const [tooltipPos, setTooltipPos] = useState<{ x: number; y: number } | null>(null)
|
||||||
|
const gridRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
setIsLoading(true)
|
||||||
|
try {
|
||||||
|
const result = await getDailyStats(siteId, dateRange.start, dateRange.end, 'hour')
|
||||||
|
setData(result)
|
||||||
|
setAnimKey(k => k + 1)
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(e)
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchData()
|
||||||
|
}, [siteId, dateRange])
|
||||||
|
|
||||||
|
const { grid, max, dayTotals, bucketTotals, weekTotal } = useMemo(() => {
|
||||||
|
// grid[day][bucket] — aggregate 2-hour buckets
|
||||||
|
const grid: number[][] = Array.from({ length: 7 }, () => Array(BUCKETS).fill(0))
|
||||||
|
for (const d of data) {
|
||||||
|
const date = new Date(d.date)
|
||||||
|
const day = date.getDay()
|
||||||
|
const hour = date.getHours()
|
||||||
|
const adjustedDay = day === 0 ? 6 : day - 1
|
||||||
|
const bucket = Math.floor(hour / 2)
|
||||||
|
grid[adjustedDay][bucket] += d.pageviews
|
||||||
|
}
|
||||||
|
const max = Math.max(...grid.flat(), 1)
|
||||||
|
const dayTotals = grid.map(buckets => buckets.reduce((a, b) => a + b, 0))
|
||||||
|
const bucketTotals = Array.from({ length: BUCKETS }, (_, b) => grid.reduce((a, row) => a + row[b], 0))
|
||||||
|
const weekTotal = dayTotals.reduce((a, b) => a + b, 0)
|
||||||
|
return { grid, max, dayTotals, bucketTotals, weekTotal }
|
||||||
|
}, [data])
|
||||||
|
|
||||||
|
const hasData = data.some(d => d.pageviews > 0)
|
||||||
|
|
||||||
|
const bestTime = useMemo(() => {
|
||||||
|
if (!hasData) return null
|
||||||
|
let bestDay = 0, bestBucket = 0, bestVal = 0
|
||||||
|
for (let d = 0; d < 7; d++) {
|
||||||
|
for (let b = 0; b < BUCKETS; b++) {
|
||||||
|
if (grid[d][b] > bestVal) {
|
||||||
|
bestVal = grid[d][b]
|
||||||
|
bestDay = d
|
||||||
|
bestBucket = b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { day: bestDay, bucket: bestBucket }
|
||||||
|
}, [grid, hasData])
|
||||||
|
|
||||||
|
const tooltipData = useMemo(() => {
|
||||||
|
if (!hovered) return null
|
||||||
|
const { day, bucket } = hovered
|
||||||
|
const value = grid[day][bucket]
|
||||||
|
const pct = weekTotal > 0 ? Math.round((value / weekTotal) * 100) : 0
|
||||||
|
return { value, dayTotal: dayTotals[day], bucketTotal: bucketTotals[bucket], pct }
|
||||||
|
}, [hovered, grid, dayTotals, bucketTotals, weekTotal])
|
||||||
|
|
||||||
|
const handleCellMouseEnter = (
|
||||||
|
e: React.MouseEvent<HTMLDivElement>,
|
||||||
|
dayIdx: number,
|
||||||
|
bucket: number
|
||||||
|
) => {
|
||||||
|
setHovered({ day: dayIdx, bucket })
|
||||||
|
if (gridRef.current) {
|
||||||
|
const gridRect = gridRef.current.getBoundingClientRect()
|
||||||
|
const cellRect = (e.currentTarget as HTMLDivElement).getBoundingClientRect()
|
||||||
|
setTooltipPos({
|
||||||
|
x: cellRect.left - gridRect.left + cellRect.width / 2,
|
||||||
|
y: cellRect.top - gridRect.top,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">Peak Hours</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-5">
|
||||||
|
When your visitors are most active
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex-1 min-h-[270px] flex flex-col justify-center gap-1.5">
|
||||||
|
{Array.from({ length: 7 }).map((_, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-1">
|
||||||
|
<div className="w-7 h-3 rounded bg-neutral-200 dark:bg-neutral-800 animate-pulse" />
|
||||||
|
<div className="flex-1 h-5 rounded bg-neutral-200 dark:bg-neutral-800 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : hasData ? (
|
||||||
|
<>
|
||||||
|
<div className="flex-1 min-h-[270px] flex flex-col justify-center gap-[5px] relative" ref={gridRef}>
|
||||||
|
{grid.map((buckets, dayIdx) => (
|
||||||
|
<div key={dayIdx} className="flex items-center gap-1.5">
|
||||||
|
<span className="text-[11px] text-neutral-400 dark:text-neutral-500 w-7 flex-shrink-0 text-right leading-none">
|
||||||
|
{DAYS[dayIdx]}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
className="flex-1"
|
||||||
|
style={{ display: 'grid', gridTemplateColumns: `repeat(${BUCKETS}, 1fr)`, gap: '5px' }}
|
||||||
|
>
|
||||||
|
{buckets.map((value, bucket) => {
|
||||||
|
const isHoveredCell = hovered?.day === dayIdx && hovered?.bucket === bucket
|
||||||
|
const isBestCell = bestTime?.day === dayIdx && bestTime?.bucket === bucket
|
||||||
|
const isActive = value > 0
|
||||||
|
const highlightColor = getHighlightColor(value, max)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${animKey}-${dayIdx}-${bucket}`}
|
||||||
|
className={[
|
||||||
|
'aspect-square w-full rounded-[4px] border cursor-default transition-transform duration-100',
|
||||||
|
'border-neutral-200 dark:border-neutral-800',
|
||||||
|
isActive ? 'animate-cell-highlight' : '',
|
||||||
|
isHoveredCell ? 'scale-110 z-10 relative' : '',
|
||||||
|
isBestCell && !isHoveredCell ? 'ring-1 ring-brand-orange/40' : '',
|
||||||
|
].join(' ')}
|
||||||
|
style={{
|
||||||
|
animationDelay: isActive
|
||||||
|
? `${((dayIdx * BUCKETS + bucket) * 0.008).toFixed(3)}s`
|
||||||
|
: undefined,
|
||||||
|
'--highlight': highlightColor,
|
||||||
|
} as CSSProperties}
|
||||||
|
onMouseEnter={(e) => handleCellMouseEnter(e, dayIdx, bucket)}
|
||||||
|
onMouseLeave={() => { setHovered(null); setTooltipPos(null) }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Hour axis labels */}
|
||||||
|
<div className="flex items-center gap-1.5 mt-1">
|
||||||
|
<span className="w-7 flex-shrink-0" />
|
||||||
|
<div className="flex-1 relative h-3">
|
||||||
|
{Object.entries(BUCKET_LABELS).map(([b, label]) => (
|
||||||
|
<span
|
||||||
|
key={b}
|
||||||
|
className="absolute text-[10px] text-neutral-400 dark:text-neutral-600 -translate-x-1/2"
|
||||||
|
style={{ left: `${(Number(b) / BUCKETS) * 100}%` }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<span
|
||||||
|
className="absolute text-[10px] text-neutral-400 dark:text-neutral-600 -translate-x-full"
|
||||||
|
style={{ left: '100%' }}
|
||||||
|
>
|
||||||
|
12am
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cell-anchored tooltip */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{hovered && tooltipData && tooltipPos && (
|
||||||
|
<motion.div
|
||||||
|
key="tooltip"
|
||||||
|
initial={{ opacity: 0, y: 4, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: 4, scale: 0.95 }}
|
||||||
|
transition={{ duration: 0.12 }}
|
||||||
|
className="absolute pointer-events-none z-20"
|
||||||
|
style={{
|
||||||
|
left: tooltipPos.x,
|
||||||
|
top: tooltipPos.y - 8,
|
||||||
|
transform: 'translate(-50%, -100%)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="bg-neutral-900 dark:bg-neutral-800 border border-neutral-700 text-white text-xs px-3 py-2 rounded-lg shadow-xl whitespace-nowrap">
|
||||||
|
<div className="font-semibold mb-1">
|
||||||
|
{DAYS[hovered.day]} {formatBucket(hovered.bucket)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-0.5 text-neutral-300">
|
||||||
|
<span>{tooltipData.value.toLocaleString()} pageviews</span>
|
||||||
|
<span>{tooltipData.pct}% of week's traffic</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="absolute left-1/2 -translate-x-1/2 bottom-0 translate-y-full w-0 h-0"
|
||||||
|
style={{ borderLeft: '5px solid transparent', borderRight: '5px solid transparent', borderTop: '5px solid #404040' }}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Best time callout */}
|
||||||
|
{bestTime && (
|
||||||
|
<motion.p
|
||||||
|
initial={{ opacity: 0, y: 4 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.4, delay: 0.6 }}
|
||||||
|
className="mt-4 text-xs text-neutral-500 dark:text-neutral-400 text-center"
|
||||||
|
>
|
||||||
|
Your busiest time is{' '}
|
||||||
|
<span className="text-brand-orange font-medium">
|
||||||
|
{DAYS[bestTime.day]}s at {formatHour(bestTime.bucket * 2)}
|
||||||
|
</span>
|
||||||
|
</motion.p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 min-h-[270px] flex flex-col items-center justify-center text-center gap-3">
|
||||||
|
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||||
|
No data available for this period
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { motion } from 'framer-motion'
|
|||||||
import { ChevronDownIcon } from '@ciphera-net/ui'
|
import { ChevronDownIcon } from '@ciphera-net/ui'
|
||||||
import { PerformanceStats as Stats, PerformanceByPageStat, getPerformanceByPage } from '@/lib/api/stats'
|
import { PerformanceStats as Stats, PerformanceByPageStat, getPerformanceByPage } from '@/lib/api/stats'
|
||||||
import { Select } from '@ciphera-net/ui'
|
import { Select } from '@ciphera-net/ui'
|
||||||
|
import { TableSkeleton } from '@/components/skeletons'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
stats: Stats
|
stats: Stats
|
||||||
@@ -108,12 +109,12 @@ export default function PerformanceStats({ stats, performanceByPage, siteId, sta
|
|||||||
const summaryText = `LCP ${Math.round(stats.lcp)} ms · CLS ${Number(stats.cls.toFixed(3))} · INP ${Math.round(stats.inp)} ms`
|
const summaryText = `LCP ${Math.round(stats.lcp)} ms · CLS ${Number(stats.cls.toFixed(3))} · INP ${Math.round(stats.inp)} ms`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-4">
|
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
|
||||||
{/* * One-line summary: Performance score + metric summary. Click to expand. */}
|
{/* * One-line summary: Performance score + metric summary. Click to expand. */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setMainExpanded((o) => !o)}
|
onClick={() => setMainExpanded((o) => !o)}
|
||||||
className="flex w-full items-center justify-between gap-4 text-left rounded cursor-pointer hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-neutral-400 dark:focus:ring-neutral-500 focus:ring-offset-2 dark:focus:ring-offset-neutral-900"
|
className="flex w-full items-center justify-between gap-4 text-left rounded cursor-pointer hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-400 dark:focus-visible:ring-neutral-500 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-neutral-900"
|
||||||
aria-expanded={mainExpanded}
|
aria-expanded={mainExpanded}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
@@ -169,7 +170,7 @@ export default function PerformanceStats({ stats, performanceByPage, siteId, sta
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setWorstPagesOpen((o) => !o)}
|
onClick={() => setWorstPagesOpen((o) => !o)}
|
||||||
className="flex items-center gap-2 text-left rounded cursor-pointer hover:opacity-80 focus:outline-none focus:ring-2 focus:ring-neutral-400 dark:focus:ring-neutral-500 focus:ring-offset-2 dark:focus:ring-offset-neutral-900"
|
className="flex items-center gap-2 text-left rounded cursor-pointer hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-400 dark:focus-visible:ring-neutral-500 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-neutral-900"
|
||||||
aria-expanded={worstPagesOpen}
|
aria-expanded={worstPagesOpen}
|
||||||
>
|
>
|
||||||
<ChevronDownIcon
|
<ChevronDownIcon
|
||||||
@@ -205,7 +206,7 @@ export default function PerformanceStats({ stats, performanceByPage, siteId, sta
|
|||||||
style={{ overflow: 'hidden' }}
|
style={{ overflow: 'hidden' }}
|
||||||
>
|
>
|
||||||
{loadingTable ? (
|
{loadingTable ? (
|
||||||
<div className="py-8 text-center text-neutral-500 text-sm">Loading…</div>
|
<div className="py-4"><TableSkeleton rows={5} cols={5} /></div>
|
||||||
) : rows.length === 0 ? (
|
) : rows.length === 0 ? (
|
||||||
<div className="py-6 text-center text-neutral-500 text-sm">
|
<div className="py-6 text-center text-neutral-500 text-sm">
|
||||||
No per-page metrics yet. Data appears as visitors are tracked with performance insights enabled.
|
No per-page metrics yet. Data appears as visitors are tracked with performance insights enabled.
|
||||||
|
|||||||
@@ -1,19 +1,11 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation'
|
|
||||||
|
|
||||||
interface RealtimeVisitorsProps {
|
interface RealtimeVisitorsProps {
|
||||||
count: number
|
count: number
|
||||||
siteId?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RealtimeVisitors({ count, siteId }: RealtimeVisitorsProps) {
|
export default function RealtimeVisitors({ count }: RealtimeVisitorsProps) {
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={() => siteId && router.push(`/sites/${siteId}/realtime`)}
|
className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6"
|
||||||
className={`bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 ${siteId ? 'cursor-pointer hover:border-neutral-300 dark:hover:border-neutral-700 transition-colors' : ''}`}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<div className="text-sm text-neutral-600 dark:text-neutral-400">
|
<div className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||||
|
|||||||
89
components/dashboard/ScrollDepth.tsx
Normal file
89
components/dashboard/ScrollDepth.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { PolarAngleAxis, PolarGrid, Radar, RadarChart, Tooltip } from 'recharts'
|
||||||
|
import { BarChartIcon } from '@ciphera-net/ui'
|
||||||
|
import type { GoalCountStat } from '@/lib/api/stats'
|
||||||
|
|
||||||
|
interface ScrollDepthProps {
|
||||||
|
goalCounts: GoalCountStat[]
|
||||||
|
totalPageviews: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const THRESHOLDS = [25, 50, 75, 100] as const
|
||||||
|
|
||||||
|
export default function ScrollDepth({ goalCounts, totalPageviews }: ScrollDepthProps) {
|
||||||
|
const scrollCounts = new Map<number, number>()
|
||||||
|
for (const row of goalCounts) {
|
||||||
|
const match = row.event_name.match(/^scroll_(\d+)$/)
|
||||||
|
if (match) {
|
||||||
|
scrollCounts.set(Number(match[1]), row.count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasData = scrollCounts.size > 0 && totalPageviews > 0
|
||||||
|
|
||||||
|
const chartData = THRESHOLDS.map((threshold) => ({
|
||||||
|
label: `${threshold}%`,
|
||||||
|
value: totalPageviews > 0 ? Math.round(((scrollCounts.get(threshold) ?? 0) / totalPageviews) * 100) : 0,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
|
||||||
|
Scroll Depth
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-4">
|
||||||
|
% of visitors who scrolled this far
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{hasData ? (
|
||||||
|
<div className="flex-1 min-h-[270px] flex items-center justify-center">
|
||||||
|
<RadarChart
|
||||||
|
width={320}
|
||||||
|
height={260}
|
||||||
|
data={chartData}
|
||||||
|
margin={{ top: 16, right: 32, bottom: 16, left: 32 }}
|
||||||
|
>
|
||||||
|
<PolarGrid stroke="#404040" />
|
||||||
|
<PolarAngleAxis
|
||||||
|
dataKey="label"
|
||||||
|
tick={{ fill: '#a3a3a3', fontSize: 12, fontWeight: 500 }}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
cursor={false}
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: '#171717',
|
||||||
|
border: '1px solid #404040',
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#fff',
|
||||||
|
}}
|
||||||
|
formatter={(value: number) => [`${value}%`, 'Reached']}
|
||||||
|
/>
|
||||||
|
<Radar
|
||||||
|
dataKey="value"
|
||||||
|
stroke="#FD5E0F"
|
||||||
|
fill="#FD5E0F"
|
||||||
|
fillOpacity={0.6}
|
||||||
|
dot={{ r: 4, fill: '#FD5E0F', fillOpacity: 1, strokeWidth: 0 }}
|
||||||
|
/>
|
||||||
|
</RadarChart>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 min-h-[270px] flex flex-col items-center justify-center text-center px-6 py-8 gap-4">
|
||||||
|
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
|
||||||
|
<BarChartIcon className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
|
||||||
|
</div>
|
||||||
|
<h4 className="font-semibold text-neutral-900 dark:text-white">
|
||||||
|
No scroll data yet
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-md">
|
||||||
|
Scroll depth tracking is automatic — data will appear here once visitors start scrolling on your pages.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
64
components/dashboard/SiteNav.tsx
Normal file
64
components/dashboard/SiteNav.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { usePathname } from 'next/navigation'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import { useTabListKeyboard } from '@/lib/hooks/useTabListKeyboard'
|
||||||
|
import { useAuth } from '@/lib/auth/context'
|
||||||
|
|
||||||
|
interface SiteNavProps {
|
||||||
|
siteId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SiteNav({ siteId }: SiteNavProps) {
|
||||||
|
const pathname = usePathname()
|
||||||
|
const handleTabKeyDown = useTabListKeyboard()
|
||||||
|
const { user } = useAuth()
|
||||||
|
const canEdit = user?.role === 'owner' || user?.role === 'admin'
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ label: 'Dashboard', href: `/sites/${siteId}` },
|
||||||
|
{ label: 'Journeys', href: `/sites/${siteId}/journeys` },
|
||||||
|
{ label: 'Funnels', href: `/sites/${siteId}/funnels` },
|
||||||
|
{ label: 'Behavior', href: `/sites/${siteId}/behavior` },
|
||||||
|
{ label: 'Uptime', href: `/sites/${siteId}/uptime` },
|
||||||
|
...(canEdit ? [{ label: 'Settings', href: `/sites/${siteId}/settings` }] : []),
|
||||||
|
]
|
||||||
|
|
||||||
|
const isActive = (href: string) => {
|
||||||
|
if (href === `/sites/${siteId}`) {
|
||||||
|
return pathname === href
|
||||||
|
}
|
||||||
|
return pathname.startsWith(href)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-b border-neutral-200 dark:border-neutral-800 mb-6">
|
||||||
|
<nav className="flex gap-1" role="tablist" aria-label="Site navigation" onKeyDown={handleTabKeyDown}>
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<Link
|
||||||
|
key={tab.href}
|
||||||
|
href={tab.href}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={isActive(tab.href)}
|
||||||
|
tabIndex={isActive(tab.href) ? 0 : -1}
|
||||||
|
className={`relative px-3 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange rounded-t cursor-pointer -mb-px ${
|
||||||
|
isActive(tab.href)
|
||||||
|
? 'text-neutral-900 dark:text-white'
|
||||||
|
: 'text-neutral-400 dark:text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
{isActive(tab.href) && (
|
||||||
|
<motion.div
|
||||||
|
layoutId="activeTab"
|
||||||
|
className="absolute inset-x-0 -bottom-px h-0.5 bg-brand-orange"
|
||||||
|
transition={{ type: 'spring', stiffness: 500, damping: 35 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
50
components/dashboard/Sparkline.tsx
Normal file
50
components/dashboard/Sparkline.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mini sparkline SVG for KPI cards.
|
||||||
|
* Renders a line chart from an array of data points.
|
||||||
|
*/
|
||||||
|
export default function Sparkline({
|
||||||
|
data,
|
||||||
|
dataKey,
|
||||||
|
color,
|
||||||
|
width = 56,
|
||||||
|
height = 20,
|
||||||
|
}: {
|
||||||
|
/** Array of objects with numeric values (e.g. DailyStat with visitors, pageviews) */
|
||||||
|
data: ReadonlyArray<object>
|
||||||
|
dataKey: string
|
||||||
|
color: string
|
||||||
|
width?: number
|
||||||
|
height?: number
|
||||||
|
}) {
|
||||||
|
if (!data.length) return null
|
||||||
|
const values = data.map((d) => Number((d as Record<string, unknown>)[dataKey] ?? 0))
|
||||||
|
const max = Math.max(...values, 1)
|
||||||
|
const min = Math.min(...values, 0)
|
||||||
|
const range = max - min || 1
|
||||||
|
const padding = 2
|
||||||
|
const w = width - padding * 2
|
||||||
|
const h = height - padding * 2
|
||||||
|
|
||||||
|
const points = values.map((v, i) => {
|
||||||
|
const x = padding + (i / Math.max(values.length - 1, 1)) * w
|
||||||
|
const y = padding + h - ((v - min) / range) * h
|
||||||
|
return `${x},${y}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const pathD = points.length > 1 ? `M ${points.join(' L ')}` : `M ${points[0]} L ${points[0]}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg width={width} height={height} className="flex-shrink-0" aria-hidden>
|
||||||
|
<path
|
||||||
|
d={pathD}
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,11 +1,17 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import { logger } from '@/lib/utils/logger'
|
||||||
import { formatNumber } from '@ciphera-net/ui'
|
import { formatNumber } from '@ciphera-net/ui'
|
||||||
|
import { useTabListKeyboard } from '@/lib/hooks/useTabListKeyboard'
|
||||||
import { getBrowserIcon, getOSIcon, getDeviceIcon } from '@/lib/utils/icons'
|
import { getBrowserIcon, getOSIcon, getDeviceIcon } from '@/lib/utils/icons'
|
||||||
import { MdMonitor } from 'react-icons/md'
|
import { Monitor, FrameCornersIcon } from '@phosphor-icons/react'
|
||||||
import { Modal, GridIcon, Spinner } from '@ciphera-net/ui'
|
import { Modal, GridIcon } from '@ciphera-net/ui'
|
||||||
|
import { ListSkeleton } from '@/components/skeletons'
|
||||||
|
import VirtualList from './VirtualList'
|
||||||
import { getBrowsers, getOS, getDevices, getScreenResolutions } from '@/lib/api/stats'
|
import { getBrowsers, getOS, getDevices, getScreenResolutions } from '@/lib/api/stats'
|
||||||
|
import { type DimensionFilter } from '@/lib/filters'
|
||||||
|
|
||||||
interface TechSpecsProps {
|
interface TechSpecsProps {
|
||||||
browsers: Array<{ browser: string; pageviews: number }>
|
browsers: Array<{ browser: string; pageviews: number }>
|
||||||
@@ -16,16 +22,27 @@ interface TechSpecsProps {
|
|||||||
collectScreenResolution?: boolean
|
collectScreenResolution?: boolean
|
||||||
siteId: string
|
siteId: string
|
||||||
dateRange: { start: string, end: string }
|
dateRange: { start: string, end: string }
|
||||||
|
onFilter?: (filter: DimensionFilter) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type Tab = 'browsers' | 'os' | 'devices' | 'screens'
|
type Tab = 'browsers' | 'os' | 'devices' | 'screens'
|
||||||
|
|
||||||
|
function capitalize(s: string): string {
|
||||||
|
if (!s) return s
|
||||||
|
return s.charAt(0).toUpperCase() + s.slice(1)
|
||||||
|
}
|
||||||
|
|
||||||
const LIMIT = 7
|
const 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 [activeTab, setActiveTab] = useState<Tab>('browsers')
|
||||||
|
const handleTabKeyDown = useTabListKeyboard()
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||||
const [fullData, setFullData] = useState<any[]>([])
|
const [modalSearch, setModalSearch] = useState('')
|
||||||
|
type TechItem = { name: string; pageviews: number; icon: React.ReactNode }
|
||||||
|
const [fullData, setFullData] = useState<TechItem[]>([])
|
||||||
const [isLoadingFull, setIsLoadingFull] = useState(false)
|
const [isLoadingFull, setIsLoadingFull] = useState(false)
|
||||||
|
|
||||||
// Filter out "Unknown" entries that result from disabled collection
|
// Filter out "Unknown" entries that result from disabled collection
|
||||||
@@ -38,7 +55,7 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
|
|||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
setIsLoadingFull(true)
|
setIsLoadingFull(true)
|
||||||
try {
|
try {
|
||||||
let data: any[] = []
|
let data: TechItem[] = []
|
||||||
if (activeTab === 'browsers') {
|
if (activeTab === 'browsers') {
|
||||||
const res = await getBrowsers(siteId, dateRange.start, dateRange.end, 100)
|
const res = await getBrowsers(siteId, dateRange.start, dateRange.end, 100)
|
||||||
data = res.map(b => ({ name: b.browser, pageviews: b.pageviews, icon: getBrowserIcon(b.browser) }))
|
data = res.map(b => ({ name: b.browser, pageviews: b.pageviews, icon: getBrowserIcon(b.browser) }))
|
||||||
@@ -50,11 +67,11 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
|
|||||||
data = res.map(d => ({ name: d.device, pageviews: d.pageviews, icon: getDeviceIcon(d.device) }))
|
data = res.map(d => ({ name: d.device, pageviews: d.pageviews, icon: getDeviceIcon(d.device) }))
|
||||||
} else if (activeTab === 'screens') {
|
} else if (activeTab === 'screens') {
|
||||||
const res = await getScreenResolutions(siteId, dateRange.start, dateRange.end, 100)
|
const res = await getScreenResolutions(siteId, dateRange.start, dateRange.end, 100)
|
||||||
data = res.map(s => ({ name: s.screen_resolution, pageviews: s.pageviews, icon: <MdMonitor className="text-neutral-500" /> }))
|
data = res.map(s => ({ name: s.screen_resolution, pageviews: s.pageviews, icon: <Monitor className="text-neutral-500" /> }))
|
||||||
}
|
}
|
||||||
setFullData(filterUnknown(data))
|
setFullData(filterUnknown(data))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
logger.error(e)
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoadingFull(false)
|
setIsLoadingFull(false)
|
||||||
}
|
}
|
||||||
@@ -74,7 +91,7 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
|
|||||||
case 'devices':
|
case 'devices':
|
||||||
return devices.map(d => ({ name: d.device, pageviews: d.pageviews, icon: getDeviceIcon(d.device) }))
|
return devices.map(d => ({ name: d.device, pageviews: d.pageviews, icon: getDeviceIcon(d.device) }))
|
||||||
case 'screens':
|
case 'screens':
|
||||||
return screenResolutions.map(s => ({ name: s.screen_resolution, pageviews: s.pageviews, icon: <MdMonitor className="text-neutral-500" /> }))
|
return screenResolutions.map(s => ({ name: s.screen_resolution, pageviews: s.pageviews, icon: <Monitor className="text-neutral-500" /> }))
|
||||||
default:
|
default:
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
@@ -103,6 +120,7 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
|
|||||||
|
|
||||||
const rawData = getRawData()
|
const rawData = getRawData()
|
||||||
const data = filterUnknown(rawData)
|
const data = filterUnknown(rawData)
|
||||||
|
const totalPageviews = data.reduce((sum, item) => sum + item.pageviews, 0)
|
||||||
const hasData = data && data.length > 0
|
const hasData = data && data.length > 0
|
||||||
const displayedData = hasData ? data.slice(0, LIMIT) : []
|
const displayedData = hasData ? data.slice(0, LIMIT) : []
|
||||||
const emptySlots = Math.max(0, LIMIT - displayedData.length)
|
const emptySlots = Math.max(0, LIMIT - displayedData.length)
|
||||||
@@ -112,33 +130,41 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
|
|||||||
<>
|
<>
|
||||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
|
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-2">
|
||||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
|
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
|
||||||
Technology
|
Technology
|
||||||
</h3>
|
</h3>
|
||||||
{showViewAll && (
|
{showViewAll && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsModalOpen(true)}
|
onClick={() => setIsModalOpen(true)}
|
||||||
className="text-xs font-medium text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded"
|
className="p-1.5 text-neutral-400 dark:text-neutral-500 hover:text-brand-orange dark:hover:text-brand-orange hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-all cursor-pointer rounded-lg"
|
||||||
|
aria-label="View all technology"
|
||||||
>
|
>
|
||||||
View All
|
<FrameCornersIcon className="w-4 h-4" weight="bold" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex p-1 bg-neutral-100 dark:bg-neutral-800 rounded-lg" role="tablist" aria-label="Technology view tabs">
|
<div className="flex gap-1" role="tablist" aria-label="Technology view tabs" onKeyDown={handleTabKeyDown}>
|
||||||
{(['browsers', 'os', 'devices', 'screens'] as Tab[]).map((tab) => (
|
{(['browsers', 'os', 'devices', 'screens'] as Tab[]).map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab}
|
key={tab}
|
||||||
onClick={() => setActiveTab(tab)}
|
onClick={() => setActiveTab(tab)}
|
||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={activeTab === tab}
|
aria-selected={activeTab === tab}
|
||||||
className={`px-3 py-1 text-xs font-medium rounded-lg transition-colors capitalize focus:outline-none focus:ring-2 focus:ring-brand-orange ${
|
className={`relative px-2.5 py-1 text-xs font-medium transition-colors capitalize focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange rounded cursor-pointer ${
|
||||||
activeTab === tab
|
activeTab === tab
|
||||||
? 'bg-white dark:bg-neutral-700 text-neutral-900 dark:text-white shadow-sm'
|
? 'text-neutral-900 dark:text-white'
|
||||||
: 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white'
|
: 'text-neutral-400 dark:text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{tab}
|
{tab}
|
||||||
|
{activeTab === tab && (
|
||||||
|
<motion.div
|
||||||
|
layoutId="techSpecsTab"
|
||||||
|
className="absolute inset-x-0 -bottom-px h-0.5 bg-brand-orange"
|
||||||
|
transition={{ type: 'spring', stiffness: 500, damping: 35 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -151,17 +177,30 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
|
|||||||
</div>
|
</div>
|
||||||
) : hasData ? (
|
) : hasData ? (
|
||||||
<>
|
<>
|
||||||
{displayedData.map((item, index) => (
|
{displayedData.map((item) => {
|
||||||
<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">
|
const dim = TAB_TO_DIMENSION[activeTab]
|
||||||
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
|
const canFilter = onFilter && dim
|
||||||
{item.icon && <span className="text-lg">{item.icon}</span>}
|
return (
|
||||||
<span className="truncate">{item.name}</span>
|
<div
|
||||||
|
key={item.name}
|
||||||
|
onClick={() => canFilter && onFilter({ dimension: dim, operator: 'is', values: [item.name] })}
|
||||||
|
className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors${canFilter ? ' cursor-pointer' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
|
||||||
|
{item.icon && <span className="text-lg">{item.icon}</span>}
|
||||||
|
<span className="truncate">{capitalize(item.name)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 ml-4">
|
||||||
|
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
|
||||||
|
{totalPageviews > 0 ? `${Math.round((item.pageviews / totalPageviews) * 100)}%` : ''}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
|
||||||
|
{formatNumber(item.pageviews)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<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) => (
|
{Array.from({ length: emptySlots }).map((_, i) => (
|
||||||
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
|
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
|
||||||
))}
|
))}
|
||||||
@@ -184,28 +223,59 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
|
|||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={isModalOpen}
|
isOpen={isModalOpen}
|
||||||
onClose={() => setIsModalOpen(false)}
|
onClose={() => { setIsModalOpen(false); setModalSearch('') }}
|
||||||
title={`Technology - ${activeTab.charAt(0).toUpperCase() + activeTab.slice(1)}`}
|
title={activeTab.charAt(0).toUpperCase() + activeTab.slice(1)}
|
||||||
|
className="max-w-2xl"
|
||||||
>
|
>
|
||||||
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-2">
|
<div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={modalSearch}
|
||||||
|
onChange={(e) => setModalSearch(e.target.value)}
|
||||||
|
placeholder="Search technology..."
|
||||||
|
className="w-full px-3 py-2 mb-3 text-sm bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg text-neutral-900 dark:text-white placeholder-neutral-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-brand-orange/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[80vh]">
|
||||||
{isLoadingFull ? (
|
{isLoadingFull ? (
|
||||||
<div className="py-8 flex flex-col items-center gap-2">
|
<div className="py-4">
|
||||||
<Spinner />
|
<ListSkeleton rows={10} />
|
||||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">Loading...</p>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (() => {
|
||||||
(fullData.length > 0 ? fullData : data).map((item, index) => (
|
const modalData = (fullData.length > 0 ? fullData : data).filter(item => !modalSearch || item.name.toLowerCase().includes(modalSearch.toLowerCase()))
|
||||||
<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">
|
const modalTotal = modalData.reduce((sum, item) => sum + item.pageviews, 0)
|
||||||
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
|
const dim = TAB_TO_DIMENSION[activeTab]
|
||||||
{item.icon && <span className="text-lg">{item.icon}</span>}
|
return (
|
||||||
<span className="truncate">{item.name === 'Unknown' ? 'Unknown' : item.name}</span>
|
<VirtualList
|
||||||
</div>
|
items={modalData}
|
||||||
<div className="text-sm font-semibold text-neutral-600 dark:text-neutral-400 ml-4">
|
estimateSize={36}
|
||||||
{formatNumber(item.pageviews)}
|
className="max-h-[80vh] overflow-y-auto pr-2"
|
||||||
</div>
|
renderItem={(item) => {
|
||||||
</div>
|
const canFilter = onFilter && dim
|
||||||
))
|
return (
|
||||||
)}
|
<div
|
||||||
|
key={item.name}
|
||||||
|
onClick={() => { if (canFilter) { onFilter({ dimension: dim, operator: 'is', values: [item.name] }); setIsModalOpen(false) } }}
|
||||||
|
className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 transition-colors${canFilter ? ' cursor-pointer' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
|
||||||
|
{item.icon && <span className="text-lg">{item.icon}</span>}
|
||||||
|
<span className="truncate">{capitalize(item.name)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 ml-4">
|
||||||
|
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
|
||||||
|
{modalTotal > 0 ? `${Math.round((item.pageviews / modalTotal) * 100)}%` : ''}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
|
||||||
|
{formatNumber(item.pageviews)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { formatNumber } from '@ciphera-net/ui'
|
|
||||||
import { LayoutDashboardIcon } from '@ciphera-net/ui'
|
|
||||||
|
|
||||||
interface TopPagesProps {
|
|
||||||
pages: Array<{ path: string; pageviews: number }>
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TopPages({ pages }: TopPagesProps) {
|
|
||||||
if (!pages || pages.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 flex flex-col">
|
|
||||||
<h3 className="text-lg font-semibold mb-4 text-neutral-900 dark:text-white">
|
|
||||||
Top Pages
|
|
||||||
</h3>
|
|
||||||
<div className="flex-1 flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
|
|
||||||
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
|
|
||||||
<LayoutDashboardIcon className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
|
|
||||||
</div>
|
|
||||||
<h4 className="font-semibold text-neutral-900 dark:text-white">
|
|
||||||
No page data yet
|
|
||||||
</h4>
|
|
||||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs">
|
|
||||||
Your most visited pages will appear here as traffic arrives.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
|
|
||||||
<h3 className="text-lg font-semibold mb-4 text-neutral-900 dark:text-white">
|
|
||||||
Top Pages
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{pages.map((page, index) => (
|
|
||||||
<div key={index} className="flex items-center justify-between">
|
|
||||||
<div className="flex-1 truncate text-neutral-900 dark:text-white">
|
|
||||||
{page.path}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm font-semibold text-neutral-600 dark:text-neutral-400 ml-4">
|
|
||||||
{formatNumber(page.pageviews)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,22 +1,30 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
|
import { logger } from '@/lib/utils/logger'
|
||||||
|
import Image from 'next/image'
|
||||||
import { formatNumber } from '@ciphera-net/ui'
|
import { formatNumber } from '@ciphera-net/ui'
|
||||||
import { getReferrerDisplayName, getReferrerFavicon, getReferrerIcon, mergeReferrersByDisplayName } from '@/lib/utils/icons'
|
import { getReferrerDisplayName, getReferrerFavicon, getReferrerIcon, mergeReferrersByDisplayName } from '@/lib/utils/icons'
|
||||||
import { Modal, GlobeIcon, Spinner } from '@ciphera-net/ui'
|
import { FrameCornersIcon } from '@phosphor-icons/react'
|
||||||
|
import { Modal, GlobeIcon } from '@ciphera-net/ui'
|
||||||
|
import { ListSkeleton } from '@/components/skeletons'
|
||||||
|
import VirtualList from './VirtualList'
|
||||||
import { getTopReferrers, TopReferrer } from '@/lib/api/stats'
|
import { getTopReferrers, TopReferrer } from '@/lib/api/stats'
|
||||||
|
import { type DimensionFilter } from '@/lib/filters'
|
||||||
|
|
||||||
interface TopReferrersProps {
|
interface TopReferrersProps {
|
||||||
referrers: Array<{ referrer: string; pageviews: number }>
|
referrers: Array<{ referrer: string; pageviews: number }>
|
||||||
collectReferrers?: boolean
|
collectReferrers?: boolean
|
||||||
siteId: string
|
siteId: string
|
||||||
dateRange: { start: string, end: string }
|
dateRange: { start: string, end: string }
|
||||||
|
onFilter?: (filter: DimensionFilter) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const LIMIT = 7
|
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 [isModalOpen, setIsModalOpen] = useState(false)
|
||||||
|
const [modalSearch, setModalSearch] = useState('')
|
||||||
const [fullData, setFullData] = useState<TopReferrer[]>([])
|
const [fullData, setFullData] = useState<TopReferrer[]>([])
|
||||||
const [isLoadingFull, setIsLoadingFull] = useState(false)
|
const [isLoadingFull, setIsLoadingFull] = useState(false)
|
||||||
const [faviconFailed, setFaviconFailed] = useState<Set<string>>(new Set())
|
const [faviconFailed, setFaviconFailed] = useState<Set<string>>(new Set())
|
||||||
@@ -28,6 +36,7 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
|
|||||||
|
|
||||||
const mergedReferrers = mergeReferrersByDisplayName(filteredReferrers)
|
const mergedReferrers = mergeReferrersByDisplayName(filteredReferrers)
|
||||||
|
|
||||||
|
const totalPageviews = mergedReferrers.reduce((sum, r) => sum + r.pageviews, 0)
|
||||||
const hasData = mergedReferrers.length > 0
|
const hasData = mergedReferrers.length > 0
|
||||||
const displayedReferrers = hasData ? mergedReferrers.slice(0, LIMIT) : []
|
const displayedReferrers = hasData ? mergedReferrers.slice(0, LIMIT) : []
|
||||||
const emptySlots = Math.max(0, LIMIT - displayedReferrers.length)
|
const emptySlots = Math.max(0, LIMIT - displayedReferrers.length)
|
||||||
@@ -38,11 +47,14 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
|
|||||||
const useFavicon = faviconUrl && !faviconFailed.has(referrer)
|
const useFavicon = faviconUrl && !faviconFailed.has(referrer)
|
||||||
if (useFavicon) {
|
if (useFavicon) {
|
||||||
return (
|
return (
|
||||||
<img
|
<Image
|
||||||
src={faviconUrl}
|
src={faviconUrl}
|
||||||
alt=""
|
alt=""
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
className="w-5 h-5 flex-shrink-0 rounded object-contain"
|
className="w-5 h-5 flex-shrink-0 rounded object-contain"
|
||||||
onError={() => setFaviconFailed((prev) => new Set(prev).add(referrer))}
|
onError={() => setFaviconFailed((prev) => new Set(prev).add(referrer))}
|
||||||
|
unoptimized
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -61,7 +73,7 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
|
|||||||
)
|
)
|
||||||
setFullData(filtered)
|
setFullData(filtered)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
logger.error(e)
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoadingFull(false)
|
setIsLoadingFull(false)
|
||||||
}
|
}
|
||||||
@@ -76,17 +88,20 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
|
|||||||
<>
|
<>
|
||||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
|
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
|
<div className="flex items-center gap-2">
|
||||||
Top Referrers
|
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
|
||||||
</h3>
|
Referrers
|
||||||
{showViewAll && (
|
</h3>
|
||||||
<button
|
{showViewAll && (
|
||||||
onClick={() => setIsModalOpen(true)}
|
<button
|
||||||
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"
|
onClick={() => setIsModalOpen(true)}
|
||||||
>
|
className="p-1.5 text-neutral-400 dark:text-neutral-500 hover:text-brand-orange dark:hover:text-brand-orange hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-all cursor-pointer rounded-lg"
|
||||||
View All
|
aria-label="View all referrers"
|
||||||
</button>
|
>
|
||||||
)}
|
<FrameCornersIcon className="w-4 h-4" weight="bold" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2 flex-1 min-h-[270px]">
|
<div className="space-y-2 flex-1 min-h-[270px]">
|
||||||
@@ -96,14 +111,23 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
|
|||||||
</div>
|
</div>
|
||||||
) : hasData ? (
|
) : hasData ? (
|
||||||
<>
|
<>
|
||||||
{displayedReferrers.map((ref, index) => (
|
{displayedReferrers.map((ref) => (
|
||||||
<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
|
||||||
|
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">
|
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
|
||||||
{renderReferrerIcon(ref.referrer)}
|
{renderReferrerIcon(ref.referrer)}
|
||||||
<span className="truncate" title={ref.referrer}>{getReferrerDisplayName(ref.referrer)}</span>
|
<span className="truncate" title={ref.referrer}>{getReferrerDisplayName(ref.referrer)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm font-semibold text-neutral-600 dark:text-neutral-400 ml-4">
|
<div className="flex items-center gap-2 ml-4">
|
||||||
{formatNumber(ref.pageviews)}
|
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
|
||||||
|
{totalPageviews > 0 ? `${Math.round((ref.pageviews / totalPageviews) * 100)}%` : ''}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
|
||||||
|
{formatNumber(ref.pageviews)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -129,28 +153,55 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
|
|||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={isModalOpen}
|
isOpen={isModalOpen}
|
||||||
onClose={() => setIsModalOpen(false)}
|
onClose={() => { setIsModalOpen(false); setModalSearch('') }}
|
||||||
title="Top Referrers"
|
title="Referrers"
|
||||||
|
className="max-w-2xl"
|
||||||
>
|
>
|
||||||
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-2">
|
<div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={modalSearch}
|
||||||
|
onChange={(e) => setModalSearch(e.target.value)}
|
||||||
|
placeholder="Search referrers..."
|
||||||
|
className="w-full px-3 py-2 mb-3 text-sm bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg text-neutral-900 dark:text-white placeholder-neutral-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-brand-orange/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[80vh]">
|
||||||
{isLoadingFull ? (
|
{isLoadingFull ? (
|
||||||
<div className="py-8 flex flex-col items-center gap-2">
|
<div className="py-4">
|
||||||
<Spinner />
|
<ListSkeleton rows={10} />
|
||||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">Loading...</p>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (() => {
|
||||||
mergeReferrersByDisplayName(fullData.length > 0 ? fullData : filteredReferrers).map((ref, index) => (
|
const modalData = mergeReferrersByDisplayName(fullData.length > 0 ? fullData : filteredReferrers).filter(r => !modalSearch || getReferrerDisplayName(r.referrer).toLowerCase().includes(modalSearch.toLowerCase()))
|
||||||
<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">
|
const modalTotal = modalData.reduce((sum, r) => sum + r.pageviews, 0)
|
||||||
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
|
return (
|
||||||
{renderReferrerIcon(ref.referrer)}
|
<VirtualList
|
||||||
<span className="truncate" title={ref.referrer}>{getReferrerDisplayName(ref.referrer)}</span>
|
items={modalData}
|
||||||
</div>
|
estimateSize={36}
|
||||||
<div className="text-sm font-semibold text-neutral-600 dark:text-neutral-400 ml-4">
|
className="max-h-[80vh] overflow-y-auto pr-2"
|
||||||
{formatNumber(ref.pageviews)}
|
renderItem={(ref) => (
|
||||||
</div>
|
<div
|
||||||
</div>
|
key={ref.referrer}
|
||||||
))
|
onClick={() => { if (onFilter) { onFilter({ dimension: 'referrer', operator: 'is', values: [ref.referrer] }); setIsModalOpen(false) } }}
|
||||||
)}
|
className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 transition-colors${onFilter ? ' cursor-pointer' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
|
||||||
|
{renderReferrerIcon(ref.referrer)}
|
||||||
|
<span className="truncate" title={ref.referrer}>{getReferrerDisplayName(ref.referrer)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 ml-4">
|
||||||
|
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
|
||||||
|
{modalTotal > 0 ? `${Math.round((ref.pageviews / modalTotal) * 100)}%` : ''}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
|
||||||
|
{formatNumber(ref.pageviews)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
</>
|
</>
|
||||||
|
|||||||
53
components/dashboard/VirtualList.tsx
Normal file
53
components/dashboard/VirtualList.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useRef } from 'react'
|
||||||
|
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||||
|
|
||||||
|
interface VirtualListProps<T> {
|
||||||
|
items: T[]
|
||||||
|
estimateSize: number
|
||||||
|
className?: string
|
||||||
|
renderItem: (item: T, index: number) => React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VirtualList<T>({ items, estimateSize, className, renderItem }: VirtualListProps<T>) {
|
||||||
|
const parentRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const virtualizer = useVirtualizer({
|
||||||
|
count: items.length,
|
||||||
|
getScrollElement: () => parentRef.current,
|
||||||
|
estimateSize: () => estimateSize,
|
||||||
|
overscan: 10,
|
||||||
|
})
|
||||||
|
|
||||||
|
// For small lists (< 50 items), render directly without virtualization
|
||||||
|
if (items.length < 50) {
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
{items.map((item, index) => renderItem(item, index))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={parentRef} className={className} style={{ overflow: 'auto' }}>
|
||||||
|
<div style={{ height: `${virtualizer.getTotalSize()}px`, width: '100%', position: 'relative' }}>
|
||||||
|
{virtualizer.getVirtualItems().map((virtualRow) => (
|
||||||
|
<div
|
||||||
|
key={virtualRow.key}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: `${virtualRow.size}px`,
|
||||||
|
transform: `translateY(${virtualRow.start}px)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderItem(items[virtualRow.index], virtualRow.index)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import React, { memo, useMemo, useState } from 'react'
|
|
||||||
import { ComposableMap, Geographies, Geography } from 'react-simple-maps'
|
|
||||||
import countries from 'i18n-iso-countries'
|
|
||||||
import enLocale from 'i18n-iso-countries/langs/en.json'
|
|
||||||
import { useTheme } from '@ciphera-net/ui'
|
|
||||||
|
|
||||||
countries.registerLocale(enLocale)
|
|
||||||
|
|
||||||
const geoUrl = "https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json"
|
|
||||||
|
|
||||||
interface WorldMapProps {
|
|
||||||
data: Array<{ country: string; pageviews: number }>
|
|
||||||
}
|
|
||||||
|
|
||||||
const WorldMap = ({ data }: WorldMapProps) => {
|
|
||||||
const { resolvedTheme } = useTheme()
|
|
||||||
const [tooltipContent, setTooltipContent] = useState<{ content: string; x: number; y: number } | null>(null)
|
|
||||||
|
|
||||||
const processedData = useMemo(() => {
|
|
||||||
const map = new Map<string, number>()
|
|
||||||
let max = 0
|
|
||||||
data.forEach(item => {
|
|
||||||
if (item.country === 'Unknown') return
|
|
||||||
// API returns 2 letter code. Convert to numeric (3 digits string)
|
|
||||||
const numericCode = countries.alpha2ToNumeric(item.country)
|
|
||||||
if (numericCode) {
|
|
||||||
map.set(numericCode, item.pageviews)
|
|
||||||
if (item.pageviews > max) max = item.pageviews
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return { map, max }
|
|
||||||
}, [data])
|
|
||||||
|
|
||||||
// Plausible-like colors based on provided SVG snippet
|
|
||||||
const isDark = resolvedTheme === 'dark'
|
|
||||||
const defaultFill = isDark ? "#262626" : "#f5f5f5" // neutral-800 / neutral-100
|
|
||||||
const defaultStroke = isDark ? "#171717" : "#ffffff" // neutral-900 / white
|
|
||||||
const brandOrange = "#FD5E0F"
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative w-full">
|
|
||||||
<ComposableMap
|
|
||||||
width={800}
|
|
||||||
height={400}
|
|
||||||
projectionConfig={{ rotate: [-10, 0, 0], scale: 170, center: [0, 10] }}
|
|
||||||
className="w-full h-auto"
|
|
||||||
>
|
|
||||||
<Geographies geography={geoUrl}>
|
|
||||||
{({ geographies }) =>
|
|
||||||
geographies
|
|
||||||
.filter(geo => geo.id !== "010") // Remove Antarctica
|
|
||||||
.map((geo) => {
|
|
||||||
const id = String(geo.id).padStart(3, '0')
|
|
||||||
const count = processedData.map.get(id) || 0
|
|
||||||
const fillColor = count > 0 ? brandOrange : defaultFill
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Geography
|
|
||||||
key={geo.rsmKey}
|
|
||||||
geography={geo}
|
|
||||||
fill={fillColor}
|
|
||||||
stroke={defaultStroke}
|
|
||||||
strokeWidth={0.5}
|
|
||||||
style={{
|
|
||||||
default: { outline: "none", transition: "all 250ms" },
|
|
||||||
hover: {
|
|
||||||
fill: fillColor,
|
|
||||||
stroke: brandOrange,
|
|
||||||
strokeWidth: 2,
|
|
||||||
outline: "none",
|
|
||||||
cursor: 'pointer',
|
|
||||||
zIndex: 100 // Bring border to front
|
|
||||||
},
|
|
||||||
pressed: { outline: "none" },
|
|
||||||
}}
|
|
||||||
onMouseEnter={(evt) => {
|
|
||||||
const { name } = geo.properties
|
|
||||||
setTooltipContent({
|
|
||||||
content: `${name}: ${count} visitors`,
|
|
||||||
x: evt.clientX,
|
|
||||||
y: evt.clientY
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
onMouseLeave={() => {
|
|
||||||
setTooltipContent(null)
|
|
||||||
}}
|
|
||||||
onMouseMove={(evt) => {
|
|
||||||
setTooltipContent(prev => prev ? { ...prev, x: evt.clientX, y: evt.clientY } : null)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</Geographies>
|
|
||||||
</ComposableMap>
|
|
||||||
{tooltipContent && (
|
|
||||||
<div
|
|
||||||
className="fixed z-50 px-2 py-1 text-xs font-medium text-white bg-black/80 backdrop-blur-sm rounded shadow pointer-events-none transform -translate-x-1/2 -translate-y-full mt-[-10px]"
|
|
||||||
style={{ left: tooltipContent.x, top: tooltipContent.y }}
|
|
||||||
>
|
|
||||||
{tooltipContent.content}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default memo(WorldMap)
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user