Compare commits
749 Commits
0.13.0-alp
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15cb0c58ce | ||
|
|
6c73fd1dbc | ||
|
|
b1ed7518b1 | ||
|
|
627613dc13 | ||
|
|
a992afe04b | ||
|
|
066f1288f1 | ||
|
|
20d7bdd482 | ||
|
|
ef1cb32c51 | ||
|
|
3067101fec | ||
|
|
16fd913351 | ||
|
|
c7f2600460 | ||
|
|
62df9b3521 | ||
|
|
24fb5258d5 | ||
|
|
9053004e25 | ||
|
|
4c1f70655a | ||
|
|
48320c4db3 | ||
|
|
ff256a5986 | ||
|
|
2113ee348a | ||
|
|
9feffa5cc6 | ||
|
|
663abc9b9e | ||
|
|
c36c1b0696 | ||
|
|
45c518b3ba | ||
|
|
9413fb2a07 | ||
|
|
a6054469ee | ||
|
|
07546576c1 | ||
|
|
4c5102ced1 | ||
|
|
05d183fe2c | ||
|
|
9c5a47ff3a | ||
|
|
84edcf9889 | ||
|
|
07401a4ce2 | ||
|
|
8c0700f406 | ||
|
|
94f9db9e51 | ||
|
|
0af290dc0b | ||
|
|
00423ee599 | ||
|
|
23132a5194 | ||
|
|
6aea24f018 | ||
|
|
a9cf1484fd | ||
|
|
c2b448672c | ||
|
|
80ee2fb614 | ||
|
|
22295302ee | ||
|
|
9c9066b75f | ||
|
|
773e91d490 | ||
|
|
324ba131d4 | ||
|
|
e206399f9d | ||
|
|
5faa0dec80 | ||
|
|
eca42d56ca | ||
|
|
5c90b15b2e | ||
|
|
9c7afda80d | ||
|
|
a55f9a91bd | ||
|
|
3306508bf0 | ||
|
|
cb7e4c7c98 | ||
|
|
9656225b60 | ||
|
|
8db8f65fca | ||
|
|
a495ef8389 | ||
|
|
c7cf50ef1d | ||
|
|
342d86c26d | ||
|
|
20628fa6ab | ||
|
|
0ca65a50fb | ||
|
|
ad207dc23f | ||
|
|
fc5372ff26 | ||
|
|
eb52b7fae6 | ||
|
|
d9e3f90c27 | ||
|
|
0fcc4866fb | ||
|
|
5ca24f6c9c | ||
|
|
f4ba6c8a2a | ||
|
|
4e9439770f | ||
|
|
ef83176089 | ||
|
|
5cff767e32 | ||
|
|
342ee1fdf3 | ||
|
|
af1d718a18 | ||
|
|
0bfde33050 | ||
|
|
088db2a104 | ||
|
|
977425fdb9 | ||
|
|
7a44787438 | ||
|
|
b5150e3b7a | ||
|
|
4896089463 | ||
|
|
fba1f84ce5 | ||
|
|
7c55e5f763 | ||
|
|
e5ac1893dc | ||
|
|
75bf071d98 | ||
|
|
4c6020535a | ||
|
|
3a29fb5a09 | ||
|
|
9297e20604 | ||
|
|
497f0f791a | ||
|
|
48f71ee65b | ||
|
|
742c24fa6b | ||
|
|
f72a140ca6 | ||
|
|
3e7a32dc91 | ||
|
|
e089640fb9 | ||
|
|
22dddc6b6f | ||
|
|
512368d79e | ||
|
|
0f41eb4df4 | ||
|
|
6be8952fbe | ||
|
|
58ac7b9cc5 | ||
|
|
e23ec2ca40 | ||
|
|
89575c9fcb | ||
|
|
837f440107 | ||
|
|
6ea520e0ed | ||
|
|
d419322ab7 | ||
|
|
4e7ad88763 | ||
|
|
94d0b3498f | ||
|
|
704557f233 | ||
|
|
0bf9424200 | ||
|
|
ef3edd963a | ||
|
|
952cebc59a | ||
|
|
c63e72a578 | ||
|
|
e7d2ecf50b | ||
|
|
012b0d494f | ||
|
|
ee1196f061 | ||
|
|
c99278e7fa | ||
|
|
6fef6da468 | ||
|
|
97b9486382 | ||
|
|
b352fa00e4 | ||
|
|
4a950f7070 | ||
|
|
6b33483c81 | ||
|
|
cc3047edba | ||
|
|
61a106eed6 | ||
|
|
5165b885ff | ||
|
|
f6e43976d8 | ||
|
|
ae54e0f10a | ||
|
|
14695a52dd | ||
|
|
dc867e84f4 | ||
|
|
3e603c77a9 | ||
|
|
2be0841a54 | ||
|
|
b1254bcad0 | ||
|
|
bd8fae626c | ||
|
|
67334f1fd6 | ||
|
|
b18199aa48 | ||
|
|
d97eb77569 | ||
|
|
df286ab0e4 | ||
|
|
941dcd73ce | ||
|
|
603e910d40 | ||
|
|
d13372c864 | ||
|
|
09181affbb | ||
|
|
477a3b4568 | ||
|
|
4af5daa298 | ||
|
|
c299b10d19 | ||
|
|
98ba751c2c | ||
|
|
8fb2f603bd | ||
|
|
8d894ff92a | ||
|
|
1121a72d63 | ||
|
|
d819b4bd17 | ||
|
|
095b68d769 | ||
|
|
1da71aa1a2 | ||
|
|
e7b8943097 | ||
|
|
9893b283cf | ||
|
|
a3b746deeb | ||
|
|
4d9c3aeabd | ||
|
|
1cbc8064e2 | ||
|
|
db12ad04cf | ||
|
|
1c916bb598 | ||
|
|
712169187b | ||
|
|
cbf2125f0a | ||
|
|
f794696e90 | ||
|
|
6c0061733b | ||
|
|
c53152fc68 | ||
|
|
3f884fca76 | ||
|
|
5d21a81fad | ||
|
|
549ac273a1 | ||
|
|
570dda7bd2 | ||
|
|
8ec9edb126 | ||
|
|
43005fb9ee | ||
|
|
1c21bf5ff6 | ||
|
|
81fafcf711 | ||
|
|
7181d68d85 | ||
|
|
0de8f927a4 | ||
|
|
eb3c3b2738 | ||
|
|
93401cc1a1 | ||
|
|
9dceca765c | ||
|
|
9a3fab3535 | ||
|
|
1ad68943c8 | ||
|
|
688d268fbf | ||
|
|
0f5a3388a0 | ||
|
|
1fef7b175c | ||
|
|
0cb13e08fd | ||
|
|
8bef4b7c9f | ||
|
|
b64c4c036f | ||
|
|
851c607b7a | ||
|
|
b164160d6a | ||
|
|
ce992e331f | ||
|
|
7dc6e0daf5 | ||
|
|
f844751142 | ||
|
|
b3ccb58431 | ||
|
|
d0d7a97102 | ||
|
|
4e6837a9ee | ||
|
|
45a8adff0f | ||
|
|
294629edfe | ||
|
|
48b404eb37 | ||
|
|
b78f5d4b96 | ||
|
|
1aeb9cf275 | ||
|
|
24858030ba | ||
|
|
e39c10d50f | ||
|
|
953d828cd9 | ||
|
|
540c0b51ca | ||
|
|
9aacd63d1d | ||
|
|
132afa749c | ||
|
|
4e5dd6e3f3 | ||
|
|
4702bb91b9 | ||
|
|
5eabc52133 | ||
|
|
de10fb5daf | ||
|
|
d6627413b8 | ||
|
|
bb55782dba | ||
|
|
0f462314e2 | ||
|
|
102551b1ce | ||
|
|
b74742e15e | ||
|
|
f3d72c9841 | ||
|
|
505454b7d6 | ||
|
|
14e0c9b4dc | ||
|
|
b607a9a76e | ||
|
|
441fd9afda | ||
|
|
441abbd568 | ||
|
|
71e98d72b4 | ||
|
|
def483cf6d | ||
|
|
f686063f0a | ||
|
|
d48479ee5b | ||
|
|
538df57d2b | ||
|
|
5a03e1f9a5 | ||
|
|
5dfc3a5636 | ||
|
|
bb4861dbdc | ||
|
|
c48023be9f | ||
|
|
e12a3661fa | ||
|
|
ea2c47b53f | ||
|
|
e55a3c4ce4 | ||
|
|
d050d32d24 | ||
|
|
3c17895d64 | ||
|
|
345f4ff4e1 | ||
|
|
ca2f1ce19d | ||
|
|
6521b694f4 | ||
|
|
a3c1af7c95 | ||
|
|
eca21bf627 | ||
|
|
cd347ea072 | ||
|
|
21cee4f4ae | ||
|
|
c07c020015 | ||
|
|
9510e2da8c | ||
|
|
414e112d3d | ||
|
|
645e3e78ef | ||
|
|
d6cef95c4b | ||
|
|
198bd3b00f | ||
|
|
cbb7445d74 | ||
|
|
8c3b77e8e5 | ||
|
|
01c50ab971 | ||
|
|
55a08301f4 | ||
|
|
75bf8acd1e | ||
|
|
4064f7eabf | ||
|
|
508bb006a8 | ||
|
|
31471792f8 | ||
|
|
a0ef570137 | ||
|
|
8d9a3f3592 | ||
|
|
d02d8429e2 | ||
|
|
98fcce4647 | ||
|
|
bba25c722a | ||
|
|
354331646b | ||
|
|
d232a8a6d1 | ||
|
|
9d1d2dbb80 | ||
|
|
98429f82f5 | ||
|
|
a0173636d4 | ||
|
|
dfcf6bebde | ||
|
|
5003175305 | ||
|
|
ab6008daf9 | ||
|
|
8b95620ec1 | ||
|
|
783530940e | ||
|
|
dd0700cbea | ||
|
|
8649f37bb9 | ||
|
|
fcbf21b715 | ||
|
|
50960d0556 | ||
|
|
6b00b8b04a | ||
|
|
b0e6db36a1 | ||
|
|
2fd9bf82f1 | ||
|
|
d1af25266b | ||
|
|
52906344cf | ||
|
|
780dd464a1 | ||
|
|
b026476311 | ||
|
|
6a1698b794 | ||
|
|
1d26819727 | ||
|
|
5c30043550 | ||
|
|
b7e92abb40 | ||
|
|
e626350f14 | ||
|
|
bd023e76f5 | ||
|
|
c85f305f1e | ||
|
|
430e6f5d48 | ||
|
|
82a201a043 | ||
|
|
ef21004519 | ||
|
|
0805bbaeee | ||
|
|
3f3d81a41f | ||
|
|
0878bde259 | ||
|
|
42b7363cf9 | ||
|
|
6444cec454 | ||
|
|
5fc1a33745 | ||
|
|
185cb8699f | ||
|
|
7e48d70411 | ||
|
|
4043a678db | ||
|
|
5008992f59 | ||
|
|
5b0d0e1dc1 | ||
|
|
9d253523e2 | ||
|
|
d4e4ca819c | ||
|
|
830da49c5f | ||
|
|
9e128c4945 | ||
|
|
9c06a845a0 | ||
|
|
1270aa99a9 | ||
|
|
d37e817cc9 | ||
|
|
1c7667562c | ||
|
|
24fa01dd25 | ||
|
|
028e4e5425 | ||
|
|
6098b5e158 | ||
|
|
4ef92b9e3a | ||
|
|
93347f6454 | ||
|
|
b3bb0685f9 | ||
|
|
9ce272d3e5 | ||
|
|
0bd2f94dd7 | ||
|
|
af62532615 | ||
|
|
39cd1c596c | ||
|
|
941782efe1 | ||
|
|
ca199b59fd | ||
|
|
536bb8c872 | ||
|
|
0e8629951c | ||
|
|
911704cff2 | ||
|
|
4afaf32e58 | ||
|
|
74a48299ab | ||
|
|
1e4bb34513 | ||
|
|
a361649e60 | ||
|
|
e789fb525b | ||
|
|
0b7c4d528a | ||
|
|
acfd532194 | ||
|
|
3710f081a6 | ||
|
|
7bf7e5cc3d | ||
|
|
64b245caca | ||
|
|
6d253e6d18 | ||
|
|
21c68b4334 | ||
|
|
ec9d1a2c2d | ||
|
|
e6910b77ca | ||
|
|
8fdb8c4a2f | ||
|
|
4b46bba883 | ||
|
|
32ca818c3c | ||
|
|
09b4266a49 | ||
|
|
ed7d519ed2 | ||
|
|
693c975b24 | ||
|
|
e6d840abb9 | ||
|
|
ac9e10b436 | ||
|
|
8338988471 | ||
|
|
73fc47e910 | ||
|
|
bf3097c26e | ||
|
|
e7c7ab8f9c | ||
|
|
7cbfbc54ca | ||
|
|
0e759e8fcf | ||
|
|
dc7bffdf56 | ||
|
|
7c3215d662 | ||
|
|
6b1e6876c6 | ||
|
|
04b4059392 | ||
|
|
1696e428ab | ||
|
|
5287c078bd | ||
|
|
a7ac2cb9d7 | ||
|
|
f52f98a836 | ||
|
|
e464b87471 | ||
|
|
740796dcb7 | ||
|
|
a9af5d4593 | ||
|
|
39912c5024 | ||
|
|
177c33830c | ||
|
|
dd76aed157 | ||
|
|
a31f183b7b | ||
|
|
89343caf65 | ||
|
|
1755dcb9dc | ||
|
|
3e67af5646 | ||
|
|
2fa498fb8f | ||
|
|
0b545eaa76 | ||
|
|
342e3705e8 | ||
|
|
f1fc8facb4 | ||
|
|
66d63f7a3b | ||
|
|
e8b3227dcf | ||
|
|
323ed9c137 | ||
|
|
c24a053c07 | ||
|
|
6d649d8dc4 | ||
|
|
7ed04fb85c | ||
|
|
a63dfa231e | ||
|
|
137ab4c2ba | ||
|
|
29127d7ed5 | ||
|
|
db055c758c | ||
|
|
c021d8ccf6 | ||
|
|
879df18502 | ||
|
|
684448159a | ||
|
|
9f8a6606bb | ||
|
|
7cdbb34f9d | ||
|
|
9b8ae08460 | ||
|
|
01dfa6954f | ||
|
|
9773735d2b | ||
|
|
84c23faa0f | ||
|
|
981eaaff39 | ||
|
|
b0983e5a3f | ||
|
|
6fcb6df295 | ||
|
|
5c8f334017 | ||
|
|
5807a50092 | ||
|
|
2474d6558f | ||
|
|
db5cd4cbcb | ||
|
|
66a70f676f | ||
|
|
d00d2e5592 | ||
|
|
1d25368292 | ||
|
|
7ae5facd0c | ||
|
|
61ce505ee5 | ||
|
|
80ae8311dc | ||
|
|
9f7987fe07 | ||
|
|
94112161f0 | ||
|
|
4c7ed858f7 | ||
|
|
efd0c144b5 | ||
|
|
585cb4fd88 | ||
|
|
2811945d3e | ||
|
|
18e66917d3 | ||
|
|
d5b594d6f9 | ||
|
|
ff58ba5953 | ||
|
|
311f546261 | ||
|
|
ad1c8c5420 | ||
|
|
51723bea5d | ||
|
|
d7f374472a | ||
|
|
7a0f106bc3 | ||
|
|
10ad276c38 | ||
|
|
e4fa320b39 | ||
|
|
78fed269db | ||
|
|
90944ce6bd | ||
|
|
d97818dfd7 | ||
|
|
fa9fa26a1f | ||
|
|
3aaf199a19 | ||
|
|
431128f4ad | ||
|
|
dedb55b113 | ||
|
|
c833a759f4 | ||
|
|
553b44328e | ||
|
|
81e2e8bd6c | ||
|
|
109aca62c0 | ||
|
|
e7ebe2a923 | ||
|
|
ebd25770b4 | ||
|
|
d45d39aa60 | ||
|
|
01222bf0a9 | ||
|
|
1ba6f6609d | ||
|
|
b16f01bd7f | ||
|
|
52427fea93 | ||
|
|
17f2bdc9e9 | ||
|
|
4007056e44 | ||
|
|
bec61c599e | ||
|
|
40f223cf38 | ||
|
|
e9ec86b10b | ||
|
|
16020a166c | ||
|
|
e444985295 | ||
|
|
f797d89131 | ||
|
|
1aace48d73 | ||
|
|
d3f5e6b361 | ||
|
|
6f42d4d3de | ||
|
|
71f922976d | ||
|
|
bb1ed9d8b5 | ||
|
|
47ea6fa6f6 | ||
|
|
3b09758881 | ||
|
|
4f419f8b04 | ||
|
|
336520e401 | ||
|
|
ec9f72455a | ||
|
|
be1d9a2f46 | ||
|
|
e4291c44a8 | ||
|
|
ed865c9a6f | ||
|
|
8287a38b43 | ||
|
|
2e444849ef | ||
|
|
df10d4e747 | ||
|
|
c21d7b9073 | ||
|
|
df2b3cadd7 | ||
|
|
4f4f2f4f9a | ||
|
|
d864d951f9 | ||
|
|
d5b48ac985 | ||
|
|
3c9d5b47be | ||
|
|
0ea9b31b63 | ||
|
|
25f4cd5eb9 | ||
|
|
2068f839fd | ||
|
|
76248233b9 | ||
|
|
849986edf1 | ||
|
|
220d3905be | ||
|
|
6d6c1ee8f6 | ||
|
|
24c71f7991 | ||
|
|
7103a39273 | ||
|
|
3c8904ffe4 | ||
|
|
aba67592bb | ||
|
|
e7907d68bf | ||
|
|
342bf46946 | ||
|
|
de16991bb3 | ||
|
|
3954ee0a97 | ||
|
|
b000d0e1f7 | ||
|
|
58272f3fb5 | ||
|
|
722b5de88d | ||
|
|
ada2c65d8f | ||
|
|
b10abd38fc | ||
|
|
9f9f4286b7 | ||
|
|
a7e9f7c998 | ||
|
|
4103014cdb | ||
|
|
9528eca443 | ||
|
|
e8f00e06ec | ||
|
|
1e147c955b | ||
|
|
7e30d04df3 | ||
|
|
47d884e47b | ||
|
|
f858bb7811 | ||
|
|
302e683b32 | ||
|
|
20cda8d464 | ||
|
|
bc2534a22b | ||
|
|
b305b5345b | ||
|
|
7247281ce2 | ||
|
|
f278aada7a | ||
|
|
1e61926bc6 | ||
|
|
77b280341b | ||
|
|
d9c01b9b06 | ||
|
|
2512be0d57 | ||
|
|
fb85c431f0 | ||
|
|
a8fe171c8c | ||
|
|
4ceb33b946 | ||
|
|
4d869d8cb1 | ||
|
|
a3f50dc38f | ||
|
|
8f00193e0f | ||
|
|
af29bb77cd | ||
|
|
34c705549b | ||
|
|
9b7781115f | ||
|
|
cf0b6b8a68 | ||
|
|
8e7c273ebc | ||
|
|
11ef95ef45 | ||
|
|
19db02e945 | ||
|
|
2242a159c7 | ||
|
|
25210013d3 | ||
|
|
7ba5e063ca | ||
|
|
ed80290431 | ||
|
|
d6d42b5759 | ||
|
|
3619a1e644 | ||
|
|
618c4fd5fe | ||
|
|
68536ed71a | ||
|
|
39e06183c3 | ||
|
|
cad588da52 | ||
|
|
6c31f3fc60 | ||
|
|
86077557a8 | ||
|
|
e86021caf8 | ||
|
|
0dd1f00095 | ||
|
|
84312ebf59 | ||
|
|
91f4743f48 | ||
|
|
f7bd61187a | ||
|
|
344838e0cd | ||
|
|
e336d2c7e5 | ||
|
|
8f06c9168a | ||
|
|
66a9ac1f31 | ||
|
|
98c08e3996 | ||
|
|
dc422b5920 | ||
|
|
f976fbdb2e | ||
|
|
2a2a64f6d7 | ||
|
|
b5d408b4e8 | ||
|
|
00d232ab3f | ||
|
|
87f5905bd6 | ||
|
|
58f42f945c | ||
|
|
570a84889a | ||
|
|
2cc120ca3f | ||
|
|
fcfa4bfed9 | ||
|
|
969887cc67 | ||
|
|
453a596eaf | ||
|
|
9a54d93c79 | ||
|
|
eb0dc4a27b | ||
|
|
8c4bb8f861 | ||
|
|
0abc5cd4a8 | ||
|
|
3bda7215db | ||
|
|
6380f216aa | ||
|
|
b6a7c642f2 | ||
|
|
6a13e5480a | ||
|
|
57e43b1b4f | ||
|
|
c0ad0cfb7a | ||
|
|
2d3388546f | ||
|
|
34c80d0857 | ||
|
|
1c26e4cc6c | ||
|
|
f7340fa763 | ||
|
|
6e213539ea | ||
|
|
f69248ecfa | ||
|
|
360d6e7e71 | ||
|
|
63144a136e | ||
|
|
1d71a13df4 | ||
|
|
6edd5ac0b6 | ||
|
|
a57ed871f1 | ||
|
|
765f8ec63e | ||
|
|
aae1714b02 | ||
|
|
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 |
7
.github/workflows/test.yml
vendored
7
.github/workflows/test.yml
vendored
@@ -1,4 +1,5 @@
|
||||
# * Runs unit tests on push/PR to main and staging.
|
||||
# * Uses self-hosted runner for push events, GitHub-hosted for PRs (public repo security).
|
||||
name: Test
|
||||
|
||||
on:
|
||||
@@ -7,6 +8,10 @@ on:
|
||||
pull_request:
|
||||
branches: [main, staging]
|
||||
|
||||
concurrency:
|
||||
group: test-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
@@ -14,7 +19,7 @@ permissions:
|
||||
jobs:
|
||||
test:
|
||||
name: unit-tests
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ github.event_name == 'pull_request' && 'ubuntu-latest' || 'self-hosted' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,5 +1,8 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# auto-generated
|
||||
/lib/integration-guides.gen.ts
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
|
||||
1
.npmrc
1
.npmrc
@@ -1,2 +1,3 @@
|
||||
@ciphera-net:registry=https://npm.pkg.github.com
|
||||
//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}
|
||||
legacy-peer-deps=true
|
||||
|
||||
155
CHANGELOG.md
155
CHANGELOG.md
@@ -6,6 +6,161 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **Funnels now track actions, not just pages.** When creating or editing a funnel, you can now choose between "Page Visit" and "Custom Event" for each step. Page Visit steps work as before — matching URLs. Custom Event steps let you track specific actions like signups, purchases, or button clicks. You can also add property filters to event steps (e.g., "purchase where plan is pro") to get even more specific about what you're measuring.
|
||||
- **Edit your funnels.** You can now edit existing funnels — change the name, description, steps, or conversion window without having to delete and recreate them. Click the pencil icon on any funnel's detail page.
|
||||
- **Conversion window.** Funnels now have a configurable time limit. Visitors must complete all steps within your chosen window (e.g., 7 days, 24 hours) to count as converted. Set it when creating or editing a funnel — quick presets for common windows, or type your own. Default is 7 days.
|
||||
- **Filter your funnels.** Apply the same filters you use on the dashboard — by device, country, browser, UTM source, and more — directly on your funnel stats. See how your funnel performs for mobile visitors vs desktop, or for traffic from a specific campaign.
|
||||
- **See where visitors go after dropping off.** Each funnel step now shows the top pages visitors navigated to after leaving the funnel. A quick preview appears inline, and you can expand to see the full list. Helps you understand why visitors aren't converting.
|
||||
- **Conversion trends over time.** A new chart below your funnel shows how conversion rates change day by day. See at a glance whether your funnel is improving or degrading. Toggle individual steps on or off to pinpoint which step is changing.
|
||||
- **Step-level breakdowns.** Click any step in your funnel stats to open a breakdown panel showing who converts at that step — split by device, country, browser, or traffic source. Useful for spotting segments that convert better or worse than average.
|
||||
- **Up to 8 steps per funnel.** The step limit has been increased from 5 to 8, so you can track longer user journeys like multi-page onboarding flows or detailed checkout processes.
|
||||
- **BunnyCDN integration.** Connect your BunnyCDN account in Settings > Integrations to monitor your CDN performance right alongside your analytics. A new "CDN" tab on your dashboard shows total bandwidth served, request volume, cache hit rate, origin response time, and error counts — each with percentage changes compared to the previous period. Charts show bandwidth trends (total vs cached), daily request volume, and error breakdowns over time. A geographic breakdown shows which countries consume the most bandwidth. When connecting, Pulse automatically filters your pull zones to only show ones matching your site's domain. Pulse only stores your API key encrypted and only reads statistics — it never modifies anything in your BunnyCDN account. You can disconnect and fully remove all CDN data at any time.
|
||||
- **Google Search Console integration.** Connect your Google Search Console account in Settings > Integrations to see which search queries bring visitors to your site. A new "Search" tab on your dashboard shows total clicks, impressions, average CTR, and average ranking position — with percentage changes compared to the previous period. Browse your top search queries and top pages in sortable, paginated tables. Click any query to see which pages rank for it, or click any page to see which queries drive traffic to it. A trend chart shows how clicks and impressions change over time, and a green badge highlights new queries that appeared this period. Pulse only requests read-only access to your Search Console data, encrypts your Google credentials, and lets you disconnect and fully remove all search data at any time.
|
||||
- **Free plan now visible on the Pricing page.** The free tier is no longer hidden — it's displayed as the first option on the Pricing page so you can see exactly what you get before signing up: 1 site, 5,000 monthly pageviews, and 6 months of data retention, completely free.
|
||||
- **Free plan limited to 1 site.** Free accounts are now limited to a single site. If you need more, you can upgrade to Solo or above from the Pricing page.
|
||||
|
||||
### Improved
|
||||
|
||||
- **Redesigned Search card on the dashboard.** The Search section of the dashboard has been completely refreshed to match the rest of Pulse. Search queries now show proportional bars so you can visually compare which queries get the most impressions. Hovering a row reveals the impression share percentage. Position badges are now color-coded — green for page 1 rankings, orange for page 2, and red for queries buried beyond page 5. You can switch between your top search queries and top pages using tabs, and expand the full list in a searchable popup without leaving the dashboard.
|
||||
- **Smaller, faster tracking script.** The tracking script is now about 20% smaller. Logic like page path cleaning, referrer filtering, error page detection, and input validation has been moved from your browser to the Pulse server. This means the script loads faster on every page, and Pulse can improve these features without needing you to update anything.
|
||||
- **Automatic 404 page detection.** Pulse now detects error pages (404 / "Page Not Found") automatically on the server by reading your page title — no extra setup needed. Previously this ran in the browser and couldn't be improved without updating the script. Now Pulse can recognize more error page patterns over time, including pages in other languages, without any changes on your end.
|
||||
- **Smarter bot filtering.** Pulse now catches more types of automated traffic that were slipping through — like headless browsers with default screen sizes, bot farms that rotate through different locations, and bots that fire duplicate events within milliseconds. Bot detection checks have also been moved from the tracking script to the server, making the script smaller and faster for real visitors.
|
||||
- **Actionable empty states.** When a dashboard section has no data yet, you now get a direct action — like "Install tracking script" or "Build a UTM URL" — instead of just passive text. Gets you set up faster.
|
||||
- **Animated numbers across the dashboard.** Stats like visitors, pageviews, bounce rate, and visit duration now smoothly count up or down when you switch date ranges, apply filters, or when real-time visitor counts change — instead of just jumping to the new value.
|
||||
- **Inline bar charts on dashboard lists.** Pages, referrers, locations, technology, and campaigns now show subtle proportional bars behind each row, making it easier to compare values at a glance without reading numbers.
|
||||
- **Redesigned Journeys page.** The Journeys page has been rebuilt — the depth slider now matches the rest of the UI and goes up to 10 steps, controls are integrated into the chart card, and Top Paths uses a clean compact list with inline bars instead of bulky cards.
|
||||
- **More reliable visit duration tracking.** Visit duration was silently dropping to 0s for visitors who only viewed one page — especially on mobile or when closing a tab quickly. The tracking script now captures time-on-page more reliably across all browsers, and sessions where duration couldn't be measured are excluded from the average instead of counting as 0s. This makes the Visit Duration metric, Journeys, and Top Paths much more accurate.
|
||||
- **More accurate rage click detection.** Rage clicks no longer fire when you triple-click to select text on a page. Previously, selecting a paragraph (a normal 3-click action) was being counted as a rage click, which inflated frustration metrics. Only genuinely frustrated rapid clicking is tracked now.
|
||||
- **Fresher CDN data.** BunnyCDN statistics now refresh every 3 hours instead of once a day, so your CDN tab shows much more current bandwidth, request, and cache data.
|
||||
- **More accurate dead click detection.** Dead clicks were being reported on elements that actually worked — like close buttons on cart drawers, modal dismiss buttons, and page content areas. Three fixes make dead clicks much more reliable:
|
||||
- Buttons that trigger changes elsewhere on the page (closing a drawer, opening a modal) are no longer flagged as dead.
|
||||
- Page content areas that aren't actually clickable (like `<main>` containers) are no longer treated as interactive elements.
|
||||
- Single-page app navigations are now properly detected, so links that use client-side routing aren't mistakenly reported as broken.
|
||||
- **Journeys page now shows data on low-traffic sites.** The Journeys page previously required at least 2–3 sessions following the same path before showing any data. It now shows all navigation flows immediately, so you can see how visitors move through your site from day one.
|
||||
- **European date and time formatting.** All dates across Pulse now use day-first ordering (14 Mar 2025) and 24-hour time (14:30) instead of the US-style month-first format. This applies everywhere — dashboard charts, exports, billing dates, invoices, uptime checks, audit logs, and more.
|
||||
- **Sites now show their verification status.** Each site on your dashboard now displays either a green "Active" badge (if verified) or an amber "Unverified" badge. The Settings page also shows a green confirmation bar once verified. When you verify your tracking script installation, the status is saved permanently — no more showing "Active" for sites that haven't been set up yet.
|
||||
- **Cleaner page paths in your reports.** Pages like `/products?_t=123456` or `/about?session=abc` now correctly show as `/products` and `/about`. Trailing slashes are also normalized — `/about/` and `/about` count as the same page. Only marketing attribution parameters (like UTM tags) are preserved for traffic source tracking — all other junk parameters are automatically removed, so your Top Pages and Journeys stay clean.
|
||||
- **Easier to hover country dots on the map.** The orange location markers on the world map are now much easier to interact with — you no longer need pixel-perfect aim to see the tooltip.
|
||||
- **Smoother chart curves and filled area.** The dashboard chart line now flows with natural curves instead of sharp flat tops at peaks. The area beneath the line is filled with a soft transparent orange gradient that fades toward the bottom, making trends easier to read at a glance.
|
||||
- **Smoother loading transitions.** When your data finishes loading, the page now fades in smoothly instead of appearing all at once. This applies across Dashboard, Journeys, Funnels, Behavior, Uptime, Settings, Notifications, and shared dashboards. If your data was already cached from a previous visit, it still loads instantly with no animation — the fade only kicks in when you're actually waiting for fresh data.
|
||||
- **Faster tab switching across the board.** Switching between Settings, Funnels, Uptime, and other tabs now shows your data instantly instead of flashing a loading skeleton every time. Previously visited tabs remember their data and show it right away, while quietly refreshing in the background so you always see the latest numbers without the wait.
|
||||
|
||||
### Removed
|
||||
|
||||
- **Performance insights removed.** The Performance tab, Core Web Vitals tracking (LCP, CLS, INP), and the "Enable performance insights" toggle in Settings have been removed. The tracking script no longer collects Web Vitals data. Visit duration tracking continues to work as before.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Your BunnyCDN API key is no longer visible in network URLs.** When loading pull zones, the API key was previously sent as a URL parameter. It's now sent securely in the request body, just like when connecting.
|
||||
- **No more "Site not found" when switching back to Pulse.** If you left Pulse in the background and came back, you could see a wall of errors and a blank page. This happened because the browser fired several requests at once when the tab regained focus, and if any failed, they all retried repeatedly — flooding the connection and making it worse. Failed requests now back off gracefully instead of retrying in a loop.
|
||||
- **No more random errors when switching tabs.** Navigating between Dashboard, Funnels, Uptime, and Settings no longer shows "Invalid credentials", "Something went wrong", or "Site not found" errors. This was caused by a timing issue when your login session refreshed in the background while multiple pages were loading at the same time — all those requests now wait for the refresh to finish and retry cleanly.
|
||||
- **More accurate pageview counts.** Refreshing a page no longer inflates your pageview numbers. The tracking script now detects when the same page is loaded again within a few seconds and skips the duplicate, so metrics like total pageviews, pages per session, and visit duration reflect real navigation instead of reload habits.
|
||||
- **Self-referrals no longer pollute your traffic sources.** Internal navigation within your own site (e.g. clicking from your homepage to your about page) no longer shows your own domain as a referrer. Only external traffic sources appear in your Referrers panel now.
|
||||
- **Screen size fallback now works correctly.** A variable naming issue prevented the fallback screen dimensions from being read when the primary value wasn't available. Screen size data is now reliably captured on all browsers.
|
||||
- **Browser back/forward no longer double-counts pageviews.** Pressing the back or forward button could occasionally register two pageviews instead of one. The tracking script now correctly deduplicates these navigations.
|
||||
- **Preloaded pages no longer count as visits.** Modern browsers sometimes preload pages in the background before you actually visit them. These ghost visits no longer inflate your pageview counts — only pages the visitor actually sees are tracked.
|
||||
- **Marketing parameters no longer fragment your pages.** Pages like `/about?utm_source=google` and `/about?utm_campaign=spring` now correctly show as just `/about` in your Top Pages. UTM tags, Facebook click IDs, Google click IDs, and other tracking parameters are stripped from the page path so all visits to the same page are grouped together.
|
||||
- **Traffic sources are no longer over-counted.** When a visitor arrived from Facebook (or any external source) and browsed multiple pages, every page was credited to Facebook instead of just the first. Now only the landing page shows the referrer, giving you accurate traffic source numbers.
|
||||
- **UTM attribution now works correctly.** Visitors arriving via campaign links (e.g. from Facebook Ads, Google Ads, or email campaigns) now have their traffic source, medium, and campaign properly recorded. Previously, this data was accidentally lost before it reached the server.
|
||||
- **Outbound links and file downloads now show the URL.** Previously you could only see how many outbound clicks or downloads happened. Now you can see exactly which external links visitors clicked and which files they downloaded.
|
||||
- **Dead click detection no longer triggers on form fields.** Clicking on a text input, dropdown, or text area to interact with it is normal — it no longer gets flagged as a dead click.
|
||||
|
||||
## [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
|
||||
|
||||
672
LICENSE
672
LICENSE
@@ -1,17 +1,663 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2024-2026 Ciphera
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
@@ -15,23 +15,23 @@ function ComparisonTable({ title, competitors }: { title: string, competitors: {
|
||||
|
||||
return (
|
||||
<div className="mb-16">
|
||||
<h2 className="text-2xl font-bold mb-6 text-neutral-900 dark:text-white">{title}</h2>
|
||||
<div className="overflow-hidden rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-white/50 dark:bg-neutral-900/50 backdrop-blur-sm">
|
||||
<h2 className="text-2xl font-bold mb-6 text-white">{title}</h2>
|
||||
<div className="overflow-hidden rounded-2xl border border-neutral-800 bg-neutral-900/50 backdrop-blur-sm">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-neutral-200 dark:border-neutral-800">
|
||||
<tr className="border-b border-neutral-800">
|
||||
<th className="p-4 sm:p-6 text-sm font-medium text-neutral-500">Feature</th>
|
||||
{competitors.map((comp) => (
|
||||
<th key={comp.name} className={`p-4 sm:p-6 text-sm font-bold ${comp.isPulse ? 'text-brand-orange' : 'text-neutral-900 dark:text-white'}`}>
|
||||
<th key={comp.name} className={`p-4 sm:p-6 text-sm font-bold ${comp.isPulse ? 'text-brand-orange' : 'text-white'}`}>
|
||||
{comp.name}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-800">
|
||||
<tbody className="divide-y divide-neutral-800">
|
||||
{allFeatures.map((feature) => (
|
||||
<tr key={feature} className="hover:bg-neutral-50/50 dark:hover:bg-neutral-800/50 transition-colors">
|
||||
<td className="p-4 sm:p-6 text-neutral-900 dark:text-white font-medium text-sm sm:text-base">{feature}</td>
|
||||
<tr key={feature} className="hover:bg-neutral-800/50 transition-colors">
|
||||
<td className="p-4 sm:p-6 text-white font-medium text-sm sm:text-base">{feature}</td>
|
||||
{competitors.map((comp) => {
|
||||
const val = comp.features[feature]
|
||||
return (
|
||||
@@ -41,7 +41,7 @@ function ComparisonTable({ title, competitors }: { title: string, competitors: {
|
||||
) : val === false ? (
|
||||
<XIcon className="w-5 h-5 text-red-500" />
|
||||
) : (
|
||||
<span className={comp.isPulse ? 'text-green-500 font-medium' : 'text-neutral-600 dark:text-neutral-400'}>{val}</span>
|
||||
<span className={comp.isPulse ? 'text-green-500 font-medium' : 'text-neutral-400'}>{val}</span>
|
||||
)}
|
||||
</td>
|
||||
)
|
||||
@@ -60,10 +60,9 @@ export default function AboutPage() {
|
||||
<div className="relative min-h-screen flex flex-col overflow-hidden">
|
||||
{/* * --- ATMOSPHERE (Background) --- */}
|
||||
<div className="absolute inset-0 -z-10 pointer-events-none">
|
||||
<div className="absolute top-0 left-1/4 w-[500px] h-[500px] bg-brand-orange/10 rounded-full blur-[128px] opacity-60" />
|
||||
<div className="absolute bottom-0 right-1/4 w-[500px] h-[500px] bg-neutral-500/10 dark:bg-neutral-400/10 rounded-full blur-[128px] opacity-40" />
|
||||
<div className="absolute bottom-0 right-1/4 w-[500px] h-[500px] bg-neutral-400/10 rounded-full blur-[128px] opacity-40" />
|
||||
<div
|
||||
className="absolute inset-0 bg-grid-pattern opacity-[0.02] dark:opacity-[0.05]"
|
||||
className="absolute inset-0 bg-grid-pattern opacity-[0.05]"
|
||||
style={{ maskImage: 'radial-gradient(ellipse at center, black 0%, transparent 70%)' }}
|
||||
/>
|
||||
</div>
|
||||
@@ -75,10 +74,10 @@ export default function AboutPage() {
|
||||
transition={{ duration: 0.5 }}
|
||||
className="text-center mb-16"
|
||||
>
|
||||
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-neutral-900 dark:text-white mb-6">
|
||||
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-white mb-6">
|
||||
Why Pulse?
|
||||
</h1>
|
||||
<p className="text-xl text-neutral-600 dark:text-neutral-400 max-w-2xl mx-auto leading-relaxed">
|
||||
<p className="text-xl text-neutral-400 max-w-2xl mx-auto leading-relaxed">
|
||||
We built Pulse because we were tired of complex, invasive analytics tools.
|
||||
Here is how we stack up against the giants.
|
||||
</p>
|
||||
@@ -88,9 +87,9 @@ export default function AboutPage() {
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.1 }}
|
||||
className="prose prose-neutral dark:prose-invert max-w-none mb-16"
|
||||
className="prose prose-invert max-w-none mb-16"
|
||||
>
|
||||
<p className="text-lg text-neutral-600 dark:text-neutral-400">
|
||||
<p className="text-lg text-neutral-400">
|
||||
Most analytics tools are overkill. They track everything, slow down your site, and require annoying cookie banners.
|
||||
Pulse is different. We focus on the metrics that actually matter—visitors, pageviews, and sources—while respecting user privacy.
|
||||
</p>
|
||||
@@ -163,10 +162,10 @@ export default function AboutPage() {
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="mt-8 p-6 bg-neutral-100 dark:bg-neutral-800/50 rounded-xl border border-neutral-200 dark:border-neutral-800"
|
||||
className="mt-8 p-6 bg-neutral-800/50 rounded-xl border border-neutral-800"
|
||||
>
|
||||
<h3 className="text-xl font-bold mb-2 text-neutral-900 dark:text-white">What about Plausible?</h3>
|
||||
<p className="text-neutral-600 dark:text-neutral-400 text-sm">
|
||||
<h3 className="text-xl font-bold mb-2 text-white">What about Plausible?</h3>
|
||||
<p className="text-neutral-400 text-sm">
|
||||
We love Plausible! They paved the way for privacy-friendly analytics.
|
||||
Pulse offers a similar philosophy but with a focus on even deeper integration with the Ciphera ecosystem
|
||||
and more flexible pricing for developers.
|
||||
|
||||
91
app/admin/filtered-traffic/page.tsx
Normal file
91
app/admin/filtered-traffic/page.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { LoadingOverlay } from '@ciphera-net/ui'
|
||||
import { getFilteredReferrers, FilteredReferrer } from '@/lib/api/admin'
|
||||
|
||||
export default function FilteredTrafficPage() {
|
||||
const [referrers, setReferrers] = useState<FilteredReferrer[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [days, setDays] = useState(30)
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
const endDate = new Date().toISOString().split('T')[0]
|
||||
const startDate = new Date(Date.now() - days * 86400000).toISOString().split('T')[0]
|
||||
getFilteredReferrers(startDate, endDate)
|
||||
.then(setReferrers)
|
||||
.finally(() => setLoading(false))
|
||||
}, [days])
|
||||
|
||||
if (loading) {
|
||||
return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Loading filtered traffic..." />
|
||||
}
|
||||
|
||||
const totalBlocked = referrers.reduce((sum, r) => sum + r.count, 0)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white">Filtered Traffic</h2>
|
||||
<p className="text-sm text-neutral-400 mt-1">
|
||||
{totalBlocked.toLocaleString()} spam referrers blocked in the last {days} days
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{[7, 30, 90].map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
onClick={() => setDays(d)}
|
||||
className={`px-3 py-1.5 text-sm rounded-lg transition-colors ${
|
||||
days === d
|
||||
? 'bg-neutral-900 text-white dark:bg-white dark:text-neutral-900'
|
||||
: 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200 dark:bg-neutral-800 dark:text-neutral-400 dark:hover:bg-neutral-700'
|
||||
}`}
|
||||
>
|
||||
{d}d
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 shadow-sm overflow-hidden">
|
||||
{referrers.length === 0 ? (
|
||||
<div className="p-12 text-center text-neutral-400">
|
||||
No filtered referrers in this period
|
||||
</div>
|
||||
) : (
|
||||
<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-400">Domain</th>
|
||||
<th className="px-4 py-3 font-medium text-neutral-400">Reason</th>
|
||||
<th className="px-4 py-3 font-medium text-neutral-400 text-right">Blocked</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-800">
|
||||
{referrers.map((r) => (
|
||||
<tr key={`${r.domain}-${r.reason}`} className="hover:bg-neutral-50 dark:hover:bg-neutral-900/50">
|
||||
<td className="px-4 py-3 text-white font-mono text-xs">{r.domain}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
r.reason === 'blocklist'
|
||||
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
|
||||
: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'
|
||||
}`}>
|
||||
{r.reason}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-white tabular-nums">
|
||||
{r.count.toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -37,7 +37,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
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>
|
||||
<h1 className="text-2xl font-bold text-white">Pulse Admin</h1>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -4,13 +4,7 @@ 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' })
|
||||
}
|
||||
import { formatDate, formatDateTime } from '@/lib/utils/formatDate'
|
||||
function addMonths(d: Date, months: number) {
|
||||
const out = new Date(d)
|
||||
out.setMonth(out.getMonth() + months)
|
||||
@@ -113,7 +107,7 @@ export default function AdminOrgDetailPage() {
|
||||
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">
|
||||
<h2 className="text-2xl font-bold text-white">
|
||||
{org.business_name || 'Unnamed Organization'}
|
||||
</h2>
|
||||
<span className="text-sm font-mono text-neutral-500">{org.organization_id}</span>
|
||||
@@ -122,7 +116,7 @@ export default function AdminOrgDetailPage() {
|
||||
<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>
|
||||
<h3 className="text-lg font-semibold 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>
|
||||
@@ -141,17 +135,17 @@ export default function AdminOrgDetailPage() {
|
||||
{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>
|
||||
<span className="text-neutral-500">Customer ID:</span>
|
||||
<span className="font-mono text-xs">{org.billing_customer_id || '-'}</span>
|
||||
|
||||
<span className="text-neutral-500">Subscription ID:</span>
|
||||
<span className="font-mono text-xs">{org.billing_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>
|
||||
<h3 className="text-lg font-semibold 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">
|
||||
@@ -166,7 +160,7 @@ export default function AdminOrgDetailPage() {
|
||||
|
||||
{/* 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>
|
||||
<h3 className="text-lg font-semibold 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">
|
||||
@@ -202,7 +196,7 @@ export default function AdminOrgDetailPage() {
|
||||
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"
|
||||
className="w-full px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-lg bg-white dark:bg-neutral-900 text-white focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-offset-2"
|
||||
required
|
||||
/>
|
||||
<div className="flex gap-2 mt-1">
|
||||
|
||||
@@ -4,10 +4,7 @@ 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' })
|
||||
}
|
||||
import { formatDate } from '@/lib/utils/formatDate'
|
||||
|
||||
function CopyableOrgId({ id }: { id: string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
@@ -46,28 +43,28 @@ export default function AdminOrgsPage() {
|
||||
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>
|
||||
<h2 className="text-xl font-semibold 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>
|
||||
<h3 className="text-lg font-semibold 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>
|
||||
<th className="px-4 py-3 font-medium text-neutral-400">Name</th>
|
||||
<th className="px-4 py-3 font-medium text-neutral-400">Org ID</th>
|
||||
<th className="px-4 py-3 font-medium text-neutral-400">Plan</th>
|
||||
<th className="px-4 py-3 font-medium text-neutral-400">Status</th>
|
||||
<th className="px-4 py-3 font-medium text-neutral-400">Limit</th>
|
||||
<th className="px-4 py-3 font-medium text-neutral-400">Updated</th>
|
||||
<th className="px-4 py-3 font-medium 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">
|
||||
<td className="px-4 py-3 text-white font-medium">
|
||||
{org.business_name || 'N/A'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
|
||||
@@ -9,12 +9,22 @@ export default function AdminDashboard() {
|
||||
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">
|
||||
<h3 className="text-lg font-semibold text-white">Organizations</h3>
|
||||
<p className="text-sm text-neutral-400 mt-1">Manage organization plans and limits</p>
|
||||
<p className="text-sm text-neutral-400 mt-4">
|
||||
View all organizations, check billing status, and manually grant plans.
|
||||
</p>
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/filtered-traffic"
|
||||
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-white">Filtered Traffic</h3>
|
||||
<p className="text-sm text-neutral-400 mt-1">Monitor blocked referrer spam</p>
|
||||
<p className="text-sm text-neutral-400 mt-4">
|
||||
View domains blocked by the spam filter and check for false positives.
|
||||
</p>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,18 @@ export async function POST() {
|
||||
return NextResponse.json({ error: 'No refresh token' }, { status: 401 })
|
||||
}
|
||||
|
||||
// * Read org_id from existing access token (if still present) before refreshing
|
||||
let previousOrgId: string | null = null
|
||||
const existingToken = cookieStore.get('access_token')?.value
|
||||
if (existingToken) {
|
||||
try {
|
||||
const payload = JSON.parse(Buffer.from(existingToken.split('.')[1], 'base64').toString())
|
||||
previousOrgId = payload.org_id || null
|
||||
} catch { /* token may be malformed, proceed without org */ }
|
||||
}
|
||||
|
||||
try {
|
||||
// * Step 1: Refresh the base token
|
||||
const res = await fetch(`${AUTH_API_URL}/api/v1/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -29,11 +40,58 @@ export async function POST() {
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
let finalAccessToken = data.access_token
|
||||
|
||||
// * Get CSRF token from Auth API response header (for cookie rotation)
|
||||
// * Get CSRF token from Auth API refresh response (needed for switch-context call)
|
||||
const csrfToken = res.headers.get('X-CSRF-Token')
|
||||
// * Also check for CSRF token in the cookie store (browser may have sent it)
|
||||
const csrfFromCookie = cookieStore.get('csrf_token')?.value
|
||||
const csrfForRequests = csrfToken || csrfFromCookie || ''
|
||||
|
||||
cookieStore.set('access_token', data.access_token, {
|
||||
// * Step 2: Restore organization context
|
||||
// * The auth service's refresh endpoint returns a "base" token without org_id.
|
||||
// * We need to call switch-context to get an org-scoped token so that
|
||||
// * Pulse API requests don't fail with 403 after a mid-session refresh.
|
||||
let orgId = previousOrgId
|
||||
|
||||
if (!orgId) {
|
||||
// * No org_id from old token — look up user's organizations
|
||||
try {
|
||||
const orgsRes = await fetch(`${AUTH_API_URL}/api/v1/auth/organizations`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${finalAccessToken}`,
|
||||
},
|
||||
})
|
||||
if (orgsRes.ok) {
|
||||
const orgsData = await orgsRes.json()
|
||||
if (orgsData.organizations?.length > 0) {
|
||||
orgId = orgsData.organizations[0].organization_id
|
||||
}
|
||||
}
|
||||
} catch { /* proceed with base token */ }
|
||||
}
|
||||
|
||||
if (orgId) {
|
||||
try {
|
||||
const switchRes = await fetch(`${AUTH_API_URL}/api/v1/auth/switch-context`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${finalAccessToken}`,
|
||||
'X-CSRF-Token': csrfForRequests,
|
||||
'Cookie': `csrf_token=${csrfForRequests}`,
|
||||
},
|
||||
body: JSON.stringify({ organization_id: orgId }),
|
||||
})
|
||||
if (switchRes.ok) {
|
||||
const switchData = await switchRes.json()
|
||||
finalAccessToken = switchData.access_token
|
||||
}
|
||||
} catch { /* proceed with base token */ }
|
||||
}
|
||||
|
||||
cookieStore.set('access_token', finalAccessToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
@@ -63,7 +121,7 @@ export async function POST() {
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, access_token: data.access_token })
|
||||
return NextResponse.json({ success: true, access_token: finalAccessToken })
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: 'Internal error' }, { status: 500 })
|
||||
}
|
||||
|
||||
@@ -22,7 +22,14 @@ function AuthCallbackContent() {
|
||||
const codeVerifier = localStorage.getItem('oauth_code_verifier')
|
||||
const redirectUri = typeof window !== 'undefined' ? window.location.origin + '/auth/callback' : ''
|
||||
if (!code) return
|
||||
const result = await exchangeAuthCode(code, codeVerifier, redirectUri)
|
||||
let result: Awaited<ReturnType<typeof exchangeAuthCode>>
|
||||
try {
|
||||
result = await exchangeAuthCode(code, codeVerifier, redirectUri)
|
||||
} catch {
|
||||
// * Stale build or network error — show error so user can retry via full navigation
|
||||
setError('Something went wrong. Please try logging in again.')
|
||||
return
|
||||
}
|
||||
if (result.success && result.user) {
|
||||
// * Fetch full profile (including display_name) before navigating so header shows correct name on first paint
|
||||
try {
|
||||
|
||||
@@ -18,7 +18,7 @@ export default function ChangelogPage() {
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-4 sm:px-6 py-8">
|
||||
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-neutral-900 dark:text-white mb-2">
|
||||
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-white mb-2">
|
||||
Changelog
|
||||
</h1>
|
||||
<p className="text-neutral-600 dark:text-neutral-400 mb-8 text-sm">
|
||||
|
||||
8
app/checkout/layout.tsx
Normal file
8
app/checkout/layout.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
export const metadata = {
|
||||
title: 'Checkout — Pulse',
|
||||
robots: 'noindex, nofollow',
|
||||
}
|
||||
|
||||
export default function CheckoutLayout({ children }: { children: React.ReactNode }) {
|
||||
return children
|
||||
}
|
||||
252
app/checkout/page.tsx
Normal file
252
app/checkout/page.tsx
Normal file
@@ -0,0 +1,252 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense, useEffect, useState } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { motion } from 'framer-motion'
|
||||
import { useAuth } from '@/lib/auth/context'
|
||||
import { useSubscription } from '@/lib/swr/dashboard'
|
||||
import { getSubscription } from '@/lib/api/billing'
|
||||
import { PLAN_PRICES, TRAFFIC_TIERS } from '@/lib/plans'
|
||||
import PlanSummary from '@/components/checkout/PlanSummary'
|
||||
import PaymentForm from '@/components/checkout/PaymentForm'
|
||||
import FeatureSlideshow from '@/components/checkout/FeatureSlideshow'
|
||||
import pulseIcon from '@/public/pulse_icon_no_margins.png'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validation helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const VALID_PLANS = new Set(Object.keys(PLAN_PRICES))
|
||||
const VALID_INTERVALS = new Set(['month', 'year'])
|
||||
const VALID_LIMITS = new Set<number>(TRAFFIC_TIERS.map((t) => t.value))
|
||||
|
||||
function isValidCheckoutParams(plan: string | null, interval: string | null, limit: string | null) {
|
||||
if (!plan || !interval || !limit) return false
|
||||
const limitNum = Number(limit)
|
||||
if (!VALID_PLANS.has(plan)) return false
|
||||
if (!VALID_INTERVALS.has(interval)) return false
|
||||
if (!VALID_LIMITS.has(limitNum)) return false
|
||||
if (!PLAN_PRICES[plan]?.[limitNum]) return false
|
||||
return true
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Success polling component (post-3DS return)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function CheckoutSuccess() {
|
||||
const router = useRouter()
|
||||
const [ready, setReady] = useState(false)
|
||||
const [timedOut, setTimedOut] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const timeout = setTimeout(() => setTimedOut(true), 30000)
|
||||
|
||||
const poll = async () => {
|
||||
for (let i = 0; i < 15; i++) {
|
||||
if (cancelled) return
|
||||
try {
|
||||
const data = await getSubscription()
|
||||
if (data.subscription_status === 'active' || data.subscription_status === 'trialing') {
|
||||
setReady(true)
|
||||
clearTimeout(timeout)
|
||||
setTimeout(() => router.push('/'), 2000)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// ignore — keep polling
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 2000))
|
||||
}
|
||||
setTimedOut(true)
|
||||
}
|
||||
poll()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}, [router])
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="text-center"
|
||||
>
|
||||
{ready ? (
|
||||
<>
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-emerald-500/20">
|
||||
<svg className="h-8 w-8 text-emerald-400" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-white">You're all set!</h2>
|
||||
<p className="mt-2 text-sm text-zinc-400">Redirecting to dashboard...</p>
|
||||
</>
|
||||
) : timedOut ? (
|
||||
<>
|
||||
<h2 className="text-xl font-semibold text-white">Taking longer than expected</h2>
|
||||
<p className="mt-2 text-sm text-zinc-400">
|
||||
Your payment was received. It may take a moment to activate.
|
||||
</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="mt-4 inline-block text-sm font-medium text-blue-400 hover:text-blue-300 transition-colors"
|
||||
>
|
||||
Go to dashboard
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-2 border-zinc-600 border-t-white" />
|
||||
<h2 className="text-xl font-semibold text-white">Setting up your subscription...</h2>
|
||||
<p className="mt-2 text-sm text-zinc-400">This usually takes a few seconds.</p>
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main checkout content (reads searchParams)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function CheckoutContent() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { user, loading: authLoading } = useAuth()
|
||||
const { data: subscription } = useSubscription()
|
||||
const [country, setCountry] = useState('')
|
||||
const [vatId, setVatId] = useState('')
|
||||
|
||||
const status = searchParams.get('status')
|
||||
const plan = searchParams.get('plan')
|
||||
const interval = searchParams.get('interval')
|
||||
const limit = searchParams.get('limit')
|
||||
|
||||
// -- Auth guard --
|
||||
useEffect(() => {
|
||||
if (!authLoading && !user) {
|
||||
const returnUrl = encodeURIComponent(window.location.pathname + window.location.search)
|
||||
router.replace(`/login?redirect=${returnUrl}`)
|
||||
}
|
||||
}, [authLoading, user, router])
|
||||
|
||||
// -- Subscription guard (skip on success page — it handles its own redirect) --
|
||||
useEffect(() => {
|
||||
if (status === 'success') return
|
||||
if (subscription && (subscription.subscription_status === 'active' || subscription.subscription_status === 'trialing')) {
|
||||
router.replace('/')
|
||||
}
|
||||
}, [subscription, status, router])
|
||||
|
||||
// -- Param validation --
|
||||
useEffect(() => {
|
||||
if (status === 'success') return // success state doesn't need plan params
|
||||
if (!authLoading && user && !isValidCheckoutParams(plan, interval, limit)) {
|
||||
router.replace('/pricing')
|
||||
}
|
||||
}, [authLoading, user, plan, interval, limit, status, router])
|
||||
|
||||
// -- Post-3DS success --
|
||||
if (status === 'success') {
|
||||
return <CheckoutSuccess />
|
||||
}
|
||||
|
||||
// -- Loading state --
|
||||
if (authLoading || !user || !isValidCheckoutParams(plan, interval, limit)) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-zinc-600 border-t-white" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const planId = plan!
|
||||
const billingInterval = interval as 'month' | 'year'
|
||||
const pageviewLimit = Number(limit)
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
{/* Left — Feature slideshow (hidden on mobile) */}
|
||||
<div className="hidden lg:flex lg:w-1/2 relative h-full overflow-hidden">
|
||||
<FeatureSlideshow />
|
||||
</div>
|
||||
|
||||
{/* Right — Payment (scrollable) */}
|
||||
<div className="w-full lg:w-1/2 flex flex-col h-full overflow-y-auto">
|
||||
{/* Logo on mobile only (desktop logo is on the left panel) */}
|
||||
<div className="px-6 py-5 lg:hidden">
|
||||
<Link href="/pricing" className="flex items-center gap-2 w-fit hover:opacity-80 transition-opacity">
|
||||
<Image
|
||||
src={pulseIcon}
|
||||
alt="Pulse"
|
||||
width={36}
|
||||
height={36}
|
||||
unoptimized
|
||||
className="object-contain w-8 h-8"
|
||||
/>
|
||||
<span className="text-xl font-bold text-white tracking-tight">Pulse</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex flex-1 flex-col px-4 pb-12 pt-6 lg:pt-10 sm:px-6 lg:px-10">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.45, ease: 'easeOut' }}
|
||||
className="w-full max-w-lg mx-auto flex flex-col gap-6"
|
||||
>
|
||||
{/* Plan summary (compact) */}
|
||||
<PlanSummary
|
||||
plan={planId}
|
||||
interval={billingInterval}
|
||||
limit={pageviewLimit}
|
||||
country={country}
|
||||
vatId={vatId}
|
||||
onCountryChange={setCountry}
|
||||
onVatIdChange={setVatId}
|
||||
/>
|
||||
|
||||
{/* Payment form */}
|
||||
<PaymentForm
|
||||
plan={planId}
|
||||
interval={billingInterval}
|
||||
limit={pageviewLimit}
|
||||
country={country}
|
||||
vatId={vatId}
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page wrapper with Suspense (required for useSearchParams in App Router)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function CheckoutPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-zinc-900/40 via-zinc-950 to-zinc-950">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-zinc-600 border-t-white" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<CheckoutContent />
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
108
app/faq/page.tsx
108
app/faq/page.tsx
@@ -1,37 +1,18 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'framer-motion'
|
||||
import { useState } from 'react'
|
||||
import { ChevronDownIcon } from '@ciphera-net/ui'
|
||||
|
||||
const faqs = [
|
||||
{
|
||||
question: "Is Pulse GDPR compliant?",
|
||||
answer: "Yes, Pulse is GDPR compliant by design. We don't use cookies, don't collect personal data, and process all data anonymously."
|
||||
},
|
||||
{
|
||||
question: "Do I need a cookie consent banner?",
|
||||
answer: "No, you don't need a cookie consent banner. Pulse doesn't use cookies, so it's exempt from cookie consent requirements under GDPR."
|
||||
},
|
||||
{
|
||||
question: "How does Pulse track visitors?",
|
||||
answer: "We use a lightweight JavaScript snippet that sends anonymous pageview events. No cookies, no cross-session identifiers (we use sessionStorage only to group events within a single visit), and no cross-site tracking."
|
||||
},
|
||||
{
|
||||
question: "What data does Pulse collect?",
|
||||
answer: "We collect anonymous pageview data including page path, referrer, device type, browser, and country (derived from IP at request time; the IP itself is not stored). No personal information is collected."
|
||||
},
|
||||
{
|
||||
question: "How accurate is the data?",
|
||||
answer: "Our data is highly accurate. We exclude bot traffic and data center visits. Since we don't use cookies, we count unique sessions rather than unique users."
|
||||
},
|
||||
{
|
||||
question: "Can I export my data?",
|
||||
answer: "Yes, you can access all your analytics data through the dashboard. We're working on export functionality for bulk data downloads."
|
||||
}
|
||||
]
|
||||
import PulseFAQ from '@/components/marketing/PulseFAQ'
|
||||
|
||||
// * JSON-LD FAQ Schema for rich snippets
|
||||
const faqs = [
|
||||
{ question: "Is Pulse GDPR compliant?", answer: "Yes, Pulse is GDPR compliant by design. We don't use cookies, don't collect personal data, and process all data anonymously." },
|
||||
{ question: "Do I need a cookie consent banner?", answer: "No, you don't need a cookie consent banner. Pulse doesn't use cookies, so it's exempt from cookie consent requirements under GDPR." },
|
||||
{ question: "How does Pulse track visitors?", answer: "We use a lightweight JavaScript snippet that sends anonymous pageview events. No cookies, no cross-session identifiers (we use sessionStorage only to group events within a single visit), and no cross-site tracking." },
|
||||
{ question: "What data does Pulse collect?", answer: "We collect anonymous pageview data including page path, referrer, device type, browser, and country (derived from IP at request time; the IP itself is not stored). No personal information is collected." },
|
||||
{ question: "How accurate is the data?", answer: "Our data is highly accurate. We exclude bot traffic and data center visits. Since we don't use cookies, we count unique sessions rather than unique users." },
|
||||
{ question: "Can I export my data?", answer: "Yes, you can access all your analytics data through the dashboard. We're working on export functionality for bulk data downloads." },
|
||||
]
|
||||
|
||||
const faqSchema = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
@@ -45,47 +26,6 @@ const faqSchema = {
|
||||
})),
|
||||
}
|
||||
|
||||
function FAQItem({ faq, index }: { faq: typeof faqs[0]; index: number }) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: index * 0.05 }}
|
||||
className="border-b border-neutral-200 dark:border-neutral-800"
|
||||
>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="w-full py-6 flex items-center justify-between text-left hover:text-brand-orange transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-offset-2"
|
||||
>
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white pr-4">
|
||||
{faq.question}
|
||||
</h3>
|
||||
<ChevronDownIcon
|
||||
className={`w-5 h-5 text-neutral-500 shrink-0 transition-transform duration-300 ${
|
||||
isOpen ? 'rotate-180' : ''
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="pb-6"
|
||||
>
|
||||
<p className="text-neutral-600 dark:text-neutral-400 leading-relaxed">
|
||||
{faq.answer}
|
||||
</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function FAQPage() {
|
||||
return (
|
||||
<>
|
||||
@@ -94,29 +34,9 @@ export default function FAQPage() {
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqSchema) }}
|
||||
/>
|
||||
|
||||
<div className="container mx-auto px-4 py-16 max-w-4xl">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="text-center mb-16"
|
||||
>
|
||||
<span className="badge-primary mb-4 inline-flex">FAQ</span>
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-neutral-900 dark:text-white mb-4">
|
||||
Frequently asked questions
|
||||
</h1>
|
||||
<p className="text-lg text-neutral-600 dark:text-neutral-400 max-w-2xl mx-auto">
|
||||
Learn more about how Pulse respects your privacy and handles your data.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="max-w-3xl mx-auto">
|
||||
{faqs.map((faq, index) => (
|
||||
<FAQItem key={faq.question} faq={faq} index={index} />
|
||||
))}
|
||||
</div>
|
||||
<div className="pt-8 pb-16">
|
||||
<PulseFAQ />
|
||||
|
||||
{/* * CTA */}
|
||||
<motion.div
|
||||
@@ -126,12 +46,12 @@ export default function FAQPage() {
|
||||
transition={{ duration: 0.5, delay: 0.3 }}
|
||||
className="text-center mt-12"
|
||||
>
|
||||
<p className="text-neutral-600 dark:text-neutral-400 mb-4">
|
||||
<p className="text-neutral-400 mb-4">
|
||||
Still have questions?
|
||||
</p>
|
||||
<a
|
||||
href="mailto:support@ciphera.net"
|
||||
className="inline-flex items-center justify-center gap-2 bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 text-neutral-900 dark:text-white px-5 py-2.5 rounded-xl font-medium hover:bg-neutral-50 dark:hover:bg-neutral-800 shadow-sm hover:shadow-md dark:shadow-none transition-all duration-200"
|
||||
className="inline-flex items-center justify-center gap-2 bg-neutral-900 border border-neutral-800 text-white px-5 py-2.5 rounded-xl font-medium hover:bg-neutral-800 transition-all duration-200"
|
||||
>
|
||||
Contact us
|
||||
</a>
|
||||
|
||||
@@ -83,12 +83,12 @@ const capabilities = [
|
||||
description: 'Automatically parse UTM parameters. Built-in link builder for campaigns, sources, and mediums.',
|
||||
},
|
||||
{
|
||||
icon: Share2Icon,
|
||||
icon: <Share2Icon className="w-5 h-5" />,
|
||||
title: 'Shared Dashboards',
|
||||
description: 'Generate a public link to share analytics with clients or teammates — no login required.',
|
||||
},
|
||||
{
|
||||
icon: GlobeIcon,
|
||||
icon: <GlobeIcon className="w-5 h-5" />,
|
||||
title: 'Geographic Insights',
|
||||
description: 'Country, region, and city-level breakdowns. IPs are never stored — derived at request time only.',
|
||||
},
|
||||
@@ -109,10 +109,9 @@ export default function FeaturesPage() {
|
||||
<div className="relative min-h-screen flex flex-col overflow-hidden">
|
||||
{/* * --- ATMOSPHERE (Background) --- */}
|
||||
<div className="absolute inset-0 -z-10 pointer-events-none">
|
||||
<div className="absolute top-0 left-1/4 w-[500px] h-[500px] bg-brand-orange/10 rounded-full blur-[128px] opacity-60" />
|
||||
<div className="absolute bottom-0 right-1/4 w-[500px] h-[500px] bg-neutral-500/10 dark:bg-neutral-400/10 rounded-full blur-[128px] opacity-40" />
|
||||
<div className="absolute bottom-0 right-1/4 w-[500px] h-[500px] bg-neutral-400/10 rounded-full blur-[128px] opacity-40" />
|
||||
<div
|
||||
className="absolute inset-0 bg-grid-pattern opacity-[0.02] dark:opacity-[0.05]"
|
||||
className="absolute inset-0 bg-grid-pattern opacity-[0.05]"
|
||||
style={{ maskImage: 'radial-gradient(ellipse at center, black 0%, transparent 70%)' }}
|
||||
/>
|
||||
</div>
|
||||
@@ -129,11 +128,11 @@ export default function FeaturesPage() {
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-brand-orange animate-pulse" />
|
||||
Product Tour
|
||||
</span>
|
||||
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-neutral-900 dark:text-white mb-6">
|
||||
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-white mb-6">
|
||||
Everything you need. <br />
|
||||
<span className="gradient-text">Nothing you don't.</span>
|
||||
</h1>
|
||||
<p className="text-xl text-neutral-600 dark:text-neutral-400 max-w-2xl mx-auto leading-relaxed">
|
||||
<p className="text-xl text-neutral-400 max-w-2xl mx-auto leading-relaxed">
|
||||
Pulse gives you meaningful analytics without the complexity, the cookies, or the privacy trade-offs.
|
||||
</p>
|
||||
</motion.div>
|
||||
@@ -152,10 +151,10 @@ export default function FeaturesPage() {
|
||||
<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" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-neutral-900 dark:text-white mb-3">
|
||||
<h3 className="text-xl font-bold text-white mb-3">
|
||||
{feature.title}
|
||||
</h3>
|
||||
<p className="text-neutral-600 dark:text-neutral-400 leading-relaxed">
|
||||
<p className="text-neutral-400 leading-relaxed">
|
||||
{feature.description}
|
||||
</p>
|
||||
</motion.div>
|
||||
@@ -171,10 +170,10 @@ export default function FeaturesPage() {
|
||||
className="mb-28"
|
||||
>
|
||||
<div className="text-center mb-14">
|
||||
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-4">
|
||||
<h2 className="text-2xl font-bold text-white mb-4">
|
||||
Powerful analytics, <span className="gradient-text">simplified</span>
|
||||
</h2>
|
||||
<p className="text-lg text-neutral-600 dark:text-neutral-400 max-w-2xl mx-auto">
|
||||
<p className="text-lg text-neutral-400 max-w-2xl mx-auto">
|
||||
Everything from real-time dashboards to conversion funnels — without the bloat.
|
||||
</p>
|
||||
</div>
|
||||
@@ -190,13 +189,13 @@ export default function FeaturesPage() {
|
||||
className="flex gap-4"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-lg bg-brand-orange/10 flex items-center justify-center shrink-0 text-brand-orange mt-0.5">
|
||||
{typeof cap.icon === 'object' ? cap.icon : <cap.icon className="w-5 h-5" />}
|
||||
{cap.icon}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-neutral-900 dark:text-white mb-1">
|
||||
<h3 className="font-bold text-white mb-1">
|
||||
{cap.title}
|
||||
</h3>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400 leading-relaxed">
|
||||
<p className="text-sm text-neutral-400 leading-relaxed">
|
||||
{cap.description}
|
||||
</p>
|
||||
</div>
|
||||
@@ -211,14 +210,14 @@ export default function FeaturesPage() {
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="mb-28 p-10 md:p-14 bg-white/50 dark:bg-neutral-900/50 backdrop-blur-sm border border-neutral-200 dark:border-neutral-800 rounded-2xl"
|
||||
className="mb-28 p-10 md:p-14 bg-neutral-900/50 backdrop-blur-sm border border-neutral-800 rounded-2xl"
|
||||
>
|
||||
<div className="grid md:grid-cols-2 gap-10 items-center">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-4">
|
||||
<h2 className="text-2xl font-bold text-white mb-4">
|
||||
Content that <span className="gradient-text">performs</span>
|
||||
</h2>
|
||||
<p className="text-neutral-600 dark:text-neutral-400 leading-relaxed mb-6">
|
||||
<p className="text-neutral-400 leading-relaxed mb-6">
|
||||
See which pages drive the most traffic, where visitors enter your site, and where they leave. Use data to double down on what works.
|
||||
</p>
|
||||
<ul className="space-y-3">
|
||||
@@ -229,7 +228,7 @@ export default function FeaturesPage() {
|
||||
'Referral sources — where traffic comes from',
|
||||
'Browser, OS & device breakdowns',
|
||||
].map((item) => (
|
||||
<li key={item} className="flex items-start gap-3 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
<li key={item} className="flex items-start gap-3 text-sm text-neutral-400">
|
||||
<svg className="w-5 h-5 text-brand-orange shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
|
||||
</svg>
|
||||
@@ -251,17 +250,17 @@ export default function FeaturesPage() {
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.4, delay: i * 0.1 }}
|
||||
className="p-4 bg-neutral-50 dark:bg-neutral-800/50 rounded-xl"
|
||||
className="p-4 bg-neutral-800/50 rounded-xl"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-neutral-900 dark:text-white truncate mr-4">
|
||||
<span className="text-sm font-medium text-white truncate mr-4">
|
||||
{page.label}
|
||||
</span>
|
||||
<span className="text-sm text-neutral-500 dark:text-neutral-400 shrink-0">
|
||||
<span className="text-sm text-neutral-400 shrink-0">
|
||||
{page.views} views
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-neutral-200 dark:bg-neutral-700 rounded-full overflow-hidden">
|
||||
<div className="h-1.5 bg-neutral-700 rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
whileInView={{ width: `${page.pct}%` }}
|
||||
@@ -285,10 +284,10 @@ export default function FeaturesPage() {
|
||||
className="mb-28"
|
||||
>
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-4">
|
||||
<h2 className="text-2xl font-bold text-white mb-4">
|
||||
Built for trust
|
||||
</h2>
|
||||
<p className="text-lg text-neutral-600 dark:text-neutral-400 max-w-2xl mx-auto">
|
||||
<p className="text-lg text-neutral-400 max-w-2xl mx-auto">
|
||||
Open source, Swiss hosted, and designed to keep your visitors' data where it belongs.
|
||||
</p>
|
||||
</div>
|
||||
@@ -307,8 +306,8 @@ export default function FeaturesPage() {
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
|
||||
</svg>
|
||||
<div>
|
||||
<span className="font-semibold text-neutral-900 dark:text-white text-sm">{signal.label}</span>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-0.5">{signal.detail}</p>
|
||||
<span className="font-semibold text-white text-sm">{signal.label}</span>
|
||||
<p className="text-xs text-neutral-400 mt-0.5">{signal.detail}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
@@ -341,10 +340,10 @@ export default function FeaturesPage() {
|
||||
className="mb-28"
|
||||
>
|
||||
<div className="text-center mb-14">
|
||||
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-4">
|
||||
<h2 className="text-2xl font-bold text-white mb-4">
|
||||
Up and running in <span className="gradient-text">3 minutes</span>
|
||||
</h2>
|
||||
<p className="text-lg text-neutral-600 dark:text-neutral-400 max-w-2xl mx-auto">
|
||||
<p className="text-lg text-neutral-400 max-w-2xl mx-auto">
|
||||
No SDKs to install, no build steps, no configuration files.
|
||||
</p>
|
||||
</div>
|
||||
@@ -367,15 +366,15 @@ export default function FeaturesPage() {
|
||||
{s.step}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-neutral-900 dark:text-white text-sm">
|
||||
<h3 className="font-bold text-white text-sm">
|
||||
{s.title}
|
||||
</h3>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
<p className="text-xs text-neutral-400">
|
||||
{s.desc}
|
||||
</p>
|
||||
</div>
|
||||
{i < 2 && (
|
||||
<ArrowRightIcon className="w-5 h-5 text-neutral-300 dark:text-neutral-600 shrink-0 hidden md:block" />
|
||||
<ArrowRightIcon className="w-5 h-5 text-neutral-600 shrink-0 hidden md:block" />
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
@@ -390,10 +389,10 @@ export default function FeaturesPage() {
|
||||
transition={{ duration: 0.5 }}
|
||||
className="text-center mb-20"
|
||||
>
|
||||
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-4">
|
||||
<h2 className="text-2xl font-bold text-white mb-4">
|
||||
Ready to see it in action?
|
||||
</h2>
|
||||
<p className="text-neutral-600 dark:text-neutral-400 mb-8 max-w-lg mx-auto">
|
||||
<p className="text-neutral-400 mb-8 max-w-lg mx-auto">
|
||||
Start for free. No credit card required. Cancel anytime.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
|
||||
|
||||
@@ -8,32 +8,30 @@ export default function InstallationPage() {
|
||||
|
||||
{/* * --- 1. ATMOSPHERE (Background) --- */}
|
||||
<div className="absolute inset-0 -z-10 pointer-events-none">
|
||||
{/* * Top-left Orange Glow */}
|
||||
<div className="absolute top-0 left-1/4 w-[500px] h-[500px] bg-brand-orange/10 rounded-full blur-[128px] opacity-60" />
|
||||
{/* * Bottom-right Neutral Glow */}
|
||||
<div className="absolute bottom-0 right-1/4 w-[500px] h-[500px] bg-neutral-500/10 dark:bg-neutral-400/10 rounded-full blur-[128px] opacity-40" />
|
||||
<div className="absolute bottom-0 right-1/4 w-[500px] h-[500px] bg-neutral-400/10 rounded-full blur-[128px] opacity-40" />
|
||||
{/* * Grid Pattern with Radial Mask */}
|
||||
<div
|
||||
className="absolute inset-0 bg-grid-pattern opacity-[0.02] dark:opacity-[0.05]"
|
||||
className="absolute inset-0 bg-grid-pattern opacity-[0.05]"
|
||||
style={{ maskImage: 'radial-gradient(ellipse at center, black 0%, transparent 70%)' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-grow w-full max-w-4xl mx-auto px-4 pt-20 pb-10 z-10">
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-neutral-900 dark:text-white mb-6">
|
||||
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-white mb-6">
|
||||
Installation
|
||||
</h1>
|
||||
<p className="text-xl text-neutral-600 dark:text-neutral-400 max-w-2xl mx-auto leading-relaxed">
|
||||
<p className="text-xl text-neutral-400 max-w-2xl mx-auto leading-relaxed">
|
||||
Get up and running with Pulse in seconds.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full text-center">
|
||||
<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-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>
|
||||
|
||||
<div className="max-w-2xl mx-auto bg-neutral-900 rounded-xl overflow-hidden shadow-2xl text-left border border-neutral-800">
|
||||
<div className="max-w-2xl mx-auto bg-neutral-900/80 rounded-xl overflow-hidden shadow-2xl text-left border border-white/[0.08]">
|
||||
<div className="flex items-center px-4 py-3 bg-neutral-800 border-b border-neutral-800">
|
||||
<div className="flex gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-red-500/20" />
|
||||
@@ -55,15 +53,22 @@ export default function InstallationPage() {
|
||||
<span className="text-blue-400">></script></span>
|
||||
</code>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 px-6 py-3 border-t border-neutral-800 text-xs text-neutral-500">
|
||||
<span>1.6 KB gzipped</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-green-500" />
|
||||
Non-blocking, async
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full mt-16 text-center">
|
||||
<h2 className="text-2xl font-bold mb-4 text-neutral-900 dark:text-white">Custom events (goals)</h2>
|
||||
<h2 className="text-2xl font-bold mb-4 text-white">Custom events (goals)</h2>
|
||||
<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-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>
|
||||
<div className="max-w-2xl mx-auto bg-neutral-900 rounded-xl overflow-hidden shadow-2xl text-left border border-neutral-800">
|
||||
<div className="max-w-2xl mx-auto bg-neutral-900/80 rounded-xl overflow-hidden shadow-2xl text-left border border-white/[0.08]">
|
||||
<div className="flex items-center px-4 py-3 bg-neutral-800 border-b border-neutral-800">
|
||||
<div className="flex gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-red-500/20" />
|
||||
|
||||
@@ -1,46 +1,70 @@
|
||||
/**
|
||||
* @file Dynamic route for individual integration guide pages.
|
||||
*
|
||||
* Handles all 50 integration routes via [slug].
|
||||
* Renders MDX content from content/integrations/*.mdx via next-mdx-remote.
|
||||
* Exports generateStaticParams for static generation and
|
||||
* generateMetadata for per-page SEO (title, description, OG, JSON-LD).
|
||||
*/
|
||||
|
||||
import type { Metadata } from 'next'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { MDXRemote } from 'next-mdx-remote/rsc'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import rehypeMdxCodeProps from 'rehype-mdx-code-props'
|
||||
import { CodeBlock } from '@ciphera-net/ui'
|
||||
import { integrations, getIntegration } from '@/lib/integrations'
|
||||
import { getGuideContent } from '@/lib/integration-guides'
|
||||
import { getIntegrationGuide } from '@/lib/integration-content'
|
||||
import { IntegrationGuide } from '@/components/IntegrationGuide'
|
||||
|
||||
// * ─── Static Params ───────────────────────────────────────────────
|
||||
export function generateStaticParams() {
|
||||
return integrations.map((i) => ({ slug: i.id }))
|
||||
// * ─── MDX Components ────────────────────────────────────────────
|
||||
// rehype-mdx-code-props passes meta (e.g. filename="app.tsx") as props
|
||||
// on the <pre> element. We intercept <pre> to extract filename and render CodeBlock.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const mdxComponents = {
|
||||
pre: ({ children, filename, ...props }: any) => {
|
||||
const code = children?.props?.children
|
||||
if (typeof code === 'string') {
|
||||
return (
|
||||
<CodeBlock filename={filename || 'code'}>
|
||||
{code.replace(/\n$/, '')}
|
||||
</CodeBlock>
|
||||
)
|
||||
}
|
||||
return <pre {...props}>{children}</pre>
|
||||
},
|
||||
}
|
||||
|
||||
// * ─── SEO Metadata ────────────────────────────────────────────────
|
||||
// * ─── Static Params ─────────────────────────────────────────────
|
||||
export function generateStaticParams() {
|
||||
return integrations
|
||||
.filter((i) => i.dedicatedPage)
|
||||
.map((i) => ({ slug: i.id }))
|
||||
}
|
||||
|
||||
// * ─── SEO Metadata ──────────────────────────────────────────────
|
||||
interface PageProps {
|
||||
params: Promise<{ slug: string }>
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { slug } = await params
|
||||
const integration = getIntegration(slug)
|
||||
if (!integration) return {}
|
||||
const guide = getIntegrationGuide(slug)
|
||||
if (!guide) return {}
|
||||
|
||||
const title = `How to Add Pulse Analytics to ${integration.name} | Pulse by Ciphera`
|
||||
const description = integration.seoDescription
|
||||
const url = `https://pulse.ciphera.net/integrations/${integration.id}`
|
||||
const title = `How to Add Pulse Analytics to ${guide.title} | Pulse by Ciphera`
|
||||
const description = guide.description
|
||||
const url = `https://pulse.ciphera.net/integrations/${guide.slug}`
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
keywords: [
|
||||
`${integration.name} analytics`,
|
||||
`${integration.name} Pulse`,
|
||||
`${guide.title} analytics`,
|
||||
`${guide.title} Pulse`,
|
||||
'privacy-first analytics',
|
||||
'website analytics',
|
||||
'Ciphera Pulse',
|
||||
integration.name,
|
||||
guide.title,
|
||||
],
|
||||
alternates: { canonical: url },
|
||||
openGraph: {
|
||||
@@ -58,21 +82,19 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
||||
}
|
||||
}
|
||||
|
||||
// * ─── Page Component ──────────────────────────────────────────────
|
||||
// * ─── Page Component ────────────────────────────────────────────
|
||||
export default async function IntegrationPage({ params }: PageProps) {
|
||||
const { slug } = await params
|
||||
const integration = getIntegration(slug)
|
||||
if (!integration) return notFound()
|
||||
|
||||
const content = getGuideContent(slug)
|
||||
if (!content) return notFound()
|
||||
const guide = getIntegrationGuide(slug)
|
||||
if (!integration || !guide) return notFound()
|
||||
|
||||
// * HowTo JSON-LD for rich search snippets
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'HowTo',
|
||||
name: `How to Add Pulse Analytics to ${integration.name}`,
|
||||
description: integration.seoDescription,
|
||||
description: guide.description,
|
||||
step: [
|
||||
{
|
||||
'@type': 'HowToStep',
|
||||
@@ -104,7 +126,11 @@ export default async function IntegrationPage({ params }: PageProps) {
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
<IntegrationGuide integration={integration}>
|
||||
{content}
|
||||
<MDXRemote
|
||||
source={guide.content}
|
||||
components={mdxComponents}
|
||||
options={{ mdxOptions: { remarkPlugins: [remarkGfm], rehypePlugins: [rehypeMdxCodeProps] } }}
|
||||
/>
|
||||
</IntegrationGuide>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { ArrowLeftIcon } from '@ciphera-net/ui'
|
||||
|
||||
export default function NextJsIntegrationPage() {
|
||||
return (
|
||||
<div className="relative min-h-screen flex flex-col overflow-hidden">
|
||||
{/* * --- ATMOSPHERE (Background) --- */}
|
||||
<div className="absolute inset-0 -z-10 pointer-events-none">
|
||||
<div className="absolute top-0 left-1/4 w-[500px] h-[500px] bg-brand-orange/10 rounded-full blur-[128px] opacity-60" />
|
||||
<div className="absolute bottom-0 right-1/4 w-[500px] h-[500px] bg-neutral-500/10 dark:bg-neutral-400/10 rounded-full blur-[128px] opacity-40" />
|
||||
<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="flex-grow w-full max-w-4xl mx-auto px-4 pt-20 pb-10 z-10">
|
||||
<Link
|
||||
href="/integrations"
|
||||
className="inline-flex items-center text-sm text-neutral-500 hover:text-brand-orange mb-8 transition-colors"
|
||||
>
|
||||
<ArrowLeftIcon className="w-4 h-4 mr-2" />
|
||||
Back to Integrations
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<div className="p-3 bg-neutral-100 dark:bg-neutral-800 rounded-xl">
|
||||
<svg viewBox="0 0 128 128" className="w-10 h-10 dark:invert">
|
||||
<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>
|
||||
</div>
|
||||
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-neutral-900 dark:text-white">
|
||||
Next.js Integration
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="prose prose-neutral dark:prose-invert max-w-none">
|
||||
<p className="lead text-xl text-neutral-600 dark:text-neutral-400">
|
||||
The best way to add Pulse to your Next.js application is using the built-in <code>next/script</code> component.
|
||||
</p>
|
||||
|
||||
<hr className="my-8 border-neutral-200 dark:border-neutral-800" />
|
||||
|
||||
<h3>Using App Router (Recommended)</h3>
|
||||
<p>
|
||||
Add the script to your root layout file (usually <code>app/layout.tsx</code> or <code>app/layout.js</code>).
|
||||
</p>
|
||||
|
||||
<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-neutral-800 border-b border-neutral-800">
|
||||
<span className="text-xs text-neutral-400 font-mono">app/layout.tsx</span>
|
||||
</div>
|
||||
<div className="p-4 overflow-x-auto">
|
||||
<pre className="text-sm font-mono text-neutral-300">
|
||||
{`import Script from 'next/script'
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<Script
|
||||
defer
|
||||
src="https://pulse.ciphera.net/script.js"
|
||||
data-domain="your-site.com"
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
</head>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
)
|
||||
}`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Using Pages Router</h3>
|
||||
<p>
|
||||
If you are using the older Pages Router, add the script to your custom <code>_app.tsx</code> or <code>_document.tsx</code>.
|
||||
</p>
|
||||
|
||||
<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-neutral-800 border-b border-neutral-800">
|
||||
<span className="text-xs text-neutral-400 font-mono">pages/_app.tsx</span>
|
||||
</div>
|
||||
<div className="p-4 overflow-x-auto">
|
||||
<pre className="text-sm font-mono text-neutral-300">
|
||||
{`import Script from 'next/script'
|
||||
import type { AppProps } from 'next/app'
|
||||
|
||||
export default function App({ Component, pageProps }: AppProps) {
|
||||
return (
|
||||
<>
|
||||
<Script
|
||||
defer
|
||||
src="https://pulse.ciphera.net/script.js"
|
||||
data-domain="your-site.com"
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
<Component {...pageProps} />
|
||||
</>
|
||||
)
|
||||
}`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Configuration Options</h3>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>data-domain</strong>: The domain name you added to your Pulse dashboard (e.g., <code>example.com</code>).
|
||||
</li>
|
||||
<li>
|
||||
<strong>src</strong>: The URL of our tracking script: <code>https://pulse.ciphera.net/script.js</code>
|
||||
</li>
|
||||
<li>
|
||||
<strong>strategy</strong>: We recommend <code>afterInteractive</code> to ensure it loads quickly without blocking hydration.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -93,10 +93,9 @@ export default function IntegrationsPage() {
|
||||
<div className="relative min-h-screen flex flex-col overflow-hidden">
|
||||
{/* * --- ATMOSPHERE (Background) --- */}
|
||||
<div className="absolute inset-0 -z-10 pointer-events-none">
|
||||
<div className="absolute top-0 left-1/4 w-[500px] h-[500px] bg-brand-orange/10 rounded-full blur-[128px] opacity-60" />
|
||||
<div className="absolute bottom-0 right-1/4 w-[500px] h-[500px] bg-neutral-500/10 dark:bg-neutral-400/10 rounded-full blur-[128px] opacity-40" />
|
||||
<div className="absolute bottom-0 right-1/4 w-[500px] h-[500px] bg-neutral-400/10 rounded-full blur-[128px] opacity-40" />
|
||||
<div
|
||||
className="absolute inset-0 bg-grid-pattern opacity-[0.02] dark:opacity-[0.05]"
|
||||
className="absolute inset-0 bg-grid-pattern opacity-[0.05]"
|
||||
style={{ maskImage: 'radial-gradient(ellipse at center, black 0%, transparent 70%)' }}
|
||||
/>
|
||||
</div>
|
||||
@@ -110,14 +109,14 @@ export default function IntegrationsPage() {
|
||||
>
|
||||
{/* * --- Title with count badge --- */}
|
||||
<div className="flex items-center justify-center gap-3 mb-6">
|
||||
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-neutral-900 dark:text-white">
|
||||
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-white">
|
||||
Integrations
|
||||
</h1>
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-semibold bg-brand-orange/10 text-brand-orange border border-brand-orange/20">
|
||||
{integrations.length}+
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xl text-neutral-600 dark:text-neutral-400 max-w-2xl mx-auto leading-relaxed mb-8">
|
||||
<p className="text-xl text-neutral-400 max-w-2xl mx-auto leading-relaxed mb-8">
|
||||
Connect Pulse with {integrations.length}+ frameworks and platforms in minutes.
|
||||
</p>
|
||||
|
||||
@@ -144,12 +143,12 @@ export default function IntegrationsPage() {
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search integrations..."
|
||||
className="w-full pl-12 pr-16 py-3 bg-white/70 dark:bg-neutral-900/70 backdrop-blur-sm border border-neutral-200 dark:border-neutral-800 rounded-xl text-neutral-900 dark:text-white placeholder:text-neutral-400 focus:outline-none focus:ring-2 focus:ring-brand-orange/50 focus:border-brand-orange/50 transition-all"
|
||||
className="w-full pl-12 pr-16 py-3 bg-neutral-900/70 backdrop-blur-sm border border-white/[0.08] rounded-xl text-white placeholder:text-neutral-400 focus:outline-none focus:ring-2 focus:ring-brand-orange/50 focus:border-brand-orange/50 transition-all"
|
||||
/>
|
||||
{query ? (
|
||||
<button
|
||||
onClick={() => setQuery('')}
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-4 text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors"
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-4 text-neutral-400 hover:text-neutral-600 hover:text-neutral-300 transition-colors"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
||||
@@ -158,7 +157,7 @@ export default function IntegrationsPage() {
|
||||
</button>
|
||||
) : (
|
||||
<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-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 className="hidden sm:inline-flex items-center px-1.5 py-0.5 rounded text-xs font-mono font-medium bg-neutral-700/80 text-neutral-400 border border-neutral-600">
|
||||
/
|
||||
</kbd>
|
||||
</div>
|
||||
@@ -170,7 +169,7 @@ export default function IntegrationsPage() {
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: -5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-sm text-neutral-500 dark:text-neutral-400 mt-3"
|
||||
className="text-sm text-neutral-400 mt-3"
|
||||
>
|
||||
{totalResults} {totalResults === 1 ? 'integration' : 'integrations'} found
|
||||
{query && <> for “{query}”</>}
|
||||
@@ -189,8 +188,8 @@ export default function IntegrationsPage() {
|
||||
onClick={() => handleCategoryClick('all')}
|
||||
className={`px-4 py-1.5 rounded-full text-sm font-medium transition-all ${
|
||||
activeCategory === 'all'
|
||||
? 'bg-brand-orange text-white shadow-sm'
|
||||
: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-200 dark:hover:bg-neutral-700'
|
||||
? 'bg-brand-orange-button text-white shadow-sm'
|
||||
: 'bg-neutral-800 text-neutral-400 hover:bg-neutral-700'
|
||||
}`}
|
||||
>
|
||||
All
|
||||
@@ -201,8 +200,8 @@ export default function IntegrationsPage() {
|
||||
onClick={() => handleCategoryClick(cat)}
|
||||
className={`px-4 py-1.5 rounded-full text-sm font-medium transition-all ${
|
||||
activeCategory === cat
|
||||
? 'bg-brand-orange text-white shadow-sm'
|
||||
: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-200 dark:hover:bg-neutral-700'
|
||||
? 'bg-brand-orange-button text-white shadow-sm'
|
||||
: 'bg-neutral-800 text-neutral-400 hover:bg-neutral-700'
|
||||
}`}
|
||||
>
|
||||
{categoryLabels[cat]}
|
||||
@@ -227,7 +226,7 @@ export default function IntegrationsPage() {
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="text-lg font-semibold text-neutral-500 dark:text-neutral-400 mb-6 tracking-wide uppercase flex items-center gap-2"
|
||||
className="text-lg font-semibold text-neutral-400 mb-6 tracking-wide uppercase flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5 text-brand-orange" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M10 1l2.39 4.84 5.34.78-3.87 3.77.91 5.33L10 13.27l-4.77 2.5.91-5.33L2.27 6.67l5.34-.78L10 1z" />
|
||||
@@ -245,13 +244,13 @@ export default function IntegrationsPage() {
|
||||
transition={{ duration: 0.4, delay: i * 0.05 }}
|
||||
>
|
||||
<Link
|
||||
href={`/integrations/${integration!.id}`}
|
||||
className="group flex items-center gap-3 p-4 bg-white/50 dark:bg-neutral-900/50 backdrop-blur-sm border border-neutral-200 dark:border-neutral-800 rounded-xl hover:border-brand-orange/50 dark:hover:border-brand-orange/50 transition-all duration-300 hover:-translate-y-0.5 hover:shadow-lg h-full"
|
||||
href={integration!.dedicatedPage ? `/integrations/${integration!.id}` : '/integrations/script-tag'}
|
||||
className="group flex items-center gap-3 p-4 bg-neutral-900/50 backdrop-blur-sm border border-neutral-800 rounded-xl hover:border-brand-orange/50 transition-all duration-300 hover:-translate-y-0.5 hover:shadow-lg h-full"
|
||||
>
|
||||
<div className="p-2 bg-neutral-100 dark:bg-neutral-800 rounded-lg shrink-0 group-hover:scale-110 transition-transform duration-300 [&_svg]:w-6 [&_svg]:h-6">
|
||||
<div className="p-2 bg-neutral-800 rounded-lg shrink-0 group-hover:scale-110 transition-transform duration-300 [&_svg]:w-6 [&_svg]:h-6">
|
||||
{integration!.icon}
|
||||
</div>
|
||||
<span className="font-semibold text-neutral-900 dark:text-white text-sm">
|
||||
<span className="font-semibold text-white text-sm">
|
||||
{integration!.name}
|
||||
</span>
|
||||
</Link>
|
||||
@@ -269,7 +268,7 @@ export default function IntegrationsPage() {
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="text-lg font-semibold text-neutral-500 dark:text-neutral-400 mb-6 tracking-wide uppercase"
|
||||
className="text-lg font-semibold text-neutral-400 mb-6 tracking-wide uppercase"
|
||||
>
|
||||
{group.label}
|
||||
</motion.h2>
|
||||
@@ -284,20 +283,20 @@ export default function IntegrationsPage() {
|
||||
transition={{ duration: 0.5, delay: i * 0.05 }}
|
||||
>
|
||||
<Link
|
||||
href={`/integrations/${integration.id}`}
|
||||
className="group relative p-6 bg-white/50 dark:bg-neutral-900/50 backdrop-blur-sm border border-neutral-200 dark:border-neutral-800 rounded-2xl hover:border-brand-orange/50 dark:hover:border-brand-orange/50 transition-all duration-300 hover:-translate-y-1 hover:shadow-xl block h-full focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-offset-2"
|
||||
href={integration.dedicatedPage ? `/integrations/${integration.id}` : '/integrations/script-tag'}
|
||||
className="group relative p-6 bg-neutral-900/50 backdrop-blur-sm border border-neutral-800 rounded-2xl hover:border-brand-orange/50 transition-all duration-300 hover:-translate-y-1 hover:shadow-xl block h-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:ring-offset-2"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
<div className="p-3 bg-neutral-100 dark:bg-neutral-800 rounded-xl group-hover:scale-110 transition-transform duration-300">
|
||||
<div className="p-3 bg-neutral-800 rounded-xl group-hover:scale-110 transition-transform duration-300">
|
||||
{integration.icon}
|
||||
</div>
|
||||
<ArrowRightIcon className="w-5 h-5 text-neutral-400 group-hover:text-brand-orange transition-colors" />
|
||||
</div>
|
||||
|
||||
<h3 className="text-xl font-bold text-neutral-900 dark:text-white mb-3">
|
||||
<h3 className="text-xl font-bold text-white mb-3">
|
||||
{integration.name}
|
||||
</h3>
|
||||
<p className="text-neutral-600 dark:text-neutral-400 leading-relaxed mb-4">
|
||||
<p className="text-neutral-400 leading-relaxed mb-4">
|
||||
{integration.description}
|
||||
</p>
|
||||
<span className="text-sm font-medium text-brand-orange opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1">
|
||||
@@ -318,25 +317,25 @@ export default function IntegrationsPage() {
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="max-w-md mx-auto mt-8 p-10 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-8 p-10 border border-dashed border-neutral-700 rounded-2xl flex flex-col items-center justify-center text-center"
|
||||
>
|
||||
<div className="p-4 bg-neutral-100 dark:bg-neutral-800 rounded-full mb-4">
|
||||
<div className="p-4 bg-neutral-800 rounded-full mb-4">
|
||||
<svg className="w-8 h-8 text-neutral-400" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-neutral-900 dark:text-white mb-2">
|
||||
<h3 className="text-xl font-bold text-white mb-2">
|
||||
Missing something?
|
||||
</h3>
|
||||
<p className="text-neutral-600 dark:text-neutral-400 text-sm mb-1">
|
||||
<p className="text-neutral-400 text-sm mb-1">
|
||||
No integrations found for “{query}”.
|
||||
</p>
|
||||
<p className="text-neutral-600 dark:text-neutral-400 text-sm mb-5">
|
||||
<p className="text-neutral-400 text-sm mb-5">
|
||||
Let us know which integration you'd like to see next.
|
||||
</p>
|
||||
<a
|
||||
href="mailto:support@ciphera.net"
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 bg-brand-orange text-white font-medium rounded-lg hover:bg-brand-orange/90 transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-offset-2"
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 bg-brand-orange-button text-white font-medium rounded-lg hover:bg-brand-orange/90 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:ring-offset-2"
|
||||
>
|
||||
Request Integration
|
||||
</a>
|
||||
@@ -351,17 +350,17 @@ export default function IntegrationsPage() {
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5 }}
|
||||
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"
|
||||
className="max-w-md mx-auto mt-12 p-6 border border-dashed 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-white mb-2">
|
||||
Missing something?
|
||||
</h3>
|
||||
<p className="text-neutral-600 dark:text-neutral-400 text-sm mb-4">
|
||||
<p className="text-neutral-400 text-sm mb-4">
|
||||
Let us know which integration you'd like to see next.
|
||||
</p>
|
||||
<a
|
||||
href="mailto:support@ciphera.net"
|
||||
className="text-sm font-medium text-brand-orange hover:underline focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded"
|
||||
className="text-sm font-medium text-brand-orange hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:rounded"
|
||||
>
|
||||
Request Integration
|
||||
</a>
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { ArrowLeftIcon } from '@ciphera-net/ui'
|
||||
|
||||
export default function ReactIntegrationPage() {
|
||||
return (
|
||||
<div className="relative min-h-screen flex flex-col overflow-hidden">
|
||||
{/* * --- ATMOSPHERE (Background) --- */}
|
||||
<div className="absolute inset-0 -z-10 pointer-events-none">
|
||||
<div className="absolute top-0 left-1/4 w-[500px] h-[500px] bg-brand-orange/10 rounded-full blur-[128px] opacity-60" />
|
||||
<div className="absolute bottom-0 right-1/4 w-[500px] h-[500px] bg-neutral-500/10 dark:bg-neutral-400/10 rounded-full blur-[128px] opacity-40" />
|
||||
<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="flex-grow w-full max-w-4xl mx-auto px-4 pt-20 pb-10 z-10">
|
||||
<Link
|
||||
href="/integrations"
|
||||
className="inline-flex items-center text-sm text-neutral-500 hover:text-brand-orange mb-8 transition-colors"
|
||||
>
|
||||
<ArrowLeftIcon className="w-4 h-4 mr-2" />
|
||||
Back to Integrations
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<div className="p-3 bg-neutral-100 dark:bg-neutral-800 rounded-xl">
|
||||
<svg viewBox="0 0 128 128" className="w-10 h-10 text-[#61DAFB] fill-current">
|
||||
<path d="M64 10.6c18.4 0 34.6 5.8 44.6 14.8 6.4 5.8 10.2 12.8 10.2 20.6 0 21.6-28.6 41.2-64 41.2-1.6 0-3.2-.1-4.8-.2-1.2 10.8-6.2 20.2-13.8 27.6-8.8 8.6-20.6 13.4-33.2 13.4-2.2 0-4.4-.2-6.4-.4 10.2-12.8 15.6-29.2 15.6-46.2 0-2.6-.2-5.2-.4-7.8 13.6-1.6 26.2-5.4 37.4-11 11.2-5.6 20.2-13 26.2-21.4-6.4-5.8-15.4-10-25.6-12.2-10.2-2.2-21.4-3.4-33-3.4-1.6 0-3.2.1-4.8.2 1.2-10.8 6.2-20.2 13.8-27.6 8.8-8.6 20.6-13.4 33.2-13.4 2.2 0 4.4.2 6.4.4-10.2 12.8-15.6 29.2-15.6 46.2 0 2.6.2 5.2.4 7.8-13.6 1.6-26.2 5.4-37.4 11-11.2 5.6-20.2 13-26.2 21.4 6.4 5.8 15.4 10 25.6 12.2 10.2 2.2 21.4 3.4 33 3.4 1.6 0 3.2-.1 4.8-.2-1.2 10.8-6.2 20.2-13.8 27.6-8.8 8.6-20.6 13.4-33.2 13.4-2.2 0-4.4-.2-6.4-.4 10.2-12.8 15.6-29.2 15.6-46.2 0-2.6-.2-5.2-.4-7.8 13.6-1.6 26.2-5.4 37.4-11zm-33.4 62c-11.2 5.6-20.2 13-26.2 21.4 6.4 5.8 15.4 10 25.6 12.2 10.2 2.2 21.4 3.4 33 3.4 1.6 0 3.2-.1 4.8-.2-1.2 10.8-6.2 20.2-13.8 27.6-8.8 8.6-20.6 13.4-33.2 13.4-2.2 0-4.4-.2-6.4-.4 10.2-12.8 15.6-29.2 15.6-46.2 0-2.6-.2-5.2-.4-7.8 13.6-1.6 26.2-5.4 37.4-11zm-15.2-16.6c-6.4-5.8-10.2-12.8-10.2-20.6 0-21.6 28.6-41.2 64-41.2 1.6 0 3.2.1 4.8.2 1.2-10.8 6.2-20.2 13.8-27.6 8.8-8.6 20.6-13.4 33.2-13.4 2.2 0 4.4.2 6.4.4-10.2 12.8-15.6 29.2-15.6 46.2 0 2.6.2 5.2.4 7.8-13.6 1.6-26.2 5.4-37.4 11-11.2 5.6-20.2 13-26.2 21.4 6.4 5.8 15.4 10 25.6 12.2 10.2 2.2 21.4 3.4 33 3.4 1.6 0 3.2-.1 4.8-.2-1.2 10.8-6.2 20.2-13.8 27.6-8.8 8.6-20.6 13.4-33.2 13.4-2.2 0-4.4-.2-6.4-.4 10.2-12.8 15.6-29.2 15.6-46.2 0-2.6-.2-5.2-.4-7.8z" />
|
||||
<circle cx="64" cy="64" r="10.6" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-neutral-900 dark:text-white">
|
||||
React Integration
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="prose prose-neutral dark:prose-invert max-w-none">
|
||||
<p className="lead text-xl text-neutral-600 dark:text-neutral-400">
|
||||
For standard React SPAs (Create React App, Vite, etc.), you can simply add the script tag to your <code>index.html</code>.
|
||||
</p>
|
||||
|
||||
<hr className="my-8 border-neutral-200 dark:border-neutral-800" />
|
||||
|
||||
<h3>Method 1: index.html (Recommended)</h3>
|
||||
<p>
|
||||
The simplest way is to add the script tag directly to the <code><head></code> of your <code>index.html</code> file.
|
||||
</p>
|
||||
|
||||
<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-neutral-800 border-b border-neutral-800">
|
||||
<span className="text-xs text-neutral-400 font-mono">public/index.html</span>
|
||||
</div>
|
||||
<div className="p-4 overflow-x-auto">
|
||||
<pre className="text-sm font-mono text-neutral-300">
|
||||
{`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
|
||||
<!-- Pulse Analytics -->
|
||||
<script
|
||||
defer
|
||||
data-domain="your-site.com"
|
||||
src="https://pulse.ciphera.net/script.js"
|
||||
></script>
|
||||
|
||||
<title>My React App</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Method 2: Programmatic Injection</h3>
|
||||
<p>
|
||||
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>
|
||||
|
||||
<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-neutral-800 border-b border-neutral-800">
|
||||
<span className="text-xs text-neutral-400 font-mono">src/App.tsx</span>
|
||||
</div>
|
||||
<div className="p-4 overflow-x-auto">
|
||||
<pre className="text-sm font-mono text-neutral-300">
|
||||
{`import { useEffect } from 'react'
|
||||
|
||||
function App() {
|
||||
useEffect(() => {
|
||||
// Only load in production
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
const script = document.createElement('script')
|
||||
script.defer = true
|
||||
script.setAttribute('data-domain', 'your-site.com')
|
||||
script.src = 'https://pulse.ciphera.net/script.js'
|
||||
document.head.appendChild(script)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
<h1>Hello World</h1>
|
||||
</div>
|
||||
)
|
||||
}`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
147
app/integrations/script-tag/page.tsx
Normal file
147
app/integrations/script-tag/page.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import type { Metadata } from 'next'
|
||||
import Link from 'next/link'
|
||||
import { ArrowLeftIcon } from '@ciphera-net/ui'
|
||||
import { CodeBlock } from '@ciphera-net/ui'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Add Pulse Analytics to Any Website | Pulse by Ciphera',
|
||||
description: 'Add privacy-first analytics to any website with a single script tag. Works with any platform, CMS, or framework.',
|
||||
alternates: { canonical: 'https://pulse.ciphera.net/integrations/script-tag' },
|
||||
openGraph: {
|
||||
title: 'Add Pulse Analytics to Any Website | Pulse by Ciphera',
|
||||
description: 'Add privacy-first analytics to any website with a single script tag.',
|
||||
url: 'https://pulse.ciphera.net/integrations/script-tag',
|
||||
siteName: 'Pulse by Ciphera',
|
||||
type: 'article',
|
||||
},
|
||||
}
|
||||
|
||||
export default function ScriptTagPage() {
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'HowTo',
|
||||
name: 'How to Add Pulse Analytics to Any Website',
|
||||
description: 'Add privacy-first analytics to any website with a single script tag.',
|
||||
step: [
|
||||
{
|
||||
'@type': 'HowToStep',
|
||||
name: 'Copy the script tag',
|
||||
text: 'Copy the Pulse tracking script with your domain.',
|
||||
},
|
||||
{
|
||||
'@type': 'HowToStep',
|
||||
name: 'Paste into your HTML head',
|
||||
text: 'Add the script tag inside the <head> section of your website.',
|
||||
},
|
||||
{
|
||||
'@type': 'HowToStep',
|
||||
name: 'Deploy and verify',
|
||||
text: 'Deploy your site and check the Pulse dashboard for incoming data.',
|
||||
},
|
||||
],
|
||||
tool: {
|
||||
'@type': 'HowToTool',
|
||||
name: 'Pulse by Ciphera',
|
||||
url: 'https://pulse.ciphera.net',
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
<div className="relative min-h-screen flex flex-col overflow-hidden">
|
||||
<div className="absolute inset-0 -z-10 pointer-events-none">
|
||||
<div className="absolute bottom-0 right-1/4 w-[500px] h-[500px] bg-neutral-400/10 rounded-full blur-[128px] opacity-40" />
|
||||
<div
|
||||
className="absolute inset-0 bg-grid-pattern opacity-[0.05]"
|
||||
style={{ maskImage: 'radial-gradient(ellipse at center, black 0%, transparent 70%)' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-grow w-full max-w-4xl mx-auto px-4 pt-20 pb-10 z-10">
|
||||
<Link
|
||||
href="/integrations"
|
||||
className="inline-flex items-center text-sm text-neutral-500 hover:text-brand-orange mb-8 transition-colors"
|
||||
>
|
||||
<ArrowLeftIcon className="w-4 h-4 mr-2" />
|
||||
Back to Integrations
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<div className="p-3 bg-neutral-800 rounded-xl">
|
||||
<svg className="w-10 h-10 text-brand-orange" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17.25 6.75 22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3-4.5 16.5" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-white">
|
||||
Script Tag Integration
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="prose prose-invert max-w-none">
|
||||
<p className="lead text-xl text-neutral-400">
|
||||
Add Pulse to any website by pasting a single script tag into your HTML.
|
||||
This works with any platform, CMS, or static site.
|
||||
</p>
|
||||
|
||||
<hr className="my-8 border-neutral-800" />
|
||||
|
||||
<h2>Installation</h2>
|
||||
<p>
|
||||
Add the following script tag inside the <code><head></code> section of your website:
|
||||
</p>
|
||||
|
||||
<CodeBlock filename="index.html">{`<head>
|
||||
<!-- ... other head elements ... -->
|
||||
<script
|
||||
defer
|
||||
src="https://pulse.ciphera.net/script.js"
|
||||
data-domain="your-site.com"
|
||||
></script>
|
||||
</head>`}</CodeBlock>
|
||||
|
||||
<h2>Configuration</h2>
|
||||
<ul>
|
||||
<li><code>data-domain</code> — your site's domain as shown in your Pulse dashboard (e.g. <code>example.com</code>), without <code>https://</code></li>
|
||||
<li><code>defer</code> — loads the script without blocking page rendering</li>
|
||||
</ul>
|
||||
|
||||
<h2>Where to paste the script</h2>
|
||||
<p>
|
||||
Most platforms have a “Custom Code”, “Code Injection”, or “Header Scripts”
|
||||
section in their settings. Look for one of these:
|
||||
</p>
|
||||
<ul>
|
||||
<li><strong>Squarespace:</strong> Settings → Developer Tools → Code Injection → Header</li>
|
||||
<li><strong>Wix:</strong> Settings → Custom Code → Head</li>
|
||||
<li><strong>Webflow:</strong> Project Settings → Custom Code → Head Code</li>
|
||||
<li><strong>Ghost:</strong> Settings → Code Injection → Site Header</li>
|
||||
<li><strong>Any HTML site:</strong> Paste directly into your <code><head></code> tag</li>
|
||||
</ul>
|
||||
|
||||
<h2>Verify installation</h2>
|
||||
<p>
|
||||
After deploying, visit your site and check the Pulse dashboard. You should
|
||||
see your first page view within a few seconds.
|
||||
</p>
|
||||
|
||||
<hr className="my-8 border-neutral-800" />
|
||||
<h3>Optional: Frustration Tracking</h3>
|
||||
<p>
|
||||
Detect rage clicks and dead clicks by adding the frustration tracking
|
||||
add-on after the core script:
|
||||
</p>
|
||||
<CodeBlock filename="index.html">{`<script defer src="https://pulse.ciphera.net/script.frustration.js"></script>`}</CodeBlock>
|
||||
<p>
|
||||
No extra configuration needed. Add <code>data-no-rage</code> or{' '}
|
||||
<code>data-no-dead</code> to disable individual signals.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { ArrowLeftIcon } from '@ciphera-net/ui'
|
||||
|
||||
export default function VueIntegrationPage() {
|
||||
return (
|
||||
<div className="relative min-h-screen flex flex-col overflow-hidden">
|
||||
{/* * --- ATMOSPHERE (Background) --- */}
|
||||
<div className="absolute inset-0 -z-10 pointer-events-none">
|
||||
<div className="absolute top-0 left-1/4 w-[500px] h-[500px] bg-brand-orange/10 rounded-full blur-[128px] opacity-60" />
|
||||
<div className="absolute bottom-0 right-1/4 w-[500px] h-[500px] bg-neutral-500/10 dark:bg-neutral-400/10 rounded-full blur-[128px] opacity-40" />
|
||||
<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="flex-grow w-full max-w-4xl mx-auto px-4 pt-20 pb-10 z-10">
|
||||
<Link
|
||||
href="/integrations"
|
||||
className="inline-flex items-center text-sm text-neutral-500 hover:text-brand-orange mb-8 transition-colors"
|
||||
>
|
||||
<ArrowLeftIcon className="w-4 h-4 mr-2" />
|
||||
Back to Integrations
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<div className="p-3 bg-neutral-100 dark:bg-neutral-800 rounded-xl">
|
||||
<svg viewBox="0 0 128 128" className="w-10 h-10 text-[#4FC08D] fill-current">
|
||||
<path d="M82.8 24.6h27.8L64 103.4 17.4 24.6h27.8L64 59.4l18.8-34.8z" />
|
||||
<path d="M64 24.6H39L64 67.4l25-42.8H64z" fill="#35495E" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-neutral-900 dark:text-white">
|
||||
Vue.js Integration
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="prose prose-neutral dark:prose-invert max-w-none">
|
||||
<p className="lead text-xl text-neutral-600 dark:text-neutral-400">
|
||||
Integrating Pulse with Vue.js is straightforward. You can add the script to your <code>index.html</code> file.
|
||||
</p>
|
||||
|
||||
<hr className="my-8 border-neutral-200 dark:border-neutral-800" />
|
||||
|
||||
<h3>Method 1: index.html (Recommended)</h3>
|
||||
<p>
|
||||
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>
|
||||
|
||||
<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-neutral-800 border-b border-neutral-800">
|
||||
<span className="text-xs text-neutral-400 font-mono">index.html</span>
|
||||
</div>
|
||||
<div className="p-4 overflow-x-auto">
|
||||
<pre className="text-sm font-mono text-neutral-300">
|
||||
{`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
<!-- Pulse Analytics -->
|
||||
<script
|
||||
defer
|
||||
data-domain="your-site.com"
|
||||
src="https://pulse.ciphera.net/script.js"
|
||||
></script>
|
||||
|
||||
<title>My Vue App</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Method 2: Nuxt.js</h3>
|
||||
<p>
|
||||
For Nuxt.js applications, you should add the script to your <code>nuxt.config.js</code> or <code>nuxt.config.ts</code> file.
|
||||
</p>
|
||||
|
||||
<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-neutral-800 border-b border-neutral-800">
|
||||
<span className="text-xs text-neutral-400 font-mono">nuxt.config.ts</span>
|
||||
</div>
|
||||
<div className="p-4 overflow-x-auto">
|
||||
<pre className="text-sm font-mono text-neutral-300">
|
||||
{`export default defineNuxtConfig({
|
||||
app: {
|
||||
head: {
|
||||
script: [
|
||||
{
|
||||
src: 'https://pulse.ciphera.net/script.js',
|
||||
defer: true,
|
||||
'data-domain': 'your-site.com'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { ArrowLeftIcon } from '@ciphera-net/ui'
|
||||
|
||||
export default function WordPressIntegrationPage() {
|
||||
return (
|
||||
<div className="relative min-h-screen flex flex-col overflow-hidden">
|
||||
{/* * --- ATMOSPHERE (Background) --- */}
|
||||
<div className="absolute inset-0 -z-10 pointer-events-none">
|
||||
<div className="absolute top-0 left-1/4 w-[500px] h-[500px] bg-brand-orange/10 rounded-full blur-[128px] opacity-60" />
|
||||
<div className="absolute bottom-0 right-1/4 w-[500px] h-[500px] bg-neutral-500/10 dark:bg-neutral-400/10 rounded-full blur-[128px] opacity-40" />
|
||||
<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="flex-grow w-full max-w-4xl mx-auto px-4 pt-20 pb-10 z-10">
|
||||
<Link
|
||||
href="/integrations"
|
||||
className="inline-flex items-center text-sm text-neutral-500 hover:text-brand-orange mb-8 transition-colors"
|
||||
>
|
||||
<ArrowLeftIcon className="w-4 h-4 mr-2" />
|
||||
Back to Integrations
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<div className="p-3 bg-neutral-100 dark:bg-neutral-800 rounded-xl">
|
||||
<svg viewBox="0 0 128 128" className="w-10 h-10 text-[#21759B] fill-current">
|
||||
<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>
|
||||
</div>
|
||||
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-neutral-900 dark:text-white">
|
||||
WordPress Integration
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="prose prose-neutral dark:prose-invert max-w-none">
|
||||
<p className="lead text-xl text-neutral-600 dark:text-neutral-400">
|
||||
You can add Pulse to your WordPress site without installing any heavy plugins, or by using a simple code snippet plugin.
|
||||
</p>
|
||||
|
||||
<hr className="my-8 border-neutral-200 dark:border-neutral-800" />
|
||||
|
||||
<h3>Method 1: Using a Plugin (Easiest)</h3>
|
||||
<ol>
|
||||
<li>Install a plugin like "Insert Headers and Footers" (WPCode).</li>
|
||||
<li>Go to the plugin settings and find the "Scripts in Header" section.</li>
|
||||
<li>Paste the following code snippet:</li>
|
||||
</ol>
|
||||
|
||||
<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-neutral-800 border-b border-neutral-800">
|
||||
<span className="text-xs text-neutral-400 font-mono">Header Script</span>
|
||||
</div>
|
||||
<div className="p-4 overflow-x-auto">
|
||||
<pre className="text-sm font-mono text-neutral-300">
|
||||
{`<script
|
||||
defer
|
||||
data-domain="your-site.com"
|
||||
src="https://pulse.ciphera.net/script.js"
|
||||
></script>`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Method 2: Edit Theme Files (Advanced)</h3>
|
||||
<p>
|
||||
If you are comfortable editing your theme files, you can add the script directly to your <code>header.php</code> file.
|
||||
</p>
|
||||
<ol>
|
||||
<li>Go to Appearance > Theme File Editor.</li>
|
||||
<li>Select <code>header.php</code> from the right sidebar.</li>
|
||||
<li>Paste the script tag just before the closing <code></head></code> tag.</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,22 +3,24 @@
|
||||
import { OfflineBanner } from '@/components/OfflineBanner'
|
||||
import { Footer } from '@/components/Footer'
|
||||
import { Header, type CipheraApp } from '@ciphera-net/ui'
|
||||
import { Header as MarketingHeader } from '@/components/marketing/Header'
|
||||
import NotificationCenter from '@/components/notifications/NotificationCenter'
|
||||
import { useAuth } from '@/lib/auth/context'
|
||||
import { useOnlineStatus } from '@/lib/hooks/useOnlineStatus'
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { logger } from '@/lib/utils/logger'
|
||||
import { getUserOrganizations, switchContext, type OrganizationMember } from '@/lib/api/organization'
|
||||
import { setSessionAction } from '@/app/actions/auth'
|
||||
import { LoadingOverlay } from '@ciphera-net/ui'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { SettingsModalProvider, useSettingsModal } from '@/lib/settings-modal-context'
|
||||
import SettingsModalWrapper from '@/components/settings/SettingsModalWrapper'
|
||||
import { UnifiedSettingsProvider, useUnifiedSettings } from '@/lib/unified-settings-context'
|
||||
import UnifiedSettingsModal from '@/components/settings/unified/UnifiedSettingsModal'
|
||||
import DashboardShell from '@/components/dashboard/DashboardShell'
|
||||
|
||||
const ORG_SWITCH_KEY = 'pulse_switching_org'
|
||||
|
||||
// * Available Ciphera apps for the app switcher
|
||||
const CIPHERA_APPS: CipheraApp[] = [
|
||||
{
|
||||
id: 'pulse',
|
||||
@@ -26,7 +28,7 @@ const CIPHERA_APPS: CipheraApp[] = [
|
||||
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
|
||||
isAvailable: false,
|
||||
},
|
||||
{
|
||||
id: 'drop',
|
||||
@@ -49,15 +51,15 @@ const CIPHERA_APPS: CipheraApp[] = [
|
||||
function LayoutInner({ children }: { children: React.ReactNode }) {
|
||||
const auth = useAuth()
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const isOnline = useOnlineStatus()
|
||||
const { openSettings } = useSettingsModal()
|
||||
const { openUnifiedSettings } = useUnifiedSettings()
|
||||
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)
|
||||
@@ -66,7 +68,6 @@ function LayoutInner({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
}, [isSwitchingOrg])
|
||||
|
||||
// * Fetch organizations for the header organization switcher
|
||||
useEffect(() => {
|
||||
if (auth.user) {
|
||||
getUserOrganizations()
|
||||
@@ -76,84 +77,118 @@ function LayoutInner({ children }: { children: React.ReactNode }) {
|
||||
}, [auth.user])
|
||||
|
||||
const handleSwitchOrganization = async (orgId: string | null) => {
|
||||
if (!orgId) return // Pulse doesn't support personal organization context
|
||||
if (!orgId) return
|
||||
try {
|
||||
setIsSwitchingOrg(true)
|
||||
const { access_token } = await switchContext(orgId)
|
||||
await setSessionAction(access_token)
|
||||
sessionStorage.setItem(ORG_SWITCH_KEY, 'true')
|
||||
window.location.reload()
|
||||
// Refresh auth context (re-fetches /auth/user/me with new JWT, updates org_id + SWR cache)
|
||||
await auth.refresh()
|
||||
router.push('/')
|
||||
setTimeout(() => setIsSwitchingOrg(false), 300)
|
||||
} catch (err) {
|
||||
setIsSwitchingOrg(false)
|
||||
logger.error('Failed to switch organization', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateOrganization = () => {
|
||||
router.push('/onboarding')
|
||||
}
|
||||
|
||||
const showOfflineBar = Boolean(auth.user && !isOnline);
|
||||
const barHeightRem = 2.5;
|
||||
const headerHeightRem = 6;
|
||||
const mainTopPaddingRem = barHeightRem + headerHeightRem;
|
||||
const isAuthenticated = !!auth.user
|
||||
const showOfflineBar = Boolean(auth.user && !isOnline)
|
||||
// Site pages use DashboardShell with full sidebar — no Header needed
|
||||
const isSitePage = pathname.startsWith('/sites/') && pathname !== '/sites/new'
|
||||
// Pages that use DashboardShell with home sidebar (no site context)
|
||||
const isDashboardPage = pathname === '/' || pathname.startsWith('/integrations') || pathname === '/pricing'
|
||||
// Checkout page has its own minimal layout — no app header/footer
|
||||
const isCheckoutPage = pathname.startsWith('/checkout')
|
||||
|
||||
if (isSwitchingOrg) {
|
||||
return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Pulse" portal={false} />
|
||||
}
|
||||
|
||||
// While auth is loading on a site or checkout page, render nothing to prevent flash of public header
|
||||
if (auth.loading && (isSitePage || isCheckoutPage || isDashboardPage)) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Authenticated site pages: DashboardShell provided by sites layout
|
||||
if (isAuthenticated && isSitePage) {
|
||||
return (
|
||||
<>
|
||||
{showOfflineBar && <OfflineBanner isOnline={isOnline} />}
|
||||
{children}
|
||||
<UnifiedSettingsModal />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Authenticated dashboard pages (home, integrations, pricing): wrap in DashboardShell
|
||||
if (isAuthenticated && isDashboardPage) {
|
||||
return (
|
||||
<>
|
||||
{showOfflineBar && <OfflineBanner isOnline={isOnline} />}
|
||||
<DashboardShell siteId={null}>{children}</DashboardShell>
|
||||
<UnifiedSettingsModal />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Checkout page: render children only (has its own layout)
|
||||
if (isAuthenticated && isCheckoutPage) {
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
// Authenticated non-site pages (sites list, onboarding, etc.): static header
|
||||
if (isAuthenticated) {
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen">
|
||||
{showOfflineBar && <OfflineBanner isOnline={isOnline} />}
|
||||
<Header
|
||||
auth={auth}
|
||||
LinkComponent={Link}
|
||||
logoSrc="/pulse_icon_no_margins.png"
|
||||
appName="Pulse"
|
||||
variant="static"
|
||||
orgs={orgs}
|
||||
activeOrgId={auth.user?.org_id}
|
||||
onSwitchOrganization={handleSwitchOrganization}
|
||||
onCreateOrganization={() => router.push('/onboarding')}
|
||||
allowPersonalOrganization={false}
|
||||
showFaq={false}
|
||||
showSecurity={false}
|
||||
showPricing={false}
|
||||
rightSideActions={<NotificationCenter />}
|
||||
apps={CIPHERA_APPS}
|
||||
currentAppId="pulse"
|
||||
onOpenSettings={() => openUnifiedSettings({ context: 'account', tab: 'profile' })}
|
||||
/>
|
||||
<main className="flex-1 pb-8">
|
||||
{children}
|
||||
</main>
|
||||
<UnifiedSettingsModal />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Public/marketing: sticky header + footer
|
||||
return (
|
||||
<>
|
||||
{auth.user && <OfflineBanner isOnline={isOnline} />}
|
||||
<Header
|
||||
auth={auth}
|
||||
LinkComponent={Link}
|
||||
logoSrc="/pulse_icon_no_margins.png"
|
||||
appName="Pulse"
|
||||
orgs={orgs}
|
||||
activeOrgId={auth.user?.org_id}
|
||||
onSwitchOrganization={handleSwitchOrganization}
|
||||
onCreateOrganization={handleCreateOrganization}
|
||||
allowPersonalOrganization={false}
|
||||
showFaq={false}
|
||||
showSecurity={false}
|
||||
showPricing={true}
|
||||
topOffset={showOfflineBar ? `${barHeightRem}rem` : undefined}
|
||||
rightSideActions={auth.user ? <NotificationCenter /> : null}
|
||||
apps={CIPHERA_APPS}
|
||||
currentAppId="pulse"
|
||||
onOpenSettings={openSettings}
|
||||
customNavItems={
|
||||
<>
|
||||
{!auth.user && (
|
||||
<Link
|
||||
href="/features"
|
||||
className="px-4 py-2 text-sm font-medium text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white rounded-lg hover:bg-neutral-100/50 dark:hover:bg-neutral-800/50 transition-all duration-200"
|
||||
>
|
||||
Features
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<main
|
||||
className={`flex-1 pb-8 ${showOfflineBar ? '' : 'pt-24'}`}
|
||||
style={showOfflineBar ? { paddingTop: `${mainTopPaddingRem}rem` } : undefined}
|
||||
>
|
||||
<div className="flex flex-col min-h-screen">
|
||||
<MarketingHeader />
|
||||
<main className="flex-1 pb-8">
|
||||
{children}
|
||||
</main>
|
||||
<Footer
|
||||
LinkComponent={Link}
|
||||
appName="Pulse"
|
||||
isAuthenticated={!!auth.user}
|
||||
isAuthenticated={false}
|
||||
/>
|
||||
<SettingsModalWrapper />
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function LayoutContent({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<SettingsModalProvider>
|
||||
<UnifiedSettingsProvider>
|
||||
<LayoutInner>{children}</LayoutInner>
|
||||
</SettingsModalProvider>
|
||||
</UnifiedSettingsProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ThemeProviders, Toaster } from '@ciphera-net/ui'
|
||||
import { ThemeProvider, Toaster } from '@ciphera-net/ui'
|
||||
import { AuthProvider } from '@/lib/auth/context'
|
||||
import SWRProvider from '@/components/SWRProvider'
|
||||
import type { Metadata, Viewport } from 'next'
|
||||
import { Plus_Jakarta_Sans } from 'next/font/google'
|
||||
import LayoutContent from './layout-content'
|
||||
@@ -44,14 +45,16 @@ export default function RootLayout({
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" className={plusJakartaSans.variable} suppressHydrationWarning>
|
||||
<body className="antialiased min-h-screen flex flex-col bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-50">
|
||||
<ThemeProviders>
|
||||
<AuthProvider>
|
||||
<LayoutContent>{children}</LayoutContent>
|
||||
<Toaster />
|
||||
</AuthProvider>
|
||||
</ThemeProviders>
|
||||
<html lang="en" className={`${plusJakartaSans.variable} dark`} suppressHydrationWarning>
|
||||
<body className="antialiased min-h-screen flex flex-col bg-neutral-950 text-neutral-100">
|
||||
<SWRProvider>
|
||||
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem={false}>
|
||||
<AuthProvider>
|
||||
<LayoutContent>{children}</LayoutContent>
|
||||
<Toaster />
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</SWRProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
|
||||
@@ -6,23 +6,23 @@ export default function NotFound() {
|
||||
<div className="relative min-h-[80vh] flex flex-col items-center justify-center overflow-hidden">
|
||||
{/* * --- ATMOSPHERE (Background) --- */}
|
||||
<div className="absolute inset-0 -z-10 pointer-events-none">
|
||||
{/* * Center Orange Glow */}
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[500px] h-[500px] bg-brand-orange/10 rounded-full blur-[128px] opacity-60" />
|
||||
{/* * Grid Pattern with Radial Mask */}
|
||||
<div
|
||||
className="absolute inset-0 bg-grid-pattern opacity-[0.02] dark:opacity-[0.05]"
|
||||
className="absolute inset-0 bg-grid-pattern opacity-[0.05]"
|
||||
style={{ maskImage: 'radial-gradient(ellipse at center, black 0%, transparent 70%)' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-center px-4 z-10">
|
||||
<h1 className="text-9xl font-bold text-transparent bg-clip-text bg-gradient-to-b from-neutral-900 to-neutral-500 dark:from-white dark:to-neutral-500 mb-4">
|
||||
404
|
||||
</h1>
|
||||
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-6">
|
||||
<img
|
||||
src="/illustrations/page-not-found.svg"
|
||||
alt="Page not found"
|
||||
className="w-72 h-auto mx-auto mb-8"
|
||||
/>
|
||||
<h2 className="text-2xl font-bold text-white mb-6">
|
||||
Page not found
|
||||
</h2>
|
||||
<p className="text-lg text-neutral-600 dark:text-neutral-400 max-w-md mx-auto mb-10 leading-relaxed">
|
||||
<p className="text-lg text-neutral-400 max-w-md mx-auto mb-10 leading-relaxed">
|
||||
Sorry, we couldn't find the page you're looking for. It might have been moved or deleted.
|
||||
</p>
|
||||
|
||||
|
||||
@@ -16,13 +16,15 @@ import {
|
||||
import { getAuthErrorMessage } from '@ciphera-net/ui'
|
||||
import { formatTimeAgo, getTypeIcon } from '@/lib/utils/notifications'
|
||||
import { Button, ArrowLeftIcon } from '@ciphera-net/ui'
|
||||
import { NotificationsListSkeleton, useMinimumLoading } from '@/components/skeletons'
|
||||
import { useUnifiedSettings } from '@/lib/unified-settings-context'
|
||||
import { NotificationsListSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
|
||||
import { toast } from '@ciphera-net/ui'
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
export default function NotificationsPage() {
|
||||
const { user } = useAuth()
|
||||
const { openUnifiedSettings } = useUnifiedSettings()
|
||||
const [notifications, setNotifications] = useState<Notification[]>([])
|
||||
const [unreadCount, setUnreadCount] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -31,6 +33,7 @@ export default function NotificationsPage() {
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const showSkeleton = useMinimumLoading(loading)
|
||||
const fadeClass = useSkeletonFade(showSkeleton)
|
||||
|
||||
const fetchPage = async (pageOffset: number, append: boolean) => {
|
||||
if (append) setLoadingMore(true)
|
||||
@@ -104,7 +107,7 @@ export default function NotificationsPage() {
|
||||
}
|
||||
|
||||
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 py-8 ${fadeClass}`}>
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<Link
|
||||
@@ -121,12 +124,12 @@ export default function NotificationsPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-2">Notifications</h1>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-6">
|
||||
<h1 className="text-2xl font-bold text-white mb-2">Notifications</h1>
|
||||
<p className="text-sm text-neutral-400 mb-6">
|
||||
Manage which notifications you receive in{' '}
|
||||
<Link href="/org-settings?tab=notifications" className="text-brand-orange hover:underline">
|
||||
<button onClick={() => openUnifiedSettings({ context: 'workspace', tab: 'notifications' })} className="text-brand-orange hover:underline cursor-pointer">
|
||||
Organization Settings → Notifications
|
||||
</Link>
|
||||
</button>
|
||||
</p>
|
||||
|
||||
{showSkeleton ? (
|
||||
@@ -136,13 +139,13 @@ export default function NotificationsPage() {
|
||||
{error}
|
||||
</div>
|
||||
) : notifications.length === 0 ? (
|
||||
<div className="p-6 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-400 rounded-2xl border border-neutral-200 dark:border-neutral-800">
|
||||
<p>No notifications yet</p>
|
||||
<p className="text-sm mt-2">
|
||||
Manage which notifications you receive in{' '}
|
||||
<Link href="/org-settings?tab=notifications" className="text-brand-orange hover:underline">
|
||||
<button onClick={() => openUnifiedSettings({ context: 'workspace', tab: 'notifications' })} className="text-brand-orange hover:underline cursor-pointer">
|
||||
Organization Settings → Notifications
|
||||
</Link>
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -158,11 +161,11 @@ export default function NotificationsPage() {
|
||||
<div className="flex gap-3">
|
||||
{getTypeIcon(n.type)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={`text-sm ${!n.read ? 'font-medium' : ''} text-neutral-900 dark:text-white`}>
|
||||
<p className={`text-sm ${!n.read ? 'font-medium' : ''} text-white`}>
|
||||
{n.title}
|
||||
</p>
|
||||
{n.body && (
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-0.5">{n.body}</p>
|
||||
<p className="text-xs text-neutral-400 mt-0.5">{n.body}</p>
|
||||
)}
|
||||
<p className="text-xs text-neutral-400 dark:text-neutral-500 mt-1">
|
||||
{formatTimeAgo(n.created_at)}
|
||||
@@ -181,11 +184,11 @@ export default function NotificationsPage() {
|
||||
<div className="flex gap-3">
|
||||
{getTypeIcon(n.type)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={`text-sm ${!n.read ? 'font-medium' : ''} text-neutral-900 dark:text-white`}>
|
||||
<p className={`text-sm ${!n.read ? 'font-medium' : ''} text-white`}>
|
||||
{n.title}
|
||||
</p>
|
||||
{n.body && (
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-0.5">{n.body}</p>
|
||||
<p className="text-xs text-neutral-400 mt-0.5">{n.body}</p>
|
||||
)}
|
||||
<p className="text-xs text-neutral-400 dark:text-neutral-500 mt-1">
|
||||
{formatTimeAgo(n.created_at)}
|
||||
|
||||
@@ -47,7 +47,7 @@ export default function OnboardingPage() {
|
||||
<div className="min-h-screen flex items-center justify-center bg-neutral-50 dark:bg-neutral-900 px-4">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div className="text-center">
|
||||
<h2 className="mt-6 text-2xl font-bold text-neutral-900 dark:text-white">
|
||||
<h2 className="mt-6 text-2xl font-bold text-white">
|
||||
Welcome to Pulse
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
|
||||
@@ -1,30 +1,43 @@
|
||||
import { Suspense } from 'react'
|
||||
import OrganizationSettings from '@/components/settings/OrganizationSettings'
|
||||
import { SettingsFormSkeleton } from '@/components/skeletons'
|
||||
'use client'
|
||||
|
||||
export const metadata = {
|
||||
title: 'Organization Settings - Pulse',
|
||||
description: 'Manage your organization settings',
|
||||
}
|
||||
import { Suspense, useEffect } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { useUnifiedSettings } from '@/lib/unified-settings-context'
|
||||
import { Spinner } from '@ciphera-net/ui'
|
||||
|
||||
function OrgSettingsInner() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { openUnifiedSettings } = useUnifiedSettings()
|
||||
|
||||
useEffect(() => {
|
||||
const tab = searchParams.get('tab')
|
||||
|
||||
const tabMap: Record<string, string> = {
|
||||
general: 'general',
|
||||
members: 'members',
|
||||
billing: 'billing',
|
||||
notifications: 'notifications',
|
||||
audit: 'audit',
|
||||
}
|
||||
|
||||
const mappedTab = tab ? tabMap[tab] || 'general' : 'general'
|
||||
// Go back to wherever the user came from (not always /)
|
||||
router.back()
|
||||
setTimeout(() => openUnifiedSettings({ context: 'workspace', tab: mappedTab }), 200)
|
||||
}, [searchParams, router, openUnifiedSettings])
|
||||
|
||||
export default function OrgSettingsPage() {
|
||||
return (
|
||||
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
|
||||
<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 />
|
||||
</Suspense>
|
||||
</div>
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<Spinner className="w-6 h-6 text-neutral-500" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function OrgSettingsRedirect() {
|
||||
return (
|
||||
<Suspense fallback={<div className="flex items-center justify-center py-24"><Spinner className="w-6 h-6 text-neutral-500" /></div>}>
|
||||
<OrgSettingsInner />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
463
app/page.tsx
463
app/page.tsx
@@ -4,109 +4,26 @@ import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { motion } from 'framer-motion'
|
||||
import { useAuth } from '@/lib/auth/context'
|
||||
import { initiateOAuthFlow, initiateSignupFlow } from '@/lib/api/oauth'
|
||||
import { listSites, deleteSite, type Site } from '@/lib/api/sites'
|
||||
import { initiateOAuthFlow } from '@/lib/api/oauth'
|
||||
import { listSites, listDeletedSites, restoreSite, 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 { LoadingOverlay } from '@ciphera-net/ui'
|
||||
import SiteList from '@/components/sites/SiteList'
|
||||
import DeleteSiteModal from '@/components/sites/DeleteSiteModal'
|
||||
import { Button } from '@ciphera-net/ui'
|
||||
import Image from 'next/image'
|
||||
import { BarChartIcon, LockIcon, ZapIcon, CheckCircleIcon, XIcon, GlobeIcon } from '@ciphera-net/ui'
|
||||
import { XIcon } from '@ciphera-net/ui'
|
||||
import { Cookie, ShieldCheck, Code, Lightning, ArrowRight, GithubLogo } from '@phosphor-icons/react'
|
||||
import DashboardDemo from '@/components/marketing/DashboardDemo'
|
||||
import FeatureSections from '@/components/marketing/FeatureSections'
|
||||
import ComparisonCards from '@/components/marketing/ComparisonCards'
|
||||
import CTASection from '@/components/marketing/CTASection'
|
||||
import PulseFAQ from '@/components/marketing/PulseFAQ'
|
||||
import { toast } from '@ciphera-net/ui'
|
||||
import { getAuthErrorMessage } from '@ciphera-net/ui'
|
||||
import { getSitesLimitForPlan } from '@/lib/plans'
|
||||
|
||||
function DashboardPreview() {
|
||||
return (
|
||||
<div className="relative w-full max-w-7xl mx-auto mt-20 mb-32">
|
||||
<div className="absolute inset-0 bg-brand-orange/20 blur-[100px] -z-10 rounded-full opacity-50" />
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
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"
|
||||
>
|
||||
{/* * Browser chrome */}
|
||||
<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-400/60" />
|
||||
<div className="w-3 h-3 rounded-full bg-yellow-400/60" />
|
||||
<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>
|
||||
|
||||
{/* * Screenshot with bottom fade */}
|
||||
<div className="relative max-h-[900px] overflow-hidden">
|
||||
<Image
|
||||
src="/dashboard-preview-v2.png"
|
||||
alt="Pulse analytics dashboard showing visitor stats, charts, top pages, referrers, locations, and technology breakdown"
|
||||
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>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
function ComparisonSection() {
|
||||
return (
|
||||
<div className="w-full max-w-4xl mx-auto mb-32">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-4">Why choose Pulse?</h2>
|
||||
<p className="text-neutral-500">The lightweight, privacy-friendly alternative.</p>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-white/50 dark:bg-neutral-900/50 backdrop-blur-sm">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-neutral-200 dark:border-neutral-800">
|
||||
<th className="p-6 text-sm font-medium text-neutral-500">Feature</th>
|
||||
<th className="p-6 text-sm font-bold text-brand-orange">Pulse</th>
|
||||
<th className="p-6 text-sm font-medium text-neutral-500">Google Analytics</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-800">
|
||||
{[
|
||||
{ feature: "Cookie Banner Required", pulse: false, ga: true },
|
||||
{ feature: "GDPR Compliant", pulse: true, ga: "Complex" },
|
||||
{ feature: "Script Size", pulse: "< 1 KB", ga: "45 KB+" },
|
||||
{ feature: "Data Ownership", pulse: "Yours", ga: "Google's" },
|
||||
].map((row) => (
|
||||
<tr key={row.feature} className="hover:bg-neutral-50/50 dark:hover:bg-neutral-800/50 transition-colors">
|
||||
<td className="p-6 text-neutral-900 dark:text-white font-medium">{row.feature}</td>
|
||||
<td className="p-6">
|
||||
{row.pulse === true ? (
|
||||
<CheckCircleIcon className="w-5 h-5 text-green-500" />
|
||||
) : row.pulse === false ? (
|
||||
<span className="text-green-500 font-medium">No</span>
|
||||
) : (
|
||||
<span className="text-green-500 font-medium">{row.pulse}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-6 text-neutral-500">
|
||||
{row.ga === true ? (
|
||||
<span className="text-red-500 font-medium">Yes</span>
|
||||
) : (
|
||||
<span>{row.ga}</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
type SiteStatsMap = Record<string, { stats: Stats }>
|
||||
|
||||
export default function HomePage() {
|
||||
@@ -115,8 +32,9 @@ export default function HomePage() {
|
||||
const [sitesLoading, setSitesLoading] = useState(true)
|
||||
const [siteStats, setSiteStats] = useState<SiteStatsMap>({})
|
||||
const [subscription, setSubscription] = useState<SubscriptionDetails | null>(null)
|
||||
const [subscriptionLoading, setSubscriptionLoading] = useState(false)
|
||||
const [showFinishSetupBanner, setShowFinishSetupBanner] = useState(true)
|
||||
const [deletedSites, setDeletedSites] = useState<Site[]>([])
|
||||
const [permanentDeleteSiteModal, setPermanentDeleteSiteModal] = useState<Site | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.org_id) {
|
||||
@@ -177,6 +95,12 @@ export default function HomePage() {
|
||||
setSitesLoading(true)
|
||||
const data = await listSites()
|
||||
setSites(Array.isArray(data) ? data : [])
|
||||
try {
|
||||
const deleted = await listDeletedSites()
|
||||
setDeletedSites(deleted)
|
||||
} catch {
|
||||
setDeletedSites([])
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
toast.error(getAuthErrorMessage(error) || 'Failed to load your sites')
|
||||
setSites([])
|
||||
@@ -187,160 +111,109 @@ export default function HomePage() {
|
||||
|
||||
const loadSubscription = async () => {
|
||||
try {
|
||||
setSubscriptionLoading(true)
|
||||
const sub = await getSubscription()
|
||||
setSubscription(sub)
|
||||
} catch {
|
||||
setSubscription(null)
|
||||
} finally {
|
||||
setSubscriptionLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Are you sure you want to delete this site? This action cannot be undone.')) {
|
||||
return
|
||||
}
|
||||
|
||||
const handleRestore = async (id: string) => {
|
||||
try {
|
||||
await deleteSite(id)
|
||||
toast.success('Site deleted successfully')
|
||||
await restoreSite(id)
|
||||
toast.success('Site restored successfully')
|
||||
loadSites()
|
||||
} catch (error: unknown) {
|
||||
toast.error(getAuthErrorMessage(error) || 'Failed to delete site')
|
||||
toast.error(getAuthErrorMessage(error) || 'Failed to restore site')
|
||||
}
|
||||
}
|
||||
|
||||
const handlePermanentDelete = (id: string) => {
|
||||
const site = deletedSites.find((s) => s.id === id)
|
||||
if (site) setPermanentDeleteSiteModal(site)
|
||||
}
|
||||
|
||||
if (authLoading) {
|
||||
return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Pulse" portal={false} />
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="relative min-h-screen flex flex-col overflow-hidden">
|
||||
|
||||
{/* * --- 1. ATMOSPHERE (Background) --- */}
|
||||
<div className="absolute inset-0 -z-10 pointer-events-none">
|
||||
{/* * Top-left Orange Glow */}
|
||||
<div className="absolute top-0 left-1/4 w-[500px] h-[500px] bg-brand-orange/10 rounded-full blur-[128px] opacity-60" />
|
||||
{/* * Bottom-right Neutral Glow */}
|
||||
<div className="absolute bottom-0 right-1/4 w-[500px] h-[500px] bg-neutral-500/10 dark:bg-neutral-400/10 rounded-full blur-[128px] opacity-40" />
|
||||
{/* * Grid Pattern with Radial Mask */}
|
||||
<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="flex-grow w-full max-w-6xl mx-auto px-4 pt-20 pb-10 z-10">
|
||||
|
||||
{/* * --- 2. BADGE --- */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="inline-flex justify-center mb-8 w-full"
|
||||
>
|
||||
<span className="badge-primary">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-brand-orange animate-pulse" />
|
||||
Privacy-First Analytics
|
||||
</span>
|
||||
</motion.div>
|
||||
|
||||
{/* * --- 3. HEADLINE --- */}
|
||||
<div className="text-center mb-20">
|
||||
<>
|
||||
{/* HERO — compact headline + live demo */}
|
||||
<div className="pt-20 pb-10 lg:pt-28 lg:pb-16">
|
||||
<div className="w-full max-w-6xl mx-auto px-6 text-center mb-16">
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.1 }}
|
||||
className="text-5xl md:text-7xl font-bold tracking-tight text-neutral-900 dark:text-white mb-6"
|
||||
transition={{ duration: 0.5 }}
|
||||
className="text-4xl sm:text-5xl md:text-6xl font-bold text-white leading-[1.1] mb-6"
|
||||
>
|
||||
Simple analytics for <br />
|
||||
Analytics without the{' '}
|
||||
<span className="relative inline-block">
|
||||
<span className="gradient-text">privacy-conscious</span>
|
||||
{/* * SVG Underline from Main Site */}
|
||||
<span className="gradient-text">surveillance.</span>
|
||||
<svg className="absolute -bottom-2 left-0 w-full h-3 text-brand-orange/30" viewBox="0 0 200 12" preserveAspectRatio="none">
|
||||
<path d="M0 9C50 3 150 3 200 9" fill="none" stroke="currentColor" strokeWidth="4" strokeLinecap="round" />
|
||||
</svg>
|
||||
</span>
|
||||
{' '}apps.
|
||||
</motion.h1>
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
className="text-xl text-neutral-600 dark:text-neutral-400 max-w-2xl mx-auto mb-10 leading-relaxed"
|
||||
transition={{ duration: 0.5, delay: 0.1 }}
|
||||
className="text-xl text-neutral-300 mb-8 leading-relaxed max-w-2xl mx-auto"
|
||||
>
|
||||
Respect your users' privacy while getting the insights you need.
|
||||
Respect your users' privacy while getting the insights you need.
|
||||
No cookies, no IP tracking, fully GDPR compliant.
|
||||
</motion.p>
|
||||
|
||||
{/* * --- 4. CTAs --- */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
className="flex flex-row gap-3 flex-wrap justify-center mb-8"
|
||||
>
|
||||
<Button onClick={() => initiateOAuthFlow()} variant="primary" className="px-6 py-3 shadow-lg shadow-brand-orange/20 gap-2">
|
||||
Try Pulse Free <ArrowRight weight="bold" className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button onClick={() => window.open('https://github.com/ciphera-net/pulse', '_blank')} variant="secondary" className="px-6 py-3 border border-white/10 gap-2">
|
||||
<GithubLogo weight="bold" className="w-4 h-4" /> View on GitHub
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.3 }}
|
||||
className="flex flex-col sm:flex-row gap-4 justify-center items-center mb-20"
|
||||
className="flex flex-wrap gap-x-6 gap-y-3 text-sm text-neutral-400 justify-center"
|
||||
>
|
||||
<Button onClick={() => initiateOAuthFlow()} variant="primary" className="px-8 py-4 text-lg shadow-lg shadow-brand-orange/20">
|
||||
Get Started
|
||||
</Button>
|
||||
<Button onClick={() => initiateSignupFlow()} variant="secondary" className="px-8 py-4 text-lg">
|
||||
Create Account
|
||||
</Button>
|
||||
<span className="flex items-center gap-2"><Cookie weight="bold" className="w-4 h-4" /> Cookie-free</span>
|
||||
<span className="text-neutral-700">|</span>
|
||||
<span className="flex items-center gap-2"><Code weight="bold" className="w-4 h-4" /> Open source client</span>
|
||||
<span className="text-neutral-700">|</span>
|
||||
<span className="flex items-center gap-2"><ShieldCheck weight="bold" className="w-4 h-4" /> GDPR compliant</span>
|
||||
<span className="text-neutral-700">|</span>
|
||||
<span className="flex items-center gap-2"><Lightning weight="bold" className="w-4 h-4" /> Under 2KB</span>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* * NEW: DASHBOARD PREVIEW */}
|
||||
<DashboardPreview />
|
||||
|
||||
{/* * --- 5. GLASS CARDS --- */}
|
||||
<div className="grid md:grid-cols-3 gap-6 text-left mb-32">
|
||||
{[
|
||||
{ icon: LockIcon, title: "Privacy First", desc: "We don't track personal data. No IP addresses, no fingerprints, no cookies." },
|
||||
{ icon: BarChartIcon, title: "Simple Insights", desc: "Get the metrics that matter without the clutter. Page views, visitors, and sources." },
|
||||
{ icon: ZapIcon, title: "Lightweight", desc: "Our script is less than 1kb. It won't slow down your site or affect your SEO." }
|
||||
].map((feature, i) => (
|
||||
<motion.div
|
||||
key={feature.title}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: i * 0.1 }}
|
||||
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">
|
||||
<feature.icon className="w-6 h-6" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-neutral-900 dark:text-white mb-3">{feature.title}</h3>
|
||||
<p className="text-neutral-600 dark:text-neutral-400 leading-relaxed">
|
||||
{feature.desc}
|
||||
</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* * NEW: COMPARISON SECTION */}
|
||||
<ComparisonSection />
|
||||
|
||||
{/* * NEW: CTA BOTTOM */}
|
||||
{/* Live Dashboard Demo */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="text-center mb-20"
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.7, delay: 0.4 }}
|
||||
className="w-full max-w-7xl mx-auto px-6"
|
||||
>
|
||||
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-6">Ready to switch?</h2>
|
||||
<Button onClick={() => initiateOAuthFlow()} variant="primary" className="px-8 py-4 text-lg shadow-lg shadow-brand-orange/20">
|
||||
Start your free trial
|
||||
</Button>
|
||||
<p className="mt-4 text-sm text-neutral-500">No credit card required • Cancel anytime</p>
|
||||
<DashboardDemo />
|
||||
</motion.div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FeatureSections />
|
||||
<ComparisonCards />
|
||||
<PulseFAQ />
|
||||
<CTASection />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -350,10 +223,10 @@ export default function HomePage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
|
||||
<div className="w-full max-w-7xl mx-auto px-4 sm:px-6 pb-8">
|
||||
{showFinishSetupBanner && (
|
||||
<div className="mb-6 flex items-center justify-between gap-4 rounded-2xl border border-brand-orange/30 bg-brand-orange/5 px-4 py-3 dark:bg-brand-orange/10">
|
||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
<div className="mb-6 flex items-center justify-between gap-4 rounded-2xl border border-brand-orange/30 bg-brand-orange/10 px-4 py-3">
|
||||
<p className="text-sm text-neutral-300">
|
||||
Finish setting up your workspace and add your first site.
|
||||
</p>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
@@ -368,7 +241,7 @@ export default function HomePage() {
|
||||
if (typeof window !== 'undefined') localStorage.setItem('pulse_welcome_completed', 'true')
|
||||
setShowFinishSetupBanner(false)
|
||||
}}
|
||||
className="text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-400 p-1 rounded"
|
||||
className="text-neutral-500 hover:text-neutral-400 p-1 rounded"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
@@ -377,127 +250,51 @@ export default function HomePage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<div className="mb-6 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">Your Sites</h1>
|
||||
<p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">Manage your analytics sites and view insights.</p>
|
||||
<h1 className="text-lg font-semibold text-neutral-200 mb-1">Your Sites</h1>
|
||||
<p className="text-sm text-neutral-400">Manage your analytics sites and view insights.</p>
|
||||
</div>
|
||||
{(() => {
|
||||
const siteLimit = getSitesLimitForPlan(subscription?.plan_id)
|
||||
const atLimit = siteLimit != null && sites.length >= siteLimit
|
||||
return atLimit ? (
|
||||
<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">
|
||||
Limit reached ({sites.length}/{siteLimit})
|
||||
</span>
|
||||
<Link href="/pricing">
|
||||
<Button variant="primary" className="text-sm">
|
||||
Upgrade
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-medium text-neutral-400 bg-neutral-800 px-3 py-1.5 rounded-lg border border-neutral-700">
|
||||
Limit reached ({sites.length}/{siteLimit})
|
||||
</span>
|
||||
<Link href="/pricing">
|
||||
<Button variant="primary" className="text-sm">
|
||||
Upgrade
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
{deletedSites.length > 0 && (
|
||||
<p className="text-sm text-neutral-400 mt-2">
|
||||
You have a site pending deletion. Restore it or permanently delete it to free the slot.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : null
|
||||
})() ?? (
|
||||
<Link href="/sites/new">
|
||||
<Button variant="primary" className="text-sm">
|
||||
<Button variant="primary" className="text-sm whitespace-nowrap">
|
||||
Add New Site
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* * 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="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-2xl font-bold text-neutral-900 dark:text-white">{sites.length}</p>
|
||||
</div>
|
||||
<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-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 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>
|
||||
{subscriptionLoading ? (
|
||||
<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 ? (
|
||||
<>
|
||||
<p className="text-lg font-bold text-brand-orange">
|
||||
{(() => {
|
||||
const raw =
|
||||
subscription.plan_id?.startsWith('price_')
|
||||
? 'Pro'
|
||||
: subscription.plan_id === 'free' || !subscription.plan_id
|
||||
? 'Free'
|
||||
: subscription.plan_id
|
||||
const label = raw === 'Free' || raw === 'Pro' ? raw : raw.charAt(0).toUpperCase() + raw.slice(1)
|
||||
return `${label} Plan`
|
||||
})()}
|
||||
</p>
|
||||
{(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">
|
||||
{typeof subscription.sites_count === 'number' && (
|
||||
<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') && ' · '}
|
||||
{subscription.pageview_limit > 0 && typeof subscription.pageview_usage === 'number' && (
|
||||
<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>
|
||||
)}
|
||||
<div className="mt-2 flex gap-2">
|
||||
{subscription.has_payment_method ? (
|
||||
<Link href="/org-settings?tab=billing" className="text-sm font-medium text-brand-orange hover:underline focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded">
|
||||
Manage billing
|
||||
</Link>
|
||||
) : (
|
||||
<Link href="/pricing" className="text-sm font-medium text-brand-orange hover:underline focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded">
|
||||
Upgrade
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-lg font-bold text-brand-orange">Free Plan</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!sitesLoading && sites.length === 0 && (
|
||||
<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">
|
||||
<GlobeIcon className="h-7 w-7" />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-neutral-900 dark:text-white mb-2">Add your first site</h2>
|
||||
<p className="text-neutral-600 dark:text-neutral-400 mb-6 max-w-md mx-auto">
|
||||
<div className="mb-8 rounded-2xl border-2 border-dashed border-brand-orange/30 bg-brand-orange/10 p-8 text-center flex flex-col items-center">
|
||||
<img
|
||||
src="/illustrations/setup-analytics.svg"
|
||||
alt="Set up your first site"
|
||||
className="w-56 h-auto mb-6"
|
||||
/>
|
||||
<h2 className="text-xl font-bold text-white mb-2">Add your first site</h2>
|
||||
<p className="text-neutral-400 mb-6 max-w-md mx-auto">
|
||||
Connect a domain to start collecting privacy-friendly analytics. You can add more sites later from the dashboard.
|
||||
</p>
|
||||
<Link href="/sites/new">
|
||||
@@ -509,8 +306,56 @@ export default function HomePage() {
|
||||
)}
|
||||
|
||||
{(sitesLoading || sites.length > 0) && (
|
||||
<SiteList sites={sites} siteStats={siteStats} loading={sitesLoading} onDelete={handleDelete} />
|
||||
<SiteList sites={sites} siteStats={siteStats} loading={sitesLoading} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DeleteSiteModal
|
||||
open={!!permanentDeleteSiteModal}
|
||||
onClose={() => setPermanentDeleteSiteModal(null)}
|
||||
onDeleted={loadSites}
|
||||
siteName={permanentDeleteSiteModal?.name || ''}
|
||||
siteDomain={permanentDeleteSiteModal?.domain || ''}
|
||||
siteId={permanentDeleteSiteModal?.id || ''}
|
||||
permanentOnly
|
||||
/>
|
||||
|
||||
{deletedSites.length > 0 && (
|
||||
<div className="mt-8">
|
||||
<h3 className="text-sm font-medium text-neutral-400 mb-4">Scheduled for Deletion</h3>
|
||||
<div className="space-y-3">
|
||||
{deletedSites.map((site) => {
|
||||
const purgeAt = site.deleted_at ? new Date(new Date(site.deleted_at).getTime() + 7 * 24 * 60 * 60 * 1000) : null
|
||||
const daysLeft = purgeAt ? Math.max(0, Math.ceil((purgeAt.getTime() - Date.now()) / (1000 * 60 * 60 * 24))) : 0
|
||||
|
||||
return (
|
||||
<div key={site.id} className="flex items-center justify-between p-4 rounded-xl border border-neutral-800 bg-neutral-900/50 opacity-60">
|
||||
<div>
|
||||
<span className="font-medium text-neutral-300">{site.name}</span>
|
||||
<span className="ml-2 text-sm text-neutral-400">{site.domain}</span>
|
||||
<span className="ml-3 inline-flex items-center rounded-full bg-red-900/20 px-2 py-0.5 text-xs font-medium text-red-400">
|
||||
Deleting in {daysLeft} day{daysLeft !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleRestore(site.id)}
|
||||
className="px-3 py-1.5 text-xs font-medium text-neutral-300 border border-neutral-700 rounded-lg hover:bg-neutral-800 transition-colors"
|
||||
>
|
||||
Restore
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handlePermanentDelete(site.id)}
|
||||
className="px-3 py-1.5 text-xs font-medium text-red-400 border border-red-900 rounded-lg hover:bg-red-900/20 transition-colors"
|
||||
>
|
||||
Delete Now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -19,8 +19,8 @@ export default function PricingPage() {
|
||||
<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 className="h-10 w-64 animate-pulse rounded bg-neutral-800 mx-auto mb-4" />
|
||||
<div className="h-5 w-96 animate-pulse rounded bg-neutral-800 mx-auto" />
|
||||
</div>
|
||||
<PricingCardsSkeleton />
|
||||
</div>
|
||||
|
||||
50
app/robots.ts
Normal file
50
app/robots.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { MetadataRoute } from 'next'
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: [
|
||||
{
|
||||
userAgent: '*',
|
||||
allow: [
|
||||
'/',
|
||||
'/about',
|
||||
'/features',
|
||||
'/pricing',
|
||||
'/faq',
|
||||
'/changelog',
|
||||
'/installation',
|
||||
'/integrations',
|
||||
],
|
||||
disallow: [
|
||||
'/api/',
|
||||
'/admin/',
|
||||
'/sites/',
|
||||
'/notifications/',
|
||||
'/onboarding/',
|
||||
'/org-settings/',
|
||||
'/welcome/',
|
||||
'/auth/',
|
||||
'/actions/',
|
||||
'/share/',
|
||||
],
|
||||
},
|
||||
{
|
||||
userAgent: 'GPTBot',
|
||||
disallow: ['/'],
|
||||
},
|
||||
{
|
||||
userAgent: 'ChatGPT-User',
|
||||
disallow: ['/'],
|
||||
},
|
||||
{
|
||||
userAgent: 'Google-Extended',
|
||||
disallow: ['/'],
|
||||
},
|
||||
{
|
||||
userAgent: 'CCBot',
|
||||
disallow: ['/'],
|
||||
},
|
||||
],
|
||||
sitemap: 'https://pulse.ciphera.net/sitemap.xml',
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { FAVICON_SERVICE_URL } from '@/lib/utils/icons'
|
||||
import { FAVICON_SERVICE_URL } from '@/lib/utils/favicon'
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8082'
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import Image from 'next/image'
|
||||
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, authenticatePublicDashboard, type DashboardData, type Stats, type DailyStat } from '@/lib/api/stats'
|
||||
import { toast } from '@ciphera-net/ui'
|
||||
import { getAuthErrorMessage } from '@ciphera-net/ui'
|
||||
import { ApiError } from '@/lib/api/client'
|
||||
@@ -13,11 +13,10 @@ import TopPages from '@/components/dashboard/ContentStats'
|
||||
import TopReferrers from '@/components/dashboard/TopReferrers'
|
||||
import Locations from '@/components/dashboard/Locations'
|
||||
import TechSpecs from '@/components/dashboard/TechSpecs'
|
||||
import PerformanceStats from '@/components/dashboard/PerformanceStats'
|
||||
import { Select, DatePicker as DatePickerModal, Captcha, DownloadIcon, ZapIcon } from '@ciphera-net/ui'
|
||||
import { DashboardSkeleton, useMinimumLoading } from '@/components/skeletons'
|
||||
import { DashboardSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
|
||||
import ExportModal from '@/components/dashboard/ExportModal'
|
||||
import { FAVICON_SERVICE_URL } from '@/lib/utils/icons'
|
||||
import { FAVICON_SERVICE_URL } from '@/lib/utils/favicon'
|
||||
|
||||
// Helper to get date ranges
|
||||
const getDateRange = (days: number) => {
|
||||
@@ -41,7 +40,9 @@ export default function PublicDashboardPage() {
|
||||
const [data, setData] = useState<DashboardData | null>(null)
|
||||
const [password, setPassword] = useState(passwordParam || '')
|
||||
const [isPasswordProtected, setIsPasswordProtected] = useState(false)
|
||||
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
||||
const [authLoading, setAuthLoading] = useState(false)
|
||||
|
||||
// Captcha State
|
||||
const [captchaId, setCaptchaId] = useState('')
|
||||
const [captchaSolution, setCaptchaSolution] = useState('')
|
||||
@@ -92,81 +93,42 @@ export default function PublicDashboardPage() {
|
||||
|
||||
const loadRealtime = useCallback(async () => {
|
||||
try {
|
||||
const auth = {
|
||||
password,
|
||||
captcha: {
|
||||
captcha_id: captchaId,
|
||||
captcha_solution: captchaSolution,
|
||||
captcha_token: captchaToken
|
||||
}
|
||||
}
|
||||
const realtimeData = await getPublicRealtime(siteId, auth)
|
||||
const realtimeData = await getPublicRealtime(siteId)
|
||||
if (data) {
|
||||
setData({
|
||||
...data,
|
||||
realtime_visitors: realtimeData.visitors
|
||||
})
|
||||
setData({ ...data, realtime_visitors: realtimeData.visitors })
|
||||
}
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// Silently fail for realtime updates
|
||||
}
|
||||
}, [siteId, password, captchaId, captchaSolution, captchaToken, data])
|
||||
}, [siteId, data])
|
||||
|
||||
const loadDashboard = useCallback(async (silent = false) => {
|
||||
try {
|
||||
if (!silent) setLoading(true)
|
||||
|
||||
|
||||
const interval = dateRange.start === dateRange.end ? todayInterval : multiDayInterval
|
||||
const auth = {
|
||||
password,
|
||||
captcha: {
|
||||
captcha_id: captchaId,
|
||||
captcha_solution: captchaSolution,
|
||||
captcha_token: captchaToken
|
||||
}
|
||||
}
|
||||
|
||||
const [dashboardData, prevStatsData, prevDailyStatsData] = await Promise.all([
|
||||
getPublicDashboard(
|
||||
siteId,
|
||||
dateRange.start,
|
||||
dateRange.end,
|
||||
10,
|
||||
interval,
|
||||
password,
|
||||
auth.captcha
|
||||
),
|
||||
getPublicDashboard(siteId, dateRange.start, dateRange.end, 10, interval),
|
||||
(async () => {
|
||||
const prevRange = getPreviousDateRange(dateRange.start, dateRange.end)
|
||||
return getPublicStats(siteId, prevRange.start, prevRange.end, auth)
|
||||
return getPublicStats(siteId, prevRange.start, prevRange.end)
|
||||
})(),
|
||||
(async () => {
|
||||
const prevRange = getPreviousDateRange(dateRange.start, dateRange.end)
|
||||
return getPublicDailyStats(siteId, prevRange.start, prevRange.end, interval, auth)
|
||||
return getPublicDailyStats(siteId, prevRange.start, prevRange.end, interval)
|
||||
})()
|
||||
])
|
||||
|
||||
|
||||
setData(dashboardData)
|
||||
setPrevStats(prevStatsData)
|
||||
setPrevDailyStats(prevDailyStatsData)
|
||||
setLastUpdatedAt(Date.now())
|
||||
|
||||
setIsPasswordProtected(false)
|
||||
// Reset captcha
|
||||
setCaptchaId('')
|
||||
setCaptchaSolution('')
|
||||
setCaptchaToken('')
|
||||
} catch (error: unknown) {
|
||||
const apiErr = error instanceof ApiError ? error : null
|
||||
if (apiErr?.status === 401 && (apiErr.data as Record<string, unknown>)?.is_protected) {
|
||||
setIsPasswordProtected(true)
|
||||
if (password) {
|
||||
toast.error('Invalid password or captcha')
|
||||
// Reset captcha on failure
|
||||
setCaptchaId('')
|
||||
setCaptchaSolution('')
|
||||
setCaptchaToken('')
|
||||
}
|
||||
} else if (apiErr?.status === 404) {
|
||||
toast.error('Site not found')
|
||||
} else if (!silent) {
|
||||
@@ -175,7 +137,7 @@ export default function PublicDashboardPage() {
|
||||
} finally {
|
||||
if (!silent) setLoading(false)
|
||||
}
|
||||
}, [siteId, dateRange, todayInterval, multiDayInterval, password, captchaId, captchaSolution, captchaToken])
|
||||
}, [siteId, dateRange, todayInterval, multiDayInterval])
|
||||
|
||||
// * Auto-refresh interval: chart, KPIs, and realtime count update every 30 seconds
|
||||
useEffect(() => {
|
||||
@@ -186,18 +148,40 @@ export default function PublicDashboardPage() {
|
||||
}, 30000)
|
||||
return () => clearInterval(interval)
|
||||
}
|
||||
}, [data, isPasswordProtected, dateRange, todayInterval, multiDayInterval, password, loadDashboard, loadRealtime])
|
||||
}, [data, isPasswordProtected, dateRange, todayInterval, multiDayInterval, loadDashboard, loadRealtime])
|
||||
|
||||
useEffect(() => {
|
||||
loadDashboard()
|
||||
}, [siteId, dateRange, todayInterval, multiDayInterval, loadDashboard])
|
||||
|
||||
const handlePasswordSubmit = (e: React.FormEvent) => {
|
||||
const handlePasswordSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
loadDashboard()
|
||||
setAuthLoading(true)
|
||||
try {
|
||||
await authenticatePublicDashboard(siteId, password, captchaToken, captchaId, captchaSolution)
|
||||
// Cookie is now set — load dashboard (cookie sent automatically)
|
||||
setIsAuthenticated(true)
|
||||
await loadDashboard()
|
||||
} catch (error: unknown) {
|
||||
const apiErr = error instanceof ApiError ? error : null
|
||||
if (apiErr?.status === 401) {
|
||||
const errData = apiErr.data as Record<string, unknown> | undefined
|
||||
const errMsg = errData?.error as string | undefined
|
||||
toast.error(errMsg || 'Invalid password or captcha')
|
||||
} else {
|
||||
toast.error('Authentication failed')
|
||||
}
|
||||
// Reset captcha on failure
|
||||
setCaptchaId('')
|
||||
setCaptchaSolution('')
|
||||
setCaptchaToken('')
|
||||
} finally {
|
||||
setAuthLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const showSkeleton = useMinimumLoading(loading && !data && !isPasswordProtected)
|
||||
const fadeClass = useSkeletonFade(showSkeleton)
|
||||
|
||||
if (showSkeleton) {
|
||||
return <DashboardSkeleton />
|
||||
@@ -211,7 +195,7 @@ export default function PublicDashboardPage() {
|
||||
<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" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-2">
|
||||
<h1 className="text-2xl font-bold text-white mb-2">
|
||||
Protected Dashboard
|
||||
</h1>
|
||||
<p className="text-neutral-600 dark:text-neutral-400">
|
||||
@@ -226,7 +210,7 @@ export default function PublicDashboardPage() {
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter password"
|
||||
className="w-full px-4 py-2 border border-neutral-300 dark:border-neutral-700 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-white focus:ring-2 focus:ring-brand-orange focus:border-transparent"
|
||||
className="w-full px-4 py-2 border border-neutral-300 dark:border-neutral-700 rounded-lg bg-white dark:bg-neutral-800 text-white focus:ring-2 focus:ring-brand-orange focus:border-transparent"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
@@ -238,6 +222,7 @@ export default function PublicDashboardPage() {
|
||||
setCaptchaToken(token || '')
|
||||
}}
|
||||
apiUrl={process.env.NEXT_PUBLIC_CAPTCHA_API_URL}
|
||||
action="share-access"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
@@ -255,7 +240,7 @@ export default function PublicDashboardPage() {
|
||||
|
||||
if (!data) return null
|
||||
|
||||
const { site, stats, daily_stats, top_pages, entry_pages, exit_pages, top_referrers, countries, cities, regions, browsers, os, devices, screen_resolutions, performance, performance_by_page, realtime_visitors } = data
|
||||
const { site, stats, daily_stats, top_pages, entry_pages, exit_pages, top_referrers, countries, cities, regions, browsers, os, devices, screen_resolutions, realtime_visitors } = data
|
||||
|
||||
// Provide defaults for potentially undefined data
|
||||
const safeDailyStats = daily_stats || []
|
||||
@@ -273,7 +258,7 @@ export default function PublicDashboardPage() {
|
||||
const safeScreenResolutions = screen_resolutions || []
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<div className={`min-h-screen ${fadeClass}`}>
|
||||
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
@@ -285,7 +270,7 @@ export default function PublicDashboardPage() {
|
||||
<div className="w-2 h-2 rounded-full bg-brand-orange animate-pulse" />
|
||||
<span className="text-sm font-medium text-brand-orange uppercase tracking-wider">Public Dashboard</span>
|
||||
</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-white flex items-center gap-3">
|
||||
<Image
|
||||
src={`${FAVICON_SERVICE_URL}?domain=${site.domain}&sz=64`}
|
||||
alt={site.name}
|
||||
@@ -393,29 +378,6 @@ export default function PublicDashboardPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Performance Stats - Only show if enabled */}
|
||||
{performance && data.site?.enable_performance_insights && (
|
||||
<div className="mb-8">
|
||||
<PerformanceStats
|
||||
stats={performance}
|
||||
performanceByPage={performance_by_page}
|
||||
siteId={siteId}
|
||||
startDate={dateRange.start}
|
||||
endDate={dateRange.end}
|
||||
getPerformanceByPage={(siteId, startDate, endDate, opts) => {
|
||||
return getPublicPerformanceByPage(siteId, startDate, endDate, opts, {
|
||||
password,
|
||||
captcha: {
|
||||
captcha_id: captchaId,
|
||||
captcha_solution: captchaSolution,
|
||||
captcha_token: captchaToken
|
||||
}
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Details Grid */}
|
||||
<div className="grid gap-6 lg:grid-cols-2 mb-8">
|
||||
<TopPages
|
||||
|
||||
49
app/sitemap.ts
Normal file
49
app/sitemap.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { MetadataRoute } from 'next'
|
||||
import { integrations } from '@/lib/integrations'
|
||||
import { getIntegrationGuides } from '@/lib/integration-content'
|
||||
|
||||
const BASE_URL = 'https://pulse.ciphera.net'
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
const guides = getIntegrationGuides()
|
||||
const guidesBySlug = new Map(guides.map((g) => [g.slug, g]))
|
||||
|
||||
const publicRoutes = [
|
||||
{ url: '', priority: 1.0, changeFrequency: 'weekly' as const },
|
||||
{ url: '/about', priority: 0.8, changeFrequency: 'monthly' as const },
|
||||
{ url: '/features', priority: 0.9, changeFrequency: 'monthly' as const },
|
||||
{ url: '/pricing', priority: 0.9, changeFrequency: 'monthly' as const },
|
||||
{ url: '/faq', priority: 0.7, changeFrequency: 'monthly' as const },
|
||||
{ url: '/changelog', priority: 0.6, changeFrequency: 'weekly' as const },
|
||||
{ url: '/installation', priority: 0.8, changeFrequency: 'monthly' as const },
|
||||
{ url: '/integrations', priority: 0.8, changeFrequency: 'monthly' as const },
|
||||
{ url: '/integrations/script-tag', priority: 0.6, changeFrequency: 'monthly' as const },
|
||||
]
|
||||
|
||||
const integrationRoutes = integrations
|
||||
.filter((i) => i.dedicatedPage)
|
||||
.map((i) => {
|
||||
const guide = guidesBySlug.get(i.id)
|
||||
return {
|
||||
url: `/integrations/${i.id}`,
|
||||
priority: 0.7,
|
||||
changeFrequency: 'monthly' as const,
|
||||
lastModified: guide?.date ? new Date(guide.date) : new Date('2026-03-28'),
|
||||
}
|
||||
})
|
||||
|
||||
return [
|
||||
...publicRoutes.map((route) => ({
|
||||
url: `${BASE_URL}${route.url}`,
|
||||
lastModified: new Date('2026-03-28'),
|
||||
changeFrequency: route.changeFrequency,
|
||||
priority: route.priority,
|
||||
})),
|
||||
...integrationRoutes.map((route) => ({
|
||||
url: `${BASE_URL}${route.url}`,
|
||||
lastModified: route.lastModified,
|
||||
changeFrequency: route.changeFrequency,
|
||||
priority: route.priority,
|
||||
})),
|
||||
]
|
||||
}
|
||||
17
app/sites/[id]/SiteLayoutShell.tsx
Normal file
17
app/sites/[id]/SiteLayoutShell.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
'use client'
|
||||
|
||||
import DashboardShell from '@/components/dashboard/DashboardShell'
|
||||
|
||||
export default function SiteLayoutShell({
|
||||
siteId,
|
||||
children,
|
||||
}: {
|
||||
siteId: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<DashboardShell siteId={siteId}>
|
||||
{children}
|
||||
</DashboardShell>
|
||||
)
|
||||
}
|
||||
13
app/sites/[id]/behavior/error.tsx
Normal file
13
app/sites/[id]/behavior/error.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import ErrorDisplay from '@/components/ErrorDisplay'
|
||||
|
||||
export default function BehaviorError({ reset }: { error: Error; reset: () => void }) {
|
||||
return (
|
||||
<ErrorDisplay
|
||||
title="Behavior data failed to load"
|
||||
message="We couldn't load the frustration signals. This might be a temporary issue — try again."
|
||||
onRetry={reset}
|
||||
/>
|
||||
)
|
||||
}
|
||||
160
app/sites/[id]/behavior/page.tsx
Normal file
160
app/sites/[id]/behavior/page.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { getDateRange, formatDate, getThisWeekRange, getThisMonthRange } from '@/lib/utils/dateRanges'
|
||||
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'
|
||||
import { BehaviorSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
|
||||
|
||||
const ScrollDepth = dynamic(() => import('@/components/dashboard/ScrollDepth'))
|
||||
|
||||
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)
|
||||
|
||||
const showSkeleton = useMinimumLoading(loading && !behavior)
|
||||
const fadeClass = useSkeletonFade(showSkeleton)
|
||||
|
||||
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 ?? []
|
||||
|
||||
if (showSkeleton) return <BehaviorSkeleton />
|
||||
|
||||
return (
|
||||
<div className={`w-full max-w-7xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-neutral-200 mb-1">
|
||||
Behavior
|
||||
</h1>
|
||||
<p className="text-sm 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 [&>*]:min-w-0">
|
||||
<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 [&>*]:min-w-0">
|
||||
<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]/cdn/error.tsx
Normal file
13
app/sites/[id]/cdn/error.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import ErrorDisplay from '@/components/ErrorDisplay'
|
||||
|
||||
export default function CDNError({ reset }: { error: Error; reset: () => void }) {
|
||||
return (
|
||||
<ErrorDisplay
|
||||
title="CDN data failed to load"
|
||||
message="We couldn't load the BunnyCDN data. This might be a temporary issue — try again."
|
||||
onRetry={reset}
|
||||
/>
|
||||
)
|
||||
}
|
||||
548
app/sites/[id]/cdn/page.tsx
Normal file
548
app/sites/[id]/cdn/page.tsx
Normal file
@@ -0,0 +1,548 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useUnifiedSettings } from '@/lib/unified-settings-context'
|
||||
import * as Flags from 'country-flag-icons/react/3x2'
|
||||
|
||||
const DottedMap = dynamic(() => import('@/components/dashboard/DottedMap'), { ssr: false })
|
||||
import { getDateRange, formatDate, Select } from '@ciphera-net/ui'
|
||||
import { ArrowSquareOut, CloudArrowUp } from '@phosphor-icons/react'
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
AreaChart,
|
||||
Area,
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
} from 'recharts'
|
||||
import { useDashboard, useBunnyStatus, useBunnyOverview, useBunnyDailyStats, useBunnyTopCountries } from '@/lib/swr/dashboard'
|
||||
import { SkeletonLine, StatCardSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────
|
||||
|
||||
// US state codes → map to "US" for the dotted map
|
||||
const US_STATES = new Set([
|
||||
'AL','AK','AZ','AR','CO','CT','DC','DE','FL','GA','HI','ID','IL','IN','IA',
|
||||
'KS','KY','LA','ME','MD','MA','MI','MN','MS','MO','MT','NE','NV','NH','NJ',
|
||||
'NM','NY','NC','ND','OH','OK','OR','PA','RI','SC','SD','TN','TX','UT','VT',
|
||||
'VA','WA','WV','WI','WY',
|
||||
])
|
||||
// Canadian province codes → map to "CA"
|
||||
const CA_PROVINCES = new Set(['AB','BC','MB','NB','NL','NS','NT','NU','ON','PE','QC','SK','YT'])
|
||||
|
||||
/**
|
||||
* Extract ISO country code from BunnyCDN datacenter string.
|
||||
* e.g. "EU: Zurich, CH" → "CH", "NA: Chicago, IL" → "US", "NA: Toronto, CA" → "CA"
|
||||
*/
|
||||
function extractCountryCode(datacenter: string): string {
|
||||
const parts = datacenter.split(', ')
|
||||
const code = parts[parts.length - 1]?.trim().toUpperCase()
|
||||
if (!code || code.length !== 2) return ''
|
||||
if (US_STATES.has(code)) return 'US'
|
||||
if (CA_PROVINCES.has(code)) return 'CA'
|
||||
return code
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the city name from a BunnyCDN datacenter string.
|
||||
* e.g. "EU: Zurich, CH" → "Zurich"
|
||||
*/
|
||||
function extractCity(datacenter: string): string {
|
||||
const afterColon = datacenter.split(': ')[1] || datacenter
|
||||
return afterColon.split(',')[0]?.trim() || datacenter
|
||||
}
|
||||
|
||||
/** Get flag icon component for a country code */
|
||||
function getFlagIcon(code: string) {
|
||||
if (!code) return null
|
||||
const FlagComponent = (Flags as Record<string, React.ComponentType<{ className?: string }>>)[code]
|
||||
return FlagComponent ? <FlagComponent className="w-5 h-3.5 rounded-sm shadow-sm shrink-0" /> : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Map each datacenter entry to its country's centroid for the dotted map.
|
||||
* Each datacenter gets its own dot (sized by bandwidth) at the country's position.
|
||||
*/
|
||||
function mapToCountryCentroids(data: Array<{ country_code: string; bandwidth: number }>): Array<{ country: string; pageviews: number }> {
|
||||
return data
|
||||
.map((row) => ({
|
||||
country: extractCountryCode(row.country_code),
|
||||
pageviews: row.bandwidth,
|
||||
}))
|
||||
.filter((d) => d.country !== '')
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024))
|
||||
const value = bytes / Math.pow(1024, i)
|
||||
return value.toFixed(i === 0 ? 0 : 1) + ' ' + units[i]
|
||||
}
|
||||
|
||||
function formatNumber(n: number): string {
|
||||
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M'
|
||||
if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K'
|
||||
return n.toLocaleString()
|
||||
}
|
||||
|
||||
function formatDateShort(date: string): string {
|
||||
const d = new Date(date + 'T00:00:00')
|
||||
return d.getDate() + ' ' + d.toLocaleString('en-US', { month: 'short' })
|
||||
}
|
||||
|
||||
function changePercent(
|
||||
current: number,
|
||||
prev: number
|
||||
): { value: number; positive: boolean } | null {
|
||||
if (prev === 0) return null
|
||||
const pct = ((current - prev) / prev) * 100
|
||||
return { value: pct, positive: pct >= 0 }
|
||||
}
|
||||
|
||||
// ─── Page ───────────────────────────────────────────────────────
|
||||
|
||||
export default function CDNPage() {
|
||||
const params = useParams()
|
||||
const siteId = params.id as string
|
||||
|
||||
// Date range
|
||||
const [period, setPeriod] = useState('7')
|
||||
const [dateRange, setDateRange] = useState(() => getDateRange(7))
|
||||
|
||||
const { openUnifiedSettings } = useUnifiedSettings()
|
||||
|
||||
// Data fetching
|
||||
const { data: bunnyStatus } = useBunnyStatus(siteId)
|
||||
const { data: dashboard } = useDashboard(siteId, dateRange.start, dateRange.end)
|
||||
const { data: overview } = useBunnyOverview(siteId, dateRange.start, dateRange.end)
|
||||
const { data: dailyStats } = useBunnyDailyStats(siteId, dateRange.start, dateRange.end)
|
||||
const { data: topCountries } = useBunnyTopCountries(siteId, dateRange.start, dateRange.end)
|
||||
|
||||
const showSkeleton = useMinimumLoading(!bunnyStatus)
|
||||
const fadeClass = useSkeletonFade(showSkeleton)
|
||||
|
||||
// Document title
|
||||
useEffect(() => {
|
||||
const domain = dashboard?.site?.domain
|
||||
document.title = domain ? `CDN \u00b7 ${domain} | Pulse` : 'CDN | Pulse'
|
||||
}, [dashboard?.site?.domain])
|
||||
|
||||
// ─── Loading skeleton ─────────────────────────────────────
|
||||
|
||||
if (showSkeleton) {
|
||||
return (
|
||||
<div className="w-full max-w-7xl mx-auto px-4 sm:px-6 pb-8">
|
||||
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<SkeletonLine className="h-8 w-48 mb-2" />
|
||||
<SkeletonLine className="h-4 w-64" />
|
||||
</div>
|
||||
<SkeletonLine className="h-9 w-36 rounded-lg" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4 mb-8">
|
||||
<StatCardSkeleton />
|
||||
<StatCardSkeleton />
|
||||
<StatCardSkeleton />
|
||||
<StatCardSkeleton />
|
||||
<StatCardSkeleton />
|
||||
</div>
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-xl p-6 mb-6">
|
||||
<SkeletonLine className="h-6 w-40 mb-4" />
|
||||
<SkeletonLine className="h-64 w-full rounded-lg" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-xl p-6">
|
||||
<SkeletonLine className="h-6 w-32 mb-4" />
|
||||
<SkeletonLine className="h-48 w-full rounded-lg" />
|
||||
</div>
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-xl p-6">
|
||||
<SkeletonLine className="h-6 w-32 mb-4" />
|
||||
<SkeletonLine className="h-48 w-full rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Not connected state ──────────────────────────────────
|
||||
|
||||
if (bunnyStatus && !bunnyStatus.connected) {
|
||||
return (
|
||||
<div className={`w-full max-w-7xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
|
||||
<div className="flex flex-col items-center justify-center py-24 text-center">
|
||||
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-5 mb-6">
|
||||
<CloudArrowUp size={40} className="text-neutral-400 dark:text-neutral-500" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-white mb-2">
|
||||
Connect BunnyCDN
|
||||
</h2>
|
||||
<p className="text-sm text-neutral-400 max-w-md mb-6">
|
||||
Monitor your CDN performance including bandwidth usage, cache hit rates, request volumes, and geographic distribution.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => openUnifiedSettings({ context: 'site', tab: 'integrations' })}
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-brand-orange-button hover:bg-brand-orange-button-hover text-white text-sm font-medium transition-colors cursor-pointer"
|
||||
>
|
||||
Connect in Settings
|
||||
<ArrowSquareOut size={16} weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Connected — main view ────────────────────────────────
|
||||
|
||||
const bandwidthChange = overview ? changePercent(overview.total_bandwidth, overview.prev_total_bandwidth) : null
|
||||
const requestsChange = overview ? changePercent(overview.total_requests, overview.prev_total_requests) : null
|
||||
const cacheHitChange = overview ? changePercent(overview.cache_hit_rate, overview.prev_cache_hit_rate) : null
|
||||
const originChange = overview ? changePercent(overview.avg_origin_response, overview.prev_avg_origin_response) : null
|
||||
const errorsChange = overview ? changePercent(overview.total_errors, overview.prev_total_errors) : null
|
||||
|
||||
const daily = dailyStats?.daily_stats ?? []
|
||||
const countries = topCountries?.countries ?? []
|
||||
const totalBandwidth = countries.reduce((sum, row) => sum + row.bandwidth, 0)
|
||||
|
||||
return (
|
||||
<div className={`w-full max-w-7xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-neutral-200 mb-1">
|
||||
CDN Analytics
|
||||
</h1>
|
||||
<p className="text-sm text-neutral-400">
|
||||
BunnyCDN performance, bandwidth, and cache metrics
|
||||
</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 === '28') {
|
||||
setDateRange(getDateRange(28))
|
||||
setPeriod('28')
|
||||
} else if (value === '30') {
|
||||
setDateRange(getDateRange(30))
|
||||
setPeriod('30')
|
||||
}
|
||||
}}
|
||||
options={[
|
||||
{ value: 'today', label: 'Today' },
|
||||
{ value: '7', label: 'Last 7 days' },
|
||||
{ value: '28', label: 'Last 28 days' },
|
||||
{ value: '30', label: 'Last 30 days' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Overview cards */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4 mb-8">
|
||||
<OverviewCard
|
||||
label="Bandwidth"
|
||||
value={overview ? formatBytes(overview.total_bandwidth) : '-'}
|
||||
change={bandwidthChange}
|
||||
/>
|
||||
<OverviewCard
|
||||
label="Requests"
|
||||
value={overview ? formatNumber(overview.total_requests) : '-'}
|
||||
change={requestsChange}
|
||||
/>
|
||||
<OverviewCard
|
||||
label="Cache Hit Rate"
|
||||
value={overview ? overview.cache_hit_rate.toFixed(1) + '%' : '-'}
|
||||
change={cacheHitChange}
|
||||
/>
|
||||
<OverviewCard
|
||||
label="Origin Response"
|
||||
value={overview ? overview.avg_origin_response.toFixed(0) + 'ms' : '-'}
|
||||
change={originChange}
|
||||
invertColor
|
||||
/>
|
||||
<OverviewCard
|
||||
label="Errors"
|
||||
value={overview ? formatNumber(overview.total_errors) : '-'}
|
||||
change={errorsChange}
|
||||
invertColor
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Bandwidth chart */}
|
||||
<div className="rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-6 mb-6">
|
||||
<h2 className="text-sm font-semibold text-white mb-4">Bandwidth</h2>
|
||||
{daily.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<AreaChart data={daily} margin={{ top: 4, right: 4, bottom: 0, left: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="bandwidthGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#FD5E0F" stopOpacity={0.2} />
|
||||
<stop offset="100%" stopColor="#FD5E0F" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient id="cachedGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#22C55E" stopOpacity={0.15} />
|
||||
<stop offset="100%" stopColor="#22C55E" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-neutral-200 dark:text-neutral-800" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={formatDateShort}
|
||||
tick={{ fontSize: 12, fill: 'currentColor' }}
|
||||
className="text-neutral-400 dark:text-neutral-500"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={(v) => formatBytes(v)}
|
||||
tick={{ fontSize: 12, fill: 'currentColor' }}
|
||||
className="text-neutral-400 dark:text-neutral-500"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={60}
|
||||
/>
|
||||
<Tooltip
|
||||
content={({ active, payload, label }) => {
|
||||
if (!active || !payload?.length) return null
|
||||
return (
|
||||
<div className="rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 shadow-lg text-sm">
|
||||
<p className="text-neutral-400 mb-1">{formatDateShort(label)}</p>
|
||||
<p className="text-white font-medium">
|
||||
Total: {formatBytes(payload[0]?.value as number)}
|
||||
</p>
|
||||
{payload[1] && (
|
||||
<p className="text-green-600 dark:text-green-400">
|
||||
Cached: {formatBytes(payload[1]?.value as number)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="bandwidth_used"
|
||||
stroke="#FD5E0F"
|
||||
strokeWidth={2}
|
||||
fill="url(#bandwidthGrad)"
|
||||
name="Total"
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="bandwidth_cached"
|
||||
stroke="#22C55E"
|
||||
strokeWidth={2}
|
||||
fill="url(#cachedGrad)"
|
||||
name="Cached"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-[280px] flex items-center justify-center text-neutral-400 dark:text-neutral-500 text-sm">
|
||||
No bandwidth data for this period.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Requests + Errors charts side by side */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||
{/* Requests chart */}
|
||||
<div className="rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-6">
|
||||
<h2 className="text-sm font-semibold text-white mb-4">Requests</h2>
|
||||
{daily.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart data={daily} margin={{ top: 4, right: 4, bottom: 0, left: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-neutral-200 dark:text-neutral-800" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={formatDateShort}
|
||||
tick={{ fontSize: 11, fill: 'currentColor' }}
|
||||
className="text-neutral-400 dark:text-neutral-500"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={(v) => formatNumber(v)}
|
||||
tick={{ fontSize: 11, fill: 'currentColor' }}
|
||||
className="text-neutral-400 dark:text-neutral-500"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={50}
|
||||
/>
|
||||
<Tooltip
|
||||
content={({ active, payload, label }) => {
|
||||
if (!active || !payload?.length) return null
|
||||
return (
|
||||
<div className="rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 shadow-lg text-sm">
|
||||
<p className="text-neutral-400 mb-1">{formatDateShort(label)}</p>
|
||||
<p className="text-white font-medium">
|
||||
{formatNumber(payload[0]?.value as number)} requests
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="requests_served" fill="#FD5E0F" radius={[3, 3, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-[220px] flex items-center justify-center text-neutral-400 dark:text-neutral-500 text-sm">
|
||||
No request data for this period.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Errors chart */}
|
||||
<div className="rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-6">
|
||||
<h2 className="text-sm font-semibold text-white mb-4">Errors</h2>
|
||||
{daily.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart
|
||||
data={daily.map((d) => ({
|
||||
date: d.date,
|
||||
'3xx': d.error_3xx,
|
||||
'4xx': d.error_4xx,
|
||||
'5xx': d.error_5xx,
|
||||
}))}
|
||||
margin={{ top: 4, right: 4, bottom: 0, left: 0 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-neutral-200 dark:text-neutral-800" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={formatDateShort}
|
||||
tick={{ fontSize: 11, fill: 'currentColor' }}
|
||||
className="text-neutral-400 dark:text-neutral-500"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={(v) => formatNumber(v)}
|
||||
tick={{ fontSize: 11, fill: 'currentColor' }}
|
||||
className="text-neutral-400 dark:text-neutral-500"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={50}
|
||||
/>
|
||||
<Tooltip
|
||||
content={({ active, payload, label }) => {
|
||||
if (!active || !payload?.length) return null
|
||||
return (
|
||||
<div className="rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 shadow-lg text-sm">
|
||||
<p className="text-neutral-400 mb-1">{formatDateShort(label)}</p>
|
||||
{payload.map((entry) => (
|
||||
<p key={entry.name} style={{ color: entry.color }} className="font-medium">
|
||||
{entry.name}: {formatNumber(entry.value as number)}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="3xx" stackId="errors" fill="#FACC15" radius={[0, 0, 0, 0]} />
|
||||
<Bar dataKey="4xx" stackId="errors" fill="#F97316" radius={[0, 0, 0, 0]} />
|
||||
<Bar dataKey="5xx" stackId="errors" fill="#EF4444" radius={[3, 3, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-[220px] flex items-center justify-center text-neutral-400 dark:text-neutral-500 text-sm">
|
||||
No error data for this period.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Traffic Distribution */}
|
||||
<div className="rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-6">
|
||||
<h2 className="text-sm font-semibold text-white mb-4">Traffic Distribution</h2>
|
||||
{countries.length > 0 ? (
|
||||
<>
|
||||
<div className="h-[360px] mb-8">
|
||||
<DottedMap data={mapToCountryCentroids(countries)} formatValue={formatBytes} />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-x-6 gap-y-5">
|
||||
{countries.map((row) => {
|
||||
const pct = totalBandwidth > 0 ? (row.bandwidth / totalBandwidth) * 100 : 0
|
||||
const cc = extractCountryCode(row.country_code)
|
||||
const city = extractCity(row.country_code)
|
||||
return (
|
||||
<div key={row.country_code} className="group relative">
|
||||
<div className="flex items-center gap-2.5 mb-2">
|
||||
{cc && getFlagIcon(cc)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium text-white truncate block">{city}</span>
|
||||
</div>
|
||||
<span className="text-sm tabular-nums text-neutral-400 shrink-0">
|
||||
{formatBytes(row.bandwidth)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative h-1.5 bg-neutral-100 dark:bg-neutral-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 rounded-full bg-brand-orange transition-all"
|
||||
style={{ width: `${Math.max(pct, 1)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute -top-8 left-1/2 -translate-x-1/2 px-2 py-1 bg-neutral-900 dark:bg-neutral-700 text-white text-xs rounded shadow-lg opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap z-10">
|
||||
{pct.toFixed(1)}% of total traffic
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="h-[360px] flex items-center justify-center text-neutral-400 dark:text-neutral-500 text-sm">
|
||||
No geographic data for this period.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Sub-components ─────────────────────────────────────────────
|
||||
|
||||
function OverviewCard({
|
||||
label,
|
||||
value,
|
||||
change,
|
||||
invertColor = false,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
change: { value: number; positive: boolean } | null
|
||||
invertColor?: boolean
|
||||
}) {
|
||||
// For Origin Response and Errors, a decrease is good (green), an increase is bad (red)
|
||||
const isGood = change ? (invertColor ? !change.positive : change.positive) : false
|
||||
const isBad = change ? (invertColor ? change.positive : !change.positive) : false
|
||||
const changeLabel = change ? (change.positive ? '+' : '') + change.value.toFixed(1) + '%' : null
|
||||
|
||||
return (
|
||||
<div className="p-4 rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900">
|
||||
<p className="text-xs font-medium text-neutral-400 mb-1">{label}</p>
|
||||
<p className="text-2xl font-bold text-white">{value}</p>
|
||||
{changeLabel && (
|
||||
<p className={`text-xs mt-1 font-medium ${
|
||||
isGood ? 'text-green-600 dark:text-green-400' :
|
||||
isBad ? 'text-red-600 dark:text-red-400' :
|
||||
'text-neutral-400'
|
||||
}`}>
|
||||
{changeLabel} vs previous period
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
58
app/sites/[id]/funnels/[funnelId]/edit/page.tsx
Normal file
58
app/sites/[id]/funnels/[funnelId]/edit/page.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { useSWRConfig } from 'swr'
|
||||
import { getFunnel, updateFunnel, type Funnel, type CreateFunnelRequest } from '@/lib/api/funnels'
|
||||
import { toast } from '@ciphera-net/ui'
|
||||
import FunnelForm from '@/components/funnels/FunnelForm'
|
||||
import { FunnelDetailSkeleton } from '@/components/skeletons'
|
||||
|
||||
export default function EditFunnelPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const { mutate } = useSWRConfig()
|
||||
const siteId = params.id as string
|
||||
const funnelId = params.funnelId as string
|
||||
const [funnel, setFunnel] = useState<Funnel | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
getFunnel(siteId, funnelId).then(setFunnel).catch(() => {
|
||||
toast.error('Failed to load funnel')
|
||||
router.push(`/sites/${siteId}/funnels`)
|
||||
})
|
||||
}, [siteId, funnelId, router])
|
||||
|
||||
const handleSubmit = async (data: CreateFunnelRequest) => {
|
||||
try {
|
||||
setSaving(true)
|
||||
await updateFunnel(siteId, funnelId, data)
|
||||
await mutate(['funnels', siteId])
|
||||
toast.success('Funnel updated')
|
||||
router.push(`/sites/${siteId}/funnels/${funnelId}`)
|
||||
} catch {
|
||||
toast.error('Failed to update funnel. Please try again.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!funnel) return <FunnelDetailSkeleton />
|
||||
|
||||
return (
|
||||
<FunnelForm
|
||||
siteId={siteId}
|
||||
initialData={{
|
||||
name: funnel.name,
|
||||
description: funnel.description,
|
||||
steps: funnel.steps.map(({ order, ...rest }) => rest),
|
||||
conversion_window_value: funnel.conversion_window_value,
|
||||
conversion_window_unit: funnel.conversion_window_unit,
|
||||
}}
|
||||
onSubmit={handleSubmit}
|
||||
submitLabel={saving ? 'Saving...' : 'Save Changes'}
|
||||
cancelHref={`/sites/${siteId}/funnels/${funnelId}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,39 +1,20 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { ApiError } from '@/lib/api/client'
|
||||
import { getFunnel, getFunnelStats, deleteFunnel, type Funnel, type FunnelStats } from '@/lib/api/funnels'
|
||||
import { toast, Select, DatePicker, ChevronLeftIcon, ArrowRightIcon, TrashIcon, useTheme, Button } from '@ciphera-net/ui'
|
||||
import { FunnelDetailSkeleton, useMinimumLoading } from '@/components/skeletons'
|
||||
import { getFunnel, getFunnelStats, getFunnelTrends, deleteFunnel, type Funnel, type FunnelStats, type FunnelTrends } from '@/lib/api/funnels'
|
||||
import FilterBar from '@/components/dashboard/FilterBar'
|
||||
import AddFilterDropdown from '@/components/dashboard/AddFilterDropdown'
|
||||
import { type DimensionFilter, serializeFilters } from '@/lib/filters'
|
||||
import { toast, Select, DatePicker, ChevronLeftIcon, ArrowRightIcon, TrashIcon, Button } from '@ciphera-net/ui'
|
||||
import { PencilSimple } from '@phosphor-icons/react'
|
||||
import { FunnelDetailSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Cell
|
||||
} from 'recharts'
|
||||
import { FunnelChart } from '@/components/ui/funnel-chart'
|
||||
import { getDateRange } from '@ciphera-net/ui'
|
||||
|
||||
const CHART_COLORS_LIGHT = {
|
||||
border: 'var(--color-neutral-200)',
|
||||
axis: 'var(--color-neutral-400)',
|
||||
tooltipBg: '#ffffff',
|
||||
tooltipBorder: 'var(--color-neutral-200)',
|
||||
}
|
||||
|
||||
const CHART_COLORS_DARK = {
|
||||
border: 'var(--color-neutral-700)',
|
||||
axis: 'var(--color-neutral-500)',
|
||||
tooltipBg: 'var(--color-neutral-800)',
|
||||
tooltipBorder: 'var(--color-neutral-700)',
|
||||
}
|
||||
|
||||
const BRAND_ORANGE = 'var(--color-brand-orange)'
|
||||
import BreakdownDrawer from '@/components/funnels/BreakdownDrawer'
|
||||
import { ResponsiveContainer, LineChart, Line, XAxis, YAxis, Tooltip, CartesianGrid } from 'recharts'
|
||||
|
||||
export default function FunnelReportPage() {
|
||||
const params = useParams()
|
||||
@@ -44,21 +25,29 @@ export default function FunnelReportPage() {
|
||||
const [funnel, setFunnel] = useState<Funnel | null>(null)
|
||||
const [stats, setStats] = useState<FunnelStats | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [dateRange, setDateRange] = useState(getDateRange(30))
|
||||
const [dateRange, setDateRange] = useState(() => getDateRange(30))
|
||||
const [datePreset, setDatePreset] = useState<'7' | '30' | 'custom'>('30')
|
||||
const [isDatePickerOpen, setIsDatePickerOpen] = useState(false)
|
||||
const [loadError, setLoadError] = useState<'not_found' | 'forbidden' | 'error' | null>(null)
|
||||
const [filters, setFilters] = useState<DimensionFilter[]>([])
|
||||
const [expandedExitStep, setExpandedExitStep] = useState<number | null>(null)
|
||||
const [trends, setTrends] = useState<FunnelTrends | null>(null)
|
||||
const [visibleSteps, setVisibleSteps] = useState<Set<string>>(new Set())
|
||||
const [breakdownStep, setBreakdownStep] = useState<number | null>(null)
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoadError(null)
|
||||
try {
|
||||
setLoading(true)
|
||||
const [funnelData, statsData] = await Promise.all([
|
||||
const filterStr = serializeFilters(filters) || undefined
|
||||
const [funnelData, statsData, trendsData] = await Promise.all([
|
||||
getFunnel(siteId, funnelId),
|
||||
getFunnelStats(siteId, funnelId, dateRange.start, dateRange.end)
|
||||
getFunnelStats(siteId, funnelId, dateRange.start, dateRange.end, filterStr),
|
||||
getFunnelTrends(siteId, funnelId, dateRange.start, dateRange.end, 'day', filterStr)
|
||||
])
|
||||
setFunnel(funnelData)
|
||||
setStats(statsData)
|
||||
setTrends(trendsData)
|
||||
} catch (error) {
|
||||
const status = error instanceof ApiError ? error.status : 0
|
||||
if (status === 404) setLoadError('not_found')
|
||||
@@ -68,18 +57,12 @@ export default function FunnelReportPage() {
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [siteId, funnelId, dateRange])
|
||||
}, [siteId, funnelId, dateRange, filters])
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [loadData])
|
||||
|
||||
const { resolvedTheme } = useTheme()
|
||||
const chartColors = useMemo(
|
||||
() => (resolvedTheme === 'dark' ? CHART_COLORS_DARK : CHART_COLORS_LIGHT),
|
||||
[resolvedTheme]
|
||||
)
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm('Are you sure you want to delete this funnel?')) return
|
||||
|
||||
@@ -93,6 +76,7 @@ export default function FunnelReportPage() {
|
||||
}
|
||||
|
||||
const showSkeleton = useMinimumLoading(loading && !funnel)
|
||||
const fadeClass = useSkeletonFade(showSkeleton)
|
||||
|
||||
if (showSkeleton) {
|
||||
return <FunnelDetailSkeleton />
|
||||
@@ -100,7 +84,7 @@ export default function FunnelReportPage() {
|
||||
|
||||
if (loadError === 'not_found' || (!funnel && !stats && !loadError)) {
|
||||
return (
|
||||
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
|
||||
<div className="w-full max-w-7xl mx-auto px-4 sm:px-6 pb-8">
|
||||
<p className="text-neutral-600 dark:text-neutral-400">Funnel not found</p>
|
||||
</div>
|
||||
)
|
||||
@@ -108,7 +92,7 @@ export default function FunnelReportPage() {
|
||||
|
||||
if (loadError === 'forbidden') {
|
||||
return (
|
||||
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
|
||||
<div className="w-full max-w-7xl mx-auto px-4 sm:px-6 pb-8">
|
||||
<p className="text-neutral-600 dark:text-neutral-400">Access denied</p>
|
||||
<Link href={`/sites/${siteId}/funnels`}>
|
||||
<Button variant="primary" className="mt-4">
|
||||
@@ -121,7 +105,7 @@ export default function FunnelReportPage() {
|
||||
|
||||
if (loadError === 'error') {
|
||||
return (
|
||||
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
|
||||
<div className="w-full max-w-7xl mx-auto px-4 sm:px-6 pb-8">
|
||||
<p className="text-neutral-600 dark:text-neutral-400 mb-4">Unable to load funnel</p>
|
||||
<Button type="button" onClick={() => loadData()} variant="primary">
|
||||
Try again
|
||||
@@ -132,21 +116,34 @@ export default function FunnelReportPage() {
|
||||
|
||||
if (!funnel || !stats) {
|
||||
return (
|
||||
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
|
||||
<div className="w-full max-w-7xl mx-auto px-4 sm:px-6 pb-8">
|
||||
<p className="text-neutral-600 dark:text-neutral-400">Funnel not found</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const chartData = stats.steps.map(s => ({
|
||||
name: s.step.name,
|
||||
visitors: s.visitors,
|
||||
dropoff: s.dropoff,
|
||||
conversion: s.conversion
|
||||
label: s.step.name,
|
||||
value: s.visitors,
|
||||
}))
|
||||
|
||||
const STEP_COLORS = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#06B6D4', '#84CC16']
|
||||
|
||||
const trendsChartData = trends ? trends.dates.map((date, idx) => {
|
||||
const point: Record<string, any> = {
|
||||
date: new Date(date).toLocaleDateString('en-GB', { day: 'numeric', month: 'short' }),
|
||||
overall: Math.round(trends.overall[idx] * 10) / 10,
|
||||
}
|
||||
for (const [stepKey, values] of Object.entries(trends.steps)) {
|
||||
if (visibleSteps.has(stepKey)) {
|
||||
point[`step_${stepKey}`] = Math.round(values[idx] * 10) / 10
|
||||
}
|
||||
}
|
||||
return point
|
||||
}) : []
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
|
||||
<div className={`w-full max-w-7xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -157,7 +154,7 @@ export default function FunnelReportPage() {
|
||||
<ChevronLeftIcon className="w-5 h-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white">
|
||||
<h1 className="text-lg font-semibold text-neutral-200">
|
||||
{funnel.name}
|
||||
</h1>
|
||||
{funnel.description && (
|
||||
@@ -189,6 +186,13 @@ export default function FunnelReportPage() {
|
||||
]}
|
||||
/>
|
||||
|
||||
<Link
|
||||
href={`/sites/${siteId}/funnels/${funnelId}/edit`}
|
||||
className="p-2 text-neutral-400 hover:text-brand-orange hover:bg-orange-50 dark:hover:bg-orange-900/20 rounded-xl transition-colors"
|
||||
aria-label="Edit funnel"
|
||||
>
|
||||
<PencilSimple className="w-5 h-5" />
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="p-2 text-neutral-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-xl transition-colors"
|
||||
@@ -199,121 +203,199 @@ export default function FunnelReportPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap items-center gap-2 mb-6">
|
||||
<AddFilterDropdown
|
||||
onAdd={(f) => setFilters(prev => [...prev, f])}
|
||||
/>
|
||||
<FilterBar
|
||||
filters={filters}
|
||||
onRemove={(i) => setFilters(prev => prev.filter((_, idx) => idx !== i))}
|
||||
onClear={() => setFilters([])}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Chart */}
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden shadow-sm p-6 mb-8">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white mb-6">
|
||||
<div className="bg-neutral-900 border border-neutral-800 rounded-2xl overflow-hidden shadow-sm p-6 mb-8">
|
||||
<h3 className="text-lg font-semibold text-white mb-6">
|
||||
Funnel Visualization
|
||||
</h3>
|
||||
<div className="h-[400px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={chartData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={chartColors.border} />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
stroke={chartColors.axis}
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
stroke={chartColors.axis}
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: 'transparent' }}
|
||||
content={({ active, payload, label }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0].payload;
|
||||
return (
|
||||
<div
|
||||
className="p-3 rounded-xl shadow-lg border transition-shadow duration-300"
|
||||
style={{
|
||||
backgroundColor: chartColors.tooltipBg,
|
||||
borderColor: chartColors.tooltipBorder,
|
||||
}}
|
||||
>
|
||||
<p className="font-medium text-neutral-900 dark:text-white mb-1">{label}</p>
|
||||
<p className="text-brand-orange font-bold text-lg">
|
||||
{data.visitors.toLocaleString()} visitors
|
||||
</p>
|
||||
{data.dropoff > 0 && (
|
||||
<p className="text-red-500 text-sm">
|
||||
{Math.round(data.dropoff)}% drop-off
|
||||
</p>
|
||||
)}
|
||||
{data.conversion > 0 && (
|
||||
<p className="text-green-500 text-sm">
|
||||
{Math.round(data.conversion)}% conversion (overall)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="visitors" radius={[4, 4, 0, 0]} barSize={60}>
|
||||
{chartData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={BRAND_ORANGE} fillOpacity={Math.max(0.1, 1 - index * 0.15)} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<FunnelChart
|
||||
data={chartData}
|
||||
orientation="horizontal"
|
||||
color="var(--chart-1)"
|
||||
layers={3}
|
||||
labelLayout="grouped"
|
||||
labelAlign="center"
|
||||
labelOrientation="vertical"
|
||||
style={{ aspectRatio: '4 / 1' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Conversion Trends */}
|
||||
{trends && trends.dates.length > 1 && (
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden shadow-sm p-6 mb-8">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-white">
|
||||
Conversion Trends
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{stats?.steps.map((s, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setVisibleSteps(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(String(i))) next.delete(String(i))
|
||||
else next.add(String(i))
|
||||
return next
|
||||
})
|
||||
}}
|
||||
className={`px-2 py-1 text-xs rounded-md transition-colors ${
|
||||
visibleSteps.has(String(i))
|
||||
? 'bg-brand-orange/10 text-brand-orange border border-brand-orange/30'
|
||||
: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-500 border border-transparent'
|
||||
}`}
|
||||
>
|
||||
{s.step.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={trendsChartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-neutral-200 dark:text-neutral-700" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tick={{ fontSize: 12 }}
|
||||
className="text-neutral-500"
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, 100]}
|
||||
tickFormatter={(v) => `${v}%`}
|
||||
tick={{ fontSize: 12 }}
|
||||
className="text-neutral-500"
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number) => [`${value}%`]}
|
||||
contentStyle={{
|
||||
backgroundColor: 'var(--color-neutral-900, #171717)',
|
||||
border: '1px solid var(--color-neutral-700, #404040)',
|
||||
borderRadius: '8px',
|
||||
color: '#fff',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="overall"
|
||||
name="Overall"
|
||||
stroke="#F97316"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 4 }}
|
||||
/>
|
||||
{Array.from(visibleSteps).map((stepKey) => (
|
||||
<Line
|
||||
key={stepKey}
|
||||
type="monotone"
|
||||
dataKey={`step_${stepKey}`}
|
||||
name={stats?.steps[Number(stepKey)]?.step.name || `Step ${stepKey}`}
|
||||
stroke={STEP_COLORS[Number(stepKey) % STEP_COLORS.length]}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 4 }}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Detailed Stats Table */}
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<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">
|
||||
<tr>
|
||||
<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 dark:text-neutral-400 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">Drop-off</th>
|
||||
<th className="px-6 py-4 font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider text-right">Conversion</th>
|
||||
<th className="px-6 py-4 font-medium text-neutral-400 uppercase tracking-wider">Step</th>
|
||||
<th className="px-6 py-4 font-medium text-neutral-400 uppercase tracking-wider text-right">Visitors</th>
|
||||
<th className="px-6 py-4 font-medium text-neutral-400 uppercase tracking-wider text-right">Drop-off</th>
|
||||
<th className="px-6 py-4 font-medium text-neutral-400 uppercase tracking-wider text-right">Conversion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-800">
|
||||
{stats.steps.map((step, i) => (
|
||||
<tr key={step.step.name} className="hover:bg-neutral-50 dark:hover:bg-neutral-800/30 transition-colors">
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-6 h-6 rounded-full bg-neutral-100 dark:bg-neutral-800 flex items-center justify-center text-xs font-medium text-neutral-600 dark:text-neutral-400">
|
||||
{i + 1}
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-medium text-neutral-900 dark:text-white">{step.step.name}</p>
|
||||
<p className="text-neutral-500 dark:text-neutral-400 text-xs font-mono mt-0.5">{step.step.value}</p>
|
||||
<React.Fragment key={step.step.name}>
|
||||
<tr className="hover:bg-neutral-50 dark:hover:bg-neutral-800/30 transition-colors cursor-pointer" onClick={() => setBreakdownStep(i)}>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-6 h-6 rounded-full bg-neutral-100 dark:bg-neutral-800 flex items-center justify-center text-xs font-medium text-neutral-600 dark:text-neutral-400">
|
||||
{i + 1}
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-medium text-white">{step.step.name}</p>
|
||||
<p className="text-neutral-400 text-xs font-mono mt-0.5">{step.step.value}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<span className="font-medium text-neutral-900 dark:text-white">
|
||||
{step.visitors.toLocaleString()}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
{i > 0 ? (
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
step.dropoff > 50
|
||||
? 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400'
|
||||
: 'bg-neutral-100 text-neutral-800 dark:bg-neutral-800 dark:text-neutral-300'
|
||||
}`}>
|
||||
{Math.round(step.dropoff)}%
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<span className="font-medium text-white">
|
||||
{step.visitors.toLocaleString()}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-neutral-400">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<span className="text-green-600 dark:text-green-400 font-medium">
|
||||
{Math.round(step.conversion)}%
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
{i > 0 ? (
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
step.dropoff > 50
|
||||
? 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400'
|
||||
: 'bg-neutral-100 text-neutral-800 dark:bg-neutral-800 dark:text-neutral-300'
|
||||
}`}>
|
||||
{Math.round(step.dropoff)}%
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-neutral-400">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<span className="text-green-600 dark:text-green-400 font-medium">
|
||||
{Math.round(step.conversion)}%
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{step.exit_pages && step.exit_pages.length > 0 && (
|
||||
<tr className="bg-neutral-50/50 dark:bg-neutral-800/20">
|
||||
<td colSpan={4} className="px-6 py-3">
|
||||
<div className="ml-9">
|
||||
<p className="text-xs font-medium text-neutral-500 mb-2">
|
||||
Where visitors went after dropping off:
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(expandedExitStep === i ? step.exit_pages : step.exit_pages.slice(0, 3)).map(ep => (
|
||||
<span key={ep.path} className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg text-xs">
|
||||
<span className="font-mono text-neutral-600 dark:text-neutral-300">{ep.path}</span>
|
||||
<span className="text-neutral-400">{ep.visitors}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{step.exit_pages.length > 3 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpandedExitStep(expandedExitStep === i ? null : i)}
|
||||
className="mt-2 text-xs text-brand-orange hover:underline"
|
||||
>
|
||||
{expandedExitStep === i ? 'Show less' : `See all ${step.exit_pages.length} exit pages`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -321,6 +403,19 @@ export default function FunnelReportPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{breakdownStep !== null && stats && (
|
||||
<BreakdownDrawer
|
||||
siteId={siteId}
|
||||
funnelId={funnelId}
|
||||
stepIndex={breakdownStep}
|
||||
stepName={stats.steps[breakdownStep].step.name}
|
||||
startDate={dateRange.start}
|
||||
endDate={dateRange.end}
|
||||
filters={serializeFilters(filters) || undefined}
|
||||
onClose={() => setBreakdownStep(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DatePicker
|
||||
isOpen={isDatePickerOpen}
|
||||
onClose={() => setIsDatePickerOpen(false)}
|
||||
|
||||
@@ -2,88 +2,26 @@
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { createFunnel, type CreateFunnelRequest, type FunnelStep } from '@/lib/api/funnels'
|
||||
import { toast, Input, Button, ChevronLeftIcon, PlusIcon, TrashIcon } from '@ciphera-net/ui'
|
||||
import Link from 'next/link'
|
||||
|
||||
function isValidRegex(pattern: string): boolean {
|
||||
try {
|
||||
new RegExp(pattern)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
import { useSWRConfig } from 'swr'
|
||||
import { createFunnel, type CreateFunnelRequest } from '@/lib/api/funnels'
|
||||
import { toast } from '@ciphera-net/ui'
|
||||
import FunnelForm from '@/components/funnels/FunnelForm'
|
||||
|
||||
export default function CreateFunnelPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const { mutate } = useSWRConfig()
|
||||
const siteId = params.id as string
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
// * Backend requires at least one step (API binding min=1, DB rejects empty steps)
|
||||
const [steps, setSteps] = useState<Omit<FunnelStep, 'order'>[]>([
|
||||
{ name: 'Step 1', value: '/', type: 'exact' },
|
||||
{ name: 'Step 2', value: '', type: 'exact' }
|
||||
])
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const handleAddStep = () => {
|
||||
setSteps([...steps, { name: `Step ${steps.length + 1}`, value: '', type: 'exact' }])
|
||||
}
|
||||
|
||||
const handleRemoveStep = (index: number) => {
|
||||
if (steps.length <= 1) return
|
||||
const newSteps = steps.filter((_, i) => i !== index)
|
||||
setSteps(newSteps)
|
||||
}
|
||||
|
||||
const handleUpdateStep = (index: number, field: keyof Omit<FunnelStep, 'order'>, value: string) => {
|
||||
const newSteps = [...steps]
|
||||
newSteps[index] = { ...newSteps[index], [field]: value }
|
||||
setSteps(newSteps)
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!name.trim()) {
|
||||
toast.error('Please enter a funnel name')
|
||||
return
|
||||
}
|
||||
|
||||
if (steps.some(s => !s.name.trim())) {
|
||||
toast.error('Please enter a name for all steps')
|
||||
return
|
||||
}
|
||||
|
||||
if (steps.some(s => !s.value.trim())) {
|
||||
toast.error('Please enter a path for all steps')
|
||||
return
|
||||
}
|
||||
const invalidRegexStep = steps.find(s => s.type === 'regex' && !isValidRegex(s.value))
|
||||
if (invalidRegexStep) {
|
||||
toast.error(`Invalid regex pattern in step: ${invalidRegexStep.name}`)
|
||||
return
|
||||
}
|
||||
|
||||
const handleSubmit = async (data: CreateFunnelRequest) => {
|
||||
try {
|
||||
setSaving(true)
|
||||
const funnelSteps = steps.map((s, i) => ({
|
||||
...s,
|
||||
order: i + 1
|
||||
}))
|
||||
|
||||
await createFunnel(siteId, {
|
||||
name,
|
||||
description,
|
||||
steps: funnelSteps
|
||||
})
|
||||
|
||||
await createFunnel(siteId, data)
|
||||
await mutate(['funnels', siteId])
|
||||
toast.success('Funnel created')
|
||||
router.push(`/sites/${siteId}/funnels`)
|
||||
} catch (error) {
|
||||
} catch {
|
||||
toast.error('Failed to create funnel. Please try again.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
@@ -91,149 +29,11 @@ export default function CreateFunnelPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-3xl mx-auto px-4 sm:px-6 py-8">
|
||||
<div className="mb-8">
|
||||
<Link
|
||||
href={`/sites/${siteId}/funnels`}
|
||||
className="inline-flex items-center gap-2 text-sm text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white mb-6 rounded-xl hover:bg-neutral-100 dark:hover:bg-neutral-800 px-2 py-1.5 -ml-2 transition-colors"
|
||||
>
|
||||
<ChevronLeftIcon className="w-4 h-4" />
|
||||
Back to Funnels
|
||||
</Link>
|
||||
|
||||
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-2">
|
||||
Create New Funnel
|
||||
</h1>
|
||||
<p className="text-neutral-600 dark:text-neutral-400">
|
||||
Define the steps users take to complete a goal.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 mb-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
Funnel Name
|
||||
</label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Signup Flow"
|
||||
autoFocus
|
||||
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>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
Description (Optional)
|
||||
</label>
|
||||
<Input
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Tracks users from landing page to signup"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 mb-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
|
||||
Funnel Steps
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{steps.map((step, index) => (
|
||||
<div key={`step-${index}`} className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="mt-3 text-neutral-400">
|
||||
<div className="w-6 h-6 rounded-full bg-neutral-100 dark:bg-neutral-800 flex items-center justify-center text-sm font-medium text-neutral-600 dark:text-neutral-400">
|
||||
{index + 1}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-neutral-500 uppercase mb-1">
|
||||
Step Name
|
||||
</label>
|
||||
<Input
|
||||
value={step.name}
|
||||
onChange={(e) => handleUpdateStep(index, 'name', e.target.value)}
|
||||
placeholder="e.g. Landing Page"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-neutral-500 uppercase mb-1">
|
||||
Path / URL
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={step.type}
|
||||
onChange={(e) => handleUpdateStep(index, 'type', e.target.value)}
|
||||
className="w-24 px-2 py-2 bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-lg text-sm focus:ring-2 focus:ring-brand-orange/20 focus:border-brand-orange outline-none"
|
||||
>
|
||||
<option value="exact">Exact</option>
|
||||
<option value="contains">Contains</option>
|
||||
<option value="regex">Regex</option>
|
||||
</select>
|
||||
<Input
|
||||
value={step.value}
|
||||
onChange={(e) => handleUpdateStep(index, 'value', e.target.value)}
|
||||
placeholder={step.type === 'exact' ? '/pricing' : 'pricing'}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveStep(index)}
|
||||
disabled={steps.length <= 1}
|
||||
aria-label="Remove step"
|
||||
className={`mt-3 p-2 rounded-xl transition-colors ${
|
||||
steps.length <= 1
|
||||
? 'text-neutral-300 cursor-not-allowed'
|
||||
: 'text-neutral-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20'
|
||||
}`}
|
||||
>
|
||||
<TrashIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddStep}
|
||||
className="w-full py-3 border-2 border-dashed border-neutral-200 dark:border-neutral-800 rounded-xl text-neutral-500 hover:text-neutral-900 dark:hover:text-white hover:border-neutral-300 dark:hover:border-neutral-700 transition-colors flex items-center justify-center gap-2 font-medium"
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
Add Step
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-4">
|
||||
<Link href={`/sites/${siteId}/funnels`}>
|
||||
<Button variant="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
variant="primary"
|
||||
>
|
||||
{saving ? 'Creating...' : 'Create Funnel'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<FunnelForm
|
||||
siteId={siteId}
|
||||
onSubmit={handleSubmit}
|
||||
submitLabel={saving ? 'Creating...' : 'Create Funnel'}
|
||||
cancelHref={`/sites/${siteId}/funnels`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,35 +1,19 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { listFunnels, deleteFunnel, type Funnel } from '@/lib/api/funnels'
|
||||
import { deleteFunnel, type Funnel } from '@/lib/api/funnels'
|
||||
import { useFunnels } from '@/lib/swr/dashboard'
|
||||
import { toast, PlusIcon, ArrowRightIcon, ChevronLeftIcon, TrashIcon, Button } from '@ciphera-net/ui'
|
||||
import { FunnelsListSkeleton, useMinimumLoading } from '@/components/skeletons'
|
||||
import { FunnelsListSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
|
||||
export default function FunnelsPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const siteId = params.id as string
|
||||
|
||||
const [funnels, setFunnels] = useState<Funnel[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const loadFunnels = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const data = await listFunnels(siteId)
|
||||
setFunnels(data)
|
||||
} catch (error) {
|
||||
toast.error('Failed to load your funnels')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [siteId])
|
||||
|
||||
useEffect(() => {
|
||||
loadFunnels()
|
||||
}, [loadFunnels])
|
||||
const { data: funnels = [], isLoading, mutate } = useFunnels(siteId)
|
||||
|
||||
const handleDelete = async (e: React.MouseEvent, funnelId: string) => {
|
||||
e.preventDefault() // Prevent navigation
|
||||
@@ -38,52 +22,50 @@ export default function FunnelsPage() {
|
||||
try {
|
||||
await deleteFunnel(siteId, funnelId)
|
||||
toast.success('Funnel deleted')
|
||||
loadFunnels()
|
||||
mutate()
|
||||
} catch (error) {
|
||||
toast.error('Failed to delete funnel')
|
||||
}
|
||||
}
|
||||
|
||||
const showSkeleton = useMinimumLoading(loading)
|
||||
const showSkeleton = useMinimumLoading(isLoading && !funnels.length)
|
||||
const fadeClass = useSkeletonFade(showSkeleton)
|
||||
|
||||
if (showSkeleton) {
|
||||
return <FunnelsListSkeleton />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
|
||||
<div className={`w-full max-w-7xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<Link
|
||||
href={`/sites/${siteId}`}
|
||||
className="p-2 -ml-2 text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white rounded-xl hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
|
||||
>
|
||||
<ChevronLeftIcon className="w-5 h-5" />
|
||||
</Link>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white">
|
||||
<h1 className="text-lg font-semibold text-neutral-200">
|
||||
Funnels
|
||||
</h1>
|
||||
<p className="text-neutral-600 dark:text-neutral-400">
|
||||
Track user journeys and identify drop-off points
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-auto">
|
||||
<Link href={`/sites/${siteId}/funnels/new`}>
|
||||
<Button variant="primary" className="inline-flex items-center gap-2">
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
<span>Create Funnel</span>
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<Link href={`/sites/${siteId}/funnels/new`}>
|
||||
<Button variant="primary" className="inline-flex items-center gap-2">
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
<span>Create Funnel</span>
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{funnels.length === 0 ? (
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-12 text-center">
|
||||
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4 mx-auto mb-4 w-fit">
|
||||
<ArrowRightIcon className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white mb-2">
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-12 text-center flex flex-col items-center">
|
||||
<Image
|
||||
src="/illustrations/data-trends.svg"
|
||||
alt="Create your first funnel"
|
||||
width={260}
|
||||
height={195}
|
||||
className="mb-6"
|
||||
unoptimized
|
||||
/>
|
||||
<h3 className="text-lg font-semibold text-white mb-2">
|
||||
No funnels yet
|
||||
</h3>
|
||||
<p className="text-neutral-600 dark:text-neutral-400 mb-6 max-w-md mx-auto">
|
||||
@@ -107,7 +89,7 @@ export default function FunnelsPage() {
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 hover:border-brand-orange/50 transition-colors">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-neutral-900 dark:text-white group-hover:text-brand-orange transition-colors">
|
||||
<h3 className="text-xl font-bold text-white group-hover:text-brand-orange transition-colors">
|
||||
{funnel.name}
|
||||
</h3>
|
||||
{funnel.description && (
|
||||
|
||||
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
|
||||
}
|
||||
245
app/sites/[id]/journeys/page.tsx
Normal file
245
app/sites/[id]/journeys/page.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { motion } from 'framer-motion'
|
||||
import { getDateRange, formatDate, getThisWeekRange, getThisMonthRange } from '@/lib/utils/dateRanges'
|
||||
import { Select, DatePicker } from '@ciphera-net/ui'
|
||||
import ColumnJourney from '@/components/journeys/ColumnJourney'
|
||||
import SankeyJourney from '@/components/journeys/SankeyJourney'
|
||||
import TopPathsTable from '@/components/journeys/TopPathsTable'
|
||||
import { JourneysSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
|
||||
import {
|
||||
useDashboard,
|
||||
useJourneyTransitions,
|
||||
useJourneyTopPaths,
|
||||
useJourneyEntryPoints,
|
||||
} from '@/lib/swr/dashboard'
|
||||
|
||||
const DEFAULT_DEPTH = 4
|
||||
|
||||
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(DEFAULT_DEPTH)
|
||||
const [committedDepth, setCommittedDepth] = useState(DEFAULT_DEPTH)
|
||||
const [entryPath, setEntryPath] = useState('')
|
||||
const [viewMode, setViewMode] = useState<'columns' | 'flow'>('columns')
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setCommittedDepth(depth), 300)
|
||||
return () => clearTimeout(t)
|
||||
}, [depth])
|
||||
|
||||
const isDefault = depth === DEFAULT_DEPTH && !entryPath
|
||||
|
||||
function resetFilters() {
|
||||
setDepth(DEFAULT_DEPTH)
|
||||
setCommittedDepth(DEFAULT_DEPTH)
|
||||
setEntryPath('')
|
||||
}
|
||||
|
||||
const { data: transitionsData, isLoading: transitionsLoading } = useJourneyTransitions(
|
||||
siteId, dateRange.start, dateRange.end, committedDepth, 1, entryPath || undefined
|
||||
)
|
||||
const { data: topPaths, isLoading: topPathsLoading } = useJourneyTopPaths(
|
||||
siteId, dateRange.start, dateRange.end, 20, 1, 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 showSkeleton = useMinimumLoading(transitionsLoading && !transitionsData)
|
||||
const fadeClass = useSkeletonFade(showSkeleton)
|
||||
|
||||
const entryPointOptions = [
|
||||
{ value: '', label: 'All entry points' },
|
||||
...(entryPoints ?? []).map((ep) => ({
|
||||
value: ep.path,
|
||||
label: `${ep.path} (${ep.session_count.toLocaleString()})`,
|
||||
})),
|
||||
]
|
||||
|
||||
if (showSkeleton) return <JourneysSkeleton />
|
||||
|
||||
const totalSessions = transitionsData?.total_sessions ?? 0
|
||||
|
||||
return (
|
||||
<div className={`w-full max-w-7xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-neutral-200 mb-1">
|
||||
Journeys
|
||||
</h1>
|
||||
<p className="text-sm 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>
|
||||
|
||||
{/* Single card: toolbar + chart */}
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden">
|
||||
{/* Toolbar */}
|
||||
<div className="p-6 border-b border-neutral-200 dark:border-neutral-800 bg-neutral-50/50 dark:bg-neutral-900/50">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-6">
|
||||
{/* Depth slider */}
|
||||
<div className="flex-1">
|
||||
<div className="flex justify-between text-sm font-medium text-neutral-400 mb-3">
|
||||
<span>2 steps</span>
|
||||
<span className="text-brand-orange font-bold">
|
||||
{depth} steps deep
|
||||
</span>
|
||||
<span>6 steps</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={2}
|
||||
max={6}
|
||||
step={1}
|
||||
value={depth}
|
||||
onChange={(e) => setDepth(parseInt(e.target.value))}
|
||||
aria-label="Journey depth"
|
||||
aria-valuetext={`${depth} steps deep`}
|
||||
className="w-full h-2 bg-neutral-200 rounded-lg appearance-none cursor-pointer dark:bg-neutral-700 accent-brand-orange focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Entry point + Reset */}
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<Select
|
||||
variant="input"
|
||||
className="min-w-[180px]"
|
||||
value={entryPath}
|
||||
onChange={(value) => setEntryPath(value)}
|
||||
options={entryPointOptions}
|
||||
/>
|
||||
<button
|
||||
onClick={resetFilters}
|
||||
disabled={isDefault}
|
||||
className={`text-sm whitespace-nowrap transition-all duration-150 ${
|
||||
isDefault
|
||||
? 'opacity-0 pointer-events-none'
|
||||
: 'opacity-100 text-neutral-500 hover:text-neutral-900 dark:hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* View toggle */}
|
||||
<div className="flex gap-1 mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-800" role="tablist" aria-label="Journey view tabs">
|
||||
{(['columns', 'flow'] as const).map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => setViewMode(mode)}
|
||||
role="tab"
|
||||
aria-selected={viewMode === mode}
|
||||
className={`relative px-3 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 ${
|
||||
viewMode === mode
|
||||
? 'text-white'
|
||||
: 'text-neutral-400 dark:text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300'
|
||||
}`}
|
||||
>
|
||||
{mode === 'columns' ? 'Columns' : 'Flow'}
|
||||
{viewMode === mode && (
|
||||
<motion.div
|
||||
layoutId="journeyViewTab"
|
||||
className="absolute inset-x-0 -bottom-px h-0.5 bg-brand-orange"
|
||||
transition={{ type: 'spring', stiffness: 500, damping: 35 }}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Journey Chart */}
|
||||
<div className="p-6">
|
||||
{viewMode === 'columns' ? (
|
||||
<ColumnJourney
|
||||
transitions={transitionsData?.transitions ?? []}
|
||||
totalSessions={totalSessions}
|
||||
depth={committedDepth}
|
||||
/>
|
||||
) : (
|
||||
<SankeyJourney
|
||||
transitions={transitionsData?.transitions ?? []}
|
||||
totalSessions={totalSessions}
|
||||
depth={committedDepth}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{totalSessions > 0 && (
|
||||
<div className="px-6 pb-5 text-sm text-neutral-400">
|
||||
{totalSessions.toLocaleString()} sessions tracked
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Top Paths */}
|
||||
<div className="mt-6">
|
||||
<TopPathsTable paths={topPaths ?? []} loading={topPathsLoading} />
|
||||
</div>
|
||||
|
||||
{/* Date Picker Modal */}
|
||||
<DatePicker
|
||||
isOpen={isDatePickerOpen}
|
||||
onClose={() => setIsDatePickerOpen(false)}
|
||||
onApply={(range) => {
|
||||
setDateRange(range)
|
||||
setPeriod('custom')
|
||||
setIsDatePickerOpen(false)
|
||||
}}
|
||||
initialRange={dateRange}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Metadata } from 'next'
|
||||
import SiteLayoutShell from './SiteLayoutShell'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Dashboard | Pulse',
|
||||
@@ -6,10 +7,13 @@ export const metadata: Metadata = {
|
||||
robots: { index: false, follow: false },
|
||||
}
|
||||
|
||||
export default function SiteLayout({
|
||||
export default async function SiteLayout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
params: Promise<{ id: string }>
|
||||
}) {
|
||||
return children
|
||||
const { id } = await params
|
||||
return <SiteLayoutShell siteId={id}>{children}</SiteLayoutShell>
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { useAuth } from '@/lib/auth/context'
|
||||
|
||||
import { logger } from '@/lib/utils/logger'
|
||||
import { useCallback, useEffect, useState, useMemo } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState, useMemo } from 'react'
|
||||
import { useParams, useRouter, useSearchParams } from 'next/navigation'
|
||||
import { motion } from 'framer-motion'
|
||||
import {
|
||||
getPerformanceByPage,
|
||||
getTopPages,
|
||||
getTopReferrers,
|
||||
getCountries,
|
||||
@@ -19,38 +17,36 @@ import {
|
||||
type Stats,
|
||||
type DailyStat,
|
||||
} from '@/lib/api/stats'
|
||||
import { getDateRange } from '@ciphera-net/ui'
|
||||
import { getDateRange, formatDate, getThisWeekRange, getThisMonthRange } from '@/lib/utils/dateRanges'
|
||||
import { toast } from '@ciphera-net/ui'
|
||||
import { Button } from '@ciphera-net/ui'
|
||||
import { Select, DatePicker, DownloadIcon } from '@ciphera-net/ui'
|
||||
import { DashboardSkeleton, useMinimumLoading } from '@/components/skeletons'
|
||||
import ExportModal from '@/components/dashboard/ExportModal'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { DashboardSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
|
||||
import FilterBar from '@/components/dashboard/FilterBar'
|
||||
import AddFilterDropdown, { type FilterSuggestion, type FilterSuggestions } from '@/components/dashboard/AddFilterDropdown'
|
||||
const Chart = dynamic(() => import('@/components/dashboard/Chart'), { ssr: false })
|
||||
import ContentStats from '@/components/dashboard/ContentStats'
|
||||
import TopReferrers from '@/components/dashboard/TopReferrers'
|
||||
import Locations from '@/components/dashboard/Locations'
|
||||
import TechSpecs from '@/components/dashboard/TechSpecs'
|
||||
import Chart from '@/components/dashboard/Chart'
|
||||
import PerformanceStats from '@/components/dashboard/PerformanceStats'
|
||||
import GoalStats from '@/components/dashboard/GoalStats'
|
||||
import ScrollDepth from '@/components/dashboard/ScrollDepth'
|
||||
import Campaigns from '@/components/dashboard/Campaigns'
|
||||
import FilterBar from '@/components/dashboard/FilterBar'
|
||||
import AddFilterDropdown, { type FilterSuggestion, type FilterSuggestions } from '@/components/dashboard/AddFilterDropdown'
|
||||
import EventProperties from '@/components/dashboard/EventProperties'
|
||||
|
||||
const GoalStats = dynamic(() => import('@/components/dashboard/GoalStats'))
|
||||
const Campaigns = dynamic(() => import('@/components/dashboard/Campaigns'))
|
||||
const PeakHours = dynamic(() => import('@/components/dashboard/PeakHours'))
|
||||
const SearchPerformance = dynamic(() => import('@/components/dashboard/SearchPerformance'))
|
||||
const EventProperties = dynamic(() => import('@/components/dashboard/EventProperties'))
|
||||
const ExportModal = dynamic(() => import('@/components/dashboard/ExportModal'))
|
||||
import { type DimensionFilter, serializeFilters, parseFiltersFromURL } from '@/lib/filters'
|
||||
import {
|
||||
useDashboardOverview,
|
||||
useDashboardPages,
|
||||
useDashboardLocations,
|
||||
useDashboardDevices,
|
||||
useDashboardReferrers,
|
||||
useDashboardPerformance,
|
||||
useDashboardGoals,
|
||||
useDashboard,
|
||||
useRealtime,
|
||||
useStats,
|
||||
useDailyStats,
|
||||
useCampaigns,
|
||||
useAnnotations,
|
||||
} from '@/lib/swr/dashboard'
|
||||
import { createAnnotation, updateAnnotation, deleteAnnotation, type AnnotationCategory } from '@/lib/api/annotations'
|
||||
|
||||
function loadSavedSettings(): {
|
||||
type?: string
|
||||
@@ -67,26 +63,34 @@ function loadSavedSettings(): {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function getInitialDateRange(): { start: string; end: string } {
|
||||
const settings = loadSavedSettings()
|
||||
if (settings?.type === 'today') {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const today = formatDate(new Date())
|
||||
return { start: today, end: today }
|
||||
}
|
||||
if (settings?.type === '7') return getDateRange(7)
|
||||
if (settings?.type === 'week') return getThisWeekRange()
|
||||
if (settings?.type === 'month') return getThisMonthRange()
|
||||
if (settings?.type === 'custom' && settings.dateRange) return settings.dateRange
|
||||
return getDateRange(30)
|
||||
}
|
||||
|
||||
function getInitialPeriod(): string {
|
||||
return loadSavedSettings()?.type || '30'
|
||||
}
|
||||
|
||||
export default function SiteDashboardPage() {
|
||||
const { user } = useAuth()
|
||||
const canEdit = user?.role === 'owner' || user?.role === 'admin'
|
||||
|
||||
|
||||
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const siteId = params.id as string
|
||||
|
||||
// UI state - initialized from localStorage synchronously to avoid double-fetch
|
||||
const [period, setPeriod] = useState(getInitialPeriod)
|
||||
const [dateRange, setDateRange] = useState(getInitialDateRange)
|
||||
const [todayInterval, setTodayInterval] = useState<'minute' | 'hour'>(
|
||||
() => loadSavedSettings()?.todayInterval || 'hour'
|
||||
@@ -96,8 +100,7 @@ export default function SiteDashboardPage() {
|
||||
)
|
||||
const [isDatePickerOpen, setIsDatePickerOpen] = useState(false)
|
||||
const [isExportModalOpen, setIsExportModalOpen] = useState(false)
|
||||
const [lastUpdatedAt, setLastUpdatedAt] = useState<number | null>(null)
|
||||
const [, setTick] = useState(0)
|
||||
const lastUpdatedAtRef = useRef<number | null>(null)
|
||||
|
||||
// Dimension filters state
|
||||
const searchParams = useSearchParams()
|
||||
@@ -218,39 +221,53 @@ export default function SiteDashboardPage() {
|
||||
return { start: prevStart.toISOString().split('T')[0], end: prevEnd.toISOString().split('T')[0] }
|
||||
}, [dateRange])
|
||||
|
||||
// SWR hooks - replace manual useState + useEffect + setInterval polling
|
||||
// Each hook handles its own refresh interval, deduplication, and error retry
|
||||
// Filters are included in cache keys so changing filters auto-refetches
|
||||
const { data: overview, isLoading: overviewLoading, error: overviewError } = useDashboardOverview(siteId, dateRange.start, dateRange.end, interval, filtersParam || undefined)
|
||||
const { data: pages } = useDashboardPages(siteId, dateRange.start, dateRange.end, filtersParam || undefined)
|
||||
const { data: locations } = useDashboardLocations(siteId, dateRange.start, dateRange.end, filtersParam || undefined)
|
||||
const { data: devicesData } = useDashboardDevices(siteId, dateRange.start, dateRange.end, filtersParam || undefined)
|
||||
const { data: referrers } = useDashboardReferrers(siteId, dateRange.start, dateRange.end, filtersParam || undefined)
|
||||
const { data: performanceData } = useDashboardPerformance(siteId, dateRange.start, dateRange.end, filtersParam || undefined)
|
||||
const { data: goalsData } = useDashboardGoals(siteId, dateRange.start, dateRange.end, filtersParam || undefined)
|
||||
// Single dashboard request replaces focused hooks (overview, pages, locations,
|
||||
// devices, referrers, goals). The backend runs all queries in parallel
|
||||
// and caches the result in Redis for efficient data loading.
|
||||
const { data: dashboard, isLoading: dashboardLoading, isValidating: dashboardValidating, error: dashboardError } = useDashboard(siteId, dateRange.start, dateRange.end, interval, filtersParam || undefined)
|
||||
const { data: realtimeData } = useRealtime(siteId)
|
||||
const { data: prevStats } = useStats(siteId, prevRange.start, prevRange.end)
|
||||
const { data: prevDailyStats } = useDailyStats(siteId, prevRange.start, prevRange.end, interval)
|
||||
const { data: campaigns } = useCampaigns(siteId, dateRange.start, dateRange.end)
|
||||
const { data: annotations, mutate: mutateAnnotations } = useAnnotations(siteId, dateRange.start, dateRange.end)
|
||||
|
||||
// Derive typed values from SWR data
|
||||
const site = overview?.site ?? null
|
||||
const stats: Stats = overview?.stats ?? { pageviews: 0, visitors: 0, bounce_rate: 0, avg_duration: 0 }
|
||||
const realtime = realtimeData?.visitors ?? overview?.realtime_visitors ?? 0
|
||||
const dailyStats: DailyStat[] = overview?.daily_stats ?? []
|
||||
// Annotation mutation handlers
|
||||
const handleCreateAnnotation = async (data: { date: string; time?: string; text: string; category: string }) => {
|
||||
await createAnnotation(siteId, { ...data, category: data.category as AnnotationCategory })
|
||||
mutateAnnotations()
|
||||
toast.success('Annotation added')
|
||||
}
|
||||
|
||||
const handleUpdateAnnotation = async (id: string, data: { date: string; time?: string; text: string; category: string }) => {
|
||||
await updateAnnotation(siteId, id, { ...data, category: data.category as AnnotationCategory })
|
||||
mutateAnnotations()
|
||||
toast.success('Annotation updated')
|
||||
}
|
||||
|
||||
const handleDeleteAnnotation = async (id: string) => {
|
||||
await deleteAnnotation(siteId, id)
|
||||
mutateAnnotations()
|
||||
toast.success('Annotation deleted')
|
||||
}
|
||||
|
||||
// Derive typed values from single dashboard response
|
||||
const site = dashboard?.site ?? null
|
||||
const stats: Stats = dashboard?.stats ?? { pageviews: 0, visitors: 0, bounce_rate: 0, avg_duration: 0 }
|
||||
const realtime = realtimeData?.visitors ?? dashboard?.realtime_visitors ?? 0
|
||||
const dailyStats: DailyStat[] = dashboard?.daily_stats ?? []
|
||||
|
||||
// Build filter suggestions from current dashboard data
|
||||
const filterSuggestions = useMemo<FilterSuggestions>(() => {
|
||||
const s: FilterSuggestions = {}
|
||||
|
||||
// Pages
|
||||
const topPages = pages?.top_pages ?? []
|
||||
const topPages = dashboard?.top_pages ?? []
|
||||
if (topPages.length > 0) {
|
||||
s.page = topPages.map(p => ({ value: p.path, label: p.path, count: p.pageviews }))
|
||||
}
|
||||
|
||||
// Referrers
|
||||
const refs = referrers?.top_referrers ?? []
|
||||
const refs = dashboard?.top_referrers ?? []
|
||||
if (refs.length > 0) {
|
||||
s.referrer = refs.filter(r => r.referrer && r.referrer !== '').map(r => ({
|
||||
value: r.referrer,
|
||||
@@ -260,7 +277,7 @@ export default function SiteDashboardPage() {
|
||||
}
|
||||
|
||||
// Countries
|
||||
const ctrs = locations?.countries ?? []
|
||||
const ctrs = dashboard?.countries ?? []
|
||||
if (ctrs.length > 0) {
|
||||
const regionNames = (() => { try { return new Intl.DisplayNames(['en'], { type: 'region' }) } catch { return null } })()
|
||||
s.country = ctrs.filter(c => c.country && c.country !== 'Unknown').map(c => ({
|
||||
@@ -271,7 +288,7 @@ export default function SiteDashboardPage() {
|
||||
}
|
||||
|
||||
// Regions
|
||||
const regs = locations?.regions ?? []
|
||||
const regs = dashboard?.regions ?? []
|
||||
if (regs.length > 0) {
|
||||
s.region = regs.filter(r => r.region && r.region !== 'Unknown').map(r => ({
|
||||
value: r.region,
|
||||
@@ -281,7 +298,7 @@ export default function SiteDashboardPage() {
|
||||
}
|
||||
|
||||
// Cities
|
||||
const cts = locations?.cities ?? []
|
||||
const cts = dashboard?.cities ?? []
|
||||
if (cts.length > 0) {
|
||||
s.city = cts.filter(c => c.city && c.city !== 'Unknown').map(c => ({
|
||||
value: c.city,
|
||||
@@ -291,7 +308,7 @@ export default function SiteDashboardPage() {
|
||||
}
|
||||
|
||||
// Browsers
|
||||
const brs = devicesData?.browsers ?? []
|
||||
const brs = dashboard?.browsers ?? []
|
||||
if (brs.length > 0) {
|
||||
s.browser = brs.filter(b => b.browser && b.browser !== 'Unknown').map(b => ({
|
||||
value: b.browser,
|
||||
@@ -301,7 +318,7 @@ export default function SiteDashboardPage() {
|
||||
}
|
||||
|
||||
// OS
|
||||
const oses = devicesData?.os ?? []
|
||||
const oses = dashboard?.os ?? []
|
||||
if (oses.length > 0) {
|
||||
s.os = oses.filter(o => o.os && o.os !== 'Unknown').map(o => ({
|
||||
value: o.os,
|
||||
@@ -311,7 +328,7 @@ export default function SiteDashboardPage() {
|
||||
}
|
||||
|
||||
// Devices
|
||||
const devs = devicesData?.devices ?? []
|
||||
const devs = dashboard?.devices ?? []
|
||||
if (devs.length > 0) {
|
||||
s.device = devs.filter(d => d.device && d.device !== 'Unknown').map(d => ({
|
||||
value: d.device,
|
||||
@@ -337,25 +354,19 @@ export default function SiteDashboardPage() {
|
||||
}
|
||||
|
||||
return s
|
||||
}, [pages, referrers, locations, devicesData, campaigns])
|
||||
}, [dashboard, campaigns])
|
||||
|
||||
// Show error toast on fetch failure
|
||||
useEffect(() => {
|
||||
if (overviewError) {
|
||||
if (dashboardError) {
|
||||
toast.error('Failed to load dashboard analytics')
|
||||
}
|
||||
}, [overviewError])
|
||||
}, [dashboardError])
|
||||
|
||||
// Track when data was last updated (for "Live · Xs ago" display)
|
||||
useEffect(() => {
|
||||
if (overview) setLastUpdatedAt(Date.now())
|
||||
}, [overview])
|
||||
|
||||
// Tick every 1s so "Live · Xs ago" counts in real time
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setTick((t) => t + 1), 1000)
|
||||
return () => clearInterval(timer)
|
||||
}, [])
|
||||
if (dashboard) lastUpdatedAtRef.current = Date.now()
|
||||
}, [dashboard])
|
||||
|
||||
// Save settings to localStorage
|
||||
const saveSettings = (type: string, newDateRange?: { start: string; end: string }) => {
|
||||
@@ -376,7 +387,7 @@ export default function SiteDashboardPage() {
|
||||
// Save intervals when they change
|
||||
useEffect(() => {
|
||||
let type = 'custom'
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const today = formatDate(new Date())
|
||||
if (dateRange.start === today && dateRange.end === today) type = 'today'
|
||||
else if (dateRange.start === getDateRange(7).start) type = '7'
|
||||
else if (dateRange.start === getDateRange(30).start) type = '30'
|
||||
@@ -395,7 +406,10 @@ export default function SiteDashboardPage() {
|
||||
if (site?.domain) document.title = `${site.domain} | Pulse`
|
||||
}, [site?.domain])
|
||||
|
||||
const showSkeleton = useMinimumLoading(overviewLoading)
|
||||
// Skip the minimum-loading skeleton when SWR already has cached data
|
||||
// (prevents the 300ms flash when navigating back to the dashboard)
|
||||
const showSkeleton = useMinimumLoading(dashboardLoading && !dashboard)
|
||||
const fadeClass = useSkeletonFade(showSkeleton)
|
||||
|
||||
if (showSkeleton) {
|
||||
return <DashboardSkeleton />
|
||||
@@ -403,24 +417,19 @@ export default function SiteDashboardPage() {
|
||||
|
||||
if (!site) {
|
||||
return (
|
||||
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
|
||||
<div className="w-full max-w-7xl mx-auto px-4 sm:px-6 pb-8">
|
||||
<p className="text-neutral-600 dark:text-neutral-400">Site not found</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8"
|
||||
>
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className={`w-full max-w-7xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-2">
|
||||
<h1 className="text-lg font-semibold text-neutral-200 mb-1">
|
||||
{site.name}
|
||||
</h1>
|
||||
<p className="text-neutral-600 dark:text-neutral-400">
|
||||
@@ -429,9 +438,8 @@ export default function SiteDashboardPage() {
|
||||
</div>
|
||||
|
||||
{/* Realtime Indicator */}
|
||||
<button
|
||||
onClick={() => router.push(`/sites/${siteId}/realtime`)}
|
||||
className="flex items-center gap-2 px-3 py-1 bg-green-500/10 rounded-full border border-green-500/20 hover:bg-green-500/20 transition-colors cursor-pointer"
|
||||
<div
|
||||
className="flex items-center gap-2 px-3 py-1 bg-green-500/10 rounded-full border border-green-500/20"
|
||||
>
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-500 opacity-75"></span>
|
||||
@@ -440,7 +448,7 @@ export default function SiteDashboardPage() {
|
||||
<span className="text-sm font-medium text-green-700 dark:text-green-400">
|
||||
{realtime} current visitors
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -456,33 +464,35 @@ export default function SiteDashboardPage() {
|
||||
<Select
|
||||
variant="input"
|
||||
className="min-w-[140px]"
|
||||
value={
|
||||
dateRange.start === new Date().toISOString().split('T')[0] && dateRange.end === new Date().toISOString().split('T')[0]
|
||||
? 'today'
|
||||
: dateRange.start === getDateRange(7).start
|
||||
? '7'
|
||||
: dateRange.start === getDateRange(30).start
|
||||
? '30'
|
||||
: 'custom'
|
||||
}
|
||||
value={period}
|
||||
onChange={(value) => {
|
||||
if (value === '7') {
|
||||
const range = getDateRange(7)
|
||||
setDateRange(range)
|
||||
saveSettings('7', range)
|
||||
}
|
||||
else if (value === '30') {
|
||||
const range = getDateRange(30)
|
||||
setDateRange(range)
|
||||
saveSettings('30', range)
|
||||
}
|
||||
else if (value === 'today') {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
if (value === 'today') {
|
||||
const today = formatDate(new Date())
|
||||
const range = { start: today, end: today }
|
||||
setDateRange(range)
|
||||
setPeriod('today')
|
||||
saveSettings('today', range)
|
||||
}
|
||||
else if (value === 'custom') {
|
||||
} else if (value === '7') {
|
||||
const range = getDateRange(7)
|
||||
setDateRange(range)
|
||||
setPeriod('7')
|
||||
saveSettings('7', range)
|
||||
} else if (value === 'week') {
|
||||
const range = getThisWeekRange()
|
||||
setDateRange(range)
|
||||
setPeriod('week')
|
||||
saveSettings('week', range)
|
||||
} else if (value === '30') {
|
||||
const range = getDateRange(30)
|
||||
setDateRange(range)
|
||||
setPeriod('30')
|
||||
saveSettings('30', range)
|
||||
} else if (value === 'month') {
|
||||
const range = getThisMonthRange()
|
||||
setDateRange(range)
|
||||
setPeriod('month')
|
||||
saveSettings('month', range)
|
||||
} else if (value === 'custom') {
|
||||
setIsDatePickerOpen(true)
|
||||
}
|
||||
}}
|
||||
@@ -490,39 +500,14 @@ export default function SiteDashboardPage() {
|
||||
{ value: 'today', label: 'Today' },
|
||||
{ value: '7', label: 'Last 7 days' },
|
||||
{ value: '30', label: 'Last 30 days' },
|
||||
{ value: 'divider-1', label: '', divider: true },
|
||||
{ value: 'week', label: 'This week' },
|
||||
{ value: 'month', label: 'This month' },
|
||||
{ value: 'divider-2', label: '', divider: true },
|
||||
{ value: 'custom', label: 'Custom' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="h-6 w-px bg-neutral-200 dark:bg-neutral-700 flex-shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
onClick={() => router.push(`/sites/${siteId}/uptime`)}
|
||||
variant="ghost"
|
||||
className="text-sm"
|
||||
>
|
||||
Uptime
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => router.push(`/sites/${siteId}/funnels`)}
|
||||
variant="ghost"
|
||||
className="text-sm"
|
||||
>
|
||||
Funnels
|
||||
</Button>
|
||||
{canEdit && (
|
||||
<Button
|
||||
onClick={() => router.push(`/sites/${siteId}/settings`)}
|
||||
variant="ghost"
|
||||
className="text-sm"
|
||||
>
|
||||
Settings
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -533,8 +518,15 @@ export default function SiteDashboardPage() {
|
||||
<FilterBar filters={filters} onRemove={handleRemoveFilter} onClear={handleClearFilters} />
|
||||
</div>
|
||||
|
||||
{/* Refetch indicator — visible when SWR is revalidating with stale data on screen */}
|
||||
{dashboardValidating && !dashboardLoading && (
|
||||
<div className="h-0.5 w-full rounded-full bg-neutral-100 dark:bg-neutral-800 overflow-hidden mb-2">
|
||||
<div className="h-full w-1/3 rounded-full bg-brand-orange animate-[shimmer_1.2s_ease-in-out_infinite]" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Advanced Chart with Integrated Stats */}
|
||||
<div className="mb-8">
|
||||
<div className="mb-6">
|
||||
<Chart
|
||||
data={dailyStats}
|
||||
prevData={prevDailyStats}
|
||||
@@ -542,33 +534,25 @@ export default function SiteDashboardPage() {
|
||||
prevStats={prevStats}
|
||||
interval={dateRange.start === dateRange.end ? todayInterval : multiDayInterval}
|
||||
dateRange={dateRange}
|
||||
period={period}
|
||||
todayInterval={todayInterval}
|
||||
setTodayInterval={setTodayInterval}
|
||||
multiDayInterval={multiDayInterval}
|
||||
setMultiDayInterval={setMultiDayInterval}
|
||||
lastUpdatedAt={lastUpdatedAt}
|
||||
lastUpdatedAt={lastUpdatedAtRef.current}
|
||||
annotations={annotations}
|
||||
canManageAnnotations={true}
|
||||
onCreateAnnotation={handleCreateAnnotation}
|
||||
onUpdateAnnotation={handleUpdateAnnotation}
|
||||
onDeleteAnnotation={handleDeleteAnnotation}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Performance Stats - Only show if enabled */}
|
||||
{site.enable_performance_insights && (
|
||||
<div className="mb-8">
|
||||
<PerformanceStats
|
||||
stats={performanceData?.performance ?? { lcp: 0, cls: 0, inp: 0 }}
|
||||
performanceByPage={performanceData?.performance_by_page ?? null}
|
||||
siteId={siteId}
|
||||
startDate={dateRange.start}
|
||||
endDate={dateRange.end}
|
||||
getPerformanceByPage={getPerformanceByPage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2 mb-8">
|
||||
<div className="grid gap-6 lg:grid-cols-2 mb-6 [&>*]:min-w-0">
|
||||
<ContentStats
|
||||
topPages={pages?.top_pages ?? []}
|
||||
entryPages={pages?.entry_pages ?? []}
|
||||
exitPages={pages?.exit_pages ?? []}
|
||||
topPages={dashboard?.top_pages ?? []}
|
||||
entryPages={dashboard?.entry_pages ?? []}
|
||||
exitPages={dashboard?.exit_pages ?? []}
|
||||
domain={site.domain}
|
||||
collectPagePaths={site.collect_page_paths ?? true}
|
||||
siteId={siteId}
|
||||
@@ -576,7 +560,7 @@ export default function SiteDashboardPage() {
|
||||
onFilter={handleAddFilter}
|
||||
/>
|
||||
<TopReferrers
|
||||
referrers={referrers?.top_referrers ?? []}
|
||||
referrers={dashboard?.top_referrers ?? []}
|
||||
collectReferrers={site.collect_referrers ?? true}
|
||||
siteId={siteId}
|
||||
dateRange={dateRange}
|
||||
@@ -584,21 +568,21 @@ export default function SiteDashboardPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2 mb-8">
|
||||
<div className="grid gap-6 lg:grid-cols-2 mb-6 [&>*]:min-w-0">
|
||||
<Locations
|
||||
countries={locations?.countries ?? []}
|
||||
cities={locations?.cities ?? []}
|
||||
regions={locations?.regions ?? []}
|
||||
countries={dashboard?.countries ?? []}
|
||||
cities={dashboard?.cities ?? []}
|
||||
regions={dashboard?.regions ?? []}
|
||||
geoDataLevel={site.collect_geo_data || 'full'}
|
||||
siteId={siteId}
|
||||
dateRange={dateRange}
|
||||
onFilter={handleAddFilter}
|
||||
/>
|
||||
<TechSpecs
|
||||
browsers={devicesData?.browsers ?? []}
|
||||
os={devicesData?.os ?? []}
|
||||
devices={devicesData?.devices ?? []}
|
||||
screenResolutions={devicesData?.screen_resolutions ?? []}
|
||||
browsers={dashboard?.browsers ?? []}
|
||||
os={dashboard?.os ?? []}
|
||||
devices={dashboard?.devices ?? []}
|
||||
screenResolutions={dashboard?.screen_resolutions ?? []}
|
||||
collectDeviceInfo={site.collect_device_info ?? true}
|
||||
collectScreenResolution={site.collect_screen_resolution ?? true}
|
||||
siteId={siteId}
|
||||
@@ -607,18 +591,18 @@ export default function SiteDashboardPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2 mb-8">
|
||||
<div className="grid gap-6 lg:grid-cols-2 mb-6 [&>*]:min-w-0">
|
||||
<Campaigns siteId={siteId} dateRange={dateRange} filters={filtersParam || undefined} onFilter={handleAddFilter} />
|
||||
<PeakHours siteId={siteId} dateRange={dateRange} />
|
||||
</div>
|
||||
<div className="grid gap-6 lg:grid-cols-2 mb-6 [&>*]:min-w-0">
|
||||
<SearchPerformance siteId={siteId} dateRange={dateRange} />
|
||||
<GoalStats
|
||||
goalCounts={(goalsData?.goal_counts ?? []).filter(g => !/^scroll_\d+$/.test(g.event_name))}
|
||||
goalCounts={(dashboard?.goal_counts ?? []).filter(g => !/^scroll_\d+$/.test(g.event_name))}
|
||||
onSelectEvent={setSelectedEvent}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<ScrollDepth goalCounts={goalsData?.goal_counts ?? []} totalPageviews={stats.pageviews} />
|
||||
</div>
|
||||
|
||||
{/* Event Properties Breakdown */}
|
||||
{selectedEvent && (
|
||||
<div className="mb-8">
|
||||
@@ -636,6 +620,7 @@ export default function SiteDashboardPage() {
|
||||
onClose={() => setIsDatePickerOpen(false)}
|
||||
onApply={(range) => {
|
||||
setDateRange(range)
|
||||
setPeriod('custom')
|
||||
saveSettings('custom', range)
|
||||
setIsDatePickerOpen(false)
|
||||
}}
|
||||
@@ -647,10 +632,10 @@ export default function SiteDashboardPage() {
|
||||
onClose={() => setIsExportModalOpen(false)}
|
||||
data={dailyStats}
|
||||
stats={stats}
|
||||
topPages={pages?.top_pages}
|
||||
topReferrers={referrers?.top_referrers}
|
||||
topPages={dashboard?.top_pages}
|
||||
topReferrers={dashboard?.top_referrers}
|
||||
campaigns={campaigns}
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
13
app/sites/[id]/pagespeed/error.tsx
Normal file
13
app/sites/[id]/pagespeed/error.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import ErrorDisplay from '@/components/ErrorDisplay'
|
||||
|
||||
export default function PageSpeedError({ reset }: { error: Error; reset: () => void }) {
|
||||
return (
|
||||
<ErrorDisplay
|
||||
title="PageSpeed data failed to load"
|
||||
message="We couldn't load the PageSpeed data. This might be a temporary issue — try again."
|
||||
onRetry={reset}
|
||||
/>
|
||||
)
|
||||
}
|
||||
937
app/sites/[id]/pagespeed/page.tsx
Normal file
937
app/sites/[id]/pagespeed/page.tsx
Normal file
@@ -0,0 +1,937 @@
|
||||
'use client'
|
||||
|
||||
import { useAuth } from '@/lib/auth/context'
|
||||
import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useSite, usePageSpeedConfig, usePageSpeedLatest, usePageSpeedHistory } from '@/lib/swr/dashboard'
|
||||
import { updatePageSpeedConfig, triggerPageSpeedCheck, getPageSpeedLatest, getPageSpeedCheck, type PageSpeedCheck, type AuditSummary } from '@/lib/api/pagespeed'
|
||||
import { toast, Button } from '@ciphera-net/ui'
|
||||
import { motion } from 'framer-motion'
|
||||
import ScoreGauge from '@/components/pagespeed/ScoreGauge'
|
||||
import { remapLearnUrl } from '@/lib/learn-links'
|
||||
import { AreaChart as VisxAreaChart, Area as VisxArea, Grid as VisxGrid, XAxis as VisxXAxis, YAxis as VisxYAxis, ChartTooltip as VisxChartTooltip } from '@/components/ui/area-chart'
|
||||
import { useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
|
||||
|
||||
// * Metric status thresholds (Google's Core Web Vitals thresholds)
|
||||
function getMetricStatus(metric: string, value: number | null): { label: string; color: string } {
|
||||
if (value === null) return { label: '--', color: 'text-neutral-400' }
|
||||
const thresholds: Record<string, [number, number]> = {
|
||||
lcp: [2500, 4000],
|
||||
cls: [0.1, 0.25],
|
||||
tbt: [200, 600],
|
||||
fcp: [1800, 3000],
|
||||
si: [3400, 5800],
|
||||
tti: [3800, 7300],
|
||||
}
|
||||
const [good, poor] = thresholds[metric] ?? [0, 0]
|
||||
if (value <= good) return { label: 'Good', color: 'text-emerald-600 dark:text-emerald-400' }
|
||||
if (value <= poor) return { label: 'Needs Improvement', color: 'text-amber-600 dark:text-amber-400' }
|
||||
return { label: 'Poor', color: 'text-red-600 dark:text-red-400' }
|
||||
}
|
||||
|
||||
// * Format metric values for display
|
||||
function formatMetricValue(metric: string, value: number | null): string {
|
||||
if (value === null) return '--'
|
||||
if (metric === 'cls') return value.toFixed(3)
|
||||
if (value < 1000) return `${value}ms`
|
||||
return `${(value / 1000).toFixed(1)}s`
|
||||
}
|
||||
|
||||
// * Format time ago for last checked display
|
||||
function formatTimeAgo(dateString: string | null): string {
|
||||
if (!dateString) return 'Never'
|
||||
const date = new Date(dateString)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffSec = Math.floor(diffMs / 1000)
|
||||
|
||||
if (diffSec < 60) return 'just now'
|
||||
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`
|
||||
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago`
|
||||
return `${Math.floor(diffSec / 86400)}d ago`
|
||||
}
|
||||
|
||||
// * Get dot color for audit items based on score
|
||||
function getAuditDotColor(score: number | null): string {
|
||||
if (score === null) return 'bg-neutral-400'
|
||||
if (score >= 0.9) return 'bg-emerald-500'
|
||||
if (score >= 0.5) return 'bg-amber-500'
|
||||
return 'bg-red-500'
|
||||
}
|
||||
|
||||
// * Main PageSpeed page
|
||||
export default function PageSpeedPage() {
|
||||
const { user } = useAuth()
|
||||
const canEdit = user?.role === 'owner' || user?.role === 'admin'
|
||||
const params = useParams()
|
||||
const siteId = params.id as string
|
||||
|
||||
const { data: site } = useSite(siteId)
|
||||
const { data: config, mutate: mutateConfig } = usePageSpeedConfig(siteId)
|
||||
const { data: latestChecks, isLoading, mutate: mutateLatest } = usePageSpeedLatest(siteId)
|
||||
|
||||
const [strategy, setStrategy] = useState<'mobile' | 'desktop'>('mobile')
|
||||
const [running, setRunning] = useState(false)
|
||||
const [toggling, setToggling] = useState(false)
|
||||
const [frequency, setFrequency] = useState<string>('weekly')
|
||||
|
||||
const { data: historyChecks } = usePageSpeedHistory(siteId, strategy)
|
||||
|
||||
// * Check history navigation — build unique check timestamps from history data
|
||||
const [selectedCheckId, setSelectedCheckId] = useState<string | null>(null)
|
||||
const [selectedCheckData, setSelectedCheckData] = useState<PageSpeedCheck | null>(null)
|
||||
const [loadingCheck, setLoadingCheck] = useState(false)
|
||||
|
||||
// * Build unique check timestamps (each check has mobile+desktop at the same time)
|
||||
const checkTimestamps = useMemo(() => {
|
||||
if (!historyChecks?.length) return []
|
||||
const seen = new Set<string>()
|
||||
const timestamps: { id: string; checked_at: string }[] = []
|
||||
// * History is sorted ASC by checked_at, reverse for newest first
|
||||
for (let i = historyChecks.length - 1; i >= 0; i--) {
|
||||
const c = historyChecks[i]
|
||||
// * Group by minute to deduplicate mobile+desktop pairs
|
||||
const key = c.checked_at.slice(0, 16)
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key)
|
||||
timestamps.push({ id: c.id, checked_at: c.checked_at })
|
||||
}
|
||||
}
|
||||
return timestamps
|
||||
}, [historyChecks])
|
||||
|
||||
const selectedIndex = selectedCheckId
|
||||
? checkTimestamps.findIndex(t => t.id === selectedCheckId)
|
||||
: 0 // * 0 = latest
|
||||
|
||||
const canGoPrev = selectedIndex < checkTimestamps.length - 1
|
||||
const canGoNext = selectedIndex > 0
|
||||
|
||||
const handlePrevCheck = () => {
|
||||
if (!canGoPrev) return
|
||||
const next = checkTimestamps[selectedIndex + 1]
|
||||
setSelectedCheckId(next.id)
|
||||
}
|
||||
|
||||
const handleNextCheck = () => {
|
||||
if (selectedIndex <= 1) {
|
||||
// * Going back to latest
|
||||
setSelectedCheckId(null)
|
||||
setSelectedCheckData(null)
|
||||
return
|
||||
}
|
||||
const next = checkTimestamps[selectedIndex - 1]
|
||||
setSelectedCheckId(next.id)
|
||||
}
|
||||
|
||||
// * Fetch full check data when navigating to a historical check
|
||||
useEffect(() => {
|
||||
if (!selectedCheckId || !siteId) {
|
||||
setSelectedCheckData(null)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
setLoadingCheck(true)
|
||||
getPageSpeedCheck(siteId, selectedCheckId).then(data => {
|
||||
if (!cancelled) {
|
||||
setSelectedCheckData(data)
|
||||
setLoadingCheck(false)
|
||||
}
|
||||
}).catch(() => {
|
||||
if (!cancelled) setLoadingCheck(false)
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [selectedCheckId, siteId])
|
||||
|
||||
// * Determine which check to display — selected historical or latest
|
||||
const displayCheck = selectedCheckId && selectedCheckData
|
||||
? selectedCheckData
|
||||
: latestChecks?.find(c => c.strategy === strategy) ?? null
|
||||
|
||||
// * When viewing a historical check, we need both strategies — fetch the other one too
|
||||
// * For simplicity, historical view shows the selected strategy's check
|
||||
const currentCheck = displayCheck
|
||||
|
||||
// * Set document title
|
||||
useEffect(() => {
|
||||
if (site?.domain) document.title = `PageSpeed · ${site.domain} | Pulse`
|
||||
}, [site?.domain])
|
||||
|
||||
// * Sync frequency from config when loaded
|
||||
useEffect(() => {
|
||||
if (config?.frequency) setFrequency(config.frequency)
|
||||
}, [config?.frequency])
|
||||
|
||||
// * Toggle PageSpeed monitoring on/off
|
||||
const handleToggle = async (enabled: boolean) => {
|
||||
setToggling(true)
|
||||
try {
|
||||
await updatePageSpeedConfig(siteId, { enabled, frequency })
|
||||
mutateConfig()
|
||||
mutateLatest()
|
||||
toast.success(enabled ? 'PageSpeed monitoring enabled' : 'PageSpeed monitoring disabled')
|
||||
} catch {
|
||||
toast.error('Failed to update PageSpeed monitoring')
|
||||
} finally {
|
||||
setToggling(false)
|
||||
}
|
||||
}
|
||||
|
||||
// * Trigger a manual PageSpeed check
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const stopPolling = useCallback(() => {
|
||||
if (pollRef.current) {
|
||||
clearInterval(pollRef.current)
|
||||
pollRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => () => stopPolling(), [stopPolling])
|
||||
|
||||
const handleRunCheck = async () => {
|
||||
setRunning(true)
|
||||
try {
|
||||
await triggerPageSpeedCheck(siteId)
|
||||
toast.success('PageSpeed check started — results will appear in 30-60 seconds')
|
||||
|
||||
// * Poll silently without triggering SWR re-renders.
|
||||
// * Fetch latest directly and only update SWR cache once when new data arrives.
|
||||
const initialCheckedAt = latestChecks?.[0]?.checked_at
|
||||
const startedAt = Date.now()
|
||||
|
||||
stopPolling()
|
||||
pollRef.current = setInterval(async () => {
|
||||
if (Date.now() - startedAt > 120_000) {
|
||||
stopPolling()
|
||||
setRunning(false)
|
||||
toast.error('Check is taking longer than expected. Results will appear when ready.')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const fresh = await getPageSpeedLatest(siteId)
|
||||
if (fresh?.[0]?.checked_at && fresh[0].checked_at !== initialCheckedAt) {
|
||||
stopPolling()
|
||||
setRunning(false)
|
||||
mutateLatest() // * Single SWR revalidation when new data is ready
|
||||
toast.success('PageSpeed check complete')
|
||||
}
|
||||
} catch {
|
||||
// * Silent — keep polling
|
||||
}
|
||||
}, 5000)
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || 'Failed to start check')
|
||||
setRunning(false)
|
||||
}
|
||||
}
|
||||
|
||||
// * Loading state with minimum display time (consistent with other pages)
|
||||
const showSkeleton = useMinimumLoading(isLoading && !latestChecks)
|
||||
const fadeClass = useSkeletonFade(showSkeleton)
|
||||
if (showSkeleton) return <PageSpeedSkeleton />
|
||||
if (!site) return <div className="p-8 text-neutral-500">Site not found</div>
|
||||
|
||||
const enabled = config?.enabled ?? false
|
||||
|
||||
// * Disabled state — show empty state with enable toggle
|
||||
if (!enabled) {
|
||||
return (
|
||||
<div className="w-full max-w-7xl mx-auto px-4 sm:px-6 pb-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-lg font-semibold text-neutral-200 mb-1">
|
||||
PageSpeed
|
||||
</h1>
|
||||
<p className="text-sm text-neutral-400">
|
||||
Monitor your site's performance and Core Web Vitals
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Empty state */}
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-12 text-center">
|
||||
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4 w-16 h-16 mx-auto mb-4 flex items-center justify-center">
|
||||
<svg className="w-8 h-8 text-neutral-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="font-semibold text-white mb-2">
|
||||
PageSpeed monitoring is disabled
|
||||
</h3>
|
||||
<p className="text-sm text-neutral-400 mb-6 max-w-md mx-auto">
|
||||
Enable PageSpeed monitoring to track your site's performance scores, Core Web Vitals, and get actionable improvement suggestions.
|
||||
</p>
|
||||
|
||||
{/* Frequency selector */}
|
||||
<div className="flex items-center justify-center gap-3 mb-6">
|
||||
<label className="text-sm text-neutral-600 dark:text-neutral-400">Check frequency:</label>
|
||||
<select
|
||||
value={frequency}
|
||||
onChange={(e) => setFrequency(e.target.value)}
|
||||
className="text-sm border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-800 text-white rounded-lg px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-neutral-900 dark:focus:ring-neutral-100"
|
||||
>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{canEdit && (
|
||||
<Button
|
||||
onClick={() => handleToggle(true)}
|
||||
disabled={toggling}
|
||||
>
|
||||
{toggling ? 'Enabling...' : 'Enable PageSpeed Monitoring'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// * Prepare chart data from history (visx needs Date objects for x-axis)
|
||||
const chartData = (historyChecks ?? []).map(c => ({
|
||||
dateObj: new Date(c.checked_at),
|
||||
score: c.performance_score ?? 0,
|
||||
}))
|
||||
|
||||
// * Parse audits into groups by Lighthouse category
|
||||
const audits = currentCheck?.audits ?? []
|
||||
const passed = audits.filter(a => a.category === 'passed')
|
||||
|
||||
const categoryGroups = [
|
||||
{ key: 'performance', label: 'Performance' },
|
||||
{ key: 'accessibility', label: 'Accessibility' },
|
||||
{ key: 'best-practices', label: 'Best Practices' },
|
||||
{ key: 'seo', label: 'SEO' },
|
||||
]
|
||||
|
||||
// * Build per-category failing audits, sorted by impact
|
||||
const auditsByGroup: Record<string, typeof audits> = {}
|
||||
const manualByGroup: Record<string, typeof audits> = {}
|
||||
for (const group of categoryGroups) {
|
||||
auditsByGroup[group.key] = audits
|
||||
.filter(a => a.category !== 'passed' && a.category !== 'manual' && a.group === group.key)
|
||||
.sort((a, b) => {
|
||||
if (a.category === 'opportunity' && b.category !== 'opportunity') return -1
|
||||
if (a.category !== 'opportunity' && b.category === 'opportunity') return 1
|
||||
if (a.category === 'opportunity' && b.category === 'opportunity') {
|
||||
return (b.savings_ms ?? 0) - (a.savings_ms ?? 0)
|
||||
}
|
||||
return 0
|
||||
})
|
||||
manualByGroup[group.key] = audits.filter(a => a.category === 'manual' && a.group === group.key)
|
||||
}
|
||||
|
||||
// * Core Web Vitals metrics
|
||||
const metrics = [
|
||||
{ key: 'fcp', label: 'First Contentful Paint', value: currentCheck?.fcp_ms ?? null },
|
||||
{ key: 'lcp', label: 'Largest Contentful Paint', value: currentCheck?.lcp_ms ?? null },
|
||||
{ key: 'tbt', label: 'Total Blocking Time', value: currentCheck?.tbt_ms ?? null },
|
||||
{ key: 'cls', label: 'Cumulative Layout Shift', value: currentCheck?.cls ?? null },
|
||||
{ key: 'si', label: 'Speed Index', value: currentCheck?.si_ms ?? null },
|
||||
{ key: 'tti', label: 'Time to Interactive', value: currentCheck?.tti_ms ?? null },
|
||||
]
|
||||
|
||||
// * All 4 category scores for the hero row
|
||||
const allScores = [
|
||||
{ key: 'performance', label: 'Performance', score: currentCheck?.performance_score ?? null },
|
||||
{ key: 'accessibility', label: 'Accessibility', score: currentCheck?.accessibility_score ?? null },
|
||||
{ key: 'best-practices', label: 'Best Practices', score: currentCheck?.best_practices_score ?? null },
|
||||
{ key: 'seo', label: 'SEO', score: currentCheck?.seo_score ?? null },
|
||||
]
|
||||
|
||||
// * Map category key to score for diagnostics section
|
||||
const scoreByGroup: Record<string, number | null> = {
|
||||
'performance': currentCheck?.performance_score ?? null,
|
||||
'accessibility': currentCheck?.accessibility_score ?? null,
|
||||
'best-practices': currentCheck?.best_practices_score ?? null,
|
||||
'seo': currentCheck?.seo_score ?? null,
|
||||
}
|
||||
|
||||
function getMetricDotColor(metric: string, value: number | null): string {
|
||||
if (value === null) return 'bg-neutral-400'
|
||||
const status = getMetricStatus(metric, value)
|
||||
if (status.label === 'Good') return 'bg-emerald-500'
|
||||
if (status.label === 'Needs Improvement') return 'bg-amber-500'
|
||||
return 'bg-red-500'
|
||||
}
|
||||
|
||||
// * Enabled state — show full PageSpeed dashboard
|
||||
return (
|
||||
<div className="w-full max-w-7xl 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-lg font-semibold text-neutral-200 mb-1">
|
||||
PageSpeed
|
||||
</h1>
|
||||
<p className="text-sm text-neutral-400">
|
||||
Performance scores and Core Web Vitals for {site.domain}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Mobile / Desktop toggle */}
|
||||
<div className="flex gap-1" role="tablist" aria-label="Strategy tabs">
|
||||
{(['mobile', 'desktop'] as const).map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => { setStrategy(tab); setSelectedCheckId(null); setSelectedCheckData(null) }}
|
||||
role="tab"
|
||||
aria-selected={strategy === tab}
|
||||
className={`relative px-3 py-1.5 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange rounded cursor-pointer ${
|
||||
strategy === tab
|
||||
? 'text-white'
|
||||
: 'text-neutral-400 dark:text-neutral-500 hover:text-neutral-300'
|
||||
}`}
|
||||
>
|
||||
{tab === 'mobile' ? 'Mobile' : 'Desktop'}
|
||||
{strategy === tab && (
|
||||
<motion.div
|
||||
layoutId="pagespeedStrategyTab"
|
||||
className="absolute inset-x-0 -bottom-px h-0.5 bg-brand-orange"
|
||||
transition={{ type: 'spring', stiffness: 500, damping: 35 }}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{canEdit && (
|
||||
<>
|
||||
<Button
|
||||
onClick={handleRunCheck}
|
||||
disabled={running}
|
||||
>
|
||||
{running ? 'Running...' : 'Run Check'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => handleToggle(false)}
|
||||
disabled={toggling}
|
||||
className="text-sm"
|
||||
>
|
||||
{toggling ? 'Disabling...' : 'Disable'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 1 — Score Overview: 4 equal gauges + screenshot */}
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 sm:p-8 mb-6">
|
||||
<div className="flex flex-col lg:flex-row items-center gap-8">
|
||||
{/* 4 equal gauges — click to scroll to diagnostics */}
|
||||
<div className="flex-1 flex items-center justify-center gap-6 sm:gap-8 flex-wrap">
|
||||
{allScores.map(({ key, label, score }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => document.getElementById(`diag-${key}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' })}
|
||||
className="cursor-pointer hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<ScoreGauge score={score} label={label} size={90} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Screenshot */}
|
||||
{currentCheck?.screenshot && (
|
||||
<div className="flex-shrink-0 flex items-center justify-center">
|
||||
<img
|
||||
src={currentCheck.screenshot}
|
||||
alt={`${strategy} screenshot`}
|
||||
className="rounded-lg max-h-44 w-auto border border-neutral-200 dark:border-neutral-700 object-contain"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Check navigator + frequency + legend */}
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 mt-6 pt-4 border-t border-neutral-100 dark:border-neutral-800">
|
||||
<div className="flex items-center gap-2 text-sm text-neutral-400">
|
||||
{/* Prev/Next arrows */}
|
||||
{checkTimestamps.length > 1 && (
|
||||
<button
|
||||
onClick={handlePrevCheck}
|
||||
disabled={!canGoPrev}
|
||||
className="p-1 rounded hover:bg-neutral-100 dark:hover:bg-neutral-800 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
aria-label="Previous check"
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
{currentCheck?.checked_at && (
|
||||
<span className="tabular-nums">
|
||||
{selectedCheckId
|
||||
? new Date(currentCheck.checked_at).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
: `Last checked ${formatTimeAgo(currentCheck.checked_at)}`
|
||||
}
|
||||
</span>
|
||||
)}
|
||||
{checkTimestamps.length > 1 && (
|
||||
<button
|
||||
onClick={handleNextCheck}
|
||||
disabled={!canGoNext}
|
||||
className="p-1 rounded hover:bg-neutral-100 dark:hover:bg-neutral-800 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
aria-label="Next check"
|
||||
>
|
||||
<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="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{config?.frequency && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400">
|
||||
{config.frequency}
|
||||
</span>
|
||||
)}
|
||||
{loadingCheck && (
|
||||
<span className="text-xs text-neutral-400 animate-pulse">Loading...</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-x-3 text-[11px] text-neutral-400 dark:text-neutral-500 ml-auto">
|
||||
<span className="flex items-center gap-1"><span className="inline-block w-2 h-2 rounded-full bg-red-500" />0–49</span>
|
||||
<span className="flex items-center gap-1"><span className="inline-block w-2 h-2 rounded-full bg-amber-500" />50–89</span>
|
||||
<span className="flex items-center gap-1"><span className="inline-block w-2 h-2 rounded-full bg-emerald-500" />90–100</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filmstrip — page load progression */}
|
||||
{currentCheck?.filmstrip && currentCheck.filmstrip.length > 0 && (
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 sm:p-8 mb-6 relative">
|
||||
<h3 className="text-xs font-semibold text-neutral-400 uppercase tracking-wider mb-4">
|
||||
Page Load Timeline
|
||||
</h3>
|
||||
<div className="flex items-center overflow-x-auto gap-1 scrollbar-none">
|
||||
{currentCheck.filmstrip.map((frame, idx) => (
|
||||
<div key={idx} className="flex-shrink-0 text-center">
|
||||
<img
|
||||
src={frame.data}
|
||||
alt={`${frame.timing}ms`}
|
||||
className="h-24 rounded border border-neutral-200 dark:border-neutral-700 object-contain bg-neutral-50 dark:bg-neutral-800"
|
||||
/>
|
||||
<span className="text-[10px] text-neutral-400 mt-1 block">
|
||||
{frame.timing < 1000 ? `${frame.timing}ms` : `${(frame.timing / 1000).toFixed(1)}s`}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Fade indicator for horizontal scroll */}
|
||||
<div className="absolute right-0 top-0 bottom-0 w-12 bg-gradient-to-l from-white dark:from-neutral-900 to-transparent rounded-r-2xl pointer-events-none" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Section 2 — Metrics Card */}
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 sm:p-8 mb-6">
|
||||
<h3 className="text-xs font-semibold text-neutral-400 uppercase tracking-wider mb-5">
|
||||
Metrics
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-6">
|
||||
{metrics.map(({ key, label, value }) => (
|
||||
<div key={key} className="flex items-start gap-3">
|
||||
<span className={`mt-1.5 inline-block w-2.5 h-2.5 rounded-full flex-shrink-0 ${getMetricDotColor(key, value)}`} />
|
||||
<div>
|
||||
<div className="text-sm text-neutral-400">
|
||||
{label}
|
||||
</div>
|
||||
<div className="text-2xl font-semibold text-white tabular-nums">
|
||||
{formatMetricValue(key, value)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 3 — Score Trend Chart (visx) */}
|
||||
{chartData.length >= 2 && (
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 sm:p-8 mb-6 overflow-hidden">
|
||||
<h3 className="text-xs font-semibold text-neutral-400 uppercase tracking-wider mb-4">
|
||||
Performance Score Trend
|
||||
</h3>
|
||||
<div>
|
||||
<VisxAreaChart
|
||||
data={chartData as Record<string, unknown>[]}
|
||||
xDataKey="dateObj"
|
||||
aspectRatio="4 / 1"
|
||||
margin={{ top: 10, right: 10, bottom: 30, left: 40 }}
|
||||
>
|
||||
<VisxGrid horizontal vertical={false} stroke="var(--chart-grid)" strokeDasharray="4,4" />
|
||||
<VisxArea
|
||||
dataKey="score"
|
||||
fill="var(--chart-line-primary)"
|
||||
fillOpacity={0.15}
|
||||
stroke="var(--chart-line-primary)"
|
||||
strokeWidth={2}
|
||||
gradientToOpacity={0}
|
||||
/>
|
||||
<VisxXAxis
|
||||
numTicks={5}
|
||||
formatLabel={(d: Date) => d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })}
|
||||
/>
|
||||
<VisxYAxis
|
||||
numTicks={5}
|
||||
formatValue={(v: number) => String(Math.round(v))}
|
||||
/>
|
||||
<VisxChartTooltip
|
||||
rows={(point: Record<string, unknown>) => [{
|
||||
label: 'Score',
|
||||
value: String(Math.round(point.score as number)),
|
||||
color: 'var(--chart-line-primary)',
|
||||
}]}
|
||||
/>
|
||||
</VisxAreaChart>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Section 4 — Diagnostics by Category */}
|
||||
{audits.length > 0 && (
|
||||
<div className="space-y-6">
|
||||
{categoryGroups.map(group => {
|
||||
const groupAudits = auditsByGroup[group.key] ?? []
|
||||
const groupPassed = passed.filter(a => a.group === group.key)
|
||||
const groupManual = manualByGroup[group.key] ?? []
|
||||
if (groupAudits.length === 0 && groupPassed.length === 0 && groupManual.length === 0) return null
|
||||
return (
|
||||
<div key={group.key} id={`diag-${group.key}`} className="scroll-mt-6 bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 sm:p-8">
|
||||
{/* Category header with gauge */}
|
||||
<div className="flex items-center gap-5 mb-6">
|
||||
<ScoreGauge score={scoreByGroup[group.key]} label="" size={56} />
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">
|
||||
{group.label}
|
||||
</h3>
|
||||
<p className="text-xs text-neutral-400">
|
||||
{(() => {
|
||||
const realIssues = groupAudits.filter(a => a.score !== null && a.score !== undefined).length
|
||||
return realIssues === 0 ? 'No issues found' : `${realIssues} issue${realIssues !== 1 ? 's' : ''} found`
|
||||
})()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{groupAudits.length > 0 && (
|
||||
<AuditsBySubGroup audits={groupAudits} />
|
||||
)}
|
||||
|
||||
{groupManual.length > 0 && (
|
||||
<details className="mt-4">
|
||||
<summary className="cursor-pointer text-sm font-medium text-neutral-400 select-none hover:text-neutral-700 dark:hover:text-neutral-300 transition-colors">
|
||||
<span className="ml-1">Additional items to manually check ({groupManual.length})</span>
|
||||
</summary>
|
||||
<div className="mt-2 divide-y divide-neutral-100 dark:divide-neutral-800">
|
||||
{groupManual.map(audit => <AuditRow key={audit.id} audit={audit} />)}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{groupPassed.length > 0 && (
|
||||
<details className="mt-4">
|
||||
<summary className="cursor-pointer text-sm font-medium text-neutral-400 select-none hover:text-neutral-700 dark:hover:text-neutral-300 transition-colors">
|
||||
<span className="ml-1">{groupPassed.length} passed audit{groupPassed.length !== 1 ? 's' : ''}</span>
|
||||
</summary>
|
||||
<div className="mt-2 divide-y divide-neutral-100 dark:divide-neutral-800">
|
||||
{groupPassed.map(audit => <AuditRow key={audit.id} audit={audit} />)}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// * Sort audits by severity: red (< 0.5) → orange (0.5-0.89) → empty (null) → green (>= 0.9)
|
||||
function sortBySeverity(audits: AuditSummary[]): AuditSummary[] {
|
||||
return [...audits].sort((a, b) => {
|
||||
const rank = (s: number | null | undefined) => {
|
||||
if (s === null || s === undefined) return 2 // empty circle
|
||||
if (s < 0.5) return 0 // red
|
||||
if (s < 0.9) return 1 // orange
|
||||
return 3 // green
|
||||
}
|
||||
return rank(a.score) - rank(b.score)
|
||||
})
|
||||
}
|
||||
|
||||
// * Known sub-group ordering: insights-type groups come before diagnostics-type groups
|
||||
const subGroupPriority: Record<string, number> = {
|
||||
// * Performance
|
||||
'budgets': 0, 'load-opportunities': 0, 'diagnostics': 1,
|
||||
// * Accessibility
|
||||
'a11y-names-labels': 0, 'a11y-contrast': 1, 'a11y-best-practices': 2,
|
||||
'a11y-color-contrast': 1, 'a11y-aria': 3, 'a11y-navigation': 4,
|
||||
'a11y-language': 5, 'a11y-audio-video': 6, 'a11y-tables-lists': 7,
|
||||
// * SEO
|
||||
'seo-mobile': 0, 'seo-content': 1, 'seo-crawl': 2,
|
||||
}
|
||||
|
||||
// * Group audits by sub-group within a category (e.g., "Names and Labels", "Contrast")
|
||||
function AuditsBySubGroup({ audits }: { audits: AuditSummary[] }) {
|
||||
// * Collect unique sub-groups
|
||||
const bySubGroup: Record<string, AuditSummary[]> = {}
|
||||
|
||||
for (const audit of audits) {
|
||||
const key = audit.sub_group || '__none__'
|
||||
if (!bySubGroup[key]) {
|
||||
bySubGroup[key] = []
|
||||
}
|
||||
bySubGroup[key].push(audit)
|
||||
}
|
||||
|
||||
const subGroupOrder = Object.keys(bySubGroup).sort((a, b) => {
|
||||
const pa = subGroupPriority[a] ?? 0
|
||||
const pb = subGroupPriority[b] ?? 0
|
||||
return pa - pb
|
||||
})
|
||||
|
||||
// * If no sub-groups exist, render flat list sorted by severity
|
||||
if (subGroupOrder.length === 1 && subGroupOrder[0] === '__none__') {
|
||||
return (
|
||||
<div className="divide-y divide-neutral-100 dark:divide-neutral-800">
|
||||
{sortBySeverity(audits).map(audit => <AuditRow key={audit.id} audit={audit} />)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{subGroupOrder.map(key => {
|
||||
const items = sortBySeverity(bySubGroup[key])
|
||||
const title = items[0]?.sub_group_title
|
||||
return (
|
||||
<div key={key}>
|
||||
{title && (
|
||||
<h4 className="text-[11px] font-semibold text-neutral-400 dark:text-neutral-500 uppercase tracking-wider mb-2">
|
||||
{title}
|
||||
</h4>
|
||||
)}
|
||||
<div className="divide-y divide-neutral-100 dark:divide-neutral-800">
|
||||
{items.map(audit => <AuditRow key={audit.id} audit={audit} />)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// * Severity indicator based on audit score (pagespeed.web.dev style)
|
||||
function AuditSeverityIcon({ score }: { score: number | null }) {
|
||||
if (score === null) {
|
||||
return <span className="inline-block w-2.5 h-2.5 rounded-full border-2 border-neutral-400 flex-shrink-0" aria-label="Informative" />
|
||||
}
|
||||
if (score < 0.5) {
|
||||
return <span className="inline-block w-2.5 h-2.5 rounded-full bg-red-500 flex-shrink-0" aria-label="Poor" />
|
||||
}
|
||||
if (score < 0.9) {
|
||||
return <span className="inline-block w-2.5 h-2.5 rounded-full bg-amber-500 flex-shrink-0" aria-label="Needs Improvement" />
|
||||
}
|
||||
return <span className="inline-block w-2.5 h-2.5 rounded-full bg-emerald-500 flex-shrink-0" aria-label="Good" />
|
||||
}
|
||||
|
||||
// * Expandable audit row with description and detail items
|
||||
function AuditRow({ audit }: { audit: AuditSummary }) {
|
||||
return (
|
||||
<details className="group">
|
||||
<summary className="flex items-center gap-3 py-3 px-2 rounded-lg hover:bg-neutral-50 dark:hover:bg-neutral-800/50 cursor-pointer list-none">
|
||||
<AuditSeverityIcon score={audit.score} />
|
||||
<span className="font-medium text-sm text-white flex-1 min-w-0 truncate">{audit.title}</span>
|
||||
{audit.display_value && (
|
||||
<span className="text-xs text-neutral-500 dark:text-neutral-500 flex-shrink-0 tabular-nums">{audit.display_value}</span>
|
||||
)}
|
||||
{audit.savings_ms != null && audit.savings_ms > 0 && !audit.display_value && (
|
||||
<span className="text-sm font-medium text-amber-600 dark:text-amber-400 flex-shrink-0 tabular-nums">
|
||||
{audit.savings_ms < 1000 ? `${Math.round(audit.savings_ms)}ms` : `${(audit.savings_ms / 1000).toFixed(1)}s`}
|
||||
</span>
|
||||
)}
|
||||
<svg className="w-4 h-4 text-neutral-400 transition-transform group-open:rotate-180 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</summary>
|
||||
<div className="pl-8 pr-2 pb-3 pt-1">
|
||||
{/* Description with parsed markdown links */}
|
||||
{audit.description && (
|
||||
<p className="text-xs text-neutral-400 mb-3 leading-relaxed">
|
||||
<AuditDescription text={audit.description} />
|
||||
</p>
|
||||
)}
|
||||
{/* Items list */}
|
||||
{audit.details && Array.isArray(audit.details) && audit.details.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{audit.details.slice(0, 10).map((item: Record<string, any>, idx: number) => (
|
||||
<AuditItem key={idx} item={item} />
|
||||
))}
|
||||
{audit.details.length > 10 && (
|
||||
<p className="text-xs text-neutral-400 mt-1">+ {audit.details.length - 10} more items</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
)
|
||||
}
|
||||
|
||||
// * Parse markdown-style links [text](url) into clickable <a> tags
|
||||
function AuditDescription({ text }: { text: string }) {
|
||||
const parts = text.split(/(\[[^\]]+\]\([^)]+\))/g)
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, i) => {
|
||||
const match = part.match(/^\[([^\]]+)\]\(([^)]+)\)$/)
|
||||
if (match) {
|
||||
const href = remapLearnUrl(match[2])
|
||||
const isInternal = href.startsWith('https://ciphera.net') || href.startsWith('https://pulse.ciphera.net') || href.startsWith('https://pulse-staging.ciphera.net')
|
||||
return (
|
||||
<a
|
||||
key={i}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel={isInternal ? 'noopener' : 'noopener noreferrer'}
|
||||
className="text-brand-orange hover:underline"
|
||||
>
|
||||
{match[1]}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
return <span key={i}>{part}</span>
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// * Render a single audit detail item — handles various field types from the PSI API
|
||||
function AuditItem({ item }: { item: Record<string, any> }) {
|
||||
// * Determine the primary label
|
||||
const label = item.node?.nodeLabel || item.label || item.groupLabel || item.source?.url || null
|
||||
// * URL can be in item.url or item.href
|
||||
const url = item.url || item.href || null
|
||||
// * Text content (used by SEO audits like "link text")
|
||||
const text = item.text || item.linkText || null
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-3 py-2 border-b border-neutral-100 dark:border-neutral-800 last:border-0 text-xs text-neutral-600 dark:text-neutral-400">
|
||||
{/* Element screenshot */}
|
||||
{item.node?.screenshot?.data && (
|
||||
<img
|
||||
src={item.node.screenshot.data}
|
||||
alt=""
|
||||
className="w-20 h-14 object-contain rounded border border-neutral-200 dark:border-neutral-700 flex-shrink-0 bg-neutral-50 dark:bg-neutral-800"
|
||||
/>
|
||||
)}
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{label && (
|
||||
<div className="font-medium text-white text-xs mb-0.5">
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
{url && (
|
||||
<div className="font-mono text-xs text-neutral-400 break-all">{url}</div>
|
||||
)}
|
||||
{text && (
|
||||
<div className="text-xs text-neutral-400 mt-0.5">{text}</div>
|
||||
)}
|
||||
{item.node?.snippet && (
|
||||
<code className="text-xs bg-neutral-100 dark:bg-neutral-800 px-1.5 py-0.5 rounded break-all mt-1 inline-block">{item.node.snippet}</code>
|
||||
)}
|
||||
{/* Fallback for items with only string values we haven't handled */}
|
||||
{!label && !url && !text && !item.node && item.statistic && (
|
||||
<span>{item.statistic}</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Metrics on the right */}
|
||||
<div className="flex-shrink-0 text-right space-y-0.5">
|
||||
{item.wastedBytes != null && (
|
||||
<div className="text-amber-600 dark:text-amber-400 whitespace-nowrap">
|
||||
{item.wastedBytes < 1024 ? `${item.wastedBytes} B` : `${(item.wastedBytes / 1024).toFixed(1)} KiB`}
|
||||
</div>
|
||||
)}
|
||||
{item.totalBytes != null && !item.wastedBytes && (
|
||||
<div className="whitespace-nowrap">
|
||||
{item.totalBytes < 1024 ? `${item.totalBytes} B` : `${(item.totalBytes / 1024).toFixed(1)} KiB`}
|
||||
</div>
|
||||
)}
|
||||
{item.wastedMs != null && (
|
||||
<div className="text-amber-600 dark:text-amber-400 whitespace-nowrap">
|
||||
{item.wastedMs < 1000 ? `${Math.round(item.wastedMs)}ms` : `${(item.wastedMs / 1000).toFixed(1)}s`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// * Skeleton loading state
|
||||
function PageSpeedSkeleton() {
|
||||
return (
|
||||
<div className="w-full max-w-7xl mx-auto px-4 sm:px-6 pb-8 space-y-6 animate-pulse">
|
||||
{/* Header — title + subtitle + toggle buttons */}
|
||||
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="h-8 w-36 bg-neutral-700 rounded" />
|
||||
<div className="h-4 w-72 bg-neutral-700 rounded" />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex gap-1">
|
||||
<div className="h-8 w-16 bg-neutral-700 rounded" />
|
||||
<div className="h-8 w-20 bg-neutral-700 rounded" />
|
||||
</div>
|
||||
<div className="h-9 w-24 bg-neutral-700 rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Score overview — 4 gauge circles + screenshot */}
|
||||
<div className="bg-neutral-900 border border-neutral-800 rounded-2xl p-6 sm:p-8">
|
||||
<div className="flex flex-col lg:flex-row items-center gap-8">
|
||||
<div className="flex-1 flex items-center justify-center gap-6 sm:gap-8 flex-wrap">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="flex flex-col items-center gap-2">
|
||||
<div className="w-[90px] h-[90px] rounded-full border-[6px] border-neutral-700 bg-transparent" />
|
||||
<div className="h-3 w-16 bg-neutral-700 rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="w-48 h-44 bg-neutral-700 rounded-lg flex-shrink-0 hidden md:block" />
|
||||
</div>
|
||||
{/* Legend bar */}
|
||||
<div className="flex items-center gap-4 mt-6 pt-4 border-t border-neutral-800">
|
||||
<div className="h-3 w-32 bg-neutral-700 rounded" />
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
<div className="h-2 w-10 bg-neutral-700 rounded" />
|
||||
<div className="h-2 w-10 bg-neutral-700 rounded" />
|
||||
<div className="h-2 w-10 bg-neutral-700 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metrics card — 6 metrics in 3-col grid */}
|
||||
<div className="bg-neutral-900 border border-neutral-800 rounded-2xl p-6 sm:p-8">
|
||||
<div className="h-3 w-16 bg-neutral-700 rounded mb-5" />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-6">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<div key={i} className="flex items-start gap-3">
|
||||
<div className="mt-1.5 w-2.5 h-2.5 rounded-full bg-neutral-700 flex-shrink-0" />
|
||||
<div className="space-y-2">
|
||||
<div className="h-3 w-32 bg-neutral-700 rounded" />
|
||||
<div className="h-7 w-20 bg-neutral-700 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Score trend chart placeholder */}
|
||||
<div className="bg-neutral-900 border border-neutral-800 rounded-2xl p-6 sm:p-8">
|
||||
<div className="h-3 w-40 bg-neutral-700 rounded mb-5" />
|
||||
<div className="h-48 w-full bg-neutral-800 rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import ErrorDisplay from '@/components/ErrorDisplay'
|
||||
|
||||
export default function RealtimeError({ reset }: { error: Error; reset: () => void }) {
|
||||
return (
|
||||
<ErrorDisplay
|
||||
title="Realtime view failed to load"
|
||||
message="We couldn't connect to the realtime data stream. Please try again."
|
||||
onRetry={reset}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Realtime | Pulse',
|
||||
description: 'See who is on your site right now.',
|
||||
robots: { index: false, follow: false },
|
||||
}
|
||||
|
||||
export default function RealtimeLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return children
|
||||
}
|
||||
@@ -1,256 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { getSite, type Site } from '@/lib/api/sites'
|
||||
import { getRealtimeVisitors, getSessionDetails, type Visitor, type SessionEvent } from '@/lib/api/realtime'
|
||||
import { toast } from '@ciphera-net/ui'
|
||||
import { getAuthErrorMessage } from '@ciphera-net/ui'
|
||||
import { UserIcon } from '@ciphera-net/ui'
|
||||
import { RealtimeSkeleton, SessionEventsSkeleton, useMinimumLoading } from '@/components/skeletons'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
|
||||
function formatTimeAgo(dateString: string) {
|
||||
const date = new Date(dateString)
|
||||
const now = new Date()
|
||||
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000)
|
||||
|
||||
if (diffInSeconds < 60) return 'just now'
|
||||
if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)}m ago`
|
||||
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)}h ago`
|
||||
return `${Math.floor(diffInSeconds / 86400)}d ago`
|
||||
}
|
||||
|
||||
export default function RealtimePage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const siteId = params.id as string
|
||||
|
||||
const [site, setSite] = useState<Site | null>(null)
|
||||
const [visitors, setVisitors] = useState<Visitor[]>([])
|
||||
const [selectedVisitor, setSelectedVisitor] = useState<Visitor | null>(null)
|
||||
const [sessionEvents, setSessionEvents] = useState<SessionEvent[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadingEvents, setLoadingEvents] = useState(false)
|
||||
|
||||
// Load site info and initial visitors
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
try {
|
||||
const [siteData, visitorsData] = await Promise.all([
|
||||
getSite(siteId),
|
||||
getRealtimeVisitors(siteId)
|
||||
])
|
||||
setSite(siteData)
|
||||
setVisitors(visitorsData || [])
|
||||
// Select first visitor if available
|
||||
if (visitorsData && visitorsData.length > 0) {
|
||||
handleSelectVisitor(visitorsData[0])
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
toast.error(getAuthErrorMessage(error) || 'Failed to load realtime visitors')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
init()
|
||||
}, [siteId])
|
||||
|
||||
// Poll for updates
|
||||
useEffect(() => {
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const data = await getRealtimeVisitors(siteId)
|
||||
setVisitors(data || [])
|
||||
|
||||
// Update selected visitor reference if they are still in the list
|
||||
if (selectedVisitor) {
|
||||
const updatedVisitor = data?.find(v => v.session_id === selectedVisitor.session_id)
|
||||
if (updatedVisitor) {
|
||||
// Don't overwrite the selectedVisitor state directly to avoid flickering details
|
||||
// But we could update "last seen" indicators if we wanted
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Silent fail
|
||||
}
|
||||
}, 5000)
|
||||
return () => clearInterval(interval)
|
||||
}, [siteId, selectedVisitor])
|
||||
|
||||
const handleSelectVisitor = async (visitor: Visitor) => {
|
||||
setSelectedVisitor(visitor)
|
||||
setLoadingEvents(true)
|
||||
try {
|
||||
const events = await getSessionDetails(siteId, visitor.session_id)
|
||||
setSessionEvents(events || [])
|
||||
} catch (error: unknown) {
|
||||
toast.error(getAuthErrorMessage(error) || 'Failed to load session events')
|
||||
} finally {
|
||||
setLoadingEvents(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (site?.domain) document.title = `Realtime · ${site.domain} | Pulse`
|
||||
}, [site?.domain])
|
||||
|
||||
const showSkeleton = useMinimumLoading(loading)
|
||||
|
||||
if (showSkeleton) return <RealtimeSkeleton />
|
||||
if (!site) return <div className="p-8">Site not found</div>
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8 h-[calc(100vh-64px)] flex flex-col">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<button onClick={() => router.push(`/sites/${siteId}`)} className="text-sm text-neutral-500 hover:text-neutral-900 dark:hover:text-white transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded">
|
||||
← Back to Dashboard
|
||||
</button>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white flex items-center gap-3">
|
||||
Realtime Visitors
|
||||
<span className="relative flex h-3 w-3">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-3 w-3 bg-green-500"></span>
|
||||
</span>
|
||||
<span className="text-lg font-normal text-neutral-500" aria-live="polite" aria-atomic="true">
|
||||
{visitors.length} active now
|
||||
</span>
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row flex-1 gap-6 min-h-0">
|
||||
{/* Visitors List */}
|
||||
<div className="w-full md:w-1/3 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden flex flex-col bg-white dark:bg-neutral-900">
|
||||
<div className="p-4 border-b border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-800/50">
|
||||
<h2 className="text-xl font-semibold text-neutral-900 dark:text-white">Active Sessions</h2>
|
||||
</div>
|
||||
<div className="overflow-y-auto flex-1">
|
||||
{visitors.length === 0 ? (
|
||||
<div className="p-8 flex flex-col items-center justify-center text-center gap-3">
|
||||
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-3">
|
||||
<UserIcon className="w-6 h-6 text-neutral-500 dark:text-neutral-400" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-neutral-900 dark:text-white">
|
||||
No active visitors right now
|
||||
</p>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
New visitors will appear here in real-time
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-neutral-100 dark:divide-neutral-800">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{visitors.map((visitor) => (
|
||||
<motion.button
|
||||
key={visitor.session_id}
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
onClick={() => handleSelectVisitor(visitor)}
|
||||
className={`w-full text-left p-4 hover:bg-neutral-50 dark:hover:bg-neutral-800/50 transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-inset ${
|
||||
selectedVisitor?.session_id === visitor.session_id ? 'bg-neutral-50 dark:bg-neutral-800/50 ring-1 ring-inset ring-neutral-200 dark:ring-neutral-700' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-start mb-1">
|
||||
<div className="font-medium text-neutral-900 dark:text-white truncate pr-2">
|
||||
{visitor.country ? `${getFlagEmoji(visitor.country)} ${visitor.city || 'Unknown City'}` : 'Unknown Location'}
|
||||
</div>
|
||||
<span className="text-xs text-neutral-500 whitespace-nowrap">
|
||||
{formatTimeAgo(visitor.last_seen)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-neutral-600 dark:text-neutral-400 truncate mb-1" title={visitor.current_path}>
|
||||
{visitor.current_path}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-neutral-400">
|
||||
<span>{visitor.device_type}</span>
|
||||
<span>•</span>
|
||||
<span>{visitor.browser}</span>
|
||||
<span>•</span>
|
||||
<span>{visitor.os}</span>
|
||||
<span className="ml-auto bg-neutral-100 dark:bg-neutral-800 px-1.5 py-0.5 rounded text-neutral-600 dark:text-neutral-400">
|
||||
{visitor.pageviews} views
|
||||
</span>
|
||||
</div>
|
||||
</motion.button>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Session Details */}
|
||||
<div className="flex-1 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden flex flex-col bg-white dark:bg-neutral-900">
|
||||
<div className="p-4 border-b border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-800/50 flex justify-between items-center">
|
||||
<h2 className="text-xl font-semibold text-neutral-900 dark:text-white">
|
||||
{selectedVisitor ? 'Session Journey' : 'Select a visitor'}
|
||||
</h2>
|
||||
{selectedVisitor && (
|
||||
<span className="text-xs font-mono text-neutral-400">
|
||||
ID: {selectedVisitor.session_id.substring(0, 8)}...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{!selectedVisitor ? (
|
||||
<div className="h-full flex items-center justify-center text-neutral-500">
|
||||
Select a visitor on the left to see their activity.
|
||||
</div>
|
||||
) : loadingEvents ? (
|
||||
<SessionEventsSkeleton />
|
||||
) : (
|
||||
<div className="relative pl-6 border-l-2 border-neutral-100 dark:border-neutral-800 space-y-8">
|
||||
{sessionEvents.map((event, idx) => (
|
||||
<div key={event.id} className="relative">
|
||||
<span className={`absolute -left-[29px] top-1 h-3 w-3 rounded-full border-2 border-white dark:border-neutral-900 ${
|
||||
idx === 0 ? 'bg-green-500 ring-4 ring-green-100 dark:ring-green-900/30' : 'bg-neutral-300 dark:bg-neutral-700'
|
||||
}`}></span>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-neutral-900 dark:text-white">
|
||||
Visited {event.path}
|
||||
</span>
|
||||
<span className="text-xs text-neutral-500">
|
||||
{new Date(event.timestamp).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{event.referrer && (
|
||||
<div className="text-xs text-neutral-500">
|
||||
Referrer: <span className="text-neutral-700 dark:text-neutral-300">{event.referrer}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="relative">
|
||||
<span className="absolute -left-[29px] top-1 h-3 w-3 rounded-full border-2 border-white dark:border-neutral-900 bg-neutral-300 dark:bg-neutral-700"></span>
|
||||
<div className="text-sm text-neutral-500">
|
||||
Session started {formatTimeAgo(sessionEvents[sessionEvents.length - 1]?.timestamp || new Date().toISOString())}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getFlagEmoji(countryCode: string) {
|
||||
if (!countryCode || countryCode.length !== 2) return '🌍'
|
||||
const codePoints = countryCode
|
||||
.toUpperCase()
|
||||
.split('')
|
||||
.map(char => 127397 + char.charCodeAt(0))
|
||||
return String.fromCodePoint(...codePoints)
|
||||
}
|
||||
13
app/sites/[id]/search/error.tsx
Normal file
13
app/sites/[id]/search/error.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import ErrorDisplay from '@/components/ErrorDisplay'
|
||||
|
||||
export default function SearchError({ reset }: { error: Error; reset: () => void }) {
|
||||
return (
|
||||
<ErrorDisplay
|
||||
title="Search Console data failed to load"
|
||||
message="We couldn't load the Google Search Console data. This might be a temporary issue — try again."
|
||||
onRetry={reset}
|
||||
/>
|
||||
)
|
||||
}
|
||||
668
app/sites/[id]/search/page.tsx
Normal file
668
app/sites/[id]/search/page.tsx
Normal file
@@ -0,0 +1,668 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useUnifiedSettings } from '@/lib/unified-settings-context'
|
||||
import { Select, DatePicker } from '@ciphera-net/ui'
|
||||
import { getDateRange, formatDate, getThisWeekRange, getThisMonthRange } from '@/lib/utils/dateRanges'
|
||||
import { CaretDown, CaretUp, MagnifyingGlass, ArrowSquareOut } from '@phosphor-icons/react'
|
||||
import { useDashboard, useGSCStatus, useGSCOverview, useGSCTopQueries, useGSCTopPages, useGSCNewQueries } from '@/lib/swr/dashboard'
|
||||
import { getGSCQueryPages, getGSCPageQueries } from '@/lib/api/gsc'
|
||||
import type { GSCDataRow } from '@/lib/api/gsc'
|
||||
import { SkeletonLine, StatCardSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
|
||||
import ClicksImpressionsChart from '@/components/search/ClicksImpressionsChart'
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────
|
||||
|
||||
const formatPosition = (pos: number) => pos.toFixed(1)
|
||||
const formatCTR = (ctr: number) => (ctr * 100).toFixed(1) + '%'
|
||||
|
||||
function formatChange(current: number, previous: number) {
|
||||
if (previous === 0) return null
|
||||
const change = ((current - previous) / previous) * 100
|
||||
return { value: change, label: (change >= 0 ? '+' : '') + change.toFixed(1) + '%' }
|
||||
}
|
||||
|
||||
function formatNumber(n: number) {
|
||||
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M'
|
||||
if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K'
|
||||
return n.toLocaleString()
|
||||
}
|
||||
|
||||
// ─── Page ───────────────────────────────────────────────────────
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
export default function SearchConsolePage() {
|
||||
const params = useParams()
|
||||
const siteId = params.id as string
|
||||
const { openUnifiedSettings } = useUnifiedSettings()
|
||||
|
||||
// Date range
|
||||
const [period, setPeriod] = useState('28')
|
||||
const [dateRange, setDateRange] = useState(() => getDateRange(28))
|
||||
const [isDatePickerOpen, setIsDatePickerOpen] = useState(false)
|
||||
|
||||
// View toggle
|
||||
const [activeView, setActiveView] = useState<'queries' | 'pages'>('queries')
|
||||
|
||||
// Pagination
|
||||
const [queryPage, setQueryPage] = useState(0)
|
||||
const [pagePage, setPagePage] = useState(0)
|
||||
|
||||
// Drill-down expansion
|
||||
const [expandedQuery, setExpandedQuery] = useState<string | null>(null)
|
||||
const [expandedPage, setExpandedPage] = useState<string | null>(null)
|
||||
const [expandedData, setExpandedData] = useState<GSCDataRow[]>([])
|
||||
const [expandedLoading, setExpandedLoading] = useState(false)
|
||||
|
||||
// Data fetching
|
||||
const { data: gscStatus } = useGSCStatus(siteId)
|
||||
const { data: dashboard } = useDashboard(siteId, dateRange.start, dateRange.end)
|
||||
const { data: overview } = useGSCOverview(siteId, dateRange.start, dateRange.end)
|
||||
const { data: topQueries, isLoading: queriesLoading } = useGSCTopQueries(siteId, dateRange.start, dateRange.end, PAGE_SIZE, queryPage * PAGE_SIZE)
|
||||
const { data: topPages, isLoading: pagesLoading } = useGSCTopPages(siteId, dateRange.start, dateRange.end, PAGE_SIZE, pagePage * PAGE_SIZE)
|
||||
const { data: newQueries } = useGSCNewQueries(siteId, dateRange.start, dateRange.end)
|
||||
|
||||
const showSkeleton = useMinimumLoading(!gscStatus || (gscStatus?.connected && !overview))
|
||||
const fadeClass = useSkeletonFade(showSkeleton)
|
||||
|
||||
// Document title
|
||||
useEffect(() => {
|
||||
const domain = dashboard?.site?.domain
|
||||
document.title = domain ? `Search Console \u00b7 ${domain} | Pulse` : 'Search Console | Pulse'
|
||||
}, [dashboard?.site?.domain])
|
||||
|
||||
// Reset pagination when date range changes
|
||||
useEffect(() => {
|
||||
setQueryPage(0)
|
||||
setPagePage(0)
|
||||
setExpandedQuery(null)
|
||||
setExpandedPage(null)
|
||||
setExpandedData([])
|
||||
}, [dateRange.start, dateRange.end])
|
||||
|
||||
// ─── Expand handlers ───────────────────────────────────────
|
||||
|
||||
async function handleExpandQuery(query: string) {
|
||||
if (expandedQuery === query) {
|
||||
setExpandedQuery(null)
|
||||
setExpandedData([])
|
||||
return
|
||||
}
|
||||
setExpandedQuery(query)
|
||||
setExpandedPage(null)
|
||||
setExpandedLoading(true)
|
||||
try {
|
||||
const res = await getGSCQueryPages(siteId, query, dateRange.start, dateRange.end)
|
||||
setExpandedData(res.pages)
|
||||
} catch {
|
||||
setExpandedData([])
|
||||
} finally {
|
||||
setExpandedLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExpandPage(page: string) {
|
||||
if (expandedPage === page) {
|
||||
setExpandedPage(null)
|
||||
setExpandedData([])
|
||||
return
|
||||
}
|
||||
setExpandedPage(page)
|
||||
setExpandedQuery(null)
|
||||
setExpandedLoading(true)
|
||||
try {
|
||||
const res = await getGSCPageQueries(siteId, page, dateRange.start, dateRange.end)
|
||||
setExpandedData(res.queries)
|
||||
} catch {
|
||||
setExpandedData([])
|
||||
} finally {
|
||||
setExpandedLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Loading skeleton ─────────────────────────────────────
|
||||
|
||||
if (showSkeleton) {
|
||||
return (
|
||||
<div className="w-full max-w-7xl mx-auto px-4 sm:px-6 pb-8">
|
||||
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<SkeletonLine className="h-8 w-48 mb-2" />
|
||||
<SkeletonLine className="h-4 w-64" />
|
||||
</div>
|
||||
<SkeletonLine className="h-9 w-36 rounded-lg" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-8">
|
||||
<StatCardSkeleton />
|
||||
<StatCardSkeleton />
|
||||
<StatCardSkeleton />
|
||||
<StatCardSkeleton />
|
||||
</div>
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-xl p-6">
|
||||
<SkeletonLine className="h-9 w-48 rounded-lg mb-6" />
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center justify-between py-3">
|
||||
<SkeletonLine className="h-4 w-1/3" />
|
||||
<div className="flex gap-8">
|
||||
<SkeletonLine className="h-4 w-16" />
|
||||
<SkeletonLine className="h-4 w-16" />
|
||||
<SkeletonLine className="h-4 w-12" />
|
||||
<SkeletonLine className="h-4 w-12" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Not connected state ──────────────────────────────────
|
||||
|
||||
if (gscStatus && !gscStatus.connected) {
|
||||
return (
|
||||
<div className={`w-full max-w-7xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
|
||||
<div className="flex flex-col items-center justify-center py-24 text-center">
|
||||
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-5 mb-6">
|
||||
<MagnifyingGlass size={40} className="text-neutral-400 dark:text-neutral-500" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-white mb-2">
|
||||
Connect Google Search Console
|
||||
</h2>
|
||||
<p className="text-sm text-neutral-400 max-w-md mb-6">
|
||||
See how your site performs in Google Search. View top queries, pages, click-through rates, and average position data.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => openUnifiedSettings({ context: 'site', tab: 'integrations' })}
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-brand-orange-button hover:bg-brand-orange-button-hover text-white text-sm font-medium transition-colors cursor-pointer"
|
||||
>
|
||||
Connect in Settings
|
||||
<ArrowSquareOut size={16} weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Connected — main view ────────────────────────────────
|
||||
|
||||
const clicksChange = overview ? formatChange(overview.total_clicks, overview.prev_clicks) : null
|
||||
const impressionsChange = overview ? formatChange(overview.total_impressions, overview.prev_impressions) : null
|
||||
const ctrChange = overview ? formatChange(overview.avg_ctr, overview.prev_avg_ctr) : null
|
||||
// For position, lower is better — invert the direction
|
||||
const positionChange = overview ? formatChange(overview.avg_position, overview.prev_avg_position) : null
|
||||
|
||||
const queries = topQueries?.queries ?? []
|
||||
const queriesTotal = topQueries?.total ?? 0
|
||||
const pages = topPages?.pages ?? []
|
||||
const pagesTotal = topPages?.total ?? 0
|
||||
|
||||
return (
|
||||
<div className={`w-full max-w-7xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-neutral-200 mb-1">
|
||||
Search Console
|
||||
</h1>
|
||||
<p className="text-sm text-neutral-400">
|
||||
Google Search performance, queries, and page rankings
|
||||
</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 === '28') {
|
||||
setDateRange(getDateRange(28))
|
||||
setPeriod('28')
|
||||
} 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: '28', label: 'Last 28 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>
|
||||
|
||||
{/* Overview cards */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-8">
|
||||
<OverviewCard
|
||||
label="Total Clicks"
|
||||
value={overview ? formatNumber(overview.total_clicks) : '-'}
|
||||
change={clicksChange}
|
||||
/>
|
||||
<OverviewCard
|
||||
label="Total Impressions"
|
||||
value={overview ? formatNumber(overview.total_impressions) : '-'}
|
||||
change={impressionsChange}
|
||||
/>
|
||||
<OverviewCard
|
||||
label="Average CTR"
|
||||
value={overview ? formatCTR(overview.avg_ctr) : '-'}
|
||||
change={ctrChange}
|
||||
/>
|
||||
<OverviewCard
|
||||
label="Average Position"
|
||||
value={overview ? formatPosition(overview.avg_position) : '-'}
|
||||
change={positionChange}
|
||||
invertChange
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ClicksImpressionsChart siteId={siteId} startDate={dateRange.start} endDate={dateRange.end} />
|
||||
|
||||
{/* Position tracker */}
|
||||
{topQueries?.queries && topQueries.queries.length > 0 && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-3 mb-6">
|
||||
{topQueries.queries.slice(0, 5).map((q) => (
|
||||
<div key={q.query} className="rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-3">
|
||||
<p className="text-xs text-neutral-400 truncate mb-1">{q.query}</p>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<p className="text-lg font-semibold text-white">{q.position.toFixed(1)}</p>
|
||||
<p className="text-xs text-neutral-400">pos</p>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-500 mt-0.5">{q.clicks} {q.clicks === 1 ? 'click' : 'clicks'}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* New queries badge */}
|
||||
{newQueries && newQueries.count > 0 && (
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300 text-sm mb-4">
|
||||
<span className="font-medium">{newQueries.count} new {newQueries.count === 1 ? 'query' : 'queries'}</span>
|
||||
<span className="text-green-600 dark:text-green-400">appeared this period</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* View toggle */}
|
||||
<div className="mb-6">
|
||||
<div className="inline-flex bg-neutral-100 dark:bg-neutral-800 rounded-lg p-1">
|
||||
<button
|
||||
onClick={() => { setActiveView('queries'); setExpandedQuery(null); setExpandedData([]) }}
|
||||
className={`px-4 py-1.5 text-sm font-medium rounded-md transition-all cursor-pointer ${
|
||||
activeView === 'queries'
|
||||
? 'bg-white dark:bg-neutral-700 text-white shadow-sm'
|
||||
: 'text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300'
|
||||
}`}
|
||||
>
|
||||
Top Queries
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setActiveView('pages'); setExpandedPage(null); setExpandedData([]) }}
|
||||
className={`px-4 py-1.5 text-sm font-medium rounded-md transition-all cursor-pointer ${
|
||||
activeView === 'pages'
|
||||
? 'bg-white dark:bg-neutral-700 text-white shadow-sm'
|
||||
: 'text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300'
|
||||
}`}
|
||||
>
|
||||
Top Pages
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Queries table */}
|
||||
{activeView === 'queries' && (
|
||||
<div className="rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-neutral-200 dark:border-neutral-800">
|
||||
<th className="text-left px-4 py-3 font-medium text-neutral-400 w-8" />
|
||||
<th className="text-left px-4 py-3 font-medium text-neutral-400">Query</th>
|
||||
<th className="text-right px-4 py-3 font-medium text-neutral-400">Clicks</th>
|
||||
<th className="text-right px-4 py-3 font-medium text-neutral-400">Impressions</th>
|
||||
<th className="text-right px-4 py-3 font-medium text-neutral-400">CTR</th>
|
||||
<th className="text-right px-4 py-3 font-medium text-neutral-400">Position</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{queriesLoading && queries.length === 0 ? (
|
||||
Array.from({ length: 10 }).map((_, i) => (
|
||||
<tr key={i} className="border-b border-neutral-100 dark:border-neutral-800/50">
|
||||
<td className="px-4 py-3" />
|
||||
<td className="px-4 py-3"><SkeletonLine className="h-4 w-3/4" /></td>
|
||||
<td className="px-4 py-3"><SkeletonLine className="h-4 w-12 ml-auto" /></td>
|
||||
<td className="px-4 py-3"><SkeletonLine className="h-4 w-16 ml-auto" /></td>
|
||||
<td className="px-4 py-3"><SkeletonLine className="h-4 w-12 ml-auto" /></td>
|
||||
<td className="px-4 py-3"><SkeletonLine className="h-4 w-10 ml-auto" /></td>
|
||||
</tr>
|
||||
))
|
||||
) : queries.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-12 text-center text-neutral-400">
|
||||
No query data available for this period.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
queries.map((row) => (
|
||||
<QueryRow
|
||||
key={row.query}
|
||||
row={row}
|
||||
isExpanded={expandedQuery === row.query}
|
||||
expandedData={expandedQuery === row.query ? expandedData : []}
|
||||
expandedLoading={expandedQuery === row.query && expandedLoading}
|
||||
onToggle={() => handleExpandQuery(row.query)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* Pagination */}
|
||||
{queriesTotal > PAGE_SIZE && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-neutral-200 dark:border-neutral-800">
|
||||
<p className="text-sm text-neutral-400">
|
||||
Showing {queryPage * PAGE_SIZE + 1}-{Math.min((queryPage + 1) * PAGE_SIZE, queriesTotal)} of {queriesTotal.toLocaleString()}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
disabled={queryPage === 0}
|
||||
onClick={() => { setQueryPage((p) => p - 1); setExpandedQuery(null); setExpandedData([]) }}
|
||||
className="px-3 py-1.5 text-sm rounded-lg border border-neutral-200 dark:border-neutral-700 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-800 disabled:opacity-40 disabled:cursor-not-allowed transition-colors cursor-pointer"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
disabled={(queryPage + 1) * PAGE_SIZE >= queriesTotal}
|
||||
onClick={() => { setQueryPage((p) => p + 1); setExpandedQuery(null); setExpandedData([]) }}
|
||||
className="px-3 py-1.5 text-sm rounded-lg border border-neutral-200 dark:border-neutral-700 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-800 disabled:opacity-40 disabled:cursor-not-allowed transition-colors cursor-pointer"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pages table */}
|
||||
{activeView === 'pages' && (
|
||||
<div className="rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-neutral-200 dark:border-neutral-800">
|
||||
<th className="text-left px-4 py-3 font-medium text-neutral-400 w-8" />
|
||||
<th className="text-left px-4 py-3 font-medium text-neutral-400">Page</th>
|
||||
<th className="text-right px-4 py-3 font-medium text-neutral-400">Clicks</th>
|
||||
<th className="text-right px-4 py-3 font-medium text-neutral-400">Impressions</th>
|
||||
<th className="text-right px-4 py-3 font-medium text-neutral-400">CTR</th>
|
||||
<th className="text-right px-4 py-3 font-medium text-neutral-400">Position</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pagesLoading && pages.length === 0 ? (
|
||||
Array.from({ length: 10 }).map((_, i) => (
|
||||
<tr key={i} className="border-b border-neutral-100 dark:border-neutral-800/50">
|
||||
<td className="px-4 py-3" />
|
||||
<td className="px-4 py-3"><SkeletonLine className="h-4 w-3/4" /></td>
|
||||
<td className="px-4 py-3"><SkeletonLine className="h-4 w-12 ml-auto" /></td>
|
||||
<td className="px-4 py-3"><SkeletonLine className="h-4 w-16 ml-auto" /></td>
|
||||
<td className="px-4 py-3"><SkeletonLine className="h-4 w-12 ml-auto" /></td>
|
||||
<td className="px-4 py-3"><SkeletonLine className="h-4 w-10 ml-auto" /></td>
|
||||
</tr>
|
||||
))
|
||||
) : pages.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-12 text-center text-neutral-400">
|
||||
No page data available for this period.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
pages.map((row) => (
|
||||
<PageRow
|
||||
key={row.page}
|
||||
row={row}
|
||||
isExpanded={expandedPage === row.page}
|
||||
expandedData={expandedPage === row.page ? expandedData : []}
|
||||
expandedLoading={expandedPage === row.page && expandedLoading}
|
||||
onToggle={() => handleExpandPage(row.page)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* Pagination */}
|
||||
{pagesTotal > PAGE_SIZE && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-neutral-200 dark:border-neutral-800">
|
||||
<p className="text-sm text-neutral-400">
|
||||
Showing {pagePage * PAGE_SIZE + 1}-{Math.min((pagePage + 1) * PAGE_SIZE, pagesTotal)} of {pagesTotal.toLocaleString()}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
disabled={pagePage === 0}
|
||||
onClick={() => { setPagePage((p) => p - 1); setExpandedPage(null); setExpandedData([]) }}
|
||||
className="px-3 py-1.5 text-sm rounded-lg border border-neutral-200 dark:border-neutral-700 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-800 disabled:opacity-40 disabled:cursor-not-allowed transition-colors cursor-pointer"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
disabled={(pagePage + 1) * PAGE_SIZE >= pagesTotal}
|
||||
onClick={() => { setPagePage((p) => p + 1); setExpandedPage(null); setExpandedData([]) }}
|
||||
className="px-3 py-1.5 text-sm rounded-lg border border-neutral-200 dark:border-neutral-700 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-800 disabled:opacity-40 disabled:cursor-not-allowed transition-colors cursor-pointer"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DatePicker
|
||||
isOpen={isDatePickerOpen}
|
||||
onClose={() => setIsDatePickerOpen(false)}
|
||||
onApply={(range) => {
|
||||
setDateRange(range)
|
||||
setPeriod('custom')
|
||||
setIsDatePickerOpen(false)
|
||||
}}
|
||||
initialRange={dateRange}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Sub-components ─────────────────────────────────────────────
|
||||
|
||||
function OverviewCard({
|
||||
label,
|
||||
value,
|
||||
change,
|
||||
invertChange = false,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
change: { value: number; label: string } | null
|
||||
invertChange?: boolean
|
||||
}) {
|
||||
// For position, lower is better so a negative change is good
|
||||
const isPositive = change ? (invertChange ? change.value < 0 : change.value > 0) : false
|
||||
const isNegative = change ? (invertChange ? change.value > 0 : change.value < 0) : false
|
||||
|
||||
return (
|
||||
<div className="p-4 rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900">
|
||||
<p className="text-xs font-medium text-neutral-400 mb-1">{label}</p>
|
||||
<p className="text-2xl font-bold text-white">{value}</p>
|
||||
{change && (
|
||||
<p className={`text-xs mt-1 font-medium ${
|
||||
isPositive ? 'text-green-600 dark:text-green-400' :
|
||||
isNegative ? 'text-red-600 dark:text-red-400' :
|
||||
'text-neutral-400'
|
||||
}`}>
|
||||
{change.label} vs previous period
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function QueryRow({
|
||||
row,
|
||||
isExpanded,
|
||||
expandedData,
|
||||
expandedLoading,
|
||||
onToggle,
|
||||
}: {
|
||||
row: GSCDataRow
|
||||
isExpanded: boolean
|
||||
expandedData: GSCDataRow[]
|
||||
expandedLoading: boolean
|
||||
onToggle: () => void
|
||||
}) {
|
||||
const Caret = isExpanded ? CaretUp : CaretDown
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
onClick={onToggle}
|
||||
className="border-b border-neutral-100 dark:border-neutral-800/50 hover:bg-neutral-50 dark:hover:bg-neutral-800/50 cursor-pointer transition-colors"
|
||||
>
|
||||
<td className="px-4 py-3 text-neutral-400 dark:text-neutral-500">
|
||||
<Caret size={14} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-white font-medium">{row.query}</td>
|
||||
<td className="px-4 py-3 text-right text-neutral-700 dark:text-neutral-300 tabular-nums">{row.clicks.toLocaleString()}</td>
|
||||
<td className="px-4 py-3 text-right text-neutral-700 dark:text-neutral-300 tabular-nums">{row.impressions.toLocaleString()}</td>
|
||||
<td className="px-4 py-3 text-right text-neutral-700 dark:text-neutral-300 tabular-nums">{formatCTR(row.ctr)}</td>
|
||||
<td className="px-4 py-3 text-right text-neutral-700 dark:text-neutral-300 tabular-nums">{formatPosition(row.position)}</td>
|
||||
</tr>
|
||||
{isExpanded && (
|
||||
<tr className="bg-neutral-50 dark:bg-neutral-800/30">
|
||||
<td colSpan={6} className="px-4 py-3">
|
||||
{expandedLoading ? (
|
||||
<div className="space-y-2 py-1">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<SkeletonLine key={i} className="h-4 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : expandedData.length === 0 ? (
|
||||
<p className="text-sm text-neutral-400 py-1">No pages found for this query.</p>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="text-left px-2 py-1.5 text-xs font-medium text-neutral-400 dark:text-neutral-500">Page</th>
|
||||
<th className="text-right px-2 py-1.5 text-xs font-medium text-neutral-400 dark:text-neutral-500">Clicks</th>
|
||||
<th className="text-right px-2 py-1.5 text-xs font-medium text-neutral-400 dark:text-neutral-500">Impressions</th>
|
||||
<th className="text-right px-2 py-1.5 text-xs font-medium text-neutral-400 dark:text-neutral-500">CTR</th>
|
||||
<th className="text-right px-2 py-1.5 text-xs font-medium text-neutral-400 dark:text-neutral-500">Position</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{expandedData.map((sub) => (
|
||||
<tr key={sub.page} className="border-t border-neutral-200/50 dark:border-neutral-700/50">
|
||||
<td className="px-2 py-1.5 text-neutral-700 dark:text-neutral-300 max-w-md truncate" title={sub.page}>{sub.page}</td>
|
||||
<td className="px-2 py-1.5 text-right text-neutral-600 dark:text-neutral-400 tabular-nums">{sub.clicks.toLocaleString()}</td>
|
||||
<td className="px-2 py-1.5 text-right text-neutral-600 dark:text-neutral-400 tabular-nums">{sub.impressions.toLocaleString()}</td>
|
||||
<td className="px-2 py-1.5 text-right text-neutral-600 dark:text-neutral-400 tabular-nums">{formatCTR(sub.ctr)}</td>
|
||||
<td className="px-2 py-1.5 text-right text-neutral-600 dark:text-neutral-400 tabular-nums">{formatPosition(sub.position)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function PageRow({
|
||||
row,
|
||||
isExpanded,
|
||||
expandedData,
|
||||
expandedLoading,
|
||||
onToggle,
|
||||
}: {
|
||||
row: GSCDataRow
|
||||
isExpanded: boolean
|
||||
expandedData: GSCDataRow[]
|
||||
expandedLoading: boolean
|
||||
onToggle: () => void
|
||||
}) {
|
||||
const Caret = isExpanded ? CaretUp : CaretDown
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
onClick={onToggle}
|
||||
className="border-b border-neutral-100 dark:border-neutral-800/50 hover:bg-neutral-50 dark:hover:bg-neutral-800/50 cursor-pointer transition-colors"
|
||||
>
|
||||
<td className="px-4 py-3 text-neutral-400 dark:text-neutral-500">
|
||||
<Caret size={14} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-white font-medium max-w-md truncate" title={row.page}>{row.page}</td>
|
||||
<td className="px-4 py-3 text-right text-neutral-700 dark:text-neutral-300 tabular-nums">{row.clicks.toLocaleString()}</td>
|
||||
<td className="px-4 py-3 text-right text-neutral-700 dark:text-neutral-300 tabular-nums">{row.impressions.toLocaleString()}</td>
|
||||
<td className="px-4 py-3 text-right text-neutral-700 dark:text-neutral-300 tabular-nums">{formatCTR(row.ctr)}</td>
|
||||
<td className="px-4 py-3 text-right text-neutral-700 dark:text-neutral-300 tabular-nums">{formatPosition(row.position)}</td>
|
||||
</tr>
|
||||
{isExpanded && (
|
||||
<tr className="bg-neutral-50 dark:bg-neutral-800/30">
|
||||
<td colSpan={6} className="px-4 py-3">
|
||||
{expandedLoading ? (
|
||||
<div className="space-y-2 py-1">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<SkeletonLine key={i} className="h-4 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : expandedData.length === 0 ? (
|
||||
<p className="text-sm text-neutral-400 py-1">No queries found for this page.</p>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="text-left px-2 py-1.5 text-xs font-medium text-neutral-400 dark:text-neutral-500">Query</th>
|
||||
<th className="text-right px-2 py-1.5 text-xs font-medium text-neutral-400 dark:text-neutral-500">Clicks</th>
|
||||
<th className="text-right px-2 py-1.5 text-xs font-medium text-neutral-400 dark:text-neutral-500">Impressions</th>
|
||||
<th className="text-right px-2 py-1.5 text-xs font-medium text-neutral-400 dark:text-neutral-500">CTR</th>
|
||||
<th className="text-right px-2 py-1.5 text-xs font-medium text-neutral-400 dark:text-neutral-500">Position</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{expandedData.map((sub) => (
|
||||
<tr key={sub.query} className="border-t border-neutral-200/50 dark:border-neutral-700/50">
|
||||
<td className="px-2 py-1.5 text-neutral-700 dark:text-neutral-300">{sub.query}</td>
|
||||
<td className="px-2 py-1.5 text-right text-neutral-600 dark:text-neutral-400 tabular-nums">{sub.clicks.toLocaleString()}</td>
|
||||
<td className="px-2 py-1.5 text-right text-neutral-600 dark:text-neutral-400 tabular-nums">{sub.impressions.toLocaleString()}</td>
|
||||
<td className="px-2 py-1.5 text-right text-neutral-600 dark:text-neutral-400 tabular-nums">{formatCTR(sub.ctr)}</td>
|
||||
<td className="px-2 py-1.5 text-right text-neutral-600 dark:text-neutral-400 tabular-nums">{formatPosition(sub.position)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -113,7 +113,7 @@ export default function NewSitePage() {
|
||||
<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" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white">
|
||||
<h2 className="text-2xl font-bold text-white">
|
||||
Site created
|
||||
</h2>
|
||||
<p className="mt-2 text-neutral-600 dark:text-neutral-400">
|
||||
@@ -133,11 +133,11 @@ export default function NewSitePage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowVerificationModal(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 text-neutral-700 dark:text-neutral-300 rounded-xl hover:bg-neutral-50 dark:hover:bg-neutral-700 transition-all text-sm font-medium focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-offset-2"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 text-neutral-700 dark:text-neutral-300 rounded-xl hover:bg-neutral-50 dark:hover:bg-neutral-700 transition-all text-sm font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:ring-offset-2"
|
||||
>
|
||||
<span className="text-brand-orange">Verify installation</span>
|
||||
</button>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
<p className="text-xs text-neutral-400">
|
||||
Check if your site is sending data correctly.
|
||||
</p>
|
||||
</div>
|
||||
@@ -146,7 +146,7 @@ export default function NewSitePage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBackToForm}
|
||||
className="text-sm text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300 underline"
|
||||
className="text-sm text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300 underline"
|
||||
>
|
||||
Edit site details
|
||||
</button>
|
||||
@@ -174,7 +174,7 @@ export default function NewSitePage() {
|
||||
// * Step 1: Name & domain form
|
||||
return (
|
||||
<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-white">
|
||||
Create New Site
|
||||
</h1>
|
||||
|
||||
@@ -186,7 +186,7 @@ export default function NewSitePage() {
|
||||
|
||||
<form onSubmit={handleSubmit} className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
|
||||
<div className="mb-4">
|
||||
<label htmlFor="name" className="block text-sm font-medium mb-2 text-neutral-900 dark:text-white">
|
||||
<label htmlFor="name" className="block text-sm font-medium mb-2 text-white">
|
||||
Site Name
|
||||
</label>
|
||||
<Input
|
||||
@@ -201,7 +201,7 @@ export default function NewSitePage() {
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<label htmlFor="domain" className="block text-sm font-medium mb-2 text-neutral-900 dark:text-white">
|
||||
<label htmlFor="domain" className="block text-sm font-medium mb-2 text-white">
|
||||
Domain
|
||||
</label>
|
||||
<Input
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
type Organization,
|
||||
type OrganizationMember,
|
||||
} from '@/lib/api/organization'
|
||||
import { createCheckoutSession } from '@/lib/api/billing'
|
||||
import { createSite, type Site } from '@/lib/api/sites'
|
||||
import { setSessionAction } from '@/app/actions/auth'
|
||||
import { useAuth } from '@/lib/auth/context'
|
||||
@@ -39,8 +38,6 @@ import {
|
||||
ArrowRightIcon,
|
||||
ArrowLeftIcon,
|
||||
BarChartIcon,
|
||||
GlobeIcon,
|
||||
ZapIcon,
|
||||
PlusIcon,
|
||||
} from '@ciphera-net/ui'
|
||||
import Link from 'next/link'
|
||||
@@ -90,7 +87,6 @@ function WelcomeContent() {
|
||||
const [orgLoading, setOrgLoading] = useState(false)
|
||||
const [orgError, setOrgError] = useState('')
|
||||
|
||||
const [planLoading, setPlanLoading] = useState(false)
|
||||
const [planError, setPlanError] = useState('')
|
||||
|
||||
const [siteName, setSiteName] = useState('')
|
||||
@@ -100,7 +96,6 @@ function WelcomeContent() {
|
||||
const [createdSite, setCreatedSite] = useState<Site | null>(null)
|
||||
const [showVerificationModal, setShowVerificationModal] = useState(false)
|
||||
|
||||
const [redirectingCheckout, setRedirectingCheckout] = useState(false)
|
||||
const [hadPendingCheckout, setHadPendingCheckout] = useState<boolean | null>(null)
|
||||
const [dismissedPendingCheckout, setDismissedPendingCheckout] = useState(false)
|
||||
|
||||
@@ -213,28 +208,15 @@ function WelcomeContent() {
|
||||
setStep(4)
|
||||
return
|
||||
}
|
||||
setPlanLoading(true)
|
||||
setPlanError('')
|
||||
|
||||
trackWelcomePlanContinue()
|
||||
try {
|
||||
trackWelcomePlanContinue()
|
||||
const intent = JSON.parse(raw)
|
||||
const { url } = await createCheckoutSession({
|
||||
plan_id: intent.planId,
|
||||
interval: intent.interval || 'month',
|
||||
limit: intent.limit ?? 100000,
|
||||
})
|
||||
const { planId, interval, limit } = JSON.parse(raw)
|
||||
localStorage.removeItem('pulse_pending_checkout')
|
||||
if (url) {
|
||||
setRedirectingCheckout(true)
|
||||
window.location.href = url
|
||||
return
|
||||
}
|
||||
throw new Error('No checkout URL returned')
|
||||
} catch (err: unknown) {
|
||||
setPlanError(getAuthErrorMessage(err) || (err as Error)?.message || 'Failed to start checkout')
|
||||
router.push(`/checkout?plan=${planId}&interval=${interval || 'month'}&limit=${limit ?? 100000}`)
|
||||
} catch {
|
||||
setPlanError('Failed to parse checkout data')
|
||||
localStorage.removeItem('pulse_pending_checkout')
|
||||
} finally {
|
||||
setPlanLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,15 +304,6 @@ function WelcomeContent() {
|
||||
return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Switching organization..." />
|
||||
}
|
||||
|
||||
if (redirectingCheckout || (planLoading && step === 3)) {
|
||||
return (
|
||||
<LoadingOverlay
|
||||
logoSrc="/pulse_icon_no_margins.png"
|
||||
title={redirectingCheckout ? 'Taking you to checkout...' : 'Preparing your plan...'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const cardClass =
|
||||
'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'
|
||||
|
||||
@@ -380,10 +353,10 @@ function WelcomeContent() {
|
||||
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-brand-orange/20 to-brand-orange/5 text-brand-orange mb-5 shadow-sm">
|
||||
<BarChartIcon className="h-8 w-8" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold tracking-tight text-neutral-900 dark:text-white">
|
||||
<h2 className="text-2xl font-bold tracking-tight text-white">
|
||||
Choose your organization
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-neutral-500 dark:text-neutral-400 max-w-sm mx-auto">
|
||||
<p className="mt-2 text-sm text-neutral-400 max-w-sm mx-auto">
|
||||
Continue with an existing one or create a new organization.
|
||||
</p>
|
||||
</div>
|
||||
@@ -415,7 +388,7 @@ function WelcomeContent() {
|
||||
>
|
||||
{initial}
|
||||
</div>
|
||||
<span className="flex-1 font-medium text-neutral-900 dark:text-white truncate">
|
||||
<span className="flex-1 font-medium text-white truncate">
|
||||
{org.organization_name || 'Organization'}
|
||||
</span>
|
||||
{isCurrent && (
|
||||
@@ -440,10 +413,12 @@ function WelcomeContent() {
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center">
|
||||
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-brand-orange/10 text-brand-orange mb-6">
|
||||
<ZapIcon className="h-7 w-7" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white">
|
||||
<img
|
||||
src="/illustrations/welcome.svg"
|
||||
alt="Welcome to Pulse"
|
||||
className="w-48 h-auto mx-auto mb-6"
|
||||
/>
|
||||
<h1 className="text-2xl font-bold text-white">
|
||||
Welcome to Pulse
|
||||
</h1>
|
||||
<p className="mt-2 text-neutral-600 dark:text-neutral-400">
|
||||
@@ -475,7 +450,7 @@ function WelcomeContent() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep(1)}
|
||||
className="flex items-center gap-2 text-sm text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300 mb-6 focus:outline-none focus:ring-2 focus:ring-brand-orange rounded"
|
||||
className="flex items-center gap-2 text-sm text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300 mb-6 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange rounded"
|
||||
aria-label="Back to welcome"
|
||||
>
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
@@ -485,7 +460,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">
|
||||
<BarChartIcon className="h-7 w-7" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white">
|
||||
<h1 className="text-2xl font-bold text-white">
|
||||
Name your organization
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
@@ -520,7 +495,7 @@ function WelcomeContent() {
|
||||
onChange={(e) => setOrgSlug(e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
<p className="mt-1 text-xs text-neutral-400">
|
||||
Used in your organization URL.
|
||||
</p>
|
||||
</div>
|
||||
@@ -546,7 +521,7 @@ function WelcomeContent() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep(2)}
|
||||
className="flex items-center gap-2 text-sm text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300 mb-6 focus:outline-none focus:ring-2 focus:ring-brand-orange rounded"
|
||||
className="flex items-center gap-2 text-sm text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300 mb-6 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange rounded"
|
||||
aria-label="Back to organization"
|
||||
>
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
@@ -556,7 +531,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">
|
||||
<CheckCircleIcon className="h-7 w-7" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white">
|
||||
<h1 className="text-2xl font-bold text-white">
|
||||
{showPendingCheckoutInStep3 ? 'Complete your plan' : "You're on the free plan"}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
@@ -575,7 +550,6 @@ function WelcomeContent() {
|
||||
variant="primary"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={handlePlanContinue}
|
||||
disabled={planLoading}
|
||||
>
|
||||
Continue to checkout
|
||||
</Button>
|
||||
@@ -583,7 +557,6 @@ function WelcomeContent() {
|
||||
variant="secondary"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={handlePlanSkip}
|
||||
disabled={planLoading}
|
||||
>
|
||||
Stay on free plan
|
||||
</Button>
|
||||
@@ -604,14 +577,14 @@ function WelcomeContent() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push('/pricing')}
|
||||
className="text-sm text-brand-orange hover:underline focus:outline-none focus:ring-2 focus:ring-brand-orange rounded"
|
||||
className="text-sm text-brand-orange hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange rounded"
|
||||
>
|
||||
Choose a different plan
|
||||
</button>
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-4 text-center">
|
||||
<Link href="/pricing" className="text-sm text-brand-orange hover:underline focus:outline-none focus:ring-2 focus:ring-brand-orange rounded">
|
||||
<Link href="/pricing" className="text-sm text-brand-orange hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange rounded">
|
||||
View pricing
|
||||
</Link>
|
||||
</p>
|
||||
@@ -631,17 +604,19 @@ function WelcomeContent() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep(3)}
|
||||
className="flex items-center gap-2 text-sm text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300 mb-6 focus:outline-none focus:ring-2 focus:ring-brand-orange rounded"
|
||||
className="flex items-center gap-2 text-sm text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300 mb-6 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange rounded"
|
||||
aria-label="Back to plan"
|
||||
>
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
Back
|
||||
</button>
|
||||
<div className="text-center mb-6">
|
||||
<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" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white">
|
||||
<img
|
||||
src="/illustrations/website-setup.svg"
|
||||
alt="Add your first site"
|
||||
className="w-44 h-auto mx-auto mb-4"
|
||||
/>
|
||||
<h1 className="text-2xl font-bold text-white">
|
||||
Add your first site
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
@@ -723,10 +698,12 @@ function WelcomeContent() {
|
||||
className={cardClass}
|
||||
>
|
||||
<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">
|
||||
<CheckCircleIcon className="h-7 w-7" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white">
|
||||
<img
|
||||
src="/illustrations/confirmed.svg"
|
||||
alt="All set"
|
||||
className="w-44 h-auto mx-auto mb-6"
|
||||
/>
|
||||
<h1 className="text-2xl font-bold text-white">
|
||||
You're all set
|
||||
</h1>
|
||||
<p className="mt-2 text-neutral-600 dark:text-neutral-400">
|
||||
@@ -750,11 +727,11 @@ function WelcomeContent() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowVerificationModal(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 text-neutral-700 dark:text-neutral-300 rounded-xl hover:bg-neutral-50 dark:hover:bg-neutral-700 transition-all text-sm font-medium focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-offset-2"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 text-neutral-700 dark:text-neutral-300 rounded-xl hover:bg-neutral-50 dark:hover:bg-neutral-700 transition-all text-sm font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:ring-offset-2"
|
||||
>
|
||||
<span className="text-brand-orange">Verify installation</span>
|
||||
</button>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
<p className="text-xs text-neutral-400">
|
||||
Check if your site is sending data correctly.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -30,13 +30,13 @@ export default function ErrorDisplay({
|
||||
</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>
|
||||
<img
|
||||
src="/illustrations/server-down.svg"
|
||||
alt="Something went wrong"
|
||||
className="w-56 h-auto mx-auto mb-8"
|
||||
/>
|
||||
|
||||
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-4">
|
||||
<h2 className="text-2xl font-bold 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">
|
||||
|
||||
@@ -48,20 +48,20 @@ export function Footer({ LinkComponent = Link, appName = 'Pulse', isAuthenticate
|
||||
<footer className="w-full py-8 mt-auto border-t border-neutral-100 dark:border-neutral-800 bg-white/50 dark:bg-neutral-900/50 backdrop-blur-sm">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex flex-col md:flex-row justify-between items-center gap-4">
|
||||
<div className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
<div className="text-sm text-neutral-400">
|
||||
© 2024-{year} Ciphera. All rights reserved.
|
||||
</div>
|
||||
<div className="flex gap-6 text-sm font-medium text-neutral-600 dark:text-neutral-300">
|
||||
<Component href="/about" className="hover:text-brand-orange transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded">
|
||||
<Component href="/about" className="hover:text-brand-orange transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus:rounded">
|
||||
Why {appName}
|
||||
</Component>
|
||||
<Component href="/changelog" className="hover:text-brand-orange transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded">
|
||||
<Component href="/changelog" className="hover:text-brand-orange transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus:rounded">
|
||||
Changelog
|
||||
</Component>
|
||||
<Component href="/pricing" className="hover:text-brand-orange transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded">
|
||||
<Component href="/pricing" className="hover:text-brand-orange transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus:rounded">
|
||||
Pricing
|
||||
</Component>
|
||||
<Component href="/faq" className="hover:text-brand-orange transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded">
|
||||
<Component href="/faq" className="hover:text-brand-orange transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus:rounded">
|
||||
FAQ
|
||||
</Component>
|
||||
</div>
|
||||
@@ -88,7 +88,7 @@ export function Footer({ LinkComponent = Link, appName = 'Pulse', isAuthenticate
|
||||
loading="lazy"
|
||||
className="w-9 h-9 object-contain group-hover:scale-105 transition-transform duration-300"
|
||||
/>
|
||||
<span className="text-xl font-bold text-neutral-900 dark:text-white group-hover:text-brand-orange transition-colors duration-300">
|
||||
<span className="text-xl font-bold text-white group-hover:text-brand-orange transition-colors duration-300">
|
||||
Pulse
|
||||
</span>
|
||||
</Link>
|
||||
@@ -106,7 +106,7 @@ export function Footer({ LinkComponent = Link, appName = 'Pulse', isAuthenticate
|
||||
href="https://github.com/ciphera-net"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="w-9 h-9 rounded-lg bg-neutral-100 dark:bg-neutral-800 flex items-center justify-center text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange"
|
||||
className="w-9 h-9 rounded-lg bg-neutral-100 dark:bg-neutral-800 flex items-center justify-center text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange"
|
||||
aria-label="GitHub"
|
||||
>
|
||||
<GithubIcon className="w-5 h-5" />
|
||||
@@ -115,7 +115,7 @@ export function Footer({ LinkComponent = Link, appName = 'Pulse', isAuthenticate
|
||||
href="https://x.com/cipheranet"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="w-9 h-9 rounded-lg bg-neutral-100 dark:bg-neutral-800 flex items-center justify-center text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange"
|
||||
className="w-9 h-9 rounded-lg bg-neutral-100 dark:bg-neutral-800 flex items-center justify-center text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange"
|
||||
aria-label="X (Twitter)"
|
||||
>
|
||||
<TwitterIcon className="w-5 h-5" />
|
||||
@@ -125,7 +125,7 @@ export function Footer({ LinkComponent = Link, appName = 'Pulse', isAuthenticate
|
||||
|
||||
{/* * Products */}
|
||||
<div>
|
||||
<h4 className="font-semibold text-neutral-900 dark:text-white mb-4">Products</h4>
|
||||
<h4 className="font-semibold text-white mb-4">Products</h4>
|
||||
<ul className="space-y-3">
|
||||
{footerLinks.products.map((link) => (
|
||||
<li key={link.name}>
|
||||
@@ -134,14 +134,14 @@ export function Footer({ LinkComponent = Link, appName = 'Pulse', isAuthenticate
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded"
|
||||
className="text-sm text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus:rounded"
|
||||
>
|
||||
{link.name}
|
||||
</a>
|
||||
) : (
|
||||
<Component
|
||||
href={link.href}
|
||||
className="text-sm text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded"
|
||||
className="text-sm text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus:rounded"
|
||||
>
|
||||
{link.name}
|
||||
</Component>
|
||||
@@ -153,7 +153,7 @@ export function Footer({ LinkComponent = Link, appName = 'Pulse', isAuthenticate
|
||||
|
||||
{/* * Company */}
|
||||
<div>
|
||||
<h4 className="font-semibold text-neutral-900 dark:text-white mb-4">Company</h4>
|
||||
<h4 className="font-semibold text-white mb-4">Company</h4>
|
||||
<ul className="space-y-3">
|
||||
{footerLinks.company.map((link) => (
|
||||
<li key={link.name}>
|
||||
@@ -162,14 +162,14 @@ export function Footer({ LinkComponent = Link, appName = 'Pulse', isAuthenticate
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded"
|
||||
className="text-sm text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus:rounded"
|
||||
>
|
||||
{link.name}
|
||||
</a>
|
||||
) : (
|
||||
<Component
|
||||
href={link.href}
|
||||
className="text-sm text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded"
|
||||
className="text-sm text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus:rounded"
|
||||
>
|
||||
{link.name}
|
||||
</Component>
|
||||
@@ -181,7 +181,7 @@ export function Footer({ LinkComponent = Link, appName = 'Pulse', isAuthenticate
|
||||
|
||||
{/* * Resources */}
|
||||
<div>
|
||||
<h4 className="font-semibold text-neutral-900 dark:text-white mb-4">Resources</h4>
|
||||
<h4 className="font-semibold text-white mb-4">Resources</h4>
|
||||
<ul className="space-y-3">
|
||||
{footerLinks.resources.map((link) => (
|
||||
<li key={link.name}>
|
||||
@@ -190,14 +190,14 @@ export function Footer({ LinkComponent = Link, appName = 'Pulse', isAuthenticate
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded"
|
||||
className="text-sm text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus:rounded"
|
||||
>
|
||||
{link.name}
|
||||
</a>
|
||||
) : (
|
||||
<Component
|
||||
href={link.href}
|
||||
className="text-sm text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded"
|
||||
className="text-sm text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus:rounded"
|
||||
>
|
||||
{link.name}
|
||||
</Component>
|
||||
@@ -209,7 +209,7 @@ export function Footer({ LinkComponent = Link, appName = 'Pulse', isAuthenticate
|
||||
|
||||
{/* * Legal */}
|
||||
<div>
|
||||
<h4 className="font-semibold text-neutral-900 dark:text-white mb-4">Legal</h4>
|
||||
<h4 className="font-semibold text-white mb-4">Legal</h4>
|
||||
<ul className="space-y-3">
|
||||
{footerLinks.legal.map((link) => (
|
||||
<li key={link.name}>
|
||||
@@ -232,10 +232,10 @@ export function Footer({ LinkComponent = Link, appName = 'Pulse', isAuthenticate
|
||||
|
||||
{/* * Bottom bar */}
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
<p className="text-sm text-neutral-400">
|
||||
© 2024-{year} Ciphera. All rights reserved.
|
||||
</p>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
<p className="text-sm text-neutral-400">
|
||||
Where Privacy Still Exists
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
* @file Shared layout component for individual integration guide pages.
|
||||
*
|
||||
* Provides the background atmosphere, back-link, header (logo + title),
|
||||
* prose-styled content area, and a related integrations section.
|
||||
* category badge, prose-styled content area, and a related integrations section.
|
||||
* Styling matches ciphera-website /learn article layout for consistency.
|
||||
*/
|
||||
|
||||
import Link from 'next/link'
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from '@ciphera-net/ui'
|
||||
import { ArrowLeftIcon, ArrowRightIcon, CodeBlock } from '@ciphera-net/ui'
|
||||
import { type ReactNode } from 'react'
|
||||
import { type Integration, getIntegration } from '@/lib/integrations'
|
||||
import { type Integration, getIntegration, categoryLabels } from '@/lib/integrations'
|
||||
|
||||
interface IntegrationGuideProps {
|
||||
/** Integration metadata (name, icon, etc.) */
|
||||
@@ -35,19 +36,21 @@ export function IntegrationGuide({ integration, children }: IntegrationGuideProp
|
||||
.filter((i): i is Integration => i !== undefined)
|
||||
.slice(0, 4)
|
||||
|
||||
const categoryLabel = categoryLabels[integration.category]
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen flex flex-col overflow-hidden">
|
||||
{/* * --- ATMOSPHERE (Background) --- */}
|
||||
<div className="absolute inset-0 -z-10 pointer-events-none">
|
||||
<div className="absolute top-0 left-1/4 w-[500px] h-[500px] bg-brand-orange/10 rounded-full blur-[128px] opacity-60" />
|
||||
<div className="absolute bottom-0 right-1/4 w-[500px] h-[500px] bg-neutral-500/10 dark:bg-neutral-400/10 rounded-full blur-[128px] opacity-40" />
|
||||
<div className="absolute bottom-0 right-1/4 w-[500px] h-[500px] bg-neutral-400/10 rounded-full blur-[128px] opacity-40" />
|
||||
<div
|
||||
className="absolute inset-0 bg-grid-pattern opacity-[0.02] dark:opacity-[0.05]"
|
||||
className="absolute inset-0 bg-grid-pattern opacity-[0.05]"
|
||||
style={{ maskImage: 'radial-gradient(ellipse at center, black 0%, transparent 70%)' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-grow w-full max-w-4xl mx-auto px-4 pt-20 pb-10 z-10">
|
||||
{/* * --- Back link --- */}
|
||||
<Link
|
||||
href="/integrations"
|
||||
className="inline-flex items-center text-sm text-neutral-500 hover:text-brand-orange mb-8 transition-colors"
|
||||
@@ -56,23 +59,50 @@ export function IntegrationGuide({ integration, children }: IntegrationGuideProp
|
||||
Back to Integrations
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<div className="p-3 bg-neutral-100 dark:bg-neutral-800 rounded-xl">
|
||||
{headerIcon}
|
||||
</div>
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-neutral-900 dark:text-white">
|
||||
{integration.name} Integration
|
||||
</h1>
|
||||
{/* * --- Category + Official site badges --- */}
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium border"
|
||||
style={{
|
||||
color: integration.brandColor,
|
||||
borderColor: `${integration.brandColor}33`,
|
||||
backgroundColor: `${integration.brandColor}15`,
|
||||
}}
|
||||
>
|
||||
<div className="[&_svg]:w-3.5 [&_svg]:h-3.5">{integration.icon}</div>
|
||||
{integration.name}
|
||||
</span>
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full border border-neutral-700 bg-neutral-800 text-xs text-neutral-400">
|
||||
{categoryLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="prose prose-neutral dark:prose-invert max-w-none">
|
||||
{/* * --- Title --- */}
|
||||
<h1 className="text-3xl sm:text-4xl font-bold text-white leading-tight mb-8">
|
||||
{integration.name} Integration
|
||||
</h1>
|
||||
|
||||
{/* * --- Prose content (matches ciphera-website /learn styling) --- */}
|
||||
<div className="prose prose-invert prose-neutral max-w-none prose-headings:text-white prose-a:text-brand-orange prose-a:no-underline hover:prose-a:underline prose-strong:text-white prose-code:text-brand-orange prose-code:bg-neutral-800 prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded prose-code:before:content-none prose-code:after:content-none">
|
||||
{children}
|
||||
|
||||
<hr className="my-8 border-neutral-800" />
|
||||
<h3>Optional: Frustration Tracking</h3>
|
||||
<p>
|
||||
Detect rage clicks and dead clicks by adding the frustration tracking
|
||||
add-on after the core script:
|
||||
</p>
|
||||
<CodeBlock filename="index.html">{`<script defer src="https://pulse.ciphera.net/script.frustration.js"></script>`}</CodeBlock>
|
||||
<p>
|
||||
No extra configuration needed. Add <code>data-no-rage</code> or{' '}
|
||||
<code>data-no-dead</code> to disable individual signals.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* * --- Related Integrations --- */}
|
||||
{relatedIntegrations.length > 0 && (
|
||||
<div className="mt-16 pt-10 border-t border-neutral-200 dark:border-neutral-800">
|
||||
<h2 className="text-xl font-bold text-neutral-900 dark:text-white mb-6">
|
||||
<div className="mt-16 pt-10 border-t border-neutral-800">
|
||||
<h2 className="text-xl font-bold text-white mb-6">
|
||||
Related Integrations
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
@@ -80,16 +110,16 @@ export function IntegrationGuide({ integration, children }: IntegrationGuideProp
|
||||
<Link
|
||||
key={related.id}
|
||||
href={`/integrations/${related.id}`}
|
||||
className="group flex items-center gap-4 p-4 bg-white/50 dark:bg-neutral-900/50 backdrop-blur-sm border border-neutral-200 dark:border-neutral-800 rounded-xl hover:border-brand-orange/50 dark:hover:border-brand-orange/50 transition-all duration-300"
|
||||
className="group flex items-center gap-4 p-4 bg-neutral-900/50 backdrop-blur-sm border border-neutral-800 rounded-xl hover:border-brand-orange/50 transition-all duration-300"
|
||||
>
|
||||
<div className="p-2 bg-neutral-100 dark:bg-neutral-800 rounded-lg shrink-0 [&_svg]:w-6 [&_svg]:h-6">
|
||||
<div className="p-2 bg-neutral-800 rounded-lg shrink-0 [&_svg]:w-6 [&_svg]:h-6">
|
||||
{related.icon}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="font-semibold text-neutral-900 dark:text-white block">
|
||||
<span className="font-semibold text-white block">
|
||||
{related.name}
|
||||
</span>
|
||||
<span className="text-sm text-neutral-500 dark:text-neutral-400 truncate block">
|
||||
<span className="text-sm text-neutral-400 truncate block">
|
||||
{related.description}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { FiWifiOff } from 'react-icons/fi';
|
||||
import { WifiSlash } from '@phosphor-icons/react';
|
||||
|
||||
export function OfflineBanner({ isOnline }: { isOnline: boolean }) {
|
||||
if (isOnline) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed top-0 left-0 right-0 z-[100] rounded-b-xl bg-yellow-500/15 dark:bg-yellow-500/25 border-b border-yellow-500/30 dark:border-yellow-500/40 text-yellow-700 dark:text-yellow-300 px-4 sm:px-8 py-2.5 text-sm flex items-center justify-center gap-2 font-medium shadow-md transition-shadow duration-300">
|
||||
<FiWifiOff className="w-4 h-4 shrink-0" />
|
||||
<WifiSlash className="w-4 h-4 shrink-0" />
|
||||
<span>You are currently offline. Changes may not be saved.</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,13 +2,12 @@
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { logger } from '@/lib/utils/logger'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Button, CheckCircleIcon } from '@ciphera-net/ui'
|
||||
import { useAuth } from '@/lib/auth/context'
|
||||
import { initiateOAuthFlow } from '@/lib/api/oauth'
|
||||
import { toast } from '@ciphera-net/ui'
|
||||
import { createCheckoutSession } from '@/lib/api/billing'
|
||||
|
||||
// 1. Define Plans with IDs and Site Limits
|
||||
const PLANS = [
|
||||
@@ -104,12 +103,13 @@ const TRAFFIC_TIERS = [
|
||||
|
||||
export default function PricingSection() {
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const [isYearly, setIsYearly] = useState(false)
|
||||
const [sliderIndex, setSliderIndex] = useState(2) // Default to 100k (index 2)
|
||||
const [sliderIndex, setSliderIndex] = useState(0) // Default to 10k (index 0)
|
||||
const [loadingPlan, setLoadingPlan] = useState<string | null>(null)
|
||||
const { user } = useAuth()
|
||||
|
||||
// * Show toast when redirected from Stripe Checkout with canceled=true
|
||||
// * Show toast when redirected from Mollie Checkout with canceled=true
|
||||
useEffect(() => {
|
||||
if (searchParams.get('canceled') === 'true') {
|
||||
toast.info('Checkout was canceled. You can try again whenever you’re ready.')
|
||||
@@ -166,49 +166,25 @@ export default function PricingSection() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubscribe = async (planId: string, options?: { interval?: string, limit?: number }) => {
|
||||
try {
|
||||
setLoadingPlan(planId)
|
||||
|
||||
// 1. If not logged in, redirect to login/signup
|
||||
if (!user) {
|
||||
// Store checkout intent
|
||||
const intent = {
|
||||
planId,
|
||||
interval: isYearly ? 'year' : 'month',
|
||||
limit: currentTraffic.value,
|
||||
sliderIndex, // Store UI state to restore it
|
||||
isYearly // Store UI state to restore it
|
||||
}
|
||||
localStorage.setItem('pulse_pending_checkout', JSON.stringify(intent))
|
||||
|
||||
initiateOAuthFlow()
|
||||
return
|
||||
const handleSubscribe = (planId: string, options?: { interval?: string, limit?: number }) => {
|
||||
// 1. If not logged in, redirect to login/signup
|
||||
if (!user) {
|
||||
const intent = {
|
||||
planId,
|
||||
interval: isYearly ? 'year' : 'month',
|
||||
limit: currentTraffic.value,
|
||||
sliderIndex,
|
||||
isYearly
|
||||
}
|
||||
|
||||
// 2. Call backend to create checkout session
|
||||
const interval = options?.interval || (isYearly ? 'year' : 'month')
|
||||
const limit = options?.limit || currentTraffic.value
|
||||
|
||||
const { url } = await createCheckoutSession({
|
||||
plan_id: planId,
|
||||
interval,
|
||||
limit,
|
||||
})
|
||||
|
||||
// 3. Redirect to Stripe Checkout
|
||||
if (url) {
|
||||
window.location.href = url
|
||||
} else {
|
||||
throw new Error('No checkout URL returned')
|
||||
}
|
||||
|
||||
} catch (error: unknown) {
|
||||
logger.error('Checkout error:', error)
|
||||
toast.error('Failed to start checkout — please try again')
|
||||
} finally {
|
||||
setLoadingPlan(null)
|
||||
localStorage.setItem('pulse_pending_checkout', JSON.stringify(intent))
|
||||
initiateOAuthFlow()
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Navigate to embedded checkout page
|
||||
const selectedInterval = options?.interval || (isYearly ? 'year' : 'month')
|
||||
const selectedLimit = options?.limit || currentTraffic.value
|
||||
router.push(`/checkout?plan=${planId}&interval=${selectedInterval}&limit=${selectedLimit}`)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -219,10 +195,10 @@ export default function PricingSection() {
|
||||
transition={{ duration: 0.5 }}
|
||||
className="text-center mb-12"
|
||||
>
|
||||
<h2 className="text-3xl font-bold text-neutral-900 dark:text-white mb-4">
|
||||
<h2 className="text-3xl font-bold text-white mb-4">
|
||||
Transparent Pricing
|
||||
</h2>
|
||||
<p className="text-lg text-neutral-600 dark:text-neutral-400">
|
||||
<p className="text-lg text-neutral-400">
|
||||
Scale with your traffic. No hidden fees.
|
||||
</p>
|
||||
</motion.div>
|
||||
@@ -232,13 +208,13 @@ export default function PricingSection() {
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.1 }}
|
||||
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"
|
||||
className="max-w-6xl mx-auto border border-neutral-800 rounded-2xl bg-neutral-900/50 backdrop-blur-xl shadow-sm overflow-hidden mb-20"
|
||||
>
|
||||
|
||||
{/* Top Toolbar */}
|
||||
<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="p-6 border-b border-neutral-800 flex flex-col md:flex-row items-center justify-between gap-8 bg-neutral-900/50">
|
||||
<div className="w-full md:w-2/3">
|
||||
<div className="flex justify-between text-sm font-medium text-neutral-500 dark:text-neutral-400 mb-4">
|
||||
<div className="flex justify-between text-sm font-medium text-neutral-400 mb-4">
|
||||
<span>10k</span>
|
||||
<span className="text-brand-orange font-bold text-lg">
|
||||
Up to {currentTraffic.label} monthly pageviews
|
||||
@@ -254,23 +230,23 @@ export default function PricingSection() {
|
||||
onChange={(e) => setSliderIndex(parseInt(e.target.value))}
|
||||
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"
|
||||
className="w-full h-2 bg-neutral-700 rounded-lg appearance-none cursor-pointer accent-brand-orange focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:ring-offset-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-2 shrink-0">
|
||||
<span className="text-xs text-neutral-500 dark:text-neutral-400 font-medium uppercase tracking-wide">
|
||||
<span className="text-xs text-neutral-400 font-medium uppercase tracking-wide">
|
||||
Get 1 month free with yearly
|
||||
</span>
|
||||
<div className="bg-neutral-200 dark:bg-neutral-800 p-1 rounded-lg flex" role="radiogroup" aria-label="Billing interval">
|
||||
<div className="bg-neutral-800 p-1 rounded-lg flex" role="radiogroup" aria-label="Billing interval">
|
||||
<button
|
||||
onClick={() => setIsYearly(false)}
|
||||
role="radio"
|
||||
aria-checked={!isYearly}
|
||||
className={`min-w-[88px] px-4 py-2 rounded-lg text-sm font-medium transition-all focus:outline-none focus:ring-2 focus:ring-brand-orange ${
|
||||
className={`min-w-[88px] px-4 py-2 rounded-lg text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange ${
|
||||
!isYearly
|
||||
? 'bg-white dark:bg-neutral-700 text-neutral-900 dark:text-white shadow-sm'
|
||||
: 'text-neutral-500 hover:text-neutral-900 dark:hover:text-white'
|
||||
? 'bg-neutral-700 text-white shadow-sm'
|
||||
: 'text-neutral-500 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Monthly
|
||||
@@ -279,10 +255,10 @@ export default function PricingSection() {
|
||||
onClick={() => setIsYearly(true)}
|
||||
role="radio"
|
||||
aria-checked={isYearly}
|
||||
className={`min-w-[88px] px-4 py-2 rounded-lg text-sm font-medium transition-all focus:outline-none focus:ring-2 focus:ring-brand-orange ${
|
||||
className={`min-w-[88px] px-4 py-2 rounded-lg text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange ${
|
||||
isYearly
|
||||
? 'bg-white dark:bg-neutral-700 text-neutral-900 dark:text-white shadow-sm'
|
||||
: 'text-neutral-500 hover:text-neutral-900 dark:hover:text-white'
|
||||
? 'bg-neutral-700 text-white shadow-sm'
|
||||
: 'text-neutral-500 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Yearly
|
||||
@@ -292,13 +268,48 @@ export default function PricingSection() {
|
||||
</div>
|
||||
|
||||
{/* Pricing Grid */}
|
||||
<div className="grid md:grid-cols-4 divide-y md:divide-y-0 md:divide-x divide-neutral-200 dark:divide-neutral-800">
|
||||
<div className="grid md:grid-cols-5 divide-y md:divide-y-0 md:divide-x divide-neutral-800">
|
||||
{/* Free Plan */}
|
||||
<div className="p-6 flex flex-col relative transition-colors hover:bg-neutral-800/50">
|
||||
<div className="mb-8">
|
||||
<h3 className="text-lg font-bold text-white mb-2">Free</h3>
|
||||
<p className="text-sm text-neutral-400 min-h-[40px] mb-4">For trying Pulse on a personal project</p>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-4xl font-bold text-white">€0</span>
|
||||
<span className="text-neutral-400 font-medium">/forever</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (!user) {
|
||||
initiateOAuthFlow()
|
||||
return
|
||||
}
|
||||
window.location.href = '/'
|
||||
}}
|
||||
variant="secondary"
|
||||
className="w-full mb-8"
|
||||
>
|
||||
Get started
|
||||
</Button>
|
||||
|
||||
<ul className="space-y-4 flex-grow">
|
||||
{['1 site', '5k monthly pageviews', '6 months data retention', '100% Data ownership'].map((feature) => (
|
||||
<li key={feature} className="flex items-start gap-3 text-sm text-neutral-400">
|
||||
<CheckCircleIcon className="w-5 h-5 shrink-0 text-neutral-400" />
|
||||
<span>{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{PLANS.map((plan) => {
|
||||
const priceDetails = getPriceDetails(plan.id)
|
||||
const isTeam = plan.id === 'team'
|
||||
|
||||
return (
|
||||
<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'}`}>
|
||||
<div key={plan.id} className={`p-6 flex flex-col relative transition-colors ${isTeam ? 'bg-brand-orange/[0.02]' : 'hover:bg-neutral-800/50'}`}>
|
||||
{isTeam && (
|
||||
<>
|
||||
<div className="absolute top-0 left-0 w-full h-1 bg-brand-orange" />
|
||||
@@ -309,17 +320,18 @@ export default function PricingSection() {
|
||||
)}
|
||||
|
||||
<div className="mb-8">
|
||||
<h3 className="text-lg font-bold text-neutral-900 dark:text-white mb-2">{plan.name}</h3>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 min-h-[40px] mb-4">{plan.description}</p>
|
||||
<h3 className="text-lg font-bold text-white mb-2">{plan.name}</h3>
|
||||
<p className="text-sm text-neutral-400 min-h-[40px] mb-4">{plan.description}</p>
|
||||
|
||||
{priceDetails ? (
|
||||
isYearly ? (
|
||||
<div>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-4xl font-bold text-neutral-900 dark:text-white">
|
||||
<span className="text-4xl font-bold text-white">
|
||||
€{priceDetails.yearlyTotal}
|
||||
</span>
|
||||
<span className="text-neutral-500 dark:text-neutral-400 font-medium">/year</span>
|
||||
<span className="text-neutral-400 font-medium">/year</span>
|
||||
<span className="text-xs text-neutral-500 ml-1">excl. VAT</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-2 text-sm font-medium">
|
||||
<span className="text-neutral-400 line-through decoration-neutral-400">
|
||||
@@ -332,14 +344,15 @@ export default function PricingSection() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-4xl font-bold text-neutral-900 dark:text-white">
|
||||
<span className="text-4xl font-bold text-white">
|
||||
€{priceDetails.baseMonthly}
|
||||
</span>
|
||||
<span className="text-neutral-500 dark:text-neutral-400 font-medium">/mo</span>
|
||||
<span className="text-neutral-400 font-medium">/mo</span>
|
||||
<span className="text-xs text-neutral-500 ml-1">excl. VAT</span>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="text-4xl font-bold text-neutral-900 dark:text-white">
|
||||
<div className="text-4xl font-bold text-white">
|
||||
Custom
|
||||
</div>
|
||||
)}
|
||||
@@ -356,7 +369,7 @@ export default function PricingSection() {
|
||||
|
||||
<ul className="space-y-4 flex-grow">
|
||||
{plan.features.map((feature) => (
|
||||
<li key={feature} className="flex items-start gap-3 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
<li key={feature} className="flex items-start gap-3 text-sm text-neutral-400">
|
||||
<CheckCircleIcon className={`w-5 h-5 shrink-0 ${isTeam ? 'text-brand-orange' : 'text-neutral-400'}`} />
|
||||
<span>{feature}</span>
|
||||
</li>
|
||||
@@ -367,11 +380,11 @@ export default function PricingSection() {
|
||||
})}
|
||||
|
||||
{/* Enterprise Section */}
|
||||
<div className="p-6 bg-neutral-50/50 dark:bg-neutral-900/50 flex flex-col">
|
||||
<div className="p-6 bg-neutral-900/50 flex flex-col">
|
||||
<div className="mb-8">
|
||||
<h3 className="text-lg font-bold text-neutral-900 dark:text-white mb-2">Enterprise</h3>
|
||||
<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">
|
||||
<h3 className="text-lg font-bold text-white mb-2">Enterprise</h3>
|
||||
<p className="text-sm text-neutral-400 min-h-[40px] mb-4">For high volume sites and custom needs</p>
|
||||
<div className="text-4xl font-bold text-white">
|
||||
Custom
|
||||
</div>
|
||||
</div>
|
||||
@@ -393,7 +406,7 @@ export default function PricingSection() {
|
||||
'Managed Proxy',
|
||||
'Raw data export'
|
||||
].map((feature) => (
|
||||
<li key={feature} className="flex items-start gap-3 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
<li key={feature} className="flex items-start gap-3 text-sm text-neutral-400">
|
||||
<CheckCircleIcon className="w-5 h-5 text-neutral-400 shrink-0" />
|
||||
<span>{feature}</span>
|
||||
</li>
|
||||
@@ -402,6 +415,7 @@ export default function PricingSection() {
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
100
components/behavior/FrustrationByPageTable.tsx
Normal file
100
components/behavior/FrustrationByPageTable.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
'use client'
|
||||
|
||||
import { formatNumber } from '@ciphera-net/ui'
|
||||
import { Files } from '@phosphor-icons/react'
|
||||
import type { FrustrationByPage } from '@/lib/api/stats'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
|
||||
interface FrustrationByPageTableProps {
|
||||
pages: FrustrationByPage[]
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
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-white">
|
||||
Frustration by Page
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-neutral-400 mb-4">
|
||||
Pages with the most frustration signals
|
||||
</p>
|
||||
|
||||
{loading ? (
|
||||
<TableSkeleton rows={5} cols={5} />
|
||||
) : hasData ? (
|
||||
<div className="overflow-x-auto -mx-6 px-6">
|
||||
{/* 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) * 75
|
||||
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/15 dark:bg-brand-orange/25 rounded-lg transition-all"
|
||||
style={{ width: `${barWidth}%` }}
|
||||
/>
|
||||
<span
|
||||
className="relative text-sm text-white truncate max-w-[200px] sm: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-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-400" />
|
||||
</div>
|
||||
<h4 className="font-semibold text-white">
|
||||
No frustration signals detected
|
||||
</h4>
|
||||
<p className="text-sm 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>
|
||||
<a href="/installation" target="_blank" rel="noopener noreferrer" className="mt-1 text-sm font-medium text-brand-orange hover:underline">
|
||||
View setup guide
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
113
components/behavior/FrustrationSummaryCards.tsx
Normal file
113
components/behavior/FrustrationSummaryCards.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
'use client'
|
||||
|
||||
import type { FrustrationSummary } from '@/lib/api/stats'
|
||||
import { StatCardSkeleton } from '@/components/skeletons'
|
||||
|
||||
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-400'
|
||||
}`}
|
||||
>
|
||||
{isUp ? '+' : ''}{change.value}%
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
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">
|
||||
<StatCardSkeleton />
|
||||
<StatCardSkeleton />
|
||||
<StatCardSkeleton />
|
||||
</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-400 mb-1">
|
||||
Rage Clicks
|
||||
</p>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-2xl font-bold 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-400 mb-1">
|
||||
Dead Clicks
|
||||
</p>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-2xl font-bold 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-400 mb-1">
|
||||
Total Signals
|
||||
</p>
|
||||
<span className="text-2xl font-bold 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>
|
||||
)
|
||||
}
|
||||
228
components/behavior/FrustrationTable.tsx
Normal file
228
components/behavior/FrustrationTable.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
'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-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 min-w-0 overflow-hidden">
|
||||
<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-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-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">
|
||||
<img
|
||||
src="/illustrations/blank-canvas.svg"
|
||||
alt="No frustration signals"
|
||||
className="w-44 h-auto mb-1"
|
||||
/>
|
||||
<h4 className="font-semibold text-white">
|
||||
No {title.toLowerCase()} detected
|
||||
</h4>
|
||||
<p className="text-sm text-neutral-400 max-w-md">
|
||||
Frustration tracking requires the add-on script. Add it after your core Pulse script:
|
||||
</p>
|
||||
<code className="text-xs bg-neutral-100 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300 px-3 py-2 rounded-lg font-mono break-all">
|
||||
{'<script defer src="https://pulse.ciphera.net/script.frustration.js"></script>'}
|
||||
</code>
|
||||
<a href="/installation" target="_blank" rel="noopener noreferrer" className="mt-1 text-sm font-medium text-brand-orange hover:underline">
|
||||
View setup guide
|
||||
</a>
|
||||
</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-400 py-8 text-center">
|
||||
No data available
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
153
components/behavior/FrustrationTrend.tsx
Normal file
153
components/behavior/FrustrationTrend.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
'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'
|
||||
import { WidgetSkeleton } from '@/components/skeletons'
|
||||
|
||||
interface FrustrationTrendProps {
|
||||
summary: FrustrationSummary | null
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
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-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 <WidgetSkeleton />
|
||||
|
||||
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-white">
|
||||
Frustration Trend
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm 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-400" />
|
||||
</div>
|
||||
<h4 className="font-semibold text-white">
|
||||
No trend data yet
|
||||
</h4>
|
||||
<p className="text-sm 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-white">
|
||||
Frustration Trend
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm 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'
|
||||
121
components/checkout/FeatureSlideshow.tsx
Normal file
121
components/checkout/FeatureSlideshow.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import pulseIcon from '@/public/pulse_icon_no_margins.png'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { PulseMockup } from '@/components/marketing/mockups/pulse-mockup'
|
||||
import { PagesCard, ReferrersCard, LocationsCard, TechnologyCard, PeakHoursCard } from '@/components/marketing/mockups/pulse-features-carousel'
|
||||
|
||||
interface Slide {
|
||||
headline: string
|
||||
mockup: React.ReactNode
|
||||
}
|
||||
|
||||
function FeatureCard({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-white/[0.08] bg-neutral-900/80 px-6 py-5 shadow-2xl">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const slides: Slide[] = [
|
||||
{ headline: 'Your traffic, at a glance.', mockup: <PulseMockup /> },
|
||||
{ headline: 'See which pages perform best.', mockup: <FeatureCard><PagesCard /></FeatureCard> },
|
||||
{ headline: 'Know where your visitors come from.', mockup: <FeatureCard><ReferrersCard /></FeatureCard> },
|
||||
{ headline: 'Visitors from around the world.', mockup: <FeatureCard><LocationsCard /></FeatureCard> },
|
||||
{ headline: 'Understand your audience\u2019s tech stack.', mockup: <FeatureCard><TechnologyCard /></FeatureCard> },
|
||||
{ headline: 'Find your peak traffic hours.', mockup: <FeatureCard><PeakHoursCard /></FeatureCard> },
|
||||
]
|
||||
|
||||
export default function FeatureSlideshow() {
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
|
||||
const advance = useCallback(() => {
|
||||
setActiveIndex((prev) => (prev + 1) % slides.length)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const start = () => { timer = setInterval(advance, 8000) }
|
||||
const stop = () => { if (timer) { clearInterval(timer); timer = null } }
|
||||
|
||||
const onVisibility = () => {
|
||||
if (document.hidden) stop()
|
||||
else start()
|
||||
}
|
||||
|
||||
start()
|
||||
document.addEventListener('visibilitychange', onVisibility)
|
||||
return () => {
|
||||
stop()
|
||||
document.removeEventListener('visibilitychange', onVisibility)
|
||||
}
|
||||
}, [advance])
|
||||
|
||||
const slide = slides[activeIndex]
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
{/* Background image */}
|
||||
<Image
|
||||
src="/pulse-showcase-bg.png"
|
||||
alt=""
|
||||
fill
|
||||
unoptimized
|
||||
className="object-cover"
|
||||
priority
|
||||
/>
|
||||
|
||||
{/* Dark overlay */}
|
||||
<div className="absolute inset-0 bg-black/40" />
|
||||
|
||||
{/* Logo */}
|
||||
<div className="absolute top-0 left-0 z-20 px-6 py-5">
|
||||
<Link href="/pricing" className="flex items-center gap-2 w-fit hover:opacity-80 transition-opacity">
|
||||
<Image
|
||||
src={pulseIcon}
|
||||
alt="Pulse"
|
||||
width={36}
|
||||
height={36}
|
||||
unoptimized
|
||||
className="object-contain w-8 h-8"
|
||||
/>
|
||||
<span className="text-xl font-bold text-white tracking-tight">Pulse</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative z-10 flex h-full flex-col items-center justify-center px-10 xl:px-14 py-12 overflow-hidden">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={activeIndex}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.45 }}
|
||||
className="flex flex-col items-center gap-6 w-full max-w-lg"
|
||||
>
|
||||
{/* Headline — centered */}
|
||||
<h2 className="text-3xl xl:text-4xl font-bold text-white leading-tight text-center">
|
||||
{slide.headline}
|
||||
</h2>
|
||||
|
||||
{/* Mockup — constrained */}
|
||||
<div className="relative w-full">
|
||||
{/* Orange glow */}
|
||||
<div className="absolute -inset-8 rounded-3xl bg-brand-orange/8 blur-3xl pointer-events-none" />
|
||||
|
||||
<div className="relative rounded-2xl overflow-hidden" style={{ maxHeight: '55vh' }}>
|
||||
{slide.mockup}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
355
components/checkout/PaymentForm.tsx
Normal file
355
components/checkout/PaymentForm.tsx
Normal file
@@ -0,0 +1,355 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Script from 'next/script'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Lock, ShieldCheck } from '@phosphor-icons/react'
|
||||
import { initMollie, getMollie, MOLLIE_FIELD_STYLES, type MollieComponent } from '@/lib/mollie'
|
||||
import { createEmbeddedCheckout, createCheckoutSession } from '@/lib/api/billing'
|
||||
|
||||
interface PaymentFormProps {
|
||||
plan: string
|
||||
interval: string
|
||||
limit: number
|
||||
country: string
|
||||
vatId: string
|
||||
}
|
||||
|
||||
const PAYMENT_METHODS = [
|
||||
{ id: 'card', label: 'Card' },
|
||||
{ id: 'bancontact', label: 'Bancontact' },
|
||||
{ id: 'ideal', label: 'iDEAL' },
|
||||
{ id: 'applepay', label: 'Apple Pay' },
|
||||
{ id: 'googlepay', label: 'Google Pay' },
|
||||
{ id: 'directdebit', label: 'SEPA' },
|
||||
]
|
||||
|
||||
const METHOD_LOGOS: Record<string, { src: string | string[]; alt: string }> = {
|
||||
card: { src: ['/images/payment/visa.svg', '/images/payment/mastercard.svg'], alt: 'Card' },
|
||||
bancontact: { src: '/images/payment/bancontact.svg', alt: 'Bancontact' },
|
||||
ideal: { src: '/images/payment/ideal.svg', alt: 'iDEAL' },
|
||||
applepay: { src: '/images/payment/applepay.svg', alt: 'Apple Pay' },
|
||||
googlepay: { src: '/images/payment/googlepay.svg', alt: 'Google Pay' },
|
||||
directdebit: { src: '/images/payment/sepa.svg', alt: 'SEPA' },
|
||||
}
|
||||
|
||||
function MethodLogo({ type }: { type: string }) {
|
||||
const logo = METHOD_LOGOS[type]
|
||||
if (!logo) return null
|
||||
|
||||
if (Array.isArray(logo.src)) {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{logo.src.map((s) => (
|
||||
<img key={s} src={s} alt="" className="h-6 w-auto rounded-sm" />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <img src={logo.src} alt={logo.alt} className="h-6 w-auto rounded-sm" />
|
||||
}
|
||||
|
||||
const mollieFieldBase =
|
||||
'w-full rounded-lg border border-neutral-700 bg-neutral-800/50 px-3 py-3 h-[48px] transition-all focus-within:ring-1 focus-within:ring-brand-orange focus-within:border-brand-orange'
|
||||
|
||||
export default function PaymentForm({ plan, interval, limit, country, vatId }: PaymentFormProps) {
|
||||
const router = useRouter()
|
||||
|
||||
const [selectedMethod, setSelectedMethod] = useState('')
|
||||
const [mollieReady, setMollieReady] = useState(false)
|
||||
const [mollieError, setMollieError] = useState(false)
|
||||
const [formError, setFormError] = useState<string | null>(null)
|
||||
const [cardErrors, setCardErrors] = useState<Record<string, string>>({})
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const submitRef = useRef<HTMLButtonElement>(null)
|
||||
const componentsRef = useRef<Record<string, MollieComponent | null>>({
|
||||
cardHolder: null,
|
||||
cardNumber: null,
|
||||
expiryDate: null,
|
||||
verificationCode: null,
|
||||
})
|
||||
const mollieInitialized = useRef(false)
|
||||
|
||||
const [scriptLoaded, setScriptLoaded] = useState(false)
|
||||
|
||||
// Mount Mollie components AFTER script loaded
|
||||
useEffect(() => {
|
||||
if (!scriptLoaded || mollieInitialized.current) return
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
const mollie = initMollie()
|
||||
if (!mollie) {
|
||||
setMollieError(true)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const fields: Array<{ type: string; selector: string; placeholder?: string }> = [
|
||||
{ type: 'cardHolder', selector: '#mollie-card-holder', placeholder: 'John Doe' },
|
||||
{ type: 'cardNumber', selector: '#mollie-card-number', placeholder: '1234 5678 9012 3456' },
|
||||
{ type: 'expiryDate', selector: '#mollie-card-expiry', placeholder: 'MM / YY' },
|
||||
{ type: 'verificationCode', selector: '#mollie-card-cvc', placeholder: 'CVC' },
|
||||
]
|
||||
|
||||
for (const { type, selector, placeholder } of fields) {
|
||||
const el = document.querySelector(selector) as HTMLElement | null
|
||||
if (!el) {
|
||||
setMollieError(true)
|
||||
return
|
||||
}
|
||||
const opts: Record<string, unknown> = { styles: MOLLIE_FIELD_STYLES }
|
||||
if (placeholder) opts.placeholder = placeholder
|
||||
const component = mollie.createComponent(type, opts)
|
||||
component.mount(el)
|
||||
component.addEventListener('change', (event: unknown) => {
|
||||
const e = event as { error?: string }
|
||||
setCardErrors((prev) => {
|
||||
const next = { ...prev }
|
||||
if (e.error) next[type] = e.error
|
||||
else delete next[type]
|
||||
return next
|
||||
})
|
||||
})
|
||||
componentsRef.current[type] = component
|
||||
}
|
||||
|
||||
mollieInitialized.current = true
|
||||
setMollieReady(true)
|
||||
} catch (err) {
|
||||
console.error('Mollie mount error:', err)
|
||||
setMollieError(true)
|
||||
}
|
||||
}, 100)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [scriptLoaded])
|
||||
|
||||
// Cleanup Mollie components on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
Object.values(componentsRef.current).forEach((c) => {
|
||||
try { c?.unmount() } catch { /* DOM already removed */ }
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSubmitted(true)
|
||||
setFormError(null)
|
||||
|
||||
if (!selectedMethod) {
|
||||
setFormError('Please select a payment method')
|
||||
return
|
||||
}
|
||||
|
||||
if (!country) {
|
||||
setFormError('Please select your country')
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
|
||||
try {
|
||||
if (selectedMethod === 'card') {
|
||||
const mollie = getMollie()
|
||||
if (!mollie) {
|
||||
setFormError('Payment system not loaded. Please refresh.')
|
||||
setSubmitting(false)
|
||||
return
|
||||
}
|
||||
|
||||
const { token, error } = await mollie.createToken()
|
||||
if (error || !token) {
|
||||
setFormError(error?.message || 'Invalid card details.')
|
||||
setSubmitting(false)
|
||||
return
|
||||
}
|
||||
|
||||
const result = await createEmbeddedCheckout({
|
||||
plan_id: plan,
|
||||
interval,
|
||||
limit,
|
||||
country,
|
||||
vat_id: vatId || undefined,
|
||||
card_token: token,
|
||||
})
|
||||
|
||||
if (result.status === 'success') router.push('/checkout?status=success')
|
||||
else if (result.status === 'pending' && result.redirect_url)
|
||||
window.location.href = result.redirect_url
|
||||
} else {
|
||||
const result = await createCheckoutSession({
|
||||
plan_id: plan,
|
||||
interval,
|
||||
limit,
|
||||
country,
|
||||
vat_id: vatId || undefined,
|
||||
method: selectedMethod,
|
||||
})
|
||||
window.location.href = result.url
|
||||
}
|
||||
} catch (err) {
|
||||
setFormError((err as Error)?.message || 'Payment failed. Please try again.')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const isCard = selectedMethod === 'card'
|
||||
|
||||
return (
|
||||
<>
|
||||
<Script
|
||||
src="https://js.mollie.com/v1/mollie.js"
|
||||
onLoad={() => setScriptLoaded(true)}
|
||||
onError={() => setMollieError(true)}
|
||||
/>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="rounded-2xl border border-neutral-800 bg-neutral-900/50 backdrop-blur-xl p-6"
|
||||
>
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Payment method</h2>
|
||||
|
||||
{/* Payment method grid */}
|
||||
<div className="grid grid-cols-3 gap-2 mb-5">
|
||||
{PAYMENT_METHODS.map((method) => {
|
||||
const isSelected = selectedMethod === method.id
|
||||
return (
|
||||
<button
|
||||
key={method.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedMethod(method.id)
|
||||
setFormError(null)
|
||||
if (method.id === 'card') {
|
||||
setTimeout(() => submitRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }), 350)
|
||||
}
|
||||
}}
|
||||
className={`flex items-center justify-center rounded-xl border h-[44px] transition-all duration-200 ${
|
||||
isSelected
|
||||
? 'border-brand-orange bg-brand-orange/5'
|
||||
: 'border-neutral-700/50 bg-neutral-800/30 hover:border-neutral-600'
|
||||
}`}
|
||||
>
|
||||
<MethodLogo type={method.id} />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Card form — always rendered for Mollie mount, animated visibility */}
|
||||
<div
|
||||
className="overflow-hidden transition-all duration-300 ease-out"
|
||||
style={{ maxHeight: isCard ? '400px' : '0px', opacity: isCard ? 1 : 0 }}
|
||||
>
|
||||
<div className="space-y-4 pb-1">
|
||||
{/* Cardholder name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-300 mb-1.5">Cardholder name</label>
|
||||
<div className="overflow-hidden transition-all duration-300" style={{ height: mollieReady ? '48px' : '0px' }}>
|
||||
<div id="mollie-card-holder" className={mollieFieldBase} />
|
||||
</div>
|
||||
{!mollieReady && isCard && <div className={`${mollieFieldBase} bg-neutral-800/30 animate-pulse`} />}
|
||||
{submitted && cardErrors.cardHolder && (
|
||||
<p className="mt-1 text-xs text-red-400">{cardErrors.cardHolder}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Card number */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-300 mb-1.5">Card number</label>
|
||||
<div className="overflow-hidden transition-all duration-300" style={{ height: mollieReady ? '48px' : '0px' }}>
|
||||
<div id="mollie-card-number" className={mollieFieldBase} />
|
||||
</div>
|
||||
{!mollieReady && isCard && <div className={`${mollieFieldBase} bg-neutral-800/30 animate-pulse`} />}
|
||||
{submitted && cardErrors.cardNumber && (
|
||||
<p className="mt-1 text-xs text-red-400">{cardErrors.cardNumber}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expiry & CVC */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-300 mb-1.5">Expiry date</label>
|
||||
<div className="overflow-hidden transition-all duration-300" style={{ height: mollieReady ? '48px' : '0px' }}>
|
||||
<div id="mollie-card-expiry" className={mollieFieldBase} />
|
||||
</div>
|
||||
{!mollieReady && isCard && <div className={`${mollieFieldBase} bg-neutral-800/30 animate-pulse`} />}
|
||||
{submitted && cardErrors.expiryDate && (
|
||||
<p className="mt-1 text-xs text-red-400">{cardErrors.expiryDate}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-300 mb-1.5">CVC</label>
|
||||
<div className="overflow-hidden transition-all duration-300" style={{ height: mollieReady ? '48px' : '0px' }}>
|
||||
<div id="mollie-card-cvc" className={mollieFieldBase} />
|
||||
</div>
|
||||
{!mollieReady && isCard && <div className={`${mollieFieldBase} bg-neutral-800/30 animate-pulse`} />}
|
||||
{submitted && cardErrors.verificationCode && (
|
||||
<p className="mt-1 text-xs text-red-400">{cardErrors.verificationCode}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Non-card info */}
|
||||
<AnimatePresence>
|
||||
{selectedMethod && !isCard && (
|
||||
<motion.p
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="text-sm text-neutral-400 mb-4 overflow-hidden"
|
||||
>
|
||||
You'll be redirected to complete payment securely via {PAYMENT_METHODS.find((m) => m.id === selectedMethod)?.label}.
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Form / API errors */}
|
||||
{formError && (
|
||||
<div className="mb-4 rounded-lg bg-red-500/10 border border-red-500/20 px-4 py-3 text-sm text-red-400">
|
||||
{formError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mollie fallback */}
|
||||
{mollieError && isCard && (
|
||||
<div className="mb-4 rounded-lg bg-yellow-500/10 border border-yellow-500/20 px-4 py-3 text-sm text-yellow-400">
|
||||
Card fields could not load. Please select another payment method.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit */}
|
||||
<button
|
||||
ref={submitRef}
|
||||
type="submit"
|
||||
disabled={submitting || !selectedMethod || (isCard && !mollieReady && !mollieError)}
|
||||
className="mt-4 w-full rounded-lg bg-brand-orange-button px-4 py-3 text-sm font-semibold text-white transition-colors hover:bg-brand-orange-button-hover disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{submitting ? 'Processing...' : 'Start free trial'}
|
||||
</button>
|
||||
|
||||
{/* Trust signals */}
|
||||
<div className="mt-4 flex items-center justify-center gap-6 text-xs text-neutral-500">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Lock weight="fill" className="h-3.5 w-3.5" />
|
||||
Secured with SSL
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<ShieldCheck weight="fill" className="h-3.5 w-3.5" />
|
||||
Cancel anytime
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
)
|
||||
}
|
||||
236
components/checkout/PlanSummary.tsx
Normal file
236
components/checkout/PlanSummary.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Select } from '@ciphera-net/ui'
|
||||
import { TRAFFIC_TIERS, PLAN_PRICES } from '@/lib/plans'
|
||||
import { COUNTRY_OPTIONS } from '@/lib/countries'
|
||||
import { calculateVAT, type VATResult } from '@/lib/api/billing'
|
||||
|
||||
interface PlanSummaryProps {
|
||||
plan: string
|
||||
interval: string
|
||||
limit: number
|
||||
country: string
|
||||
vatId: string
|
||||
onCountryChange: (country: string) => void
|
||||
onVatIdChange: (vatId: string) => void
|
||||
}
|
||||
|
||||
const inputClass =
|
||||
'w-full rounded-lg border border-neutral-700 bg-neutral-800/50 px-3 py-2.5 text-sm text-white placeholder:text-neutral-500 focus:outline-none focus:ring-1 focus:ring-brand-orange focus:border-brand-orange transition-colors'
|
||||
|
||||
/** Convert VIES ALL-CAPS text to title case (e.g. "SA SODIMAS" → "Sa Sodimas") */
|
||||
function toTitleCase(s: string) {
|
||||
return s.replace(/\S+/g, (w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
|
||||
}
|
||||
|
||||
export default function PlanSummary({ plan, interval, limit, country, vatId, onCountryChange, onVatIdChange }: PlanSummaryProps) {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const [currentInterval, setCurrentInterval] = useState(interval)
|
||||
const [vatResult, setVatResult] = useState<VATResult | null>(null)
|
||||
const [vatLoading, setVatLoading] = useState(false)
|
||||
const [verifiedVatId, setVerifiedVatId] = useState('')
|
||||
|
||||
const monthlyCents = PLAN_PRICES[plan]?.[limit] || 0
|
||||
const isYearly = currentInterval === 'year'
|
||||
const baseDisplay = isYearly ? (monthlyCents * 11) / 100 : monthlyCents / 100
|
||||
|
||||
const tierLabel =
|
||||
TRAFFIC_TIERS.find((t) => t.value === limit)?.label ||
|
||||
`${(limit / 1000).toFixed(0)}k`
|
||||
|
||||
const handleIntervalToggle = (newInterval: string) => {
|
||||
setCurrentInterval(newInterval)
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
params.set('interval', newInterval)
|
||||
router.replace(`/checkout?${params.toString()}`, { scroll: false })
|
||||
}
|
||||
|
||||
const fetchVAT = useCallback(async (c: string, v: string, iv: string) => {
|
||||
if (!c) { setVatResult(null); return }
|
||||
setVatLoading(true)
|
||||
try {
|
||||
const result = await calculateVAT({ plan_id: plan, interval: iv, limit, country: c, vat_id: v || undefined })
|
||||
setVatResult(result)
|
||||
} catch {
|
||||
setVatResult(null)
|
||||
} finally {
|
||||
setVatLoading(false)
|
||||
}
|
||||
}, [plan, limit])
|
||||
|
||||
// Auto-fetch when country or interval changes (using the already-verified VAT ID if any)
|
||||
useEffect(() => {
|
||||
if (!country) { setVatResult(null); return }
|
||||
fetchVAT(country, verifiedVatId, currentInterval)
|
||||
}, [country, currentInterval, fetchVAT, verifiedVatId])
|
||||
|
||||
// Clear verified state when VAT ID input changes after a successful verification
|
||||
useEffect(() => {
|
||||
if (verifiedVatId !== '' && vatId !== verifiedVatId) {
|
||||
setVerifiedVatId('')
|
||||
// Re-fetch without VAT ID to show the 21% rate
|
||||
if (country) fetchVAT(country, '', currentInterval)
|
||||
}
|
||||
}, [vatId]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleVerifyVatId = () => {
|
||||
if (!vatId || !country) return
|
||||
setVerifiedVatId(vatId)
|
||||
// useEffect on verifiedVatId will trigger the fetch
|
||||
}
|
||||
|
||||
const isVatChecked = verifiedVatId !== '' && verifiedVatId === vatId
|
||||
const isVatValid = isVatChecked && !!vatResult?.company_name
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-neutral-800 bg-neutral-900/50 backdrop-blur-xl p-5 space-y-4">
|
||||
{/* Plan name + interval toggle */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-lg font-semibold text-white capitalize">{plan}</h2>
|
||||
<span className="rounded-full bg-brand-orange/15 px-3 py-0.5 text-xs font-medium text-brand-orange">
|
||||
30-day trial
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 p-1 bg-neutral-800/50 rounded-xl sm:ml-auto">
|
||||
{(['month', 'year'] as const).map((iv) => (
|
||||
<button
|
||||
key={iv}
|
||||
type="button"
|
||||
onClick={() => handleIntervalToggle(iv)}
|
||||
className={`relative px-3.5 py-1.5 text-sm font-medium rounded-lg transition-colors duration-200 ${
|
||||
currentInterval === iv ? 'text-white' : 'text-neutral-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{currentInterval === iv && (
|
||||
<motion.div
|
||||
layoutId="checkout-interval-bg"
|
||||
className="absolute inset-0 bg-neutral-700 rounded-lg shadow-sm"
|
||||
transition={{ type: 'spring', bounce: 0.15, duration: 0.35 }}
|
||||
/>
|
||||
)}
|
||||
<span className="relative z-10">{iv === 'month' ? 'Monthly' : 'Yearly'}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Country */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-300 mb-1.5">Country</label>
|
||||
<Select
|
||||
value={country}
|
||||
onChange={onCountryChange}
|
||||
variant="input"
|
||||
options={[{ value: '', label: 'Select country' }, ...COUNTRY_OPTIONS.map((c) => ({ value: c.value, label: c.label }))]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* VAT ID */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-300 mb-1.5">
|
||||
VAT ID <span className="text-neutral-500">(optional)</span>
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={vatId}
|
||||
onChange={(e) => onVatIdChange(e.target.value)}
|
||||
placeholder="e.g. DE123456789"
|
||||
className={inputClass}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleVerifyVatId}
|
||||
disabled={!vatId || !country || vatLoading || isVatValid}
|
||||
className="shrink-0 rounded-lg bg-neutral-700 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-neutral-600 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{vatLoading && vatId ? 'Verifying...' : isVatValid ? 'Verified' : 'Verify'}
|
||||
</button>
|
||||
</div>
|
||||
{/* Verified company info */}
|
||||
<AnimatePresence>
|
||||
{isVatValid && vatResult?.company_name && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.25, ease: 'easeOut' }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="mt-2 rounded-lg bg-green-500/5 border border-green-500/20 px-3 py-2 text-xs text-neutral-400">
|
||||
<p className="font-medium text-green-400">{toTitleCase(vatResult.company_name)}</p>
|
||||
{vatResult.company_address && (
|
||||
<p className="mt-0.5 whitespace-pre-line">{toTitleCase(vatResult.company_address)}</p>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
{isVatChecked && !vatLoading && !isVatValid && vatResult && !vatResult.vat_exempt && (
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="mt-1.5 text-xs text-yellow-400"
|
||||
>
|
||||
VAT ID could not be verified. 21% VAT will apply.
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Price breakdown */}
|
||||
<div className={`pt-2 border-t border-neutral-800 transition-opacity duration-200 ${vatLoading ? 'opacity-50' : 'opacity-100'}`}>
|
||||
{vatResult ? (
|
||||
<div className="space-y-1.5 text-sm">
|
||||
<div className="flex justify-between text-neutral-400">
|
||||
<span>Subtotal ({tierLabel} pageviews)</span>
|
||||
<span>€{vatResult.base_amount}</span>
|
||||
</div>
|
||||
{vatResult.vat_exempt ? (
|
||||
<div className="flex justify-between text-neutral-500 text-xs">
|
||||
<span>{vatResult.vat_reason}</span>
|
||||
<span>€0.00</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-between text-neutral-400">
|
||||
<span>VAT {vatResult.vat_rate}%</span>
|
||||
<span>€{vatResult.vat_amount}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between font-semibold text-white pt-1 border-t border-neutral-800">
|
||||
<span>Total {isYearly ? '/year' : '/mo'}</span>
|
||||
<span>€{vatResult.total_amount}</span>
|
||||
</div>
|
||||
{isYearly && (
|
||||
<p className="text-xs text-neutral-500">€{(parseFloat(vatResult.total_amount) / 12).toFixed(2)}/mo</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5 text-sm">
|
||||
<div className="flex justify-between text-neutral-400">
|
||||
<span>Subtotal ({tierLabel} pageviews)</span>
|
||||
<span>€{baseDisplay.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-neutral-500 text-xs">
|
||||
<span>VAT</span>
|
||||
<span>{vatLoading ? 'Calculating...' : 'Select country'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between font-semibold text-white pt-1 border-t border-neutral-800">
|
||||
<span>Total {isYearly ? '/year' : '/mo'} <span className="font-normal text-neutral-500 text-xs">excl. VAT</span></span>
|
||||
<span>€{baseDisplay.toFixed(2)}</span>
|
||||
</div>
|
||||
{isYearly && (
|
||||
<p className="text-xs text-neutral-500">€{(baseDisplay / 12).toFixed(2)}/mo · Save 1 month</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -111,7 +111,7 @@ export default function AddFilterDropdown({ onAdd, suggestions = {}, onFetchSugg
|
||||
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'
|
||||
: 'bg-neutral-800 text-neutral-400 hover:bg-neutral-700 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}>
|
||||
@@ -121,7 +121,7 @@ export default function AddFilterDropdown({ onAdd, suggestions = {}, onFetchSugg
|
||||
</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]">
|
||||
<div className="absolute top-full left-0 mt-1.5 z-50 bg-neutral-900 border border-neutral-700 rounded-xl shadow-xl overflow-hidden min-w-[280px]">
|
||||
{!selectedDim ? (
|
||||
/* Step 1: Dimension list */
|
||||
<div className="py-1">
|
||||
@@ -129,9 +129,9 @@ export default function AddFilterDropdown({ onAdd, suggestions = {}, onFetchSugg
|
||||
<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"
|
||||
className="w-full flex items-center justify-between px-4 py-2.5 text-sm text-left hover:bg-neutral-800 transition-colors cursor-pointer"
|
||||
>
|
||||
<span className="text-neutral-900 dark:text-white font-medium">{DIMENSION_LABELS[dim]}</span>
|
||||
<span className="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>
|
||||
@@ -145,13 +145,13 @@ export default function AddFilterDropdown({ onAdd, suggestions = {}, onFetchSugg
|
||||
<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"
|
||||
className="p-1 text-neutral-400 hover:text-neutral-300 transition-colors cursor-pointer rounded-md 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">
|
||||
<span className="text-sm font-semibold text-white">
|
||||
{DIMENSION_LABELS[selectedDim]}
|
||||
</span>
|
||||
</div>
|
||||
@@ -164,8 +164,8 @@ export default function AddFilterDropdown({ onAdd, suggestions = {}, onFetchSugg
|
||||
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'
|
||||
? 'bg-brand-orange-button text-white'
|
||||
: 'bg-neutral-800 text-neutral-400 hover:bg-neutral-700'
|
||||
}`}
|
||||
>
|
||||
{OPERATOR_LABELS[op]}
|
||||
@@ -189,24 +189,24 @@ export default function AddFilterDropdown({ onAdd, suggestions = {}, onFetchSugg
|
||||
}
|
||||
}}
|
||||
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"
|
||||
className="w-full px-3 py-2 text-sm bg-neutral-800 border border-neutral-700 rounded-lg 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 className="inline-block w-4 h-4 border-2 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">
|
||||
<div className="max-h-52 overflow-y-auto border-t 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"
|
||||
className="w-full flex items-center justify-between px-4 py-2 text-sm text-left hover:bg-neutral-800 transition-colors cursor-pointer"
|
||||
>
|
||||
<span className="truncate text-neutral-900 dark:text-white">{s.label}</span>
|
||||
<span className="truncate 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()}
|
||||
@@ -216,10 +216,10 @@ export default function AddFilterDropdown({ onAdd, suggestions = {}, onFetchSugg
|
||||
))}
|
||||
</div>
|
||||
) : search.trim() ? (
|
||||
<div className="px-3 py-3 border-t border-neutral-100 dark:border-neutral-800">
|
||||
<div className="px-3 py-3 border-t 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"
|
||||
className="w-full px-3 py-2 text-sm font-medium bg-brand-orange-button text-white rounded-lg hover:bg-brand-orange/90 transition-colors cursor-pointer"
|
||||
>
|
||||
Filter by “{search.trim()}”
|
||||
</button>
|
||||
|
||||
@@ -7,9 +7,10 @@ import Image from 'next/image'
|
||||
import { formatNumber } from '@ciphera-net/ui'
|
||||
import { Modal, ArrowRightIcon } from '@ciphera-net/ui'
|
||||
import { ListSkeleton } from '@/components/skeletons'
|
||||
import VirtualList from './VirtualList'
|
||||
import { getCampaigns, CampaignStat } from '@/lib/api/stats'
|
||||
import { getReferrerFavicon, getReferrerIcon, getReferrerDisplayName } from '@/lib/utils/icons'
|
||||
import { FaBullhorn } from 'react-icons/fa'
|
||||
import { Megaphone, FrameCornersIcon } from '@phosphor-icons/react'
|
||||
import UtmBuilder from '@/components/tools/UtmBuilder'
|
||||
import { type DimensionFilter } from '@/lib/filters'
|
||||
|
||||
@@ -26,6 +27,7 @@ export default function Campaigns({ siteId, dateRange, filters, onFilter }: Camp
|
||||
const [data, setData] = useState<CampaignStat[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
const [modalSearch, setModalSearch] = useState('')
|
||||
const [isBuilderOpen, setIsBuilderOpen] = useState(false)
|
||||
const [fullData, setFullData] = useState<CampaignStat[]>([])
|
||||
const [isLoadingFull, setIsLoadingFull] = useState(false)
|
||||
@@ -122,11 +124,23 @@ export default function Campaigns({ siteId, dateRange, filters, onFilter }: Camp
|
||||
|
||||
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-neutral-900/80 border border-white/[0.08] rounded-2xl p-6 h-full flex flex-col">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
|
||||
Campaigns
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<Megaphone className="w-5 h-5 text-neutral-400 dark:text-neutral-500" weight="bold" />
|
||||
<h3 className="text-lg font-semibold text-white">
|
||||
Campaigns
|
||||
</h3>
|
||||
{showViewAll && (
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="p-1.5 text-neutral-400 dark:text-neutral-500 hover:text-brand-orange dark:hover:text-brand-orange hover:bg-neutral-800 transition-all cursor-pointer rounded-lg"
|
||||
aria-label="View all campaigns"
|
||||
>
|
||||
<FrameCornersIcon className="w-4 h-4" weight="bold" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsBuilderOpen(true)}
|
||||
className="text-xs font-medium text-neutral-400 dark:text-neutral-500 hover:text-brand-orange dark:hover:text-brand-orange transition-colors cursor-pointer"
|
||||
@@ -141,13 +155,19 @@ export default function Campaigns({ siteId, dateRange, filters, onFilter }: Camp
|
||||
) : hasData ? (
|
||||
<>
|
||||
{displayedData.map((item) => {
|
||||
const maxVis = displayedData[0]?.visitors ?? 0
|
||||
const barWidth = maxVis > 0 ? (item.visitors / maxVis) * 75 : 0
|
||||
return (
|
||||
<div
|
||||
key={`${item.source}|${item.medium}|${item.campaign}`}
|
||||
onClick={() => onFilter?.({ dimension: 'utm_source', operator: 'is', values: [item.source] })}
|
||||
className={`flex items-center justify-between py-1.5 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors${onFilter ? ' cursor-pointer' : ''}`}
|
||||
className={`relative flex items-center justify-between py-1.5 group hover:bg-neutral-800/50 rounded-lg px-2 -mx-2 transition-colors${onFilter ? ' cursor-pointer' : ''}`}
|
||||
>
|
||||
<div className="flex-1 text-neutral-900 dark:text-white flex items-center gap-3 min-w-0">
|
||||
<div
|
||||
className="absolute inset-y-0.5 left-0.5 bg-brand-orange/15 dark:bg-brand-orange/40 rounded-md transition-all"
|
||||
style={{ width: `${barWidth}%` }}
|
||||
/>
|
||||
<div className="relative flex-1 text-white flex items-center gap-3 min-w-0">
|
||||
{renderSourceIcon(item.source)}
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium text-sm" title={item.source}>
|
||||
@@ -160,51 +180,39 @@ export default function Campaigns({ siteId, dateRange, filters, onFilter }: Camp
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
<div className="relative 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">
|
||||
<span className="text-sm font-semibold text-neutral-400">
|
||||
{formatNumber(item.visitors)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{showViewAll ? (
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="flex items-center justify-center gap-1.5 h-9 w-full text-xs font-medium text-neutral-400 dark:text-neutral-500 hover:text-brand-orange dark:hover:text-brand-orange transition-colors cursor-pointer rounded-lg px-2 -mx-2"
|
||||
>
|
||||
View all
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
) : (
|
||||
Array.from({ length: emptySlots }).map((_, i) => (
|
||||
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
|
||||
))
|
||||
)}
|
||||
{Array.from({ length: emptySlots }).map((_, i) => (
|
||||
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<div className="h-full flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
|
||||
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
|
||||
<FaBullhorn className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
|
||||
<div className="rounded-full bg-neutral-800 p-4">
|
||||
<Megaphone className="w-8 h-8 text-neutral-400" />
|
||||
</div>
|
||||
<h4 className="font-semibold text-neutral-900 dark:text-white">
|
||||
<h4 className="font-semibold text-white">
|
||||
Track your marketing campaigns
|
||||
</h4>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs">
|
||||
<p className="text-sm text-neutral-400 max-w-xs">
|
||||
Add UTM parameters to your links to see campaign performance here.
|
||||
</p>
|
||||
<Link
|
||||
href="/installation"
|
||||
className="inline-flex items-center gap-2 text-sm font-medium text-brand-orange hover:text-brand-orange/90 hover:underline focus:outline-none focus:ring-2 focus:ring-brand-orange/20 rounded"
|
||||
<button
|
||||
onClick={() => setIsBuilderOpen(true)}
|
||||
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 cursor-pointer"
|
||||
>
|
||||
Learn more
|
||||
Build a UTM URL
|
||||
<ArrowRightIcon className="w-4 h-4" />
|
||||
</Link>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -212,56 +220,80 @@ export default function Campaigns({ siteId, dateRange, filters, onFilter }: Camp
|
||||
|
||||
<Modal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
title="All Campaigns"
|
||||
onClose={() => { setIsModalOpen(false); setModalSearch('') }}
|
||||
title="Campaigns"
|
||||
className="max-w-2xl"
|
||||
>
|
||||
<div className="space-y-1 max-h-[60vh] overflow-y-auto pr-2">
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={modalSearch}
|
||||
onChange={(e) => setModalSearch(e.target.value)}
|
||||
placeholder="Search campaigns..."
|
||||
className="w-full px-3 py-2 mb-3 text-sm bg-neutral-800 border border-neutral-700 rounded-lg text-white placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-brand-orange/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-[80vh]">
|
||||
{isLoadingFull ? (
|
||||
<div className="py-4">
|
||||
<ListSkeleton rows={10} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-end mb-2">
|
||||
<button
|
||||
onClick={handleExportCampaigns}
|
||||
className="text-xs font-medium text-neutral-400 hover:text-brand-orange transition-colors cursor-pointer"
|
||||
>
|
||||
Export CSV
|
||||
</button>
|
||||
</div>
|
||||
{sortedFullData.map((item) => {
|
||||
return (
|
||||
<div
|
||||
key={`${item.source}|${item.medium}|${item.campaign}`}
|
||||
className="flex items-center justify-between py-2 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors"
|
||||
) : (() => {
|
||||
const filteredCampaigns = !modalSearch ? sortedFullData : sortedFullData.filter(item => {
|
||||
const search = modalSearch.toLowerCase()
|
||||
return item.source.toLowerCase().includes(search) || (item.medium || '').toLowerCase().includes(search) || (item.campaign || '').toLowerCase().includes(search)
|
||||
})
|
||||
const modalTotal = filteredCampaigns.reduce((sum, item) => sum + item.visitors, 0)
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-end mb-2">
|
||||
<button
|
||||
onClick={handleExportCampaigns}
|
||||
className="text-xs font-medium text-neutral-400 hover:text-brand-orange transition-colors cursor-pointer"
|
||||
>
|
||||
<div className="flex-1 flex items-center gap-3 min-w-0">
|
||||
{renderSourceIcon(item.source)}
|
||||
<div className="min-w-0">
|
||||
<div className="text-neutral-900 dark:text-white font-medium truncate text-sm" title={item.source}>
|
||||
{getReferrerDisplayName(item.source)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-neutral-400 dark:text-neutral-500">
|
||||
<span>{item.medium || '—'}</span>
|
||||
<span>·</span>
|
||||
<span className="truncate">{item.campaign || '—'}</span>
|
||||
Export CSV
|
||||
</button>
|
||||
</div>
|
||||
<VirtualList
|
||||
items={filteredCampaigns}
|
||||
estimateSize={36}
|
||||
className="max-h-[80vh] overflow-y-auto pr-2"
|
||||
renderItem={(item) => (
|
||||
<div
|
||||
key={`${item.source}|${item.medium}|${item.campaign}`}
|
||||
onClick={() => { if (onFilter) { onFilter({ dimension: 'utm_source', operator: 'is', values: [item.source] }); setIsModalOpen(false) } }}
|
||||
className={`flex items-center justify-between py-2 group hover:bg-neutral-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-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-white">
|
||||
{formatNumber(item.visitors)}
|
||||
</span>
|
||||
<span className="text-neutral-400 dark:text-neutral-500 w-16 text-right">
|
||||
{formatNumber(item.pageviews)} pv
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 ml-4 text-sm">
|
||||
<span className="font-semibold text-neutral-900 dark:text-white">
|
||||
{formatNumber(item.visitors)}
|
||||
</span>
|
||||
<span className="text-neutral-400 dark:text-neutral-500 w-16 text-right">
|
||||
{formatNumber(item.pageviews)} pv
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
21
components/dashboard/ContentHeader.tsx
Normal file
21
components/dashboard/ContentHeader.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
'use client'
|
||||
|
||||
import { MenuIcon } from '@ciphera-net/ui'
|
||||
|
||||
export default function ContentHeader({
|
||||
onMobileMenuOpen,
|
||||
}: {
|
||||
onMobileMenuOpen: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="shrink-0 flex items-center border-b border-neutral-800/60 bg-neutral-900/90 backdrop-blur-xl px-4 py-3.5 md:hidden">
|
||||
<button
|
||||
onClick={onMobileMenuOpen}
|
||||
className="p-2 -ml-2 text-neutral-400 hover:text-white"
|
||||
aria-label="Open navigation"
|
||||
>
|
||||
<MenuIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { logger } from '@/lib/utils/logger'
|
||||
import { formatNumber } from '@ciphera-net/ui'
|
||||
import { useTabListKeyboard } from '@/lib/hooks/useTabListKeyboard'
|
||||
import { TopPage, getTopPages, getEntryPages, getExitPages } from '@/lib/api/stats'
|
||||
import { Modal, ArrowUpRightIcon, LayoutDashboardIcon } from '@ciphera-net/ui'
|
||||
import Link from 'next/link'
|
||||
import { Files, FrameCornersIcon } from '@phosphor-icons/react'
|
||||
import { Modal, ArrowUpRightIcon, ArrowRightIcon, LayoutDashboardIcon } from '@ciphera-net/ui'
|
||||
import { ListSkeleton } from '@/components/skeletons'
|
||||
import VirtualList from './VirtualList'
|
||||
import { type DimensionFilter } from '@/lib/filters'
|
||||
|
||||
interface ContentStatsProps {
|
||||
@@ -28,6 +32,7 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain,
|
||||
const [activeTab, setActiveTab] = useState<Tab>('top_pages')
|
||||
const handleTabKeyDown = useTabListKeyboard()
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
const [modalSearch, setModalSearch] = useState('')
|
||||
const [fullData, setFullData] = useState<TopPage[]>([])
|
||||
const [isLoadingFull, setIsLoadingFull] = useState(false)
|
||||
|
||||
@@ -94,25 +99,44 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain,
|
||||
|
||||
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-neutral-900/80 border border-white/[0.08] rounded-2xl p-6 h-full flex flex-col">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
|
||||
Pages
|
||||
</h3>
|
||||
<div className="flex gap-1" role="tablist" aria-label="Pages view tabs" onKeyDown={handleTabKeyDown}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Files className="w-5 h-5 text-neutral-400 dark:text-neutral-500" weight="bold" />
|
||||
<h3 className="text-lg font-semibold text-white">
|
||||
Pages
|
||||
</h3>
|
||||
{showViewAll && (
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="p-1.5 text-neutral-400 dark:text-neutral-500 hover:text-brand-orange dark:hover:text-brand-orange hover:bg-neutral-800 transition-all cursor-pointer rounded-lg"
|
||||
aria-label="View all pages"
|
||||
>
|
||||
<FrameCornersIcon className="w-4 h-4" weight="bold" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1 overflow-x-auto scrollbar-hide" role="tablist" aria-label="Pages view tabs" onKeyDown={handleTabKeyDown}>
|
||||
{(['top_pages', 'entry_pages', 'exit_pages'] as Tab[]).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
role="tab"
|
||||
aria-selected={activeTab === tab}
|
||||
className={`px-2.5 py-1 text-xs font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange rounded cursor-pointer border-b-2 ${
|
||||
className={`relative px-2.5 py-1 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange rounded cursor-pointer ${
|
||||
activeTab === tab
|
||||
? 'border-brand-orange text-neutral-900 dark:text-white'
|
||||
: 'border-transparent text-neutral-400 dark:text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300'
|
||||
? 'text-white'
|
||||
: 'text-neutral-400 dark:text-neutral-500 hover:text-neutral-300'
|
||||
}`}
|
||||
>
|
||||
{getTabLabel(tab)}
|
||||
{activeTab === tab && (
|
||||
<motion.div
|
||||
layoutId="contentStatsTab"
|
||||
className="absolute inset-x-0 -bottom-px h-0.5 bg-brand-orange"
|
||||
transition={{ type: 'spring', stiffness: 500, damping: 35 }}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -121,65 +145,68 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain,
|
||||
<div className="space-y-2 flex-1 min-h-[270px]">
|
||||
{!collectPagePaths ? (
|
||||
<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">Page path tracking is disabled in site settings</p>
|
||||
<p className="text-neutral-400 text-sm">Page path tracking is disabled in site settings</p>
|
||||
</div>
|
||||
) : hasData ? (
|
||||
<>
|
||||
{displayedData.map((page) => (
|
||||
<div
|
||||
key={page.path}
|
||||
onClick={() => onFilter?.({ dimension: 'page', operator: 'is', values: [page.path] })}
|
||||
className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors${onFilter ? ' cursor-pointer' : ''}`}
|
||||
>
|
||||
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center">
|
||||
<span className="truncate">{page.path}</span>
|
||||
<a
|
||||
href={`https://${domain.replace(/^https?:\/\//, '')}${page.path}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={e => e.stopPropagation()}
|
||||
className="ml-2 flex-shrink-0"
|
||||
>
|
||||
<ArrowUpRightIcon className="w-3 h-3 text-neutral-400 opacity-0 group-hover:opacity-100 transition-opacity hover:text-brand-orange" />
|
||||
</a>
|
||||
{displayedData.map((page, idx) => {
|
||||
const maxPv = displayedData[0]?.pageviews ?? 0
|
||||
const barWidth = maxPv > 0 ? (page.pageviews / maxPv) * 75 : 0
|
||||
return (
|
||||
<div
|
||||
key={page.path}
|
||||
onClick={() => onFilter?.({ dimension: 'page', operator: 'is', values: [page.path] })}
|
||||
className={`relative flex items-center justify-between h-9 group hover:bg-neutral-800/50 rounded-lg px-2 -mx-2 transition-colors${onFilter ? ' cursor-pointer' : ''}`}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-y-0.5 left-0.5 bg-brand-orange/15 dark:bg-brand-orange/40 rounded-md transition-all"
|
||||
style={{ width: `${barWidth}%` }}
|
||||
/>
|
||||
<div className="relative flex-1 truncate text-white flex items-center">
|
||||
<span className="truncate">{page.path}</span>
|
||||
<a
|
||||
href={`https://${domain.replace(/^https?:\/\//, '')}${page.path}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={e => e.stopPropagation()}
|
||||
className="ml-2 flex-shrink-0"
|
||||
>
|
||||
<ArrowUpRightIcon className="w-3 h-3 text-neutral-400 opacity-0 group-hover:opacity-100 transition-opacity hover:text-brand-orange" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="relative flex items-center gap-2 ml-4">
|
||||
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
|
||||
{totalPageviews > 0 ? `${Math.round((page.pageviews / totalPageviews) * 100)}%` : ''}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-neutral-400">
|
||||
{formatNumber(page.pageviews)}
|
||||
</span>
|
||||
</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">
|
||||
{totalPageviews > 0 ? `${Math.round((page.pageviews / totalPageviews) * 100)}%` : ''}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
|
||||
{formatNumber(page.pageviews)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{Array.from({ length: emptySlots }).map((_, i) => (
|
||||
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
|
||||
))}
|
||||
{showViewAll ? (
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="flex items-center justify-center gap-1.5 h-9 w-full text-xs font-medium text-neutral-400 dark:text-neutral-500 hover:text-brand-orange dark:hover:text-brand-orange transition-colors cursor-pointer rounded-lg px-2 -mx-2"
|
||||
>
|
||||
View all
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
) : (
|
||||
Array.from({ length: emptySlots }).map((_, i) => (
|
||||
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
|
||||
))
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="h-full flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
|
||||
<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 className="rounded-full bg-neutral-800 p-4">
|
||||
<LayoutDashboardIcon className="w-8 h-8 text-neutral-400" />
|
||||
</div>
|
||||
<h4 className="font-semibold text-neutral-900 dark:text-white">
|
||||
<h4 className="font-semibold text-white">
|
||||
No page data yet
|
||||
</h4>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs">
|
||||
<p className="text-sm text-neutral-400 max-w-xs">
|
||||
Your most visited pages will appear here as traffic arrives.
|
||||
</p>
|
||||
<Link
|
||||
href="/installation"
|
||||
className="inline-flex items-center gap-2 text-sm font-medium text-brand-orange hover:text-brand-orange/90 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange/20 rounded"
|
||||
>
|
||||
Install tracking script
|
||||
<ArrowRightIcon className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -187,34 +214,57 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain,
|
||||
|
||||
<Modal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
title={`Pages - ${getTabLabel(activeTab)}`}
|
||||
onClose={() => { setIsModalOpen(false); setModalSearch('') }}
|
||||
title={getTabLabel(activeTab)}
|
||||
className="max-w-2xl"
|
||||
>
|
||||
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-2">
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={modalSearch}
|
||||
onChange={(e) => setModalSearch(e.target.value)}
|
||||
placeholder="Search pages..."
|
||||
className="w-full px-3 py-2 mb-3 text-sm bg-neutral-800 border border-neutral-700 rounded-lg text-white placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-brand-orange/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-[80vh]">
|
||||
{isLoadingFull ? (
|
||||
<div className="py-4">
|
||||
<ListSkeleton rows={10} />
|
||||
</div>
|
||||
) : (
|
||||
(fullData.length > 0 ? fullData : data).map((page) => (
|
||||
<div key={page.path} className="flex items-center justify-between py-2 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors">
|
||||
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center">
|
||||
<a
|
||||
href={`https://${domain.replace(/^https?:\/\//, '')}${page.path}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:underline flex items-center"
|
||||
>
|
||||
{page.path}
|
||||
<ArrowUpRightIcon className="w-3 h-3 ml-2 text-neutral-400 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-neutral-600 dark:text-neutral-400 ml-4">
|
||||
{formatNumber(page.pageviews)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
) : (() => {
|
||||
const modalData = (fullData.length > 0 ? fullData : data).filter(p => !modalSearch || p.path.toLowerCase().includes(modalSearch.toLowerCase()))
|
||||
const modalTotal = modalData.reduce((sum, p) => sum + p.pageviews, 0)
|
||||
return (
|
||||
<VirtualList
|
||||
items={modalData}
|
||||
estimateSize={36}
|
||||
className="max-h-[80vh] overflow-y-auto pr-2"
|
||||
renderItem={(page) => {
|
||||
const canFilter = onFilter && page.path
|
||||
return (
|
||||
<div
|
||||
key={page.path}
|
||||
onClick={() => { if (canFilter) { onFilter({ dimension: 'page', operator: 'is', values: [page.path] }); setIsModalOpen(false) } }}
|
||||
className={`flex items-center justify-between h-9 group hover:bg-neutral-800 rounded-lg px-2 transition-colors${canFilter ? ' cursor-pointer' : ''}`}
|
||||
>
|
||||
<div className="flex-1 truncate text-white flex items-center">
|
||||
<span className="truncate">{page.path}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
|
||||
{modalTotal > 0 ? `${Math.round((page.pageviews / modalTotal) * 100)}%` : ''}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-neutral-400">
|
||||
{formatNumber(page.pageviews)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
|
||||
406
components/dashboard/DashboardShell.tsx
Normal file
406
components/dashboard/DashboardShell.tsx
Normal file
@@ -0,0 +1,406 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import dynamic from 'next/dynamic'
|
||||
import Link from 'next/link'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { formatUpdatedAgo, PlusIcon, ExternalLinkIcon, type CipheraApp } from '@ciphera-net/ui'
|
||||
import { CaretDown, CaretRight, SidebarSimple } from '@phosphor-icons/react'
|
||||
import { SidebarProvider, useSidebar } from '@/lib/sidebar-context'
|
||||
import { useRealtime } from '@/lib/swr/dashboard'
|
||||
import { getSite, listSites, type Site } from '@/lib/api/sites'
|
||||
import { FAVICON_SERVICE_URL } from '@/lib/utils/favicon'
|
||||
import ContentHeader from './ContentHeader'
|
||||
|
||||
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 },
|
||||
{ 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 },
|
||||
]
|
||||
|
||||
const PAGE_TITLES: Record<string, string> = {
|
||||
'': 'Dashboard',
|
||||
journeys: 'Journeys',
|
||||
funnels: 'Funnels',
|
||||
behavior: 'Behavior',
|
||||
search: 'Search',
|
||||
cdn: 'CDN',
|
||||
uptime: 'Uptime',
|
||||
pagespeed: 'PageSpeed',
|
||||
settings: 'Site Settings',
|
||||
}
|
||||
|
||||
function usePageTitle() {
|
||||
const pathname = usePathname()
|
||||
// pathname is /sites/:id or /sites/:id/section/...
|
||||
const segment = pathname.replace(/^\/sites\/[^/]+\/?/, '').split('/')[0]
|
||||
return PAGE_TITLES[segment] ?? (segment ? segment.charAt(0).toUpperCase() + segment.slice(1) : 'Dashboard')
|
||||
}
|
||||
|
||||
const HOME_PAGE_TITLES: Record<string, string> = {
|
||||
'': 'Your Sites',
|
||||
integrations: 'Integrations',
|
||||
pricing: 'Pricing',
|
||||
}
|
||||
|
||||
function useHomePageTitle() {
|
||||
const pathname = usePathname()
|
||||
const segment = pathname.split('/').filter(Boolean)[0] ?? ''
|
||||
return HOME_PAGE_TITLES[segment] ?? (segment ? segment.charAt(0).toUpperCase() + segment.slice(1) : 'Your Sites')
|
||||
}
|
||||
|
||||
// Load sidebar only on the client — prevents SSR flash
|
||||
const Sidebar = dynamic(() => import('./Sidebar'), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div
|
||||
className="hidden md:block shrink-0 bg-transparent overflow-hidden relative"
|
||||
style={{ width: 64 }}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-neutral-800/10 to-transparent animate-shimmer" />
|
||||
</div>
|
||||
),
|
||||
})
|
||||
|
||||
// ─── Breadcrumb App Switcher ───────────────────────────────
|
||||
|
||||
function BreadcrumbAppSwitcher() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
const [fixedPos, setFixedPos] = useState<{ left: number; top: number } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
const target = e.target as Node
|
||||
if (
|
||||
ref.current && !ref.current.contains(target) &&
|
||||
(!panelRef.current || !panelRef.current.contains(target))
|
||||
) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (open && buttonRef.current) {
|
||||
const rect = buttonRef.current.getBoundingClientRect()
|
||||
let top = rect.bottom + 4
|
||||
if (panelRef.current) {
|
||||
const maxTop = window.innerHeight - panelRef.current.offsetHeight - 8
|
||||
top = Math.min(top, Math.max(8, maxTop))
|
||||
}
|
||||
setFixedPos({ left: rect.left, top })
|
||||
requestAnimationFrame(() => {
|
||||
if (buttonRef.current) {
|
||||
const r = buttonRef.current.getBoundingClientRect()
|
||||
setFixedPos({ left: r.left, top: r.bottom + 4 })
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const dropdown = (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
ref={panelRef}
|
||||
initial={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="fixed z-50 w-72 bg-neutral-900/65 backdrop-blur-3xl backdrop-saturate-150 supports-[backdrop-filter]:bg-neutral-900/60 border border-white/[0.08] rounded-xl shadow-xl shadow-black/20 overflow-hidden origin-top-left"
|
||||
style={fixedPos ? { left: fixedPos.left, top: fixedPos.top } : undefined}
|
||||
>
|
||||
<div className="p-4">
|
||||
<div className="text-xs font-medium text-neutral-400 tracking-wider mb-3">Ciphera Apps</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{CIPHERA_APPS.map((app) => {
|
||||
const isCurrent = app.id === 'pulse'
|
||||
return (
|
||||
<a
|
||||
key={app.id}
|
||||
href={app.href}
|
||||
onClick={(e) => { if (isCurrent) { e.preventDefault(); setOpen(false) } else setOpen(false) }}
|
||||
className={`group flex flex-col items-center gap-2 p-3 rounded-xl transition-all ${
|
||||
isCurrent ? 'bg-neutral-800/50 cursor-default' : 'hover:bg-neutral-800/50'
|
||||
}`}
|
||||
>
|
||||
<div className="w-10 h-10 flex items-center justify-center shrink-0">
|
||||
<img src={app.icon} alt={app.name} className="w-8 h-8 object-contain" />
|
||||
</div>
|
||||
<span className="text-xs font-medium text-white text-center">{app.name}</span>
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="h-px bg-white/[0.06] my-3" />
|
||||
<a href="https://ciphera.net/products" target="_blank" rel="noopener noreferrer" className="flex items-center justify-center gap-1 text-xs text-brand-orange hover:underline">
|
||||
View all products
|
||||
<ExternalLinkIcon className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
onClick={() => setOpen(!open)}
|
||||
className="inline-flex items-center gap-1 text-neutral-500 hover:text-neutral-300 transition-colors cursor-pointer"
|
||||
>
|
||||
<span>Pulse</span>
|
||||
<CaretDown className="w-3 h-3 shrink-0 translate-y-px" />
|
||||
</button>
|
||||
{typeof document !== 'undefined' ? createPortal(dropdown, document.body) : dropdown}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Breadcrumb Site Picker ────────────────────────────────
|
||||
|
||||
function BreadcrumbSitePicker({ currentSiteId, currentSiteName }: { currentSiteId: string; currentSiteName: string }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
const [sites, setSites] = useState<Site[]>([])
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
const [fixedPos, setFixedPos] = useState<{ left: number; top: number } | null>(null)
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
if (open && sites.length === 0) {
|
||||
listSites().then(setSites).catch(() => {})
|
||||
}
|
||||
}, [open, sites.length])
|
||||
|
||||
const updatePosition = useCallback(() => {
|
||||
if (buttonRef.current) {
|
||||
const rect = buttonRef.current.getBoundingClientRect()
|
||||
let top = rect.bottom + 4
|
||||
if (panelRef.current) {
|
||||
const maxTop = window.innerHeight - panelRef.current.offsetHeight - 8
|
||||
top = Math.min(top, Math.max(8, maxTop))
|
||||
}
|
||||
setFixedPos({ left: rect.left, top })
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
const target = e.target as Node
|
||||
if (
|
||||
ref.current && !ref.current.contains(target) &&
|
||||
(!panelRef.current || !panelRef.current.contains(target))
|
||||
) {
|
||||
if (open) { setOpen(false); setSearch('') }
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
updatePosition()
|
||||
requestAnimationFrame(() => updatePosition())
|
||||
}
|
||||
}, [open, updatePosition])
|
||||
|
||||
const closePicker = () => { setOpen(false); setSearch('') }
|
||||
|
||||
const switchSite = (id: string) => {
|
||||
router.push(`/sites/${id}${pathname.replace(/^\/sites\/[^/]+/, '')}`)
|
||||
closePicker()
|
||||
}
|
||||
|
||||
const filtered = sites.filter(
|
||||
(s) => s.name.toLowerCase().includes(search.toLowerCase()) || s.domain.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
|
||||
const dropdown = (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
ref={panelRef}
|
||||
initial={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="fixed z-50 w-[240px] bg-neutral-900/65 backdrop-blur-3xl backdrop-saturate-150 supports-[backdrop-filter]:bg-neutral-900/60 border border-white/[0.08] rounded-xl shadow-xl shadow-black/20 overflow-hidden origin-top-left"
|
||||
style={fixedPos ? { left: fixedPos.left, top: fixedPos.top } : undefined}
|
||||
>
|
||||
<div className="p-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search sites..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Escape') closePicker() }}
|
||||
className="w-full px-3 py-1.5 text-sm bg-white/[0.04] border border-white/[0.08] rounded-lg outline-none focus:ring-2 focus:ring-brand-orange/40 text-white placeholder:text-neutral-400"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-64 overflow-y-auto">
|
||||
{filtered.map((site) => (
|
||||
<button
|
||||
key={site.id}
|
||||
onClick={() => switchSite(site.id)}
|
||||
className={`w-full flex items-center gap-2.5 px-4 py-2 text-sm text-left ${
|
||||
site.id === currentSiteId
|
||||
? 'bg-brand-orange/10 text-brand-orange font-medium'
|
||||
: 'text-neutral-300 hover:bg-white/[0.06]'
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={`${FAVICON_SERVICE_URL}?domain=${site.domain}&sz=64`}
|
||||
alt=""
|
||||
className="w-5 h-5 rounded object-contain shrink-0"
|
||||
/>
|
||||
<span className="flex flex-col min-w-0">
|
||||
<span className="truncate">{site.name}</span>
|
||||
<span className="text-xs text-neutral-400 truncate">{site.domain}</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{filtered.length === 0 && <p className="px-4 py-3 text-sm text-neutral-400">No sites found</p>}
|
||||
</div>
|
||||
<div className="border-t border-white/[0.06] p-2">
|
||||
<Link href="/sites/new" onClick={() => closePicker()} className="flex items-center gap-2 px-3 py-1.5 text-sm text-brand-orange hover:bg-white/[0.06] rounded-lg">
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
Add new site
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
onClick={() => setOpen(!open)}
|
||||
className="inline-flex items-center gap-1 text-neutral-500 hover:text-neutral-300 transition-colors max-w-[160px] cursor-pointer"
|
||||
>
|
||||
<span className="truncate">{currentSiteName}</span>
|
||||
<CaretDown className="w-3 h-3 shrink-0 translate-y-px" />
|
||||
</button>
|
||||
{typeof document !== 'undefined' ? createPortal(dropdown, document.body) : dropdown}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Glass Top Bar ─────────────────────────────────────────
|
||||
|
||||
function GlassTopBar({ siteId }: { siteId: string | null }) {
|
||||
const { collapsed, toggle } = useSidebar()
|
||||
const { data: realtime } = useRealtime(siteId ?? '')
|
||||
const lastUpdatedRef = useRef<number | null>(null)
|
||||
const [, setTick] = useState(0)
|
||||
const [siteName, setSiteName] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (siteId && realtime) lastUpdatedRef.current = Date.now()
|
||||
}, [siteId, realtime])
|
||||
|
||||
useEffect(() => {
|
||||
if (lastUpdatedRef.current == null) return
|
||||
const timer = setInterval(() => setTick((t) => t + 1), 1000)
|
||||
return () => clearInterval(timer)
|
||||
}, [realtime])
|
||||
|
||||
useEffect(() => {
|
||||
if (!siteId) { setSiteName(null); return }
|
||||
getSite(siteId).then((s) => setSiteName(s.name)).catch(() => {})
|
||||
}, [siteId])
|
||||
|
||||
const dashboardTitle = usePageTitle()
|
||||
const homeTitle = useHomePageTitle()
|
||||
const pageTitle = siteId ? dashboardTitle : homeTitle
|
||||
|
||||
return (
|
||||
<div className="hidden md:flex items-center justify-between shrink-0 px-3 pt-1.5 pb-1">
|
||||
{/* Left: collapse toggle + breadcrumbs */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={toggle}
|
||||
className="w-9 h-9 flex items-center justify-center text-neutral-400 hover:text-white rounded-lg hover:bg-white/[0.06] transition-colors"
|
||||
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
>
|
||||
<SidebarSimple className="w-[18px] h-[18px]" weight={collapsed ? 'regular' : 'fill'} />
|
||||
</button>
|
||||
<nav className="flex items-center gap-1 text-sm font-medium">
|
||||
<BreadcrumbAppSwitcher />
|
||||
<CaretRight className="w-3 h-3 text-neutral-600" />
|
||||
{siteId && siteName ? (
|
||||
<>
|
||||
<Link href="/" className="text-neutral-500 hover:text-neutral-300 transition-colors">Your Sites</Link>
|
||||
<CaretRight className="w-3 h-3 text-neutral-600" />
|
||||
<BreadcrumbSitePicker currentSiteId={siteId} currentSiteName={siteName} />
|
||||
<CaretRight className="w-3 h-3 text-neutral-600" />
|
||||
<span className="text-neutral-400">{pageTitle}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-neutral-400">{pageTitle}</span>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Realtime indicator */}
|
||||
{siteId && lastUpdatedRef.current != null && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-neutral-500">
|
||||
<span className="relative flex h-1.5 w-1.5">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-500 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-green-500" />
|
||||
</span>
|
||||
Live · {formatUpdatedAgo(lastUpdatedRef.current)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function DashboardShell({
|
||||
siteId,
|
||||
children,
|
||||
}: {
|
||||
siteId: string | null
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
const closeMobile = useCallback(() => setMobileOpen(false), [])
|
||||
const openMobile = useCallback(() => setMobileOpen(true), [])
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<div className="flex h-screen overflow-hidden bg-neutral-900/65 backdrop-blur-3xl backdrop-saturate-150 supports-[backdrop-filter]:bg-neutral-900/60">
|
||||
<Sidebar
|
||||
siteId={siteId}
|
||||
mobileOpen={mobileOpen}
|
||||
onMobileClose={closeMobile}
|
||||
onMobileOpen={openMobile}
|
||||
/>
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
{/* Glass top bar — above content only, collapse icon reaches back into sidebar column */}
|
||||
<GlassTopBar siteId={siteId} />
|
||||
{/* Content panel */}
|
||||
<div className="flex-1 flex flex-col min-w-0 mr-2 mb-2 rounded-2xl bg-neutral-950 border border-neutral-800/60 overflow-hidden">
|
||||
<ContentHeader onMobileMenuOpen={openMobile} />
|
||||
<main className="flex-1 overflow-y-auto pt-4">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
162
components/dashboard/DottedMap.tsx
Normal file
162
components/dashboard/DottedMap.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
'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
|
||||
/** Custom formatter for tooltip values. Defaults to formatNumber. */
|
||||
formatValue?: (value: number) => 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, formatValue = formatNumber }: 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]
|
||||
const cx = marker.x + offsetX
|
||||
const cy = marker.y
|
||||
return (
|
||||
<g
|
||||
key={`marker-${marker.x}-${marker.y}-${index}`}
|
||||
className="cursor-pointer"
|
||||
onMouseEnter={(e) => {
|
||||
if (info) {
|
||||
const rect = (e.target as SVGElement).closest('svg')!.getBoundingClientRect()
|
||||
setTooltip({
|
||||
x: rect.left + (cx / MAP_WIDTH) * rect.width,
|
||||
y: rect.top + (cy / MAP_HEIGHT) * rect.height,
|
||||
country: info.country,
|
||||
pageviews: info.pageviews,
|
||||
})
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => setTooltip(null)}
|
||||
>
|
||||
{/* Invisible larger hitbox */}
|
||||
<circle cx={cx} cy={cy} r={2.5} fill="transparent" />
|
||||
{/* Visible dot */}
|
||||
<circle cx={cx} cy={cy} r={marker.size ?? DOT_RADIUS} fill="#FD5E0F" filter="url(#marker-glow)" />
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{tooltip && (
|
||||
<div
|
||||
className="fixed z-50 px-2.5 py-1.5 text-xs font-medium text-white 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">{formatValue(tooltip.pageviews)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -36,14 +36,14 @@ export default function EventProperties({ siteId, eventName, dateRange, onClose
|
||||
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="bg-neutral-900/80 border border-white/[0.08] 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">
|
||||
<h3 className="text-lg font-semibold 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"
|
||||
className="text-neutral-400 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" />
|
||||
@@ -54,11 +54,11 @@ export default function EventProperties({ siteId, eventName, dateRange, onClose
|
||||
{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 key={i} className="h-8 bg-neutral-800 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : keys.length === 0 ? (
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 py-4 text-center">
|
||||
<p className="text-sm text-neutral-400 py-4 text-center">
|
||||
No properties recorded for this event yet.
|
||||
</p>
|
||||
) : (
|
||||
@@ -70,8 +70,8 @@ export default function EventProperties({ siteId, eventName, dateRange, onClose
|
||||
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'
|
||||
? 'bg-brand-orange-button text-white'
|
||||
: 'bg-neutral-800 text-neutral-400 hover:bg-neutral-700'
|
||||
}`}
|
||||
>
|
||||
{k.key}
|
||||
@@ -84,14 +84,14 @@ export default function EventProperties({ siteId, eventName, dateRange, onClose
|
||||
<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">
|
||||
<span className="text-sm font-medium 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="w-full h-1.5 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}%` }}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useCallback } from 'react'
|
||||
import { Modal, Button, Checkbox, Input, Select } from '@ciphera-net/ui'
|
||||
import * as XLSX from 'xlsx'
|
||||
import jsPDF from 'jspdf'
|
||||
import autoTable from 'jspdf-autotable'
|
||||
import type { DailyStat } from './Chart'
|
||||
import { formatNumber, formatDuration } from '@ciphera-net/ui'
|
||||
import { formatDateISO, formatDate, formatDateTime } from '@/lib/utils/formatDate'
|
||||
import { getReferrerDisplayName, mergeReferrersByDisplayName } from '@/lib/utils/icons'
|
||||
import type { TopPage, TopReferrer, CampaignStat } from '@/lib/api/stats'
|
||||
|
||||
@@ -47,8 +48,11 @@ const loadImage = (src: string): Promise<string> => {
|
||||
|
||||
export default function ExportModal({ isOpen, onClose, data, stats, topPages, topReferrers, campaigns }: ExportModalProps) {
|
||||
const [format, setFormat] = useState<ExportFormat>('csv')
|
||||
const [filename, setFilename] = useState(`pulse_export_${new Date().toISOString().split('T')[0]}`)
|
||||
const [filename, setFilename] = useState(`pulse_export_${formatDateISO(new Date())}`)
|
||||
const [includeHeader, setIncludeHeader] = useState(true)
|
||||
const [isExporting, setIsExporting] = useState(false)
|
||||
const [exportDone, setExportDone] = useState(false)
|
||||
const [exportProgress, setExportProgress] = useState({ step: 0, total: 1, label: '' })
|
||||
const [selectedFields, setSelectedFields] = useState<Record<keyof DailyStat, boolean>>({
|
||||
date: true,
|
||||
pageviews: true,
|
||||
@@ -61,300 +65,335 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to
|
||||
setSelectedFields((prev) => ({ ...prev, [field]: checked }))
|
||||
}
|
||||
|
||||
const handleExport = async () => {
|
||||
// Filter fields
|
||||
const fields = (Object.keys(selectedFields) as Array<keyof DailyStat>).filter((k) => selectedFields[k])
|
||||
|
||||
// Prepare data
|
||||
const exportData = data.map((item) => {
|
||||
const filteredItem: Record<string, string | number> = {}
|
||||
fields.forEach((field) => {
|
||||
filteredItem[field] = item[field]
|
||||
})
|
||||
return filteredItem
|
||||
})
|
||||
const finishExport = useCallback(() => {
|
||||
setExportDone(true)
|
||||
setIsExporting(false)
|
||||
setTimeout(() => {
|
||||
setExportDone(false)
|
||||
onClose()
|
||||
}, 600)
|
||||
}, [onClose])
|
||||
|
||||
let content = ''
|
||||
let mimeType = ''
|
||||
let extension = ''
|
||||
// Yield to the UI thread so the browser can paint progress updates
|
||||
const updateProgress = useCallback(async (step: number, total: number, label: string) => {
|
||||
setExportProgress({ step, total, label })
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
}, [])
|
||||
|
||||
if (format === 'csv') {
|
||||
const header = fields.join(',')
|
||||
const rows = exportData.map((row) =>
|
||||
fields.map((field) => {
|
||||
const val = row[field]
|
||||
if (field === 'date' && typeof val === 'string') {
|
||||
return new Date(val).toISOString()
|
||||
const handleExport = () => {
|
||||
setIsExporting(true)
|
||||
setExportProgress({ step: 0, total: 1, label: 'Preparing...' })
|
||||
// Let the browser paint the loading state before starting heavy work
|
||||
requestAnimationFrame(() => {
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
// Filter fields
|
||||
const fields = (Object.keys(selectedFields) as Array<keyof DailyStat>).filter((k) => selectedFields[k])
|
||||
|
||||
// Prepare data
|
||||
const exportData = data.map((item) => {
|
||||
const filteredItem: Record<string, string | number> = {}
|
||||
fields.forEach((field) => {
|
||||
filteredItem[field] = item[field]
|
||||
})
|
||||
return filteredItem
|
||||
})
|
||||
|
||||
let content = ''
|
||||
let mimeType = ''
|
||||
let extension = ''
|
||||
|
||||
if (format === 'csv') {
|
||||
const header = fields.join(',')
|
||||
const rows = exportData.map((row) =>
|
||||
fields.map((field) => {
|
||||
const val = row[field]
|
||||
if (field === 'date' && typeof val === 'string') {
|
||||
return new Date(val).toISOString()
|
||||
}
|
||||
return val
|
||||
}).join(',')
|
||||
)
|
||||
content = (includeHeader ? header + '\n' : '') + rows.join('\n')
|
||||
mimeType = 'text/csv;charset=utf-8;'
|
||||
extension = 'csv'
|
||||
} else if (format === 'xlsx') {
|
||||
await updateProgress(1, 2, 'Building spreadsheet...')
|
||||
const ws = XLSX.utils.json_to_sheet(exportData)
|
||||
const wb = XLSX.utils.book_new()
|
||||
XLSX.utils.book_append_sheet(wb, ws, 'Data')
|
||||
if (campaigns && campaigns.length > 0) {
|
||||
const campaignsSheet = XLSX.utils.json_to_sheet(
|
||||
campaigns.map(c => ({
|
||||
Source: getReferrerDisplayName(c.source),
|
||||
Medium: c.medium || '—',
|
||||
Campaign: c.campaign || '—',
|
||||
Visitors: c.visitors,
|
||||
Pageviews: c.pageviews,
|
||||
}))
|
||||
)
|
||||
XLSX.utils.book_append_sheet(wb, campaignsSheet, 'Campaigns')
|
||||
}
|
||||
return val
|
||||
}).join(',')
|
||||
)
|
||||
content = (includeHeader ? header + '\n' : '') + rows.join('\n')
|
||||
mimeType = 'text/csv;charset=utf-8;'
|
||||
extension = 'csv'
|
||||
} else if (format === 'xlsx') {
|
||||
const ws = XLSX.utils.json_to_sheet(exportData)
|
||||
const wb = XLSX.utils.book_new()
|
||||
XLSX.utils.book_append_sheet(wb, ws, 'Data')
|
||||
if (campaigns && campaigns.length > 0) {
|
||||
const campaignsSheet = XLSX.utils.json_to_sheet(
|
||||
campaigns.map(c => ({
|
||||
Source: getReferrerDisplayName(c.source),
|
||||
Medium: c.medium || '—',
|
||||
Campaign: c.campaign || '—',
|
||||
Visitors: c.visitors,
|
||||
Pageviews: c.pageviews,
|
||||
}))
|
||||
)
|
||||
XLSX.utils.book_append_sheet(wb, campaignsSheet, 'Campaigns')
|
||||
}
|
||||
const wbout = XLSX.write(wb, { bookType: 'xlsx', type: 'array' })
|
||||
const blob = new Blob([wbout], { type: 'application/octet-stream' })
|
||||
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.setAttribute('href', url)
|
||||
link.setAttribute('download', `${filename || 'export'}.${extension || 'xlsx'}`)
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
onClose()
|
||||
return
|
||||
} else if (format === 'pdf') {
|
||||
const doc = new jsPDF()
|
||||
|
||||
// Header Section
|
||||
try {
|
||||
// Logo
|
||||
const logoData = await loadImage('/pulse_icon_no_margins.png')
|
||||
doc.addImage(logoData, 'PNG', 14, 12, 12, 12) // x, y, w, h
|
||||
|
||||
// Title
|
||||
doc.setFontSize(22)
|
||||
doc.setTextColor(249, 115, 22) // Brand Orange #F97316
|
||||
doc.text('Pulse', 32, 20)
|
||||
|
||||
doc.setFontSize(12)
|
||||
doc.setTextColor(100, 100, 100)
|
||||
doc.text('Analytics Export', 32, 25)
|
||||
} catch (e) {
|
||||
// Fallback if logo fails
|
||||
doc.setFontSize(22)
|
||||
doc.setTextColor(249, 115, 22)
|
||||
doc.text('Pulse Analytics', 14, 20)
|
||||
}
|
||||
const wbout = XLSX.write(wb, { bookType: 'xlsx', type: 'array' })
|
||||
const blob = new Blob([wbout], { type: 'application/octet-stream' })
|
||||
|
||||
// Metadata (Top Right)
|
||||
doc.setFontSize(9)
|
||||
doc.setTextColor(150, 150, 150)
|
||||
const generatedDate = new Date().toLocaleDateString()
|
||||
const dateRange = data.length > 0
|
||||
? `${new Date(data[0].date).toLocaleDateString()} - ${new Date(data[data.length - 1].date).toLocaleDateString()}`
|
||||
: generatedDate
|
||||
|
||||
const pageWidth = doc.internal.pageSize.width
|
||||
doc.text(`Generated: ${generatedDate}`, pageWidth - 14, 18, { align: 'right' })
|
||||
doc.text(`Range: ${dateRange}`, pageWidth - 14, 23, { align: 'right' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.setAttribute('href', url)
|
||||
link.setAttribute('download', `${filename || 'export'}.${extension || 'xlsx'}`)
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
finishExport()
|
||||
return
|
||||
} else if (format === 'pdf') {
|
||||
const totalSteps = 3 + (topPages?.length ? 1 : 0) + (topReferrers?.length ? 1 : 0) + (campaigns?.length ? 1 : 0)
|
||||
let currentStep = 0
|
||||
const doc = new jsPDF()
|
||||
|
||||
let startY = 35
|
||||
// Header Section
|
||||
await updateProgress(++currentStep, totalSteps, 'Building header...')
|
||||
try {
|
||||
// Logo
|
||||
const logoData = await loadImage('/pulse_icon_no_margins.png')
|
||||
doc.addImage(logoData, 'PNG', 14, 12, 12, 12) // x, y, w, h
|
||||
|
||||
// Summary Section
|
||||
if (stats) {
|
||||
const summaryY = 35
|
||||
const cardWidth = (pageWidth - 28 - 15) / 4 // 4 cards with 5mm gap
|
||||
const cardHeight = 20
|
||||
|
||||
const drawCard = (x: number, label: string, value: string) => {
|
||||
doc.setFillColor(255, 247, 237) // Very light orange
|
||||
doc.setDrawColor(254, 215, 170) // Light orange border
|
||||
doc.roundedRect(x, summaryY, cardWidth, cardHeight, 2, 2, 'FD')
|
||||
|
||||
doc.setFontSize(8)
|
||||
// Title
|
||||
doc.setFontSize(22)
|
||||
doc.setTextColor(249, 115, 22) // Brand Orange #F97316
|
||||
doc.text('Pulse', 32, 20)
|
||||
|
||||
doc.setFontSize(12)
|
||||
doc.setTextColor(100, 100, 100)
|
||||
doc.text('Analytics Export', 32, 25)
|
||||
} catch (e) {
|
||||
// Fallback if logo fails
|
||||
doc.setFontSize(22)
|
||||
doc.setTextColor(249, 115, 22)
|
||||
doc.text('Pulse Analytics', 14, 20)
|
||||
}
|
||||
|
||||
// Metadata (Top Right)
|
||||
doc.setFontSize(9)
|
||||
doc.setTextColor(150, 150, 150)
|
||||
doc.text(label, x + 3, summaryY + 6)
|
||||
|
||||
doc.setFontSize(12)
|
||||
doc.setTextColor(23, 23, 23) // Neutral 900
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.text(value, x + 3, summaryY + 14)
|
||||
doc.setFont('helvetica', 'normal')
|
||||
}
|
||||
const generatedDate = formatDate(new Date())
|
||||
const dateRange = data.length > 0
|
||||
? `${formatDate(new Date(data[0].date))} - ${formatDate(new Date(data[data.length - 1].date))}`
|
||||
: generatedDate
|
||||
|
||||
drawCard(14, 'Unique Visitors', formatNumber(stats.visitors))
|
||||
drawCard(14 + cardWidth + 5, 'Total Pageviews', formatNumber(stats.pageviews))
|
||||
drawCard(14 + (cardWidth + 5) * 2, 'Bounce Rate', `${Math.round(stats.bounce_rate)}%`)
|
||||
drawCard(14 + (cardWidth + 5) * 3, 'Avg Duration', formatDuration(stats.avg_duration))
|
||||
|
||||
startY = 65 // Move table down
|
||||
}
|
||||
const pageWidth = doc.internal.pageSize.width
|
||||
doc.text(`Generated: ${generatedDate}`, pageWidth - 14, 18, { align: 'right' })
|
||||
doc.text(`Range: ${dateRange}`, pageWidth - 14, 23, { align: 'right' })
|
||||
|
||||
// Check if data is hourly (same date for multiple rows)
|
||||
const isHourly = data.length > 1 && data[0].date.split('T')[0] === data[1].date.split('T')[0]
|
||||
let startY = 35
|
||||
|
||||
const tableData = exportData.map(row =>
|
||||
fields.map(field => {
|
||||
const val = row[field]
|
||||
if (field === 'date' && typeof val === 'string') {
|
||||
const date = new Date(val)
|
||||
return isHourly
|
||||
? date.toLocaleString('en-US', { month: 'numeric', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' })
|
||||
: date.toLocaleDateString()
|
||||
// Summary Section
|
||||
if (stats) {
|
||||
const summaryY = 35
|
||||
const cardWidth = (pageWidth - 28 - 15) / 4 // 4 cards with 5mm gap
|
||||
const cardHeight = 20
|
||||
|
||||
const drawCard = (x: number, label: string, value: string) => {
|
||||
doc.setFillColor(255, 247, 237) // Very light orange
|
||||
doc.setDrawColor(254, 215, 170) // Light orange border
|
||||
doc.roundedRect(x, summaryY, cardWidth, cardHeight, 2, 2, 'FD')
|
||||
|
||||
doc.setFontSize(8)
|
||||
doc.setTextColor(150, 150, 150)
|
||||
doc.text(label, x + 3, summaryY + 6)
|
||||
|
||||
doc.setFontSize(12)
|
||||
doc.setTextColor(23, 23, 23) // Neutral 900
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.text(value, x + 3, summaryY + 14)
|
||||
doc.setFont('helvetica', 'normal')
|
||||
}
|
||||
|
||||
drawCard(14, 'Unique Visitors', formatNumber(stats.visitors))
|
||||
drawCard(14 + cardWidth + 5, 'Total Pageviews', formatNumber(stats.pageviews))
|
||||
drawCard(14 + (cardWidth + 5) * 2, 'Bounce Rate', `${Math.round(stats.bounce_rate)}%`)
|
||||
drawCard(14 + (cardWidth + 5) * 3, 'Avg Duration', formatDuration(stats.avg_duration))
|
||||
|
||||
startY = 65 // Move table down
|
||||
}
|
||||
|
||||
await updateProgress(++currentStep, totalSteps, 'Generating data table...')
|
||||
// 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 ? formatDateTime(date) : formatDate(date)
|
||||
}
|
||||
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) {
|
||||
await updateProgress(++currentStep, totalSteps, 'Adding top pages...')
|
||||
// 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) {
|
||||
await updateProgress(++currentStep, totalSteps, 'Adding top referrers...')
|
||||
// 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) {
|
||||
await updateProgress(++currentStep, totalSteps, 'Adding campaigns...')
|
||||
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] },
|
||||
})
|
||||
}
|
||||
|
||||
await updateProgress(totalSteps, totalSteps, 'Saving PDF...')
|
||||
doc.save(`${filename || 'export'}.pdf`)
|
||||
finishExport()
|
||||
return
|
||||
} else {
|
||||
content = JSON.stringify(exportData, null, 2)
|
||||
mimeType = 'application/json;charset=utf-8;'
|
||||
extension = 'json'
|
||||
}
|
||||
if (typeof val === 'number') {
|
||||
if (field === 'bounce_rate') return `${Math.round(val)}%`
|
||||
if (field === 'avg_duration') return formatDuration(val)
|
||||
if (field === 'pageviews' || field === 'visitors') return formatNumber(val)
|
||||
}
|
||||
return val ?? ''
|
||||
})
|
||||
)
|
||||
|
||||
autoTable(doc, {
|
||||
startY: startY,
|
||||
head: [fields.map(f => f.charAt(0).toUpperCase() + f.slice(1).replace('_', ' '))],
|
||||
body: tableData as (string | number)[][],
|
||||
styles: {
|
||||
font: 'helvetica',
|
||||
fontSize: 9,
|
||||
cellPadding: 4,
|
||||
lineColor: [229, 231, 235], // Neutral 200
|
||||
lineWidth: 0.1,
|
||||
},
|
||||
headStyles: {
|
||||
fillColor: [249, 115, 22], // Brand Orange
|
||||
textColor: [255, 255, 255],
|
||||
fontStyle: 'bold',
|
||||
halign: 'left'
|
||||
},
|
||||
columnStyles: {
|
||||
0: { halign: 'left' }, // Date
|
||||
1: { halign: 'right' }, // Pageviews
|
||||
2: { halign: 'right' }, // Visitors
|
||||
3: { halign: 'right' }, // Bounce Rate
|
||||
4: { halign: 'right' }, // Avg Duration
|
||||
},
|
||||
alternateRowStyles: {
|
||||
fillColor: [255, 250, 245], // Very very light orange
|
||||
},
|
||||
didDrawPage: (data) => {
|
||||
// Footer
|
||||
const pageSize = doc.internal.pageSize
|
||||
const pageHeight = pageSize.height ? pageSize.height : pageSize.getHeight()
|
||||
doc.setFontSize(8)
|
||||
doc.setTextColor(150, 150, 150)
|
||||
doc.text('Powered by Ciphera', 14, pageHeight - 10)
|
||||
|
||||
const str = 'Page ' + doc.getNumberOfPages()
|
||||
doc.text(str, pageSize.width - 14, pageHeight - 10, { align: 'right' })
|
||||
const blob = new Blob([content], { type: mimeType })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.setAttribute('href', url)
|
||||
link.setAttribute('download', `${filename || 'export'}.${extension}`)
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
|
||||
finishExport()
|
||||
} catch (e) {
|
||||
console.error('Export failed:', e)
|
||||
} finally {
|
||||
setIsExporting(false)
|
||||
}
|
||||
})
|
||||
|
||||
let finalY = doc.lastAutoTable.finalY + 10
|
||||
|
||||
// Top Pages Table
|
||||
if (topPages && topPages.length > 0) {
|
||||
// Check if we need a new page
|
||||
if (finalY + 40 > doc.internal.pageSize.height) {
|
||||
doc.addPage()
|
||||
finalY = 20
|
||||
}
|
||||
|
||||
doc.setFontSize(14)
|
||||
doc.setTextColor(23, 23, 23)
|
||||
doc.text('Top Pages', 14, finalY)
|
||||
finalY += 5
|
||||
|
||||
const pagesData = topPages.slice(0, 10).map(p => [p.path, formatNumber(p.pageviews)])
|
||||
|
||||
autoTable(doc, {
|
||||
startY: finalY,
|
||||
head: [['Path', 'Pageviews']],
|
||||
body: pagesData,
|
||||
styles: { font: 'helvetica', fontSize: 9, cellPadding: 3 },
|
||||
headStyles: { fillColor: [249, 115, 22], textColor: [255, 255, 255], fontStyle: 'bold' },
|
||||
columnStyles: { 1: { halign: 'right' } },
|
||||
alternateRowStyles: { fillColor: [255, 250, 245] },
|
||||
})
|
||||
|
||||
finalY = doc.lastAutoTable.finalY + 10
|
||||
}
|
||||
|
||||
// Top Referrers Table
|
||||
if (topReferrers && topReferrers.length > 0) {
|
||||
// Check if we need a new page
|
||||
if (finalY + 40 > doc.internal.pageSize.height) {
|
||||
doc.addPage()
|
||||
finalY = 20
|
||||
}
|
||||
|
||||
doc.setFontSize(14)
|
||||
doc.setTextColor(23, 23, 23)
|
||||
doc.text('Top Referrers', 14, finalY)
|
||||
finalY += 5
|
||||
|
||||
const mergedReferrers = mergeReferrersByDisplayName(topReferrers)
|
||||
const referrersData = mergedReferrers.slice(0, 10).map(r => [getReferrerDisplayName(r.referrer), formatNumber(r.pageviews)])
|
||||
|
||||
autoTable(doc, {
|
||||
startY: finalY,
|
||||
head: [['Referrer', 'Pageviews']],
|
||||
body: referrersData,
|
||||
styles: { font: 'helvetica', fontSize: 9, cellPadding: 3 },
|
||||
headStyles: { fillColor: [249, 115, 22], textColor: [255, 255, 255], fontStyle: 'bold' },
|
||||
columnStyles: { 1: { halign: 'right' } },
|
||||
alternateRowStyles: { fillColor: [255, 250, 245] },
|
||||
})
|
||||
|
||||
finalY = doc.lastAutoTable.finalY + 10
|
||||
}
|
||||
|
||||
// Campaigns Table
|
||||
if (campaigns && campaigns.length > 0) {
|
||||
if (finalY + 40 > doc.internal.pageSize.height) {
|
||||
doc.addPage()
|
||||
finalY = 20
|
||||
}
|
||||
doc.setFontSize(14)
|
||||
doc.setTextColor(23, 23, 23)
|
||||
doc.text('Campaigns', 14, finalY)
|
||||
finalY += 5
|
||||
const campaignsData = campaigns.slice(0, 10).map(c => [
|
||||
getReferrerDisplayName(c.source),
|
||||
c.medium || '—',
|
||||
c.campaign || '—',
|
||||
formatNumber(c.visitors),
|
||||
formatNumber(c.pageviews),
|
||||
])
|
||||
autoTable(doc, {
|
||||
startY: finalY,
|
||||
head: [['Source', 'Medium', 'Campaign', 'Visitors', 'Pageviews']],
|
||||
body: campaignsData,
|
||||
styles: { font: 'helvetica', fontSize: 9, cellPadding: 3 },
|
||||
headStyles: { fillColor: [249, 115, 22], textColor: [255, 255, 255], fontStyle: 'bold' },
|
||||
columnStyles: { 3: { halign: 'right' }, 4: { halign: 'right' } },
|
||||
alternateRowStyles: { fillColor: [255, 250, 245] },
|
||||
})
|
||||
}
|
||||
|
||||
doc.save(`${filename || 'export'}.pdf`)
|
||||
onClose()
|
||||
return
|
||||
} else {
|
||||
content = JSON.stringify(exportData, null, 2)
|
||||
mimeType = 'application/json;charset=utf-8;'
|
||||
extension = 'json'
|
||||
}
|
||||
|
||||
const blob = new Blob([content], { type: mimeType })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.setAttribute('href', url)
|
||||
link.setAttribute('download', `${filename || 'export'}.${extension}`)
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
|
||||
onClose()
|
||||
}, 0)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -438,13 +477,29 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress Bar */}
|
||||
{(isExporting || exportDone) && (
|
||||
<div className="space-y-2 pt-2">
|
||||
<div className="flex items-center justify-between text-xs text-neutral-400">
|
||||
<span>{exportDone ? 'Export complete' : exportProgress.label}</span>
|
||||
<span>{exportDone ? '100%' : `${Math.round((exportProgress.step / exportProgress.total) * 100)}%`}</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full rounded-full bg-neutral-100 dark:bg-neutral-800 overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-300 ease-out ${exportDone ? 'bg-green-500' : 'bg-brand-orange'}`}
|
||||
style={{ width: exportDone ? '100%' : `${(exportProgress.step / exportProgress.total) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
<Button variant="secondary" onClick={onClose} disabled={isExporting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleExport}>
|
||||
Export Data
|
||||
<Button variant="primary" onClick={handleExport} disabled={isExporting || exportDone}>
|
||||
{exportDone ? '✓ Done' : isExporting ? 'Exporting...' : 'Export Data'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,7 @@ export default function FilterBar({ filters, onRemove, onClear }: FilterBarProps
|
||||
<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"
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg bg-brand-orange-button text-white hover:bg-brand-orange-button-hover transition-colors cursor-pointer group"
|
||||
title={`Remove filter: ${filterLabel(f)}`}
|
||||
>
|
||||
<span>{filterLabel(f)}</span>
|
||||
@@ -29,7 +29,7 @@ export default function FilterBar({ filters, onRemove, onClear }: FilterBarProps
|
||||
{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"
|
||||
className="px-2 py-1.5 text-xs font-medium 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>
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import Link from 'next/link'
|
||||
import { formatNumber } from '@ciphera-net/ui'
|
||||
import { Target } from '@phosphor-icons/react'
|
||||
import { BookOpenIcon, ArrowRightIcon } from '@ciphera-net/ui'
|
||||
import type { GoalCountStat } from '@/lib/api/stats'
|
||||
|
||||
@@ -15,46 +16,61 @@ const LIMIT = 10
|
||||
export default function GoalStats({ goalCounts, onSelectEvent }: GoalStatsProps) {
|
||||
const list = (goalCounts || []).slice(0, LIMIT)
|
||||
const hasData = list.length > 0
|
||||
const total = list.reduce((sum, r) => sum + r.count, 0)
|
||||
const emptySlots = Math.max(0, 6 - list.length)
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
|
||||
<div className="bg-neutral-900/80 border border-white/[0.08] rounded-2xl p-6 h-full flex flex-col">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
|
||||
Goals & Events
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<Target className="w-5 h-5 text-neutral-400 dark:text-neutral-500" weight="bold" />
|
||||
<h3 className="text-lg font-semibold text-white">
|
||||
Goals & Events
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasData ? (
|
||||
<div className="space-y-2 flex-1 min-h-[200px]">
|
||||
<div className="flex-1 min-h-[270px]">
|
||||
{list.map((row) => (
|
||||
<div
|
||||
key={row.event_name}
|
||||
onClick={() => onSelectEvent?.(row.event_name)}
|
||||
className={`flex items-center justify-between py-2 px-3 rounded-lg bg-neutral-50 dark:bg-neutral-800/50 hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors${onSelectEvent ? ' cursor-pointer' : ''}`}
|
||||
className={`flex items-center justify-between h-9 group hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors${onSelectEvent ? ' cursor-pointer' : ''}`}
|
||||
>
|
||||
<span className="text-sm font-medium text-neutral-900 dark:text-white truncate">
|
||||
{row.display_name ?? row.event_name.replace(/_/g, ' ')}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-brand-orange tabular-nums">
|
||||
{formatNumber(row.count)}
|
||||
</span>
|
||||
<div className="flex items-center flex-1 min-w-0">
|
||||
<span className="text-sm font-medium text-white truncate">
|
||||
{row.display_name ?? row.event_name.replace(/_/g, ' ')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
|
||||
{total > 0 ? `${Math.round((row.count / total) * 100)}%` : ''}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-neutral-400 tabular-nums">
|
||||
{formatNumber(row.count)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{Array.from({ length: emptySlots }).map((_, i) => (
|
||||
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 min-h-[200px] flex flex-col items-center justify-center text-center px-6 py-8 gap-4">
|
||||
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
|
||||
<BookOpenIcon className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
|
||||
<div 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-800 p-4">
|
||||
<BookOpenIcon className="w-8 h-8 text-neutral-400" />
|
||||
</div>
|
||||
<h4 className="font-semibold text-neutral-900 dark:text-white">
|
||||
<h4 className="font-semibold text-white">
|
||||
Need help tracking goals?
|
||||
</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">pulse.track('event_name')</code> where actions happen on your site, then see counts here. Check our guide for step-by-step instructions.
|
||||
<p className="text-sm text-neutral-400 max-w-md">
|
||||
Add <code className="px-1.5 py-0.5 rounded bg-neutral-700 text-xs font-mono">pulse.track('event_name')</code> where actions happen on your site, then see counts here. Check our guide for step-by-step instructions.
|
||||
</p>
|
||||
<Link
|
||||
href="/installation"
|
||||
className="inline-flex items-center gap-2 text-sm font-medium text-brand-orange hover:text-brand-orange/90 hover:underline focus:outline-none focus:ring-2 focus:ring-brand-orange/20 rounded"
|
||||
className="inline-flex items-center gap-2 text-sm font-medium text-brand-orange hover:text-brand-orange/90 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange/20 rounded"
|
||||
>
|
||||
Read documentation
|
||||
<ArrowRightIcon className="w-4 h-4" />
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { motion } from 'framer-motion'
|
||||
import { logger } from '@/lib/utils/logger'
|
||||
import { formatNumber } from '@ciphera-net/ui'
|
||||
import { useTabListKeyboard } from '@/lib/hooks/useTabListKeyboard'
|
||||
import * as Flags from 'country-flag-icons/react/3x2'
|
||||
import iso3166 from 'iso-3166-2'
|
||||
import WorldMap from './WorldMap'
|
||||
import { Modal, GlobeIcon } from '@ciphera-net/ui'
|
||||
|
||||
const DottedMap = dynamic(() => import('./DottedMap'), { ssr: false })
|
||||
import Link from 'next/link'
|
||||
import { Modal, GlobeIcon, ArrowRightIcon } from '@ciphera-net/ui'
|
||||
import { ListSkeleton } from '@/components/skeletons'
|
||||
import { SiTorproject } from 'react-icons/si'
|
||||
import { FaUserSecret, FaSatellite } from 'react-icons/fa'
|
||||
import VirtualList from './VirtualList'
|
||||
import { ShieldCheck, Detective, Broadcast, MapPin, FrameCornersIcon } from '@phosphor-icons/react'
|
||||
import { getCountries, getCities, getRegions } from '@/lib/api/stats'
|
||||
import { type DimensionFilter } from '@/lib/filters'
|
||||
|
||||
@@ -31,13 +35,28 @@ const LIMIT = 7
|
||||
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>('countries')
|
||||
const handleTabKeyDown = useTabListKeyboard()
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
const [modalSearch, setModalSearch] = useState('')
|
||||
type LocationItem = { country?: string; city?: string; region?: string; pageviews: number }
|
||||
const [fullData, setFullData] = useState<LocationItem[]>([])
|
||||
const [isLoadingFull, setIsLoadingFull] = useState(false)
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [inView, setInView] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current
|
||||
if (!el) return
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => { if (entry.isIntersecting) setInView(true) },
|
||||
{ rootMargin: '200px' }
|
||||
)
|
||||
observer.observe(el)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (isModalOpen) {
|
||||
const fetchData = async () => {
|
||||
@@ -69,15 +88,15 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
|
||||
|
||||
switch (countryCode) {
|
||||
case 'T1':
|
||||
return <SiTorproject className="w-5 h-5 text-purple-600 dark:text-purple-400" />
|
||||
return <ShieldCheck className="w-5 h-5 text-purple-600 dark:text-purple-400" />
|
||||
case 'A1':
|
||||
return <FaUserSecret className="w-5 h-5 text-neutral-600 dark:text-neutral-400" />
|
||||
return <Detective className="w-5 h-5 text-neutral-400" />
|
||||
case 'A2':
|
||||
return <FaSatellite className="w-5 h-5 text-blue-500 dark:text-blue-400" />
|
||||
return <Broadcast className="w-5 h-5 text-blue-500 dark:text-blue-400" />
|
||||
case 'O1':
|
||||
case 'EU':
|
||||
case 'AP':
|
||||
return <GlobeIcon className="w-5 h-5 text-neutral-500 dark:text-neutral-400" />
|
||||
return <GlobeIcon className="w-5 h-5 text-neutral-400" />
|
||||
}
|
||||
|
||||
const FlagComponent = (Flags as Record<string, React.ComponentType<{ className?: string }>>)[countryCode]
|
||||
@@ -174,15 +193,16 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
|
||||
})
|
||||
}
|
||||
|
||||
const rawData = activeTab === 'map' ? [] : getData()
|
||||
const isVisualTab = activeTab === 'map'
|
||||
const rawData = isVisualTab ? [] : getData()
|
||||
const data = filterUnknown(rawData)
|
||||
const totalPageviews = data.reduce((sum, item) => sum + item.pageviews, 0)
|
||||
const hasData = activeTab === 'map'
|
||||
const hasData = isVisualTab
|
||||
? (countries && filterUnknown(countries).length > 0)
|
||||
: (data && data.length > 0)
|
||||
const displayedData = (activeTab !== 'map' && hasData) ? data.slice(0, LIMIT) : []
|
||||
const displayedData = (!isVisualTab && hasData) ? data.slice(0, LIMIT) : []
|
||||
const emptySlots = Math.max(0, LIMIT - displayedData.length)
|
||||
const showViewAll = activeTab !== 'map' && hasData && data.length > LIMIT
|
||||
const showViewAll = !isVisualTab && hasData && data.length > LIMIT
|
||||
|
||||
const getDisabledMessage = () => {
|
||||
if (geoDataLevel === 'none') {
|
||||
@@ -196,25 +216,44 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
|
||||
<div ref={containerRef} className="bg-neutral-900/80 border border-white/[0.08] rounded-2xl p-6 h-full flex flex-col">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
|
||||
Locations
|
||||
</h3>
|
||||
<div className="flex gap-1" role="tablist" aria-label="Location view tabs" onKeyDown={handleTabKeyDown}>
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="w-5 h-5 text-neutral-400 dark:text-neutral-500" weight="bold" />
|
||||
<h3 className="text-lg font-semibold text-white">
|
||||
Locations
|
||||
</h3>
|
||||
{showViewAll && (
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="p-1.5 text-neutral-400 dark:text-neutral-500 hover:text-brand-orange dark:hover:text-brand-orange hover:bg-neutral-800 transition-all cursor-pointer rounded-lg"
|
||||
aria-label="View all locations"
|
||||
>
|
||||
<FrameCornersIcon className="w-4 h-4" weight="bold" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1 overflow-x-auto scrollbar-hide" role="tablist" aria-label="Location view tabs" onKeyDown={handleTabKeyDown}>
|
||||
{(['map', 'countries', 'regions', 'cities'] as Tab[]).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
role="tab"
|
||||
aria-selected={activeTab === tab}
|
||||
className={`px-2.5 py-1 text-xs font-medium transition-colors capitalize focus:outline-none focus:ring-2 focus:ring-brand-orange rounded cursor-pointer border-b-2 ${
|
||||
className={`relative px-2.5 py-1 text-xs font-medium transition-colors capitalize focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange rounded cursor-pointer ${
|
||||
activeTab === tab
|
||||
? 'border-brand-orange text-neutral-900 dark:text-white'
|
||||
: 'border-transparent text-neutral-400 dark:text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300'
|
||||
? 'text-white'
|
||||
: 'text-neutral-400 dark:text-neutral-500 hover:text-neutral-300'
|
||||
}`}
|
||||
>
|
||||
{tab}
|
||||
{activeTab === tab && (
|
||||
<motion.div
|
||||
layoutId="locationsTab"
|
||||
className="absolute inset-x-0 -bottom-px h-0.5 bg-brand-orange"
|
||||
transition={{ type: 'spring', stiffness: 500, damping: 35 }}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -223,20 +262,29 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
|
||||
<div className="space-y-2 flex-1 min-h-[270px]">
|
||||
{isTabDisabled() ? (
|
||||
<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-400 text-sm">{getDisabledMessage()}</p>
|
||||
</div>
|
||||
) : activeTab === 'map' ? (
|
||||
hasData ? <WorldMap data={filterUnknown(countries) as { country: string; pageviews: number }[]} /> : (
|
||||
) : isVisualTab ? (
|
||||
hasData ? (
|
||||
inView ? <DottedMap data={filterUnknown(countries) as { country: string; pageviews: number }[]} /> : null
|
||||
) : (
|
||||
<div className="h-full flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
|
||||
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
|
||||
<GlobeIcon className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
|
||||
<div className="rounded-full bg-neutral-800 p-4">
|
||||
<GlobeIcon className="w-8 h-8 text-neutral-400" />
|
||||
</div>
|
||||
<h4 className="font-semibold text-neutral-900 dark:text-white">
|
||||
<h4 className="font-semibold text-white">
|
||||
No location data yet
|
||||
</h4>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs">
|
||||
<p className="text-sm text-neutral-400 max-w-xs">
|
||||
Visitor locations will appear here based on anonymous geographic data.
|
||||
</p>
|
||||
<Link
|
||||
href="/installation"
|
||||
className="inline-flex items-center gap-2 text-sm font-medium text-brand-orange hover:text-brand-orange/90 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange/20 rounded"
|
||||
>
|
||||
Install tracking script
|
||||
<ArrowRightIcon className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
@@ -246,13 +294,19 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
|
||||
const dim = TAB_TO_DIMENSION[activeTab]
|
||||
const filterValue = activeTab === 'countries' ? item.country : activeTab === 'regions' ? item.region : item.city
|
||||
const canFilter = onFilter && dim && filterValue
|
||||
const maxPv = displayedData[0]?.pageviews ?? 0
|
||||
const barWidth = maxPv > 0 ? (item.pageviews / maxPv) * 75 : 0
|
||||
return (
|
||||
<div
|
||||
key={`${item.country ?? ''}-${item.region ?? ''}-${item.city ?? ''}`}
|
||||
onClick={() => canFilter && onFilter({ dimension: dim, operator: 'is', values: [filterValue!] })}
|
||||
className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors${canFilter ? ' cursor-pointer' : ''}`}
|
||||
className={`relative flex items-center justify-between h-9 group hover:bg-neutral-800/50 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">
|
||||
<div
|
||||
className="absolute inset-y-0.5 left-0.5 bg-brand-orange/15 dark:bg-brand-orange/40 rounded-md transition-all"
|
||||
style={{ width: `${barWidth}%` }}
|
||||
/>
|
||||
<div className="relative flex-1 truncate text-white flex items-center gap-3">
|
||||
<span className="shrink-0">{getFlagComponent(item.country ?? '')}</span>
|
||||
<span className="truncate">
|
||||
{activeTab === 'countries' ? getCountryName(item.country ?? '') :
|
||||
@@ -260,42 +314,30 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
|
||||
getCityName(item.city ?? '')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
<div className="relative 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">
|
||||
<span className="text-sm font-semibold text-neutral-400">
|
||||
{formatNumber(item.pageviews)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{showViewAll ? (
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="flex items-center justify-center gap-1.5 h-9 w-full text-xs font-medium text-neutral-400 dark:text-neutral-500 hover:text-brand-orange dark:hover:text-brand-orange transition-colors cursor-pointer rounded-lg px-2 -mx-2"
|
||||
>
|
||||
View all
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
) : (
|
||||
Array.from({ length: emptySlots }).map((_, i) => (
|
||||
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
|
||||
))
|
||||
)}
|
||||
{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">
|
||||
<GlobeIcon className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
|
||||
<div className="rounded-full bg-neutral-800 p-4">
|
||||
<GlobeIcon className="w-8 h-8 text-neutral-400" />
|
||||
</div>
|
||||
<h4 className="font-semibold text-neutral-900 dark:text-white">
|
||||
<h4 className="font-semibold text-white">
|
||||
No location data yet
|
||||
</h4>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs">
|
||||
<p className="text-sm text-neutral-400 max-w-xs">
|
||||
Visitor locations will appear here based on anonymous geographic data.
|
||||
</p>
|
||||
</div>
|
||||
@@ -306,31 +348,69 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
|
||||
|
||||
<Modal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
title={`Locations - ${activeTab.charAt(0).toUpperCase() + activeTab.slice(1)}`}
|
||||
onClose={() => { setIsModalOpen(false); setModalSearch('') }}
|
||||
title={activeTab.charAt(0).toUpperCase() + activeTab.slice(1)}
|
||||
className="max-w-2xl"
|
||||
>
|
||||
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-2">
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={modalSearch}
|
||||
onChange={(e) => setModalSearch(e.target.value)}
|
||||
placeholder="Search locations..."
|
||||
className="w-full px-3 py-2 mb-3 text-sm bg-neutral-800 border border-neutral-700 rounded-lg text-white placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-brand-orange/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-[80vh]">
|
||||
{isLoadingFull ? (
|
||||
<div className="py-4">
|
||||
<ListSkeleton rows={10} />
|
||||
</div>
|
||||
) : (
|
||||
(fullData.length > 0 ? fullData : data).map((item) => (
|
||||
<div key={`${item.country ?? ''}-${item.region ?? ''}-${item.city ?? ''}`} className="flex items-center justify-between py-2 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors">
|
||||
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
|
||||
<span className="shrink-0">{getFlagComponent(item.country ?? '')}</span>
|
||||
<span className="truncate">
|
||||
{activeTab === 'countries' ? getCountryName(item.country ?? '') :
|
||||
activeTab === 'regions' ? getRegionName(item.region ?? '', item.country ?? '') :
|
||||
getCityName(item.city ?? '')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-neutral-600 dark:text-neutral-400 ml-4">
|
||||
{formatNumber(item.pageviews)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
) : (() => {
|
||||
const rawModalData = fullData.length > 0 ? fullData : data
|
||||
const search = modalSearch.toLowerCase()
|
||||
const modalData = !modalSearch ? rawModalData : rawModalData.filter(item => {
|
||||
const label = activeTab === 'countries' ? getCountryName(item.country ?? '') : activeTab === 'regions' ? getRegionName(item.region ?? '', item.country ?? '') : getCityName(item.city ?? '')
|
||||
return label.toLowerCase().includes(search)
|
||||
})
|
||||
const modalTotal = modalData.reduce((sum, item) => sum + item.pageviews, 0)
|
||||
return (
|
||||
<VirtualList
|
||||
items={modalData}
|
||||
estimateSize={36}
|
||||
className="max-h-[80vh] overflow-y-auto pr-2"
|
||||
renderItem={(item) => {
|
||||
const dim = TAB_TO_DIMENSION[activeTab]
|
||||
const filterValue = activeTab === 'countries' ? item.country : activeTab === 'regions' ? item.region : item.city
|
||||
const canFilter = onFilter && dim && filterValue
|
||||
return (
|
||||
<div
|
||||
key={`${item.country ?? ''}-${item.region ?? ''}-${item.city ?? ''}`}
|
||||
onClick={() => { if (canFilter) { onFilter({ dimension: dim, operator: 'is', values: [filterValue!] }); setIsModalOpen(false) } }}
|
||||
className={`flex items-center justify-between h-9 group hover:bg-neutral-800 rounded-lg px-2 transition-colors${canFilter ? ' cursor-pointer' : ''}`}
|
||||
>
|
||||
<div className="flex-1 truncate 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-400">
|
||||
{formatNumber(item.pageviews)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
|
||||
296
components/dashboard/PeakHours.tsx
Normal file
296
components/dashboard/PeakHours.tsx
Normal file
@@ -0,0 +1,296 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useMemo, useRef, type CSSProperties } from 'react'
|
||||
import { Clock } from '@phosphor-icons/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 DAYS_FULL = ['Mondays', 'Tuesdays', 'Wednesdays', 'Thursdays', 'Fridays', 'Saturdays', 'Sundays']
|
||||
const BUCKETS = 12 // 2-hour buckets
|
||||
// Label at bucket index 0=00:00, 3=06:00, 6=12:00, 9=18:00
|
||||
const BUCKET_LABELS: Record<number, string> = { 0: '00:00', 3: '06:00', 6: '12:00', 9: '18:00' }
|
||||
|
||||
const HIGHLIGHT_COLORS = [
|
||||
'transparent',
|
||||
'rgba(253,94,15,0.15)',
|
||||
'rgba(253,94,15,0.35)',
|
||||
'rgba(253,94,15,0.60)',
|
||||
'rgba(253,94,15,0.82)',
|
||||
'#FD5E0F',
|
||||
]
|
||||
|
||||
function formatBucket(bucket: number): string {
|
||||
const hour = bucket * 2
|
||||
const end = hour + 2
|
||||
return `${String(hour).padStart(2, '0')}:00–${String(end).padStart(2, '0')}:00`
|
||||
}
|
||||
|
||||
function formatHour(hour: number): string {
|
||||
return `${String(hour).padStart(2, '0')}:00`
|
||||
}
|
||||
|
||||
function getHighlightColor(value: number, max: number): string {
|
||||
if (value === 0) return HIGHLIGHT_COLORS[0]
|
||||
if (value === max) return HIGHLIGHT_COLORS[5]
|
||||
const ratio = value / max
|
||||
if (ratio <= 0.25) return HIGHLIGHT_COLORS[1]
|
||||
if (ratio <= 0.50) return HIGHLIGHT_COLORS[2]
|
||||
if (ratio <= 0.75) return HIGHLIGHT_COLORS[3]
|
||||
return HIGHLIGHT_COLORS[4]
|
||||
}
|
||||
|
||||
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-neutral-900/80 border border-white/[0.08] 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">
|
||||
<Clock className="w-5 h-5 text-neutral-400 dark:text-neutral-500" weight="bold" />
|
||||
<h3 className="text-lg font-semibold text-white">Peak Hours</h3>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm 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-800 animate-pulse" />
|
||||
<div className="flex-1 h-5 rounded 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-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-600 -translate-x-1/2"
|
||||
style={{ left: `${(Number(b) / BUCKETS) * 100}%` }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
<span
|
||||
className="absolute text-[10px] text-neutral-600 -translate-x-full"
|
||||
style={{ left: '100%' }}
|
||||
>
|
||||
24:00
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Intensity legend */}
|
||||
<div className="flex items-center justify-end gap-1.5 mt-2">
|
||||
<span className="text-[10px] text-neutral-400 dark:text-neutral-500">Less</span>
|
||||
{HIGHLIGHT_COLORS.map((color, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="w-[10px] h-[10px] rounded-[2px] border border-neutral-800"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
))}
|
||||
<span className="text-[10px] text-neutral-400 dark:text-neutral-500">More</span>
|
||||
</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-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-400 text-center"
|
||||
>
|
||||
Your busiest time is{' '}
|
||||
<span className="text-brand-orange font-medium">
|
||||
{DAYS_FULL[bestTime.day]} at {formatHour(bestTime.bucket * 2)}
|
||||
</span>
|
||||
</motion.p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 min-h-[270px] flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
|
||||
<div className="rounded-full bg-neutral-800 p-4">
|
||||
<Clock className="w-8 h-8 text-neutral-400" />
|
||||
</div>
|
||||
<h4 className="font-semibold text-white">
|
||||
No peak hours yet
|
||||
</h4>
|
||||
<p className="text-sm text-neutral-400 max-w-xs">
|
||||
Once your site receives traffic, this heatmap will show when your visitors are most active.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,254 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { ChevronDownIcon } from '@ciphera-net/ui'
|
||||
import { PerformanceStats as Stats, PerformanceByPageStat, getPerformanceByPage } from '@/lib/api/stats'
|
||||
import { Select } from '@ciphera-net/ui'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
|
||||
interface Props {
|
||||
stats: Stats
|
||||
performanceByPage?: PerformanceByPageStat[] | null
|
||||
siteId?: string
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
getPerformanceByPage?: typeof getPerformanceByPage
|
||||
}
|
||||
|
||||
function MetricCard({ label, value, unit, score }: { label: string, value: number, unit: string, score: 'good' | 'needs-improvement' | 'poor' }) {
|
||||
const colors = {
|
||||
good: 'text-green-600 bg-green-50 dark:bg-green-900/20 dark:text-green-400 border-green-200 dark:border-green-800',
|
||||
'needs-improvement': 'text-yellow-600 bg-yellow-50 dark:bg-yellow-900/20 dark:text-yellow-400 border-yellow-200 dark:border-yellow-800',
|
||||
poor: 'text-red-600 bg-red-50 dark:bg-red-900/20 dark:text-red-400 border-red-200 dark:border-red-800',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`p-4 rounded-lg border ${colors[score]}`}>
|
||||
<div className="text-sm font-medium opacity-80 mb-1">{label}</div>
|
||||
<div className="text-2xl font-bold">
|
||||
{value}
|
||||
<span className="text-sm font-normal ml-1 opacity-70">{unit}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function PerformanceStats({ stats, performanceByPage, siteId, startDate, endDate, getPerformanceByPage }: Props) {
|
||||
// * Scoring Logic (based on Google Web Vitals)
|
||||
type Score = 'good' | 'needs-improvement' | 'poor'
|
||||
const getScore = (metric: 'lcp' | 'cls' | 'inp', value: number): Score => {
|
||||
if (metric === 'lcp') return value <= 2500 ? 'good' : value <= 4000 ? 'needs-improvement' : 'poor'
|
||||
if (metric === 'cls') return value <= 0.1 ? 'good' : value <= 0.25 ? 'needs-improvement' : 'poor'
|
||||
if (metric === 'inp') return value <= 200 ? 'good' : value <= 500 ? 'needs-improvement' : 'poor'
|
||||
return 'good'
|
||||
}
|
||||
|
||||
// * Overall performance: worst of LCP, CLS, INP (matches Google’s “field” rating)
|
||||
const getOverallScore = (s: { lcp: number; cls: number; inp: number }): Score => {
|
||||
const lcp = getScore('lcp', s.lcp)
|
||||
const cls = getScore('cls', s.cls)
|
||||
const inp = getScore('inp', s.inp)
|
||||
if (lcp === 'poor' || cls === 'poor' || inp === 'poor') return 'poor'
|
||||
if (lcp === 'needs-improvement' || cls === 'needs-improvement' || inp === 'needs-improvement') return 'needs-improvement'
|
||||
return 'good'
|
||||
}
|
||||
|
||||
const overallScore = getOverallScore(stats)
|
||||
const overallLabel = { good: 'Good', 'needs-improvement': 'Needs improvement', poor: 'Poor' }[overallScore]
|
||||
const overallBadgeClass = {
|
||||
good: 'text-green-700 dark:text-green-400 bg-green-100 dark:bg-green-900/30 border-green-200 dark:border-green-800',
|
||||
'needs-improvement': 'text-yellow-700 dark:text-yellow-400 bg-yellow-100 dark:bg-yellow-900/30 border-yellow-200 dark:border-yellow-800',
|
||||
poor: 'text-red-700 dark:text-red-400 bg-red-100 dark:bg-red-900/30 border-red-200 dark:border-red-800',
|
||||
}[overallScore]
|
||||
|
||||
const [mainExpanded, setMainExpanded] = useState(false)
|
||||
const [sortBy, setSortBy] = useState<'lcp' | 'cls' | 'inp'>('lcp')
|
||||
const [overrideRows, setOverrideRows] = useState<PerformanceByPageStat[] | null>(null)
|
||||
const [loadingTable, setLoadingTable] = useState(false)
|
||||
const [worstPagesOpen, setWorstPagesOpen] = useState(false)
|
||||
|
||||
// * When props.performanceByPage changes (e.g. date range), clear override so we use dashboard data
|
||||
useEffect(() => {
|
||||
setOverrideRows(null)
|
||||
}, [performanceByPage])
|
||||
|
||||
const rows = overrideRows ?? performanceByPage ?? []
|
||||
const canRefetch = Boolean(getPerformanceByPage && siteId && startDate && endDate)
|
||||
|
||||
const handleSortChange = (value: string) => {
|
||||
const v = value as 'lcp' | 'cls' | 'inp'
|
||||
setSortBy(v)
|
||||
if (!getPerformanceByPage || !siteId || !startDate || !endDate) return
|
||||
setLoadingTable(true)
|
||||
getPerformanceByPage(siteId, startDate, endDate, { sort: v, limit: 20 })
|
||||
.then(setOverrideRows)
|
||||
.finally(() => setLoadingTable(false))
|
||||
}
|
||||
|
||||
const cellScoreClass = (score: 'good' | 'needs-improvement' | 'poor') => {
|
||||
const m: Record<string, string> = {
|
||||
good: 'text-green-600 dark:text-green-400',
|
||||
'needs-improvement': 'text-yellow-600 dark:text-yellow-400',
|
||||
poor: 'text-red-600 dark:text-red-400',
|
||||
}
|
||||
return m[score] ?? ''
|
||||
}
|
||||
|
||||
const formatMetric = (metric: 'lcp' | 'cls' | 'inp', val: number | null) => {
|
||||
if (val == null) return '—'
|
||||
if (metric === 'cls') return val.toFixed(3)
|
||||
return `${Math.round(val)} ms`
|
||||
}
|
||||
|
||||
const getCellClass = (metric: 'lcp' | 'cls' | 'inp', val: number | null) => {
|
||||
if (val == null) return 'text-neutral-400 dark:text-neutral-500'
|
||||
return cellScoreClass(getScore(metric, val))
|
||||
}
|
||||
|
||||
const summaryText = `LCP ${Math.round(stats.lcp)} ms · CLS ${Number(stats.cls.toFixed(3))} · INP ${Math.round(stats.inp)} ms`
|
||||
|
||||
return (
|
||||
<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. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMainExpanded((o) => !o)}
|
||||
className="flex w-full items-center justify-between gap-4 text-left rounded cursor-pointer hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-neutral-400 dark:focus:ring-neutral-500 focus:ring-offset-2 dark:focus:ring-offset-neutral-900"
|
||||
aria-expanded={mainExpanded}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<ChevronDownIcon
|
||||
className={`w-4 h-4 shrink-0 text-neutral-500 transition-transform ${mainExpanded ? '' : '-rotate-90'}`}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">Performance</span>
|
||||
<span className={`shrink-0 rounded-md border px-2 py-0.5 text-xs font-medium ${overallBadgeClass}`}>
|
||||
{overallLabel}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-neutral-500 truncate" title={summaryText}>
|
||||
{summaryText}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* * Expanded: full LCP/CLS/INP cards, footnote, and Worst pages (collapsible) */}
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{ height: mainExpanded ? 'auto' : 0, opacity: mainExpanded ? 1 : 0 }}
|
||||
transition={{ duration: 0.25, ease: 'easeInOut' }}
|
||||
style={{ overflow: 'hidden' }}
|
||||
>
|
||||
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-800">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<MetricCard
|
||||
label="Largest Contentful Paint (LCP)"
|
||||
value={Math.round(stats.lcp)}
|
||||
unit="ms"
|
||||
score={getScore('lcp', stats.lcp)}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Cumulative Layout Shift (CLS)"
|
||||
value={Number(stats.cls.toFixed(3))}
|
||||
unit=""
|
||||
score={getScore('cls', stats.cls)}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Interaction to Next Paint (INP)"
|
||||
value={Math.round(stats.inp)}
|
||||
unit="ms"
|
||||
score={getScore('inp', stats.inp)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 text-xs text-neutral-500">
|
||||
* Averages calculated from real user sessions. Lower is better.
|
||||
</div>
|
||||
|
||||
{/* * Worst pages by metric – collapsed by default */}
|
||||
<div className="mt-6 pt-6 border-t border-neutral-200 dark:border-neutral-800">
|
||||
<div className="flex items-center justify-between gap-4 mb-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setWorstPagesOpen((o) => !o)}
|
||||
className="flex items-center gap-2 text-left rounded cursor-pointer hover:opacity-80 focus:outline-none focus:ring-2 focus:ring-neutral-400 dark:focus:ring-neutral-500 focus:ring-offset-2 dark:focus:ring-offset-neutral-900"
|
||||
aria-expanded={worstPagesOpen}
|
||||
>
|
||||
<ChevronDownIcon
|
||||
className={`w-4 h-4 shrink-0 text-neutral-500 transition-transform ${worstPagesOpen ? '' : '-rotate-90'}`}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||
Worst pages by metric
|
||||
</span>
|
||||
</button>
|
||||
{worstPagesOpen && canRefetch && (
|
||||
<Select
|
||||
value={sortBy}
|
||||
onChange={handleSortChange}
|
||||
options={[
|
||||
{ value: 'lcp', label: 'Sort by LCP (worst)' },
|
||||
{ value: 'cls', label: 'Sort by CLS (worst)' },
|
||||
{ value: 'inp', label: 'Sort by INP (worst)' },
|
||||
]}
|
||||
variant="input"
|
||||
align="right"
|
||||
className="min-w-[180px]"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{
|
||||
height: worstPagesOpen ? 'auto' : 0,
|
||||
opacity: worstPagesOpen ? 1 : 0,
|
||||
}}
|
||||
transition={{ duration: 0.25, ease: 'easeInOut' }}
|
||||
style={{ overflow: 'hidden' }}
|
||||
>
|
||||
{loadingTable ? (
|
||||
<div className="py-4"><TableSkeleton rows={5} cols={5} /></div>
|
||||
) : rows.length === 0 ? (
|
||||
<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.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto -mx-1">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-neutral-200 dark:border-neutral-700">
|
||||
<th className="text-left py-2 px-2 font-medium text-neutral-600 dark:text-neutral-400">Path</th>
|
||||
<th className="text-right py-2 px-2 font-medium text-neutral-600 dark:text-neutral-400">Samples</th>
|
||||
<th className="text-right py-2 px-2 font-medium text-neutral-600 dark:text-neutral-400">LCP</th>
|
||||
<th className="text-right py-2 px-2 font-medium text-neutral-600 dark:text-neutral-400">CLS</th>
|
||||
<th className="text-right py-2 px-2 font-medium text-neutral-600 dark:text-neutral-400">INP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.path} className="border-b border-neutral-100 dark:border-neutral-800/80">
|
||||
<td className="py-2 px-2 text-neutral-900 dark:text-white font-mono truncate max-w-[200px]" title={r.path}>
|
||||
{r.path || '/'}
|
||||
</td>
|
||||
<td className="py-2 px-2 text-right text-neutral-600 dark:text-neutral-400">{r.samples}</td>
|
||||
<td className={`py-2 px-2 text-right font-mono ${getCellClass('lcp', r.lcp)}`}>
|
||||
{formatMetric('lcp', r.lcp)}
|
||||
</td>
|
||||
<td className={`py-2 px-2 text-right font-mono ${getCellClass('cls', r.cls)}`}>
|
||||
{formatMetric('cls', r.cls)}
|
||||
</td>
|
||||
<td className={`py-2 px-2 text-right font-mono ${getCellClass('inp', r.inp)}`}>
|
||||
{formatMetric('inp', r.inp)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,28 +1,24 @@
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { AnimatedNumber } from '@/components/ui/animated-number'
|
||||
|
||||
interface RealtimeVisitorsProps {
|
||||
count: number
|
||||
siteId?: string
|
||||
}
|
||||
|
||||
export default function RealtimeVisitors({ count, siteId }: RealtimeVisitorsProps) {
|
||||
const router = useRouter()
|
||||
|
||||
export default function RealtimeVisitors({ count }: RealtimeVisitorsProps) {
|
||||
return (
|
||||
<div
|
||||
onClick={() => siteId && router.push(`/sites/${siteId}/realtime`)}
|
||||
className={`bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 ${siteId ? 'cursor-pointer hover:border-neutral-300 dark:hover:border-neutral-700 transition-colors' : ''}`}
|
||||
<div
|
||||
className="bg-neutral-900/80 border border-white/[0.08] rounded-2xl p-6"
|
||||
>
|
||||
<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-400">
|
||||
Real-time Visitors
|
||||
</div>
|
||||
<div className="h-2 w-2 bg-green-500 rounded-full animate-pulse"></div>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-neutral-900 dark:text-white">
|
||||
{count}
|
||||
<div className="text-3xl font-bold text-white">
|
||||
<AnimatedNumber value={count} format={(v) => v.toLocaleString()} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { formatNumber } from '@ciphera-net/ui'
|
||||
import { PolarAngleAxis, PolarGrid, Radar, RadarChart, Tooltip } from 'recharts'
|
||||
import { BarChartIcon } from '@ciphera-net/ui'
|
||||
import type { GoalCountStat } from '@/lib/api/stats'
|
||||
|
||||
@@ -22,55 +22,64 @@ export default function ScrollDepth({ goalCounts, totalPageviews }: ScrollDepthP
|
||||
|
||||
const hasData = scrollCounts.size > 0 && totalPageviews > 0
|
||||
|
||||
const chartData = THRESHOLDS.map((threshold) => ({
|
||||
label: `${threshold}%`,
|
||||
value: totalPageviews > 0 ? Math.round(((scrollCounts.get(threshold) ?? 0) / totalPageviews) * 100) : 0,
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
|
||||
<div className="bg-neutral-900/80 border border-white/[0.08] 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-white">
|
||||
Scroll Depth
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-neutral-400 mb-4">
|
||||
% of visitors who scrolled this far
|
||||
</p>
|
||||
|
||||
{hasData ? (
|
||||
<div className="space-y-3 flex-1 min-h-[200px]">
|
||||
{THRESHOLDS.map((threshold) => {
|
||||
const count = scrollCounts.get(threshold) ?? 0
|
||||
const pct = totalPageviews > 0 ? Math.round((count / totalPageviews) * 100) : 0
|
||||
const barWidth = Math.max(pct, 2)
|
||||
|
||||
return (
|
||||
<div key={threshold} className="space-y-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-medium text-neutral-900 dark:text-white">
|
||||
{threshold}%
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-neutral-500 dark:text-neutral-400 tabular-nums">
|
||||
{formatNumber(count)}
|
||||
</span>
|
||||
<span className="font-semibold text-brand-orange tabular-nums w-12 text-right">
|
||||
{pct}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-neutral-100 dark:bg-neutral-800 overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-brand-orange transition-all duration-500"
|
||||
style={{ width: `${barWidth}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="flex-1 min-h-[270px] flex items-center justify-center">
|
||||
<RadarChart
|
||||
width={320}
|
||||
height={260}
|
||||
data={chartData}
|
||||
margin={{ top: 16, right: 32, bottom: 16, left: 32 }}
|
||||
>
|
||||
<PolarGrid stroke="#404040" />
|
||||
<PolarAngleAxis
|
||||
dataKey="label"
|
||||
tick={{ fill: '#a3a3a3', fontSize: 12, fontWeight: 500 }}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={false}
|
||||
contentStyle={{
|
||||
backgroundColor: '#171717',
|
||||
border: '1px solid #404040',
|
||||
borderRadius: 8,
|
||||
fontSize: 12,
|
||||
color: '#fff',
|
||||
}}
|
||||
formatter={(value: number) => [`${value}%`, 'Reached']}
|
||||
/>
|
||||
<Radar
|
||||
dataKey="value"
|
||||
stroke="#FD5E0F"
|
||||
fill="#FD5E0F"
|
||||
fillOpacity={0.6}
|
||||
dot={{ r: 4, fill: '#FD5E0F', fillOpacity: 1, strokeWidth: 0 }}
|
||||
/>
|
||||
</RadarChart>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 min-h-[200px] flex flex-col items-center justify-center text-center px-6 py-8 gap-4">
|
||||
<div className="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 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-800 p-4">
|
||||
<BarChartIcon className="w-8 h-8 text-neutral-400" />
|
||||
</div>
|
||||
<h4 className="font-semibold text-neutral-900 dark:text-white">
|
||||
<h4 className="font-semibold text-white">
|
||||
No scroll data yet
|
||||
</h4>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-md">
|
||||
<p className="text-sm text-neutral-400 max-w-md">
|
||||
Scroll depth tracking is automatic — data will appear here once visitors start scrolling on your pages.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
295
components/dashboard/SearchPerformance.tsx
Normal file
295
components/dashboard/SearchPerformance.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { logger } from '@/lib/utils/logger'
|
||||
import { formatNumber, Modal } from '@ciphera-net/ui'
|
||||
import { MagnifyingGlass, CaretUp, CaretDown, FrameCornersIcon } from '@phosphor-icons/react'
|
||||
import { useGSCStatus, useGSCOverview, useGSCTopQueries, useGSCTopPages } from '@/lib/swr/dashboard'
|
||||
import { getGSCTopQueries, getGSCTopPages } from '@/lib/api/gsc'
|
||||
import type { GSCDataRow } from '@/lib/api/gsc'
|
||||
import { useTabListKeyboard } from '@/lib/hooks/useTabListKeyboard'
|
||||
import { ListSkeleton } from '@/components/skeletons'
|
||||
import VirtualList from './VirtualList'
|
||||
|
||||
interface SearchPerformanceProps {
|
||||
siteId: string
|
||||
dateRange: { start: string; end: string }
|
||||
}
|
||||
|
||||
type Tab = 'queries' | 'pages'
|
||||
|
||||
const LIMIT = 7
|
||||
|
||||
function ChangeArrow({ current, previous, invert = false }: { current: number; previous: number; invert?: boolean }) {
|
||||
if (!previous || previous === 0) return null
|
||||
const improved = invert ? current < previous : current > previous
|
||||
const same = current === previous
|
||||
if (same) return null
|
||||
return improved ? (
|
||||
<CaretUp className="w-3 h-3 text-emerald-500" weight="fill" />
|
||||
) : (
|
||||
<CaretDown className="w-3 h-3 text-red-500" weight="fill" />
|
||||
)
|
||||
}
|
||||
|
||||
function getPositionBadgeClasses(position: number): string {
|
||||
if (position <= 10) return 'text-emerald-600 dark:text-emerald-400 bg-emerald-500/10 dark:bg-emerald-500/20'
|
||||
if (position <= 20) return 'text-brand-orange dark:text-brand-orange bg-brand-orange/10 dark:bg-brand-orange/20'
|
||||
if (position <= 50) return 'text-neutral-400 dark:text-neutral-500 bg-neutral-800'
|
||||
return 'text-red-500 dark:text-red-400 bg-red-500/10 dark:bg-red-500/20'
|
||||
}
|
||||
|
||||
export default function SearchPerformance({ siteId, dateRange }: SearchPerformanceProps) {
|
||||
const [activeTab, setActiveTab] = useState<Tab>('queries')
|
||||
const handleTabKeyDown = useTabListKeyboard()
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
const [modalSearch, setModalSearch] = useState('')
|
||||
const [fullData, setFullData] = useState<GSCDataRow[]>([])
|
||||
const [isLoadingFull, setIsLoadingFull] = useState(false)
|
||||
|
||||
const { data: gscStatus } = useGSCStatus(siteId)
|
||||
const { data: overview, isLoading: overviewLoading } = useGSCOverview(siteId, dateRange.start, dateRange.end)
|
||||
const { data: queriesData, isLoading: queriesLoading } = useGSCTopQueries(siteId, dateRange.start, dateRange.end, LIMIT, 0)
|
||||
const { data: pagesData, isLoading: pagesLoading } = useGSCTopPages(siteId, dateRange.start, dateRange.end, LIMIT, 0)
|
||||
|
||||
// Fetch full data when modal opens (matches ContentStats/TopReferrers pattern)
|
||||
useEffect(() => {
|
||||
if (isModalOpen) {
|
||||
const fetchData = async () => {
|
||||
setIsLoadingFull(true)
|
||||
try {
|
||||
if (activeTab === 'queries') {
|
||||
const data = await getGSCTopQueries(siteId, dateRange.start, dateRange.end, 100, 0)
|
||||
setFullData(data.queries ?? [])
|
||||
} else {
|
||||
const data = await getGSCTopPages(siteId, dateRange.start, dateRange.end, 100, 0)
|
||||
setFullData(data.pages ?? [])
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(e)
|
||||
} finally {
|
||||
setIsLoadingFull(false)
|
||||
}
|
||||
}
|
||||
fetchData()
|
||||
} else {
|
||||
setFullData([])
|
||||
}
|
||||
}, [isModalOpen, activeTab, siteId, dateRange])
|
||||
|
||||
// Don't render if GSC is not connected
|
||||
if (!gscStatus?.connected) return null
|
||||
|
||||
const isLoading = overviewLoading || queriesLoading || pagesLoading
|
||||
const queries = queriesData?.queries ?? []
|
||||
const pages = pagesData?.pages ?? []
|
||||
const hasData = overview && (overview.total_clicks > 0 || overview.total_impressions > 0)
|
||||
|
||||
// Hide panel entirely if loaded but no data
|
||||
if (!isLoading && !hasData) return null
|
||||
|
||||
const data = activeTab === 'queries' ? queries : pages
|
||||
const totalImpressions = data.reduce((sum, d) => sum + d.impressions, 0)
|
||||
const displayedData = data.slice(0, LIMIT)
|
||||
const emptySlots = Math.max(0, LIMIT - displayedData.length)
|
||||
const showViewAll = data.length >= LIMIT
|
||||
|
||||
const getLabel = (row: GSCDataRow) => activeTab === 'queries' ? row.query : row.page
|
||||
const getTabLabel = (tab: Tab) => tab === 'queries' ? 'Queries' : 'Pages'
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bg-neutral-900/80 border border-white/[0.08] rounded-2xl p-6 h-full flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<MagnifyingGlass className="w-5 h-5 text-neutral-400 dark:text-neutral-500" weight="bold" />
|
||||
<h3 className="text-lg font-semibold text-white">Search</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-800 transition-all cursor-pointer rounded-lg"
|
||||
aria-label="View all search data"
|
||||
>
|
||||
<FrameCornersIcon className="w-4 h-4" weight="bold" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1 overflow-x-auto scrollbar-hide" role="tablist" aria-label="Search data tabs" onKeyDown={handleTabKeyDown}>
|
||||
{(['queries', 'pages'] as Tab[]).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
role="tab"
|
||||
aria-selected={activeTab === tab}
|
||||
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
|
||||
? 'text-white'
|
||||
: 'text-neutral-400 dark:text-neutral-500 hover:text-neutral-300'
|
||||
}`}
|
||||
>
|
||||
{getTabLabel(tab)}
|
||||
{activeTab === tab && (
|
||||
<motion.div
|
||||
layoutId="searchTab"
|
||||
className="absolute inset-x-0 -bottom-px h-0.5 bg-brand-orange"
|
||||
transition={{ type: 'spring', stiffness: 500, damping: 35 }}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex-1 space-y-4">
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="h-4 w-20 bg-neutral-800 rounded animate-pulse" />
|
||||
<div className="h-4 w-24 bg-neutral-800 rounded animate-pulse" />
|
||||
<div className="h-4 w-20 bg-neutral-800 rounded animate-pulse" />
|
||||
</div>
|
||||
<div className="space-y-2 mt-4">
|
||||
<ListSkeleton rows={LIMIT} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Inline stats row */}
|
||||
<div className="flex items-center gap-5 mb-4">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-neutral-400">Clicks</span>
|
||||
<span className="text-sm font-semibold text-white">
|
||||
{formatNumber(overview?.total_clicks ?? 0)}
|
||||
</span>
|
||||
<ChangeArrow current={overview?.total_clicks ?? 0} previous={overview?.prev_clicks ?? 0} />
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-neutral-400">Impressions</span>
|
||||
<span className="text-sm font-semibold text-white">
|
||||
{formatNumber(overview?.total_impressions ?? 0)}
|
||||
</span>
|
||||
<ChangeArrow current={overview?.total_impressions ?? 0} previous={overview?.prev_impressions ?? 0} />
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-neutral-400">Avg Position</span>
|
||||
<span className="text-sm font-semibold text-white">
|
||||
{(overview?.avg_position ?? 0).toFixed(1)}
|
||||
</span>
|
||||
<ChangeArrow current={overview?.avg_position ?? 0} previous={overview?.prev_avg_position ?? 0} invert />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Data list */}
|
||||
<div className="space-y-2 flex-1 min-h-[270px]">
|
||||
{displayedData.length > 0 ? (
|
||||
<>
|
||||
{displayedData.map((row) => {
|
||||
const maxImpressions = displayedData[0]?.impressions ?? 0
|
||||
const barWidth = maxImpressions > 0 ? (row.impressions / maxImpressions) * 75 : 0
|
||||
const label = getLabel(row)
|
||||
return (
|
||||
<div
|
||||
key={label}
|
||||
className="relative flex items-center justify-between h-9 group hover:bg-neutral-800/50 rounded-lg px-2 -mx-2 transition-colors"
|
||||
>
|
||||
<div
|
||||
className="absolute inset-y-0.5 left-0.5 bg-brand-orange/15 dark:bg-brand-orange/40 rounded-md transition-all"
|
||||
style={{ width: `${barWidth}%` }}
|
||||
/>
|
||||
<span className="relative text-sm text-white truncate flex-1 min-w-0" title={label}>
|
||||
{label}
|
||||
</span>
|
||||
<div className="relative flex items-center gap-3 ml-4 shrink-0">
|
||||
<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">
|
||||
{totalImpressions > 0 ? `${Math.round((row.impressions / totalImpressions) * 100)}%` : ''}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-neutral-400">
|
||||
{formatNumber(row.clicks)}
|
||||
</span>
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded font-medium ${getPositionBadgeClasses(row.position)}`}>
|
||||
{row.position.toFixed(1)}
|
||||
</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="flex-1 flex items-center justify-center py-6">
|
||||
<p className="text-sm text-neutral-400 dark:text-neutral-500">No search data yet</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expand modal */}
|
||||
<Modal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => { setIsModalOpen(false); setModalSearch('') }}
|
||||
title={`Search ${getTabLabel(activeTab)}`}
|
||||
className="max-w-2xl"
|
||||
>
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={modalSearch}
|
||||
onChange={(e) => setModalSearch(e.target.value)}
|
||||
placeholder={`Search ${activeTab}...`}
|
||||
className="w-full px-3 py-2 mb-3 text-sm bg-neutral-800 border border-neutral-700 rounded-lg text-white placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-brand-orange/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-[80vh]">
|
||||
{isLoadingFull ? (
|
||||
<div className="py-4">
|
||||
<ListSkeleton rows={10} />
|
||||
</div>
|
||||
) : (() => {
|
||||
const source = fullData.length > 0 ? fullData : data
|
||||
const modalData = source.filter(row => {
|
||||
if (!modalSearch) return true
|
||||
return getLabel(row).toLowerCase().includes(modalSearch.toLowerCase())
|
||||
})
|
||||
const modalTotal = modalData.reduce((sum, r) => sum + r.impressions, 0)
|
||||
return (
|
||||
<VirtualList
|
||||
items={modalData}
|
||||
estimateSize={36}
|
||||
className="max-h-[80vh] overflow-y-auto pr-2"
|
||||
renderItem={(row) => {
|
||||
const label = getLabel(row)
|
||||
return (
|
||||
<div
|
||||
key={label}
|
||||
className="flex items-center justify-between h-9 group hover:bg-neutral-800 rounded-lg px-2 transition-colors"
|
||||
>
|
||||
<span className="flex-1 truncate text-sm text-white" title={label}>
|
||||
{label}
|
||||
</span>
|
||||
<div className="flex items-center gap-3 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((row.impressions / modalTotal) * 100)}%` : ''}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-neutral-400">
|
||||
{formatNumber(row.clicks)}
|
||||
</span>
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded font-medium ${getPositionBadgeClasses(row.position)}`}>
|
||||
{row.position.toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
552
components/dashboard/Sidebar.tsx
Normal file
552
components/dashboard/Sidebar.tsx
Normal file
@@ -0,0 +1,552 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import Link from 'next/link'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { listSites, type Site } from '@/lib/api/sites'
|
||||
import { useAuth } from '@/lib/auth/context'
|
||||
import { useUnifiedSettings } from '@/lib/unified-settings-context'
|
||||
import { useSidebar } from '@/lib/sidebar-context'
|
||||
// `,` shortcut handled globally by UnifiedSettingsModal
|
||||
import { getUserOrganizations, switchContext, type OrganizationMember } from '@/lib/api/organization'
|
||||
import { setSessionAction } from '@/app/actions/auth'
|
||||
import { logger } from '@/lib/utils/logger'
|
||||
import { FAVICON_SERVICE_URL } from '@/lib/utils/favicon'
|
||||
import { Gauge as GaugeIcon, Plugs as PlugsIcon, Tag as TagIcon } from '@phosphor-icons/react'
|
||||
import {
|
||||
LayoutDashboardIcon,
|
||||
PathIcon,
|
||||
FunnelIcon,
|
||||
CursorClickIcon,
|
||||
SearchIcon,
|
||||
CloudUploadIcon,
|
||||
HeartbeatIcon,
|
||||
SettingsIcon,
|
||||
PlusIcon,
|
||||
XIcon,
|
||||
BookOpenIcon,
|
||||
UserMenu,
|
||||
} from '@ciphera-net/ui'
|
||||
import NotificationCenter from '@/components/notifications/NotificationCenter'
|
||||
|
||||
const EXPANDED = 256
|
||||
const COLLAPSED = 64
|
||||
|
||||
type IconWeight = 'thin' | 'light' | 'regular' | 'bold' | 'fill' | 'duotone'
|
||||
|
||||
interface NavItem {
|
||||
label: string
|
||||
href: (siteId: string) => string
|
||||
icon: React.ComponentType<{ className?: string; weight?: IconWeight }>
|
||||
matchPrefix?: boolean
|
||||
}
|
||||
|
||||
interface NavGroup { label: string; items: NavItem[] }
|
||||
|
||||
const NAV_GROUPS: NavGroup[] = [
|
||||
{
|
||||
label: 'Analytics',
|
||||
items: [
|
||||
{ label: 'Dashboard', href: (id) => `/sites/${id}`, icon: LayoutDashboardIcon },
|
||||
{ label: 'Journeys', href: (id) => `/sites/${id}/journeys`, icon: PathIcon, matchPrefix: true },
|
||||
{ label: 'Funnels', href: (id) => `/sites/${id}/funnels`, icon: FunnelIcon, matchPrefix: true },
|
||||
{ label: 'Behavior', href: (id) => `/sites/${id}/behavior`, icon: CursorClickIcon, matchPrefix: true },
|
||||
{ label: 'Search', href: (id) => `/sites/${id}/search`, icon: SearchIcon, matchPrefix: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Infrastructure',
|
||||
items: [
|
||||
{ label: 'CDN', href: (id) => `/sites/${id}/cdn`, icon: CloudUploadIcon, matchPrefix: true },
|
||||
{ label: 'Uptime', href: (id) => `/sites/${id}/uptime`, icon: HeartbeatIcon, matchPrefix: true },
|
||||
{ label: 'PageSpeed', href: (id) => `/sites/${id}/pagespeed`, icon: GaugeIcon, matchPrefix: true },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const SETTINGS_ITEM: NavItem = {
|
||||
label: 'Site Settings', href: (id) => `/sites/${id}/settings`, icon: SettingsIcon, matchPrefix: true,
|
||||
}
|
||||
|
||||
// Label that fades with the sidebar — always in the DOM, never removed
|
||||
function Label({ children, collapsed }: { children: React.ReactNode; collapsed: boolean }) {
|
||||
return (
|
||||
<span
|
||||
className="whitespace-nowrap overflow-hidden transition-opacity duration-150"
|
||||
style={{ opacity: collapsed ? 0 : 1 }}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Sidebar Tooltip (portal-based, escapes overflow-hidden) ──
|
||||
|
||||
function SidebarTooltip({ children, label }: { children: React.ReactNode; label: string }) {
|
||||
const [show, setShow] = useState(false)
|
||||
const [pos, setPos] = useState({ x: 0, y: 0 })
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
|
||||
const handleEnter = () => {
|
||||
timerRef.current = setTimeout(() => {
|
||||
if (ref.current) {
|
||||
const rect = ref.current.getBoundingClientRect()
|
||||
setPos({ x: rect.right + 8, y: rect.top + rect.height / 2 })
|
||||
setShow(true)
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
|
||||
const handleLeave = () => {
|
||||
clearTimeout(timerRef.current)
|
||||
setShow(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} onMouseEnter={handleEnter} onMouseLeave={handleLeave}>
|
||||
{children}
|
||||
{show && typeof document !== 'undefined' && createPortal(
|
||||
<span
|
||||
className="fixed z-[100] px-3 py-2 rounded-lg bg-neutral-950 border border-neutral-800/60 text-white text-sm font-medium whitespace-nowrap pointer-events-none shadow-lg shadow-black/20 -translate-y-1/2"
|
||||
style={{ left: pos.x, top: pos.y }}
|
||||
>
|
||||
{label}
|
||||
</span>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Nav Item ───────────────────────────────────────────────
|
||||
|
||||
function NavLink({
|
||||
item, siteId, collapsed, onClick, pendingHref, onNavigate,
|
||||
}: {
|
||||
item: NavItem; siteId: string; collapsed: boolean; onClick?: () => void
|
||||
pendingHref: string | null; onNavigate: (href: string) => void
|
||||
}) {
|
||||
const pathname = usePathname()
|
||||
const href = item.href(siteId)
|
||||
const matchesPathname = item.matchPrefix ? pathname.startsWith(href) : pathname === href
|
||||
const matchesPending = pendingHref !== null && (item.matchPrefix ? pendingHref.startsWith(href) : pendingHref === href)
|
||||
const active = matchesPathname || matchesPending
|
||||
|
||||
const link = (
|
||||
<Link
|
||||
href={href}
|
||||
onClick={() => { onNavigate(href); onClick?.() }}
|
||||
className={`flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium overflow-hidden transition-all duration-150 ${
|
||||
active
|
||||
? 'bg-brand-orange/10 text-brand-orange'
|
||||
: 'text-neutral-400 hover:text-white hover:bg-white/[0.06] hover:translate-x-0.5'
|
||||
}`}
|
||||
>
|
||||
<span className="w-7 h-7 flex items-center justify-center shrink-0">
|
||||
<item.icon className="w-[18px] h-[18px]" weight={active ? 'fill' : 'regular'} />
|
||||
</span>
|
||||
<Label collapsed={collapsed}>{item.label}</Label>
|
||||
</Link>
|
||||
)
|
||||
|
||||
if (collapsed) return <SidebarTooltip label={item.label}>{link}</SidebarTooltip>
|
||||
return link
|
||||
}
|
||||
|
||||
// ─── Settings Button (opens unified modal instead of navigating) ─────
|
||||
|
||||
function SettingsButton({
|
||||
item, collapsed, onClick, settingsContext = 'site',
|
||||
}: {
|
||||
item: NavItem; collapsed: boolean; onClick?: () => void; settingsContext?: 'site' | 'workspace'
|
||||
}) {
|
||||
const { openUnifiedSettings } = useUnifiedSettings()
|
||||
|
||||
const btn = (
|
||||
<button
|
||||
onClick={() => {
|
||||
openUnifiedSettings({ context: settingsContext, tab: 'general' })
|
||||
onClick?.()
|
||||
}}
|
||||
className="flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium overflow-hidden transition-all duration-150 text-neutral-400 hover:text-white hover:bg-white/[0.06] hover:translate-x-0.5 w-full cursor-pointer"
|
||||
>
|
||||
<span className="w-7 h-7 flex items-center justify-center shrink-0">
|
||||
<item.icon className="w-[18px] h-[18px]" weight="regular" />
|
||||
</span>
|
||||
<Label collapsed={collapsed}>{item.label}</Label>
|
||||
</button>
|
||||
)
|
||||
|
||||
if (collapsed) return <SidebarTooltip label={item.label}>{btn}</SidebarTooltip>
|
||||
return btn
|
||||
}
|
||||
|
||||
// ─── Home Nav Link (static href, no siteId) ───────────────
|
||||
|
||||
function HomeNavLink({
|
||||
href, icon: Icon, label, collapsed, onClick, external,
|
||||
}: {
|
||||
href: string; icon: React.ComponentType<{ className?: string; weight?: IconWeight }>
|
||||
label: string; collapsed: boolean; onClick?: () => void; external?: boolean
|
||||
}) {
|
||||
const pathname = usePathname()
|
||||
const active = !external && pathname === href
|
||||
|
||||
const link = (
|
||||
<Link
|
||||
href={href}
|
||||
onClick={onClick}
|
||||
{...(external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
|
||||
className={`flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium overflow-hidden transition-all duration-150 ${
|
||||
active
|
||||
? 'bg-brand-orange/10 text-brand-orange'
|
||||
: 'text-neutral-400 hover:text-white hover:bg-white/[0.06] hover:translate-x-0.5'
|
||||
}`}
|
||||
>
|
||||
<span className="w-7 h-7 flex items-center justify-center shrink-0">
|
||||
<Icon className="w-[18px] h-[18px]" weight={active ? 'fill' : 'regular'} />
|
||||
</span>
|
||||
<Label collapsed={collapsed}>{label}</Label>
|
||||
</Link>
|
||||
)
|
||||
|
||||
if (collapsed) return <SidebarTooltip label={label}>{link}</SidebarTooltip>
|
||||
return link
|
||||
}
|
||||
|
||||
// ─── Home Site Link (favicon + name) ───────────────────────
|
||||
|
||||
function HomeSiteLink({
|
||||
site, collapsed, onClick,
|
||||
}: {
|
||||
site: Site; collapsed: boolean; onClick?: () => void
|
||||
}) {
|
||||
const pathname = usePathname()
|
||||
const href = `/sites/${site.id}`
|
||||
const active = pathname.startsWith(href)
|
||||
|
||||
const link = (
|
||||
<Link
|
||||
href={href}
|
||||
onClick={onClick}
|
||||
className={`flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium overflow-hidden transition-all duration-150 ${
|
||||
active
|
||||
? 'bg-brand-orange/10 text-brand-orange'
|
||||
: 'text-neutral-400 hover:text-white hover:bg-white/[0.06] hover:translate-x-0.5'
|
||||
}`}
|
||||
>
|
||||
<span className="w-7 h-7 rounded-md bg-white/[0.04] flex items-center justify-center shrink-0 overflow-hidden">
|
||||
<img
|
||||
src={`${FAVICON_SERVICE_URL}?domain=${site.domain}&sz=64`}
|
||||
alt=""
|
||||
className="w-[18px] h-[18px] rounded object-contain"
|
||||
/>
|
||||
</span>
|
||||
<Label collapsed={collapsed}>{site.name}</Label>
|
||||
</Link>
|
||||
)
|
||||
|
||||
if (collapsed) return <SidebarTooltip label={site.name}>{link}</SidebarTooltip>
|
||||
return link
|
||||
}
|
||||
|
||||
// ─── Sidebar Content ────────────────────────────────────────
|
||||
|
||||
interface SidebarContentProps {
|
||||
isMobile: boolean
|
||||
collapsed: boolean
|
||||
siteId: string | null
|
||||
sites: Site[]
|
||||
canEdit: boolean
|
||||
pendingHref: string | null
|
||||
onNavigate: (href: string) => void
|
||||
onMobileClose: () => void
|
||||
onToggle: () => void
|
||||
auth: ReturnType<typeof useAuth>
|
||||
orgs: OrganizationMember[]
|
||||
onSwitchOrganization: (orgId: string | null) => Promise<void>
|
||||
openSettings: () => void
|
||||
openOrgSettings: () => void
|
||||
}
|
||||
|
||||
function SidebarContent({
|
||||
isMobile, collapsed, siteId, sites, canEdit, pendingHref,
|
||||
onNavigate, onMobileClose, onToggle,
|
||||
auth, orgs, onSwitchOrganization, openSettings, openOrgSettings,
|
||||
}: SidebarContentProps) {
|
||||
const router = useRouter()
|
||||
const c = isMobile ? false : collapsed
|
||||
const { user } = auth
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
{/* Logo — fixed layout, text fades */}
|
||||
<Link href="/" className="flex items-center gap-3 px-[14px] py-4 shrink-0 group overflow-hidden">
|
||||
<span className="w-9 h-9 flex items-center justify-center shrink-0">
|
||||
<img src="/pulse_icon_no_margins.png" alt="Pulse" className="w-9 h-9 shrink-0 object-contain group-hover:scale-105 transition-transform duration-200" />
|
||||
</span>
|
||||
<span className={`text-xl font-bold text-white tracking-tight group-hover:text-brand-orange whitespace-nowrap transition-opacity duration-150 ${c ? 'opacity-0' : 'opacity-100'}`}>
|
||||
Pulse
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Nav Groups */}
|
||||
{siteId ? (
|
||||
<nav className="flex-1 overflow-y-auto overflow-x-hidden px-2 space-y-4">
|
||||
{NAV_GROUPS.map((group) => (
|
||||
<div key={group.label}>
|
||||
<div className="h-5 flex items-center overflow-hidden">
|
||||
{c ? (
|
||||
<div className="mx-1 w-full border-t border-white/[0.04]" />
|
||||
) : (
|
||||
<p className="px-2.5 text-[11px] font-semibold text-neutral-400 dark:text-neutral-500 uppercase tracking-wider whitespace-nowrap">
|
||||
{group.label}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{group.items.map((item) => (
|
||||
<NavLink key={item.label} item={item} siteId={siteId} collapsed={c} onClick={isMobile ? onMobileClose : undefined} pendingHref={pendingHref} onNavigate={onNavigate} />
|
||||
))}
|
||||
{group.label === 'Infrastructure' && canEdit && (
|
||||
<SettingsButton item={SETTINGS_ITEM} collapsed={c} onClick={isMobile ? onMobileClose : undefined} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
) : (
|
||||
<nav className="flex-1 overflow-y-auto overflow-x-hidden px-2 space-y-4">
|
||||
{/* Your Sites */}
|
||||
<div>
|
||||
{c ? (
|
||||
<div className="mx-3 my-2 border-t border-white/[0.04]" />
|
||||
) : (
|
||||
<div className="h-5 flex items-center overflow-hidden">
|
||||
<p className="px-2.5 text-[11px] font-semibold text-neutral-400 dark:text-neutral-500 uppercase tracking-wider whitespace-nowrap">
|
||||
Your Sites
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-0.5">
|
||||
{sites.map((site) => (
|
||||
<HomeSiteLink key={site.id} site={site} collapsed={c} onClick={isMobile ? onMobileClose : undefined} />
|
||||
))}
|
||||
<HomeNavLink href="/sites/new" icon={PlusIcon} label="Add New Site" collapsed={c} onClick={isMobile ? onMobileClose : undefined} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Workspace */}
|
||||
<div>
|
||||
{c ? (
|
||||
<div className="mx-3 my-2 border-t border-white/[0.04]" />
|
||||
) : (
|
||||
<div className="h-5 flex items-center overflow-hidden">
|
||||
<p className="px-2.5 text-[11px] font-semibold text-neutral-400 dark:text-neutral-500 uppercase tracking-wider whitespace-nowrap">
|
||||
Workspace
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-0.5">
|
||||
<HomeNavLink href="/integrations" icon={PlugsIcon} label="Integrations" collapsed={c} onClick={isMobile ? onMobileClose : undefined} />
|
||||
<HomeNavLink href="/pricing" icon={TagIcon} label="Pricing" collapsed={c} onClick={isMobile ? onMobileClose : undefined} />
|
||||
<SettingsButton item={{ label: 'Workspace Settings', href: () => '', icon: SettingsIcon, matchPrefix: false }} collapsed={c} onClick={isMobile ? onMobileClose : undefined} settingsContext="workspace" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resources */}
|
||||
<div>
|
||||
{c ? (
|
||||
<div className="mx-3 my-2 border-t border-white/[0.04]" />
|
||||
) : (
|
||||
<div className="h-5 flex items-center overflow-hidden">
|
||||
<p className="px-2.5 text-[11px] font-semibold text-neutral-400 dark:text-neutral-500 uppercase tracking-wider whitespace-nowrap">
|
||||
Resources
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-0.5">
|
||||
<HomeNavLink href="https://docs.ciphera.net" icon={BookOpenIcon} label="Documentation" collapsed={c} onClick={isMobile ? onMobileClose : undefined} external />
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{/* Bottom — utility items */}
|
||||
<div className="border-t border-white/[0.06] px-2 py-3 shrink-0">
|
||||
{/* Notifications, Profile — same layout as nav items */}
|
||||
<div className="space-y-0.5 mb-1">
|
||||
{c ? (
|
||||
<SidebarTooltip label="Notifications">
|
||||
<NotificationCenter anchor="right" variant="sidebar">
|
||||
<Label collapsed={c}>Notifications</Label>
|
||||
</NotificationCenter>
|
||||
</SidebarTooltip>
|
||||
) : (
|
||||
<NotificationCenter anchor="right" variant="sidebar">
|
||||
<Label collapsed={c}>Notifications</Label>
|
||||
</NotificationCenter>
|
||||
)}
|
||||
{c ? (
|
||||
<SidebarTooltip label={user?.display_name?.trim() || 'Profile'}>
|
||||
<UserMenu
|
||||
auth={auth}
|
||||
LinkComponent={Link}
|
||||
orgs={orgs}
|
||||
activeOrgId={auth.user?.org_id}
|
||||
onSwitchOrganization={onSwitchOrganization}
|
||||
onCreateOrganization={() => router.push('/onboarding')}
|
||||
allowPersonalOrganization={false}
|
||||
onOpenSettings={openSettings}
|
||||
onOpenOrgSettings={openOrgSettings}
|
||||
compact
|
||||
anchor="right"
|
||||
>
|
||||
<Label collapsed={c}>{user?.display_name?.trim() || 'Profile'}</Label>
|
||||
</UserMenu>
|
||||
</SidebarTooltip>
|
||||
) : (
|
||||
<UserMenu
|
||||
auth={auth}
|
||||
LinkComponent={Link}
|
||||
orgs={orgs}
|
||||
activeOrgId={auth.user?.org_id}
|
||||
onSwitchOrganization={onSwitchOrganization}
|
||||
onCreateOrganization={() => router.push('/onboarding')}
|
||||
allowPersonalOrganization={false}
|
||||
onOpenSettings={openSettings}
|
||||
onOpenOrgSettings={openOrgSettings}
|
||||
compact
|
||||
anchor="right"
|
||||
>
|
||||
<Label collapsed={c}>{user?.display_name?.trim() || 'Profile'}</Label>
|
||||
</UserMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main Sidebar ───────────────────────────────────────────
|
||||
|
||||
export default function Sidebar({
|
||||
siteId, mobileOpen, onMobileClose, onMobileOpen,
|
||||
}: {
|
||||
siteId: string | null; mobileOpen: boolean; onMobileClose: () => void; onMobileOpen: () => void
|
||||
}) {
|
||||
const auth = useAuth()
|
||||
const { user } = auth
|
||||
const canEdit = user?.role === 'owner' || user?.role === 'admin'
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const { openUnifiedSettings } = useUnifiedSettings()
|
||||
const [sites, setSites] = useState<Site[]>([])
|
||||
const [orgs, setOrgs] = useState<OrganizationMember[]>([])
|
||||
const [pendingHref, setPendingHref] = useState<string | null>(null)
|
||||
const [mobileClosing, setMobileClosing] = useState(false)
|
||||
const { collapsed, toggle } = useSidebar()
|
||||
|
||||
useEffect(() => { listSites().then(setSites).catch(() => {}) }, [])
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
getUserOrganizations()
|
||||
.then((organizations) => setOrgs(Array.isArray(organizations) ? organizations : []))
|
||||
.catch(err => logger.error('Failed to fetch orgs', err))
|
||||
}
|
||||
}, [user])
|
||||
|
||||
const handleSwitchOrganization = async (orgId: string | null) => {
|
||||
if (!orgId) return
|
||||
try {
|
||||
const { access_token } = await switchContext(orgId)
|
||||
await setSessionAction(access_token)
|
||||
await auth.refresh()
|
||||
router.push('/')
|
||||
} catch (err) {
|
||||
logger.error('Failed to switch organization', err)
|
||||
}
|
||||
}
|
||||
useEffect(() => { setPendingHref(null); onMobileClose() }, [pathname, onMobileClose])
|
||||
|
||||
const handleMobileClose = useCallback(() => {
|
||||
setMobileClosing(true)
|
||||
setTimeout(() => {
|
||||
setMobileClosing(false)
|
||||
onMobileClose()
|
||||
}, 200)
|
||||
}, [onMobileClose])
|
||||
|
||||
const handleNavigate = useCallback((href: string) => { setPendingHref(href) }, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop — ssr:false means this only renders on client, no hydration flash */}
|
||||
<aside
|
||||
className="hidden md:flex flex-col shrink-0 bg-transparent overflow-hidden relative z-10"
|
||||
style={{ width: collapsed ? COLLAPSED : EXPANDED, transition: 'width 200ms cubic-bezier(0.4, 0, 0.2, 1)' }}
|
||||
>
|
||||
<SidebarContent
|
||||
isMobile={false}
|
||||
collapsed={collapsed}
|
||||
siteId={siteId}
|
||||
sites={sites}
|
||||
canEdit={canEdit}
|
||||
pendingHref={pendingHref}
|
||||
onNavigate={handleNavigate}
|
||||
onMobileClose={onMobileClose}
|
||||
onToggle={toggle}
|
||||
auth={auth}
|
||||
orgs={orgs}
|
||||
onSwitchOrganization={handleSwitchOrganization}
|
||||
openSettings={() => openUnifiedSettings({ context: 'account', tab: 'profile' })}
|
||||
openOrgSettings={() => openUnifiedSettings({ context: 'workspace', tab: 'general' })}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
{/* Mobile overlay */}
|
||||
{(mobileOpen || mobileClosing) && (
|
||||
<>
|
||||
<div
|
||||
className={`fixed inset-0 z-40 bg-black/30 md:hidden transition-opacity duration-200 ${
|
||||
mobileClosing ? 'opacity-0' : 'opacity-100'
|
||||
}`}
|
||||
onClick={handleMobileClose}
|
||||
/>
|
||||
<aside
|
||||
className={`fixed inset-y-0 left-0 z-50 w-72 bg-neutral-900/65 backdrop-blur-3xl backdrop-saturate-150 supports-[backdrop-filter]:bg-neutral-900/60 border-r border-white/[0.08] shadow-xl shadow-black/20 md:hidden ${
|
||||
mobileClosing
|
||||
? 'animate-out slide-out-to-left duration-200 fill-mode-forwards'
|
||||
: 'animate-in slide-in-from-left duration-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-white/[0.06]">
|
||||
<span className="text-sm font-semibold text-white">Navigation</span>
|
||||
<button onClick={handleMobileClose} className="p-1.5 text-neutral-400 hover:text-neutral-300">
|
||||
<XIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<SidebarContent
|
||||
isMobile={true}
|
||||
collapsed={collapsed}
|
||||
siteId={siteId}
|
||||
sites={sites}
|
||||
canEdit={canEdit}
|
||||
pendingHref={pendingHref}
|
||||
onNavigate={handleNavigate}
|
||||
onMobileClose={handleMobileClose}
|
||||
onToggle={toggle}
|
||||
auth={auth}
|
||||
orgs={orgs}
|
||||
onSwitchOrganization={handleSwitchOrganization}
|
||||
openSettings={() => openUnifiedSettings({ context: 'account', tab: 'profile' })}
|
||||
openOrgSettings={() => openUnifiedSettings({ context: 'workspace', tab: 'general' })}
|
||||
/>
|
||||
</aside>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
62
components/dashboard/SiteNav.tsx
Normal file
62
components/dashboard/SiteNav.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { motion } from 'framer-motion'
|
||||
import { useTabListKeyboard } from '@/lib/hooks/useTabListKeyboard'
|
||||
|
||||
interface SiteNavProps {
|
||||
siteId: string
|
||||
}
|
||||
|
||||
export default function SiteNav({ siteId }: SiteNavProps) {
|
||||
const pathname = usePathname()
|
||||
const handleTabKeyDown = useTabListKeyboard()
|
||||
|
||||
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: 'Search', href: `/sites/${siteId}/search` },
|
||||
{ label: 'CDN', href: `/sites/${siteId}/cdn` },
|
||||
{ label: 'Uptime', href: `/sites/${siteId}/uptime` },
|
||||
]
|
||||
|
||||
const isActive = (href: string) => {
|
||||
if (href === `/sites/${siteId}`) {
|
||||
return pathname === href
|
||||
}
|
||||
return pathname.startsWith(href)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-6 overflow-x-auto scrollbar-hide">
|
||||
<nav className="flex gap-1 min-w-max border-b border-neutral-200 dark:border-neutral-800" 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 shrink-0 whitespace-nowrap 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-white'
|
||||
: 'text-neutral-400 dark:text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
{isActive(tab.href) && (
|
||||
<motion.div
|
||||
layoutId="activeTab"
|
||||
className="absolute inset-x-0 -bottom-px h-0.5 bg-brand-orange"
|
||||
transition={{ type: 'spring', stiffness: 500, damping: 35 }}
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +1,16 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { logger } from '@/lib/utils/logger'
|
||||
import { formatNumber } from '@ciphera-net/ui'
|
||||
import { useTabListKeyboard } from '@/lib/hooks/useTabListKeyboard'
|
||||
import { getBrowserIcon, getOSIcon, getDeviceIcon } from '@/lib/utils/icons'
|
||||
import { MdMonitor } from 'react-icons/md'
|
||||
import { Modal, GridIcon } from '@ciphera-net/ui'
|
||||
import Link from 'next/link'
|
||||
import { Monitor, DeviceMobile, FrameCornersIcon } from '@phosphor-icons/react'
|
||||
import { Modal, GridIcon, ArrowRightIcon } from '@ciphera-net/ui'
|
||||
import { ListSkeleton } from '@/components/skeletons'
|
||||
import VirtualList from './VirtualList'
|
||||
import { getBrowsers, getOS, getDevices, getScreenResolutions } from '@/lib/api/stats'
|
||||
import { type DimensionFilter } from '@/lib/filters'
|
||||
|
||||
@@ -25,6 +28,13 @@ interface TechSpecsProps {
|
||||
|
||||
type Tab = 'browsers' | 'os' | 'devices' | 'screens'
|
||||
|
||||
function capitalize(s: string): string {
|
||||
if (!s) return s
|
||||
// Preserve intentional casing (e.g. macOS, iOS, webOS, ChromeOS, FreeBSD)
|
||||
if (s !== s.toLowerCase() && s !== s.toUpperCase()) return s
|
||||
return s.charAt(0).toUpperCase() + s.slice(1)
|
||||
}
|
||||
|
||||
const LIMIT = 7
|
||||
|
||||
const TAB_TO_DIMENSION: Record<string, string> = { browsers: 'browser', os: 'os', devices: 'device' }
|
||||
@@ -33,6 +43,7 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
|
||||
const [activeTab, setActiveTab] = useState<Tab>('browsers')
|
||||
const handleTabKeyDown = useTabListKeyboard()
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
const [modalSearch, setModalSearch] = useState('')
|
||||
type TechItem = { name: string; pageviews: number; icon: React.ReactNode }
|
||||
const [fullData, setFullData] = useState<TechItem[]>([])
|
||||
const [isLoadingFull, setIsLoadingFull] = useState(false)
|
||||
@@ -59,7 +70,7 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
|
||||
data = res.map(d => ({ name: d.device, pageviews: d.pageviews, icon: getDeviceIcon(d.device) }))
|
||||
} else if (activeTab === 'screens') {
|
||||
const res = await getScreenResolutions(siteId, dateRange.start, dateRange.end, 100)
|
||||
data = res.map(s => ({ name: s.screen_resolution, pageviews: s.pageviews, icon: <MdMonitor className="text-neutral-500" /> }))
|
||||
data = res.map(s => ({ name: s.screen_resolution, pageviews: s.pageviews, icon: <Monitor className="text-neutral-500" /> }))
|
||||
}
|
||||
setFullData(filterUnknown(data))
|
||||
} catch (e) {
|
||||
@@ -83,7 +94,7 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
|
||||
case 'devices':
|
||||
return devices.map(d => ({ name: d.device, pageviews: d.pageviews, icon: getDeviceIcon(d.device) }))
|
||||
case 'screens':
|
||||
return screenResolutions.map(s => ({ name: s.screen_resolution, pageviews: s.pageviews, icon: <MdMonitor className="text-neutral-500" /> }))
|
||||
return screenResolutions.map(s => ({ name: s.screen_resolution, pageviews: s.pageviews, icon: <Monitor className="text-neutral-500" /> }))
|
||||
default:
|
||||
return []
|
||||
}
|
||||
@@ -120,25 +131,44 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
|
||||
|
||||
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-neutral-900/80 border border-white/[0.08] rounded-2xl p-6 h-full flex flex-col">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
|
||||
Technology
|
||||
</h3>
|
||||
<div className="flex gap-1" role="tablist" aria-label="Technology view tabs" onKeyDown={handleTabKeyDown}>
|
||||
<div className="flex items-center gap-2">
|
||||
<DeviceMobile className="w-5 h-5 text-neutral-400 dark:text-neutral-500" weight="bold" />
|
||||
<h3 className="text-lg font-semibold text-white">
|
||||
Technology
|
||||
</h3>
|
||||
{showViewAll && (
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="p-1.5 text-neutral-400 dark:text-neutral-500 hover:text-brand-orange dark:hover:text-brand-orange hover:bg-neutral-800 transition-all cursor-pointer rounded-lg"
|
||||
aria-label="View all technology"
|
||||
>
|
||||
<FrameCornersIcon className="w-4 h-4" weight="bold" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1 overflow-x-auto scrollbar-hide" role="tablist" aria-label="Technology view tabs" onKeyDown={handleTabKeyDown}>
|
||||
{(['browsers', 'os', 'devices', 'screens'] as Tab[]).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
role="tab"
|
||||
aria-selected={activeTab === tab}
|
||||
className={`px-2.5 py-1 text-xs font-medium transition-colors capitalize focus:outline-none focus:ring-2 focus:ring-brand-orange rounded cursor-pointer border-b-2 ${
|
||||
className={`relative px-2.5 py-1 text-xs font-medium transition-colors capitalize focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange rounded cursor-pointer ${
|
||||
activeTab === tab
|
||||
? 'border-brand-orange text-neutral-900 dark:text-white'
|
||||
: 'border-transparent text-neutral-400 dark:text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300'
|
||||
? 'text-white'
|
||||
: 'text-neutral-400 dark:text-neutral-500 hover:text-neutral-300'
|
||||
}`}
|
||||
>
|
||||
{tab}
|
||||
{activeTab === tab && (
|
||||
<motion.div
|
||||
layoutId="techSpecsTab"
|
||||
className="absolute inset-x-0 -bottom-px h-0.5 bg-brand-orange"
|
||||
transition={{ type: 'spring', stiffness: 500, damping: 35 }}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -147,61 +177,62 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
|
||||
<div className="space-y-2 flex-1 min-h-[270px]">
|
||||
{isTabDisabled() ? (
|
||||
<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-400 text-sm">{getDisabledMessage()}</p>
|
||||
</div>
|
||||
) : hasData ? (
|
||||
<>
|
||||
{displayedData.map((item) => {
|
||||
const dim = TAB_TO_DIMENSION[activeTab]
|
||||
const canFilter = onFilter && dim
|
||||
const maxPv = displayedData[0]?.pageviews ?? 0
|
||||
const barWidth = maxPv > 0 ? (item.pageviews / maxPv) * 75 : 0
|
||||
return (
|
||||
<div
|
||||
key={item.name}
|
||||
onClick={() => canFilter && onFilter({ dimension: dim, operator: 'is', values: [item.name] })}
|
||||
className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors${canFilter ? ' cursor-pointer' : ''}`}
|
||||
className={`relative flex items-center justify-between h-9 group hover:bg-neutral-800/50 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">
|
||||
<div
|
||||
className="absolute inset-y-0.5 left-0.5 bg-brand-orange/15 dark:bg-brand-orange/40 rounded-md transition-all"
|
||||
style={{ width: `${barWidth}%` }}
|
||||
/>
|
||||
<div className="relative flex-1 truncate text-white flex items-center gap-3">
|
||||
{item.icon && <span className="text-lg">{item.icon}</span>}
|
||||
<span className="truncate">{item.name}</span>
|
||||
<span className="truncate">{capitalize(item.name)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
<div className="relative 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">
|
||||
<span className="text-sm font-semibold text-neutral-400">
|
||||
{formatNumber(item.pageviews)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{showViewAll ? (
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="flex items-center justify-center gap-1.5 h-9 w-full text-xs font-medium text-neutral-400 dark:text-neutral-500 hover:text-brand-orange dark:hover:text-brand-orange transition-colors cursor-pointer rounded-lg px-2 -mx-2"
|
||||
>
|
||||
View all
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
) : (
|
||||
Array.from({ length: emptySlots }).map((_, i) => (
|
||||
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
|
||||
))
|
||||
)}
|
||||
{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">
|
||||
<GridIcon className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
|
||||
<div className="rounded-full bg-neutral-800 p-4">
|
||||
<GridIcon className="w-8 h-8 text-neutral-400" />
|
||||
</div>
|
||||
<h4 className="font-semibold text-neutral-900 dark:text-white">
|
||||
<h4 className="font-semibold text-white">
|
||||
No technology data yet
|
||||
</h4>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs">
|
||||
<p className="text-sm text-neutral-400 max-w-xs">
|
||||
Browser, OS, and device information will appear as visitors arrive.
|
||||
</p>
|
||||
<Link
|
||||
href="/installation"
|
||||
className="inline-flex items-center gap-2 text-sm font-medium text-brand-orange hover:text-brand-orange/90 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange/20 rounded"
|
||||
>
|
||||
Install tracking script
|
||||
<ArrowRightIcon className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -209,27 +240,59 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
|
||||
|
||||
<Modal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
title={`Technology - ${activeTab.charAt(0).toUpperCase() + activeTab.slice(1)}`}
|
||||
onClose={() => { setIsModalOpen(false); setModalSearch('') }}
|
||||
title={activeTab.charAt(0).toUpperCase() + activeTab.slice(1)}
|
||||
className="max-w-2xl"
|
||||
>
|
||||
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-2">
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={modalSearch}
|
||||
onChange={(e) => setModalSearch(e.target.value)}
|
||||
placeholder="Search technology..."
|
||||
className="w-full px-3 py-2 mb-3 text-sm bg-neutral-800 border border-neutral-700 rounded-lg text-white placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-brand-orange/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-[80vh]">
|
||||
{isLoadingFull ? (
|
||||
<div className="py-4">
|
||||
<ListSkeleton rows={10} />
|
||||
</div>
|
||||
) : (
|
||||
(fullData.length > 0 ? fullData : data).map((item) => (
|
||||
<div key={item.name} className="flex items-center justify-between py-2 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors">
|
||||
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
|
||||
{item.icon && <span className="text-lg">{item.icon}</span>}
|
||||
<span className="truncate">{item.name === 'Unknown' ? 'Unknown' : item.name}</span>
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-neutral-600 dark:text-neutral-400 ml-4">
|
||||
{formatNumber(item.pageviews)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
) : (() => {
|
||||
const modalData = (fullData.length > 0 ? fullData : data).filter(item => !modalSearch || item.name.toLowerCase().includes(modalSearch.toLowerCase()))
|
||||
const modalTotal = modalData.reduce((sum, item) => sum + item.pageviews, 0)
|
||||
const dim = TAB_TO_DIMENSION[activeTab]
|
||||
return (
|
||||
<VirtualList
|
||||
items={modalData}
|
||||
estimateSize={36}
|
||||
className="max-h-[80vh] overflow-y-auto pr-2"
|
||||
renderItem={(item) => {
|
||||
const canFilter = onFilter && dim
|
||||
return (
|
||||
<div
|
||||
key={item.name}
|
||||
onClick={() => { if (canFilter) { onFilter({ dimension: dim, operator: 'is', values: [item.name] }); setIsModalOpen(false) } }}
|
||||
className={`flex items-center justify-between h-9 group hover:bg-neutral-800 rounded-lg px-2 transition-colors${canFilter ? ' cursor-pointer' : ''}`}
|
||||
>
|
||||
<div className="flex-1 truncate 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-400">
|
||||
{formatNumber(item.pageviews)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { logger } from '@/lib/utils/logger'
|
||||
import Image from 'next/image'
|
||||
import { formatNumber } from '@ciphera-net/ui'
|
||||
import { getReferrerDisplayName, getReferrerFavicon, getReferrerIcon, mergeReferrersByDisplayName } from '@/lib/utils/icons'
|
||||
import { Modal, GlobeIcon } from '@ciphera-net/ui'
|
||||
import Link from 'next/link'
|
||||
import { ArrowSquareOut, FrameCornersIcon } from '@phosphor-icons/react'
|
||||
import { Modal, GlobeIcon, ArrowRightIcon } from '@ciphera-net/ui'
|
||||
import { ListSkeleton } from '@/components/skeletons'
|
||||
import VirtualList from './VirtualList'
|
||||
import { getTopReferrers, TopReferrer } from '@/lib/api/stats'
|
||||
import { type DimensionFilter } from '@/lib/filters'
|
||||
|
||||
@@ -22,6 +24,7 @@ const LIMIT = 7
|
||||
|
||||
export default function TopReferrers({ referrers, collectReferrers = true, siteId, dateRange, onFilter }: TopReferrersProps) {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
const [modalSearch, setModalSearch] = useState('')
|
||||
const [fullData, setFullData] = useState<TopReferrer[]>([])
|
||||
const [isLoadingFull, setIsLoadingFull] = useState(false)
|
||||
const [faviconFailed, setFaviconFailed] = useState<Set<string>>(new Set())
|
||||
@@ -44,14 +47,21 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
|
||||
const useFavicon = faviconUrl && !faviconFailed.has(referrer)
|
||||
if (useFavicon) {
|
||||
return (
|
||||
<Image
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={faviconUrl}
|
||||
alt=""
|
||||
width={20}
|
||||
height={20}
|
||||
className="w-5 h-5 flex-shrink-0 rounded object-contain"
|
||||
onError={() => setFaviconFailed((prev) => new Set(prev).add(referrer))}
|
||||
unoptimized
|
||||
onLoad={(e) => {
|
||||
// Google's favicon service returns a 16x16 default globe when no real favicon exists
|
||||
const img = e.currentTarget
|
||||
if (img.naturalWidth <= 16) {
|
||||
setFaviconFailed((prev) => new Set(prev).add(referrer))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -85,65 +95,80 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
|
||||
<>
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
|
||||
Referrers
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<ArrowSquareOut className="w-5 h-5 text-neutral-400 dark:text-neutral-500" weight="bold" />
|
||||
<h3 className="text-lg font-semibold text-white">
|
||||
Referrers
|
||||
</h3>
|
||||
{showViewAll && (
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="p-1.5 text-neutral-400 dark:text-neutral-500 hover:text-brand-orange dark:hover:text-brand-orange hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-all cursor-pointer rounded-lg"
|
||||
aria-label="View all referrers"
|
||||
>
|
||||
<FrameCornersIcon className="w-4 h-4" weight="bold" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 flex-1 min-h-[270px]">
|
||||
{!collectReferrers ? (
|
||||
<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">Referrer tracking is disabled in site settings</p>
|
||||
<p className="text-neutral-400 text-sm">Referrer tracking is disabled in site settings</p>
|
||||
</div>
|
||||
) : hasData ? (
|
||||
<>
|
||||
{displayedReferrers.map((ref) => (
|
||||
<div
|
||||
key={ref.referrer}
|
||||
onClick={() => onFilter?.({ dimension: 'referrer', operator: 'is', values: [ref.referrer] })}
|
||||
className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors${onFilter ? ' cursor-pointer' : ''}`}
|
||||
>
|
||||
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
|
||||
{renderReferrerIcon(ref.referrer)}
|
||||
<span className="truncate" title={ref.referrer}>{getReferrerDisplayName(ref.referrer)}</span>
|
||||
{displayedReferrers.map((ref) => {
|
||||
const maxPv = displayedReferrers[0]?.pageviews ?? 0
|
||||
const barWidth = maxPv > 0 ? (ref.pageviews / maxPv) * 75 : 0
|
||||
return (
|
||||
<div
|
||||
key={ref.referrer}
|
||||
onClick={() => onFilter?.({ dimension: 'referrer', operator: 'is', values: ref.allReferrers ?? [ref.referrer] })}
|
||||
className={`relative flex items-center justify-between h-9 group hover:bg-neutral-50/50 dark:hover:bg-neutral-800/50 rounded-lg px-2 -mx-2 transition-colors${onFilter ? ' cursor-pointer' : ''}`}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-y-0.5 left-0.5 bg-brand-orange/15 dark:bg-brand-orange/40 rounded-md transition-all"
|
||||
style={{ width: `${barWidth}%` }}
|
||||
/>
|
||||
<div className="relative flex-1 truncate text-white flex items-center gap-3">
|
||||
{renderReferrerIcon(ref.referrer)}
|
||||
<span className="truncate" title={ref.referrer}>{getReferrerDisplayName(ref.referrer)}</span>
|
||||
</div>
|
||||
<div className="relative flex items-center gap-2 ml-4">
|
||||
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
|
||||
{totalPageviews > 0 ? `${Math.round((ref.pageviews / totalPageviews) * 100)}%` : ''}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
|
||||
{formatNumber(ref.pageviews)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
|
||||
{totalPageviews > 0 ? `${Math.round((ref.pageviews / totalPageviews) * 100)}%` : ''}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
|
||||
{formatNumber(ref.pageviews)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{Array.from({ length: emptySlots }).map((_, i) => (
|
||||
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
|
||||
))}
|
||||
{showViewAll ? (
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="flex items-center justify-center gap-1.5 h-9 w-full text-xs font-medium text-neutral-400 dark:text-neutral-500 hover:text-brand-orange dark:hover:text-brand-orange transition-colors cursor-pointer rounded-lg px-2 -mx-2"
|
||||
>
|
||||
View all
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
) : (
|
||||
Array.from({ length: emptySlots }).map((_, i) => (
|
||||
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
|
||||
))
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="h-full flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
|
||||
<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-400" />
|
||||
</div>
|
||||
<h4 className="font-semibold text-neutral-900 dark:text-white">
|
||||
<h4 className="font-semibold text-white">
|
||||
No referrers yet
|
||||
</h4>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs">
|
||||
<p className="text-sm text-neutral-400 max-w-xs">
|
||||
Traffic sources will appear here when visitors come from external sites.
|
||||
</p>
|
||||
<Link
|
||||
href="/installation"
|
||||
className="inline-flex items-center gap-2 text-sm font-medium text-brand-orange hover:text-brand-orange/90 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange/20 rounded"
|
||||
>
|
||||
Install tracking script
|
||||
<ArrowRightIcon className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -151,27 +176,55 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
|
||||
|
||||
<Modal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
onClose={() => { setIsModalOpen(false); setModalSearch('') }}
|
||||
title="Referrers"
|
||||
className="max-w-2xl"
|
||||
>
|
||||
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-2">
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={modalSearch}
|
||||
onChange={(e) => setModalSearch(e.target.value)}
|
||||
placeholder="Search referrers..."
|
||||
className="w-full px-3 py-2 mb-3 text-sm bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg text-white placeholder-neutral-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-brand-orange/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-[80vh]">
|
||||
{isLoadingFull ? (
|
||||
<div className="py-4">
|
||||
<ListSkeleton rows={10} />
|
||||
</div>
|
||||
) : (
|
||||
mergeReferrersByDisplayName(fullData.length > 0 ? fullData : filteredReferrers).map((ref) => (
|
||||
<div key={ref.referrer} className="flex items-center justify-between py-2 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors">
|
||||
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
|
||||
{renderReferrerIcon(ref.referrer)}
|
||||
<span className="truncate" title={ref.referrer}>{getReferrerDisplayName(ref.referrer)}</span>
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-neutral-600 dark:text-neutral-400 ml-4">
|
||||
{formatNumber(ref.pageviews)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
) : (() => {
|
||||
const modalData = mergeReferrersByDisplayName(fullData.length > 0 ? fullData : filteredReferrers).filter(r => !modalSearch || getReferrerDisplayName(r.referrer).toLowerCase().includes(modalSearch.toLowerCase()))
|
||||
const modalTotal = modalData.reduce((sum, r) => sum + r.pageviews, 0)
|
||||
return (
|
||||
<VirtualList
|
||||
items={modalData}
|
||||
estimateSize={36}
|
||||
className="max-h-[80vh] overflow-y-auto pr-2"
|
||||
renderItem={(ref) => (
|
||||
<div
|
||||
key={ref.referrer}
|
||||
onClick={() => { if (onFilter) { onFilter({ dimension: 'referrer', operator: 'is', values: [ref.referrer] }); setIsModalOpen(false) } }}
|
||||
className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 transition-colors${onFilter ? ' cursor-pointer' : ''}`}
|
||||
>
|
||||
<div className="flex-1 truncate text-white flex items-center gap-3">
|
||||
{renderReferrerIcon(ref.referrer)}
|
||||
<span className="truncate" title={ref.referrer}>{getReferrerDisplayName(ref.referrer)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
|
||||
{modalTotal > 0 ? `${Math.round((ref.pageviews / modalTotal) * 100)}%` : ''}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
|
||||
{formatNumber(ref.pageviews)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user