571 Commits

Author SHA1 Message Date
Usman
15cb0c58ce Merge pull request #74 from ciphera-net/staging
fix: code blocks rendering + consistent styling with ciphera-website
2026-03-29 01:12:24 +01:00
Usman Baig
6c73fd1dbc fix: type error on pre component children.props access 2026-03-29 01:09:30 +01:00
Usman Baig
b1ed7518b1 fix: code block filename not showing, consistent code block styling 2026-03-29 01:07:21 +01:00
Usman Baig
627613dc13 fix: code blocks rendering + consistent styling with ciphera-website /learn
- Convert MDX CodeBlock components to standard markdown code fences
- Add rehype-mdx-code-props to pass filename meta to code components
- Custom pre/code MDX components map fences to CodeBlock
- Add brand color badges (product + category) matching /learn layout
- Match prose styling: orange inline code, orange links, white strong
- Remove brand color background glow (not in /learn)
2026-03-29 01:01:56 +01:00
Usman
a992afe04b Merge pull request #73 from ciphera-net/staging
Dashboard shell, breadcrumb navigation, sidebar redesign & integration pages SEO overhaul
2026-03-29 00:30:29 +01:00
Usman Baig
066f1288f1 feat: trim integration pages from 75 to 25 + migrate to MDX
- Add dedicatedPage flag to integration registry (25 true, 50 false)
- Delete hardcoded nextjs/react/vue/wordpress route pages (wrong metadata)
- Hub page routes non-dedicated integrations to /integrations/script-tag
- Add 301 redirects for 50 removed slugs → /integrations/script-tag
- Migrate guide content from TSX to MDX (content/integrations/*.mdx)
- Add gray-matter, next-mdx-remote, remark-gfm dependencies
- Add content loader (lib/integration-content.ts) matching ciphera-website pattern
- Add prebuild script for integration guide index generation
- Sitemap reduced from 83 to 35 URLs with real lastmod dates
- Remove seoDescription from registry (now in MDX frontmatter)
2026-03-29 00:28:47 +01:00
Usman Baig
20d7bdd482 fix: larger sidebar tooltips (text-sm, more padding) 2026-03-28 22:56:57 +01:00
Usman Baig
ef1cb32c51 fix: consistent group header height prevents icon shift on toggle 2026-03-28 22:54:47 +01:00
Usman Baig
3067101fec fix: tooltip uses content panel bg (neutral-950) + 100ms delay 2026-03-28 22:53:25 +01:00
Usman Baig
16fd913351 fix: increase sidebar tooltip delay to 400ms 2026-03-28 22:52:12 +01:00
Usman Baig
c7f2600460 fix: portal-based sidebar tooltips, visible when collapsed
Old tooltips were clipped by overflow-hidden on the aside.
New SidebarTooltip renders via createPortal with fixed positioning,
100ms delay, rounded-lg glass styling with border and shadow.
2026-03-28 22:48:30 +01:00
Usman Baig
62df9b3521 Revert "feat: double sidebar with icon rail + nav panel"
This reverts commit 24fb5258d5.
2026-03-28 22:27:15 +01:00
Usman Baig
24fb5258d5 feat: double sidebar with icon rail + nav panel
Rail (always visible, 56px): Pulse logo, home icon, site favicons
with quick switch, add site, notifications, profile.
Panel (collapsible, 200px): context-specific nav groups.
Site favicons in rail show ring highlight for active site.
Collapse toggle hides panel, rail stays visible.
2026-03-28 22:21:49 +01:00
Usman Baig
9053004e25 fix(search): show skeleton until overview data loads, not just GSC status 2026-03-28 21:26:28 +01:00
Usman Baig
4c1f70655a feat: move app switcher from sidebar to breadcrumbs
Breadcrumbs now show: Pulse ▾ > Your Sites > site ▾ > Page
"Pulse ▾" opens the Ciphera apps dropdown (Drop, Auth).
Removed AppLauncher and CIPHERA label from sidebar top.
2026-03-28 21:20:09 +01:00
Usman Baig
48320c4db3 feat: move site picker from sidebar to breadcrumbs
Replaced simple breadcrumb dropdown with the full sidebar-style
site picker (search, favicons, name+domain, add new site, animation).
Removed SitePicker from sidebar and cleaned up unused props.
2026-03-28 20:57:29 +01:00
Usman Baig
ff256a5986 fix: center breadcrumb caret, remove dropdown padding gaps 2026-03-28 20:23:08 +01:00
Usman Baig
2113ee348a feat: add site switcher dropdown to breadcrumbs
Site name in breadcrumbs is now clickable with a dropdown showing
all sites with favicons. Selecting a site navigates to the same
section on that site. Lazy-loads site list on first open.
2026-03-28 20:14:14 +01:00
Usman Baig
9feffa5cc6 feat: add breadcrumb navigation to GlassTopBar
Site pages show: Your Sites > site-name > Page Title
Each segment is clickable for navigation back.
Home/non-site pages show plain title as before.
2026-03-28 20:04:51 +01:00
Usman Baig
663abc9b9e feat: DashboardShell for all auth pages, site settings modal from home
- layout-content wraps integrations/pricing in DashboardShell
- GlassTopBar derives title per page (Integrations, Pricing, etc.)
- Site card gear icon opens settings modal with siteId context
- Removed delete button from site cards (accessible via site settings)
- Extended InitialTab to accept optional siteId for cross-page use
2026-03-28 19:42:42 +01:00
Usman Baig
c36c1b0696 feat: wrap all authenticated pages in DashboardShell, fix site card actions
- Move DashboardShell wrapping to layout-content.tsx for all dashboard
  pages (home, integrations, pricing) instead of per-page
- GlassTopBar derives page title from pathname (Integrations, Pricing)
- Site card: gear icon now opens site settings, separate trash icon for delete
2026-03-28 19:35:23 +01:00
Usman Baig
45c518b3ba feat: add home sidebar nav (sites list, workspace, resources)
Three nav groups in home mode:
- Your Sites: each site with favicon, Add New Site
- Workspace: Integrations, Pricing, Workspace Settings
- Resources: Documentation (external link)

Same styling as site dashboard sidebar nav items.
2026-03-28 19:24:41 +01:00
Usman Baig
9413fb2a07 fix: match home page max-width to dashboard (max-w-7xl) 2026-03-28 19:15:33 +01:00
Usman Baig
a6054469ee feat: wrap home page in DashboardShell, remove stat cards
Home page now uses the same sidebar layout as dashboard pages.
Sidebar shows simplified home mode (logo, app switcher, profile)
without site-specific nav groups. Stat cards removed — plan info
lives in settings, site count is self-evident from the list.
2026-03-28 19:12:45 +01:00
Usman Baig
07546576c1 fix(pricing): default slider to first tier (10k) instead of third (100k) 2026-03-28 18:57:57 +01:00
Usman
4c5102ced1 Merge pull request #72 from ciphera-net/staging
Invoice list with VAT breakdown and PDF download
2026-03-28 16:40:01 +01:00
Usman Baig
05d183fe2c fix(billing): use full API_URL for PDF download endpoint 2026-03-28 15:53:50 +01:00
Usman Baig
9c5a47ff3a feat(billing): update invoice list with real data, PDF download, and VAT breakdown 2026-03-28 14:45:02 +01:00
Usman
84edcf9889 Merge pull request #71 from ciphera-net/staging
Add Mollie checkout flow, billing UI, and payment UX polish
2026-03-28 11:28:03 +01:00
Usman Baig
07401a4ce2 fix: use accessible button color tokens for WCAG AA contrast
Swap bg-brand-orange to bg-brand-orange-button on all interactive
buttons with white text. Decorative uses unchanged. Bumps
@ciphera-net/ui to 0.3.6.
2026-03-28 00:48:05 +01:00
Usman Baig
8c0700f406 fix: filter by all merged referrers when clicking a group (e.g. Pulse covers both prod and staging) 2026-03-28 00:24:04 +01:00
Usman Baig
94f9db9e51 feat: add Pulse to referrer registry — shows 'Pulse' with logo for pulse.ciphera.net and pulse-staging.ciphera.net 2026-03-28 00:05:51 +01:00
Usman Baig
0af290dc0b feat: add 22 URL map entries for remaining Lighthouse audits 2026-03-27 23:16:57 +01:00
Usman Baig
00423ee599 fix: auto-scroll to submit button when card payment is selected 2026-03-27 22:17:04 +01:00
Usman Baig
23132a5194 fix: hide redirect text when no payment method is selected 2026-03-27 22:12:29 +01:00
Usman Baig
6aea24f018 fix: checkout UX — no auto-select payment method, stable price table during loading, add spacing before submit button 2026-03-27 22:04:09 +01:00
Usman Baig
a9cf1484fd fix: always show table-style price breakdown on checkout, even without country selected 2026-03-27 21:50:16 +01:00
Usman Baig
c2b448672c fix: also preserve referrer for pulse and pulse-staging domains 2026-03-27 18:24:15 +01:00
Usman Baig
80ee2fb614 fix: preserve referrer for ciphera.net learn links, keep noreferrer for external 2026-03-27 18:23:40 +01:00
Usman Baig
22295302ee feat: expand learn link URL map to 116 entries — full Lighthouse audit coverage 2026-03-27 18:10:51 +01:00
Usman Baig
9c9066b75f refactor: update learn link URLs to product-based routing (/learn/pulse/...) 2026-03-27 17:51:30 +01:00
Usman Baig
773e91d490 feat: remap PageSpeed audit links to ciphera.net/learn articles 2026-03-27 17:44:40 +01:00
Usman Baig
324ba131d4 chore: bump @ciphera-net/ui to 0.3.4 (session refresh retry fix) 2026-03-27 16:23:58 +01:00
Usman Baig
e206399f9d fix: pause carousel on hidden tab, remove payment tile labels
Carousel: listen to visibilitychange to prevent animation backlog when
tab is backgrounded. Payment tiles: logo-only, no text labels.
2026-03-27 15:53:20 +01:00
Usman Baig
5faa0dec80 fix: pause carousel interval when tab is hidden
Prevents animation backlog from setInterval firing while the browser
throttles rAF in background tabs, which caused a blank carousel on return.
2026-03-27 15:52:17 +01:00
Usman Baig
eca42d56ca fix: use real official payment method logos instead of hand-drawn SVGs
Add official brand SVGs (Visa, Mastercard, Bancontact, iDEAL, Apple Pay,
Google Pay, SEPA) as static assets and render via img tags.
2026-03-27 15:39:30 +01:00
Usman Baig
5c90b15b2e feat: branded payment tiles, add Google Pay, remove Bank Transfer
Replace generic icons with colored brand SVGs (Mastercard, Bancontact,
iDEAL, Apple Pay, Google Pay, SEPA). Compact equal-height tiles.
2026-03-27 15:30:52 +01:00
Usman Baig
9c7afda80d feat: payment method selector with foldable card form
Checkout shows payment method tiles (Card, Bancontact, iDEAL,
Apple Pay, SEPA DD, Bank Transfer). Card selection expands the
embedded form; other methods redirect to Mollie hosted checkout
with the method pre-selected.
2026-03-27 15:12:27 +01:00
Usman Baig
a55f9a91bd fix: hide VAT warning during loading and when VAT is valid
Warning no longer flashes during VIES lookup or shows alongside
company info for Belgian B2B (valid VAT but not exempt).
2026-03-27 12:48:51 +01:00
Usman Baig
3306508bf0 fix: show Verified for valid VAT IDs regardless of exemption
Use company_name presence to determine validity instead of
vat_exempt, so Belgian B2B shows Verified + company info
even though 21% VAT still applies.
2026-03-27 12:43:59 +01:00
Usman Baig
cb7e4c7c98 fix: title-case VIES data, animate company info, no price flash
VIES returns ALL CAPS — now title-cased for display. Company info
slides in with framer-motion. Price breakdown stays visible during
VAT verification instead of flashing to a loading spinner.
2026-03-27 12:42:07 +01:00
Usman Baig
9656225b60 fix: eliminate double VIES call on Verify click
Let the verifiedVatId useEffect handle the fetch instead of
calling fetchVAT directly in handleVerifyVatId. Prevents
VIES MS_MAX_CONCURRENT_REQ rate limiting.
2026-03-27 12:37:08 +01:00
Usman Baig
8db8f65fca fix: show Verified only when VIES confirms valid VAT ID
Split isVatVerified into isVatChecked (user clicked Verify) and
isVatValid (VIES confirmed exempt). Button now shows Verify and
stays enabled after a failed check so the user can retry.
2026-03-27 12:28:10 +01:00
Usman Baig
a495ef8389 fix: only show slider focus ring on keyboard navigation
Replace focus: with focus-visible: on range input so the
orange ring only appears during keyboard nav, not on click.
2026-03-27 12:26:54 +01:00
Usman Baig
c7cf50ef1d fix: prevent price flash on VAT ID keystroke
Only re-fetch VAT when clearing a previously verified VAT ID,
not on every keystroke when nothing was verified yet.
2026-03-27 12:22:33 +01:00
Usman Baig
342d86c26d feat: add VAT ID verify button and company info display
PlanSummary now has a Verify button for VAT ID instead of
auto-verifying on input. Shows company name and address from
VIES on successful verification, with warning on invalid IDs.
2026-03-27 12:16:46 +01:00
Usman Baig
20628fa6ab fix: preserve org_id in auth refresh, fix org switcher navigation 2026-03-27 12:03:56 +01:00
Usman Baig
0ca65a50fb fix: org switcher in sidebar uses SPA navigation instead of hard reload 2026-03-27 11:55:34 +01:00
Usman Baig
ad207dc23f fix: move useState before conditional returns, fix yearly total display 2026-03-27 11:48:00 +01:00
Usman Baig
fc5372ff26 feat: add excl. VAT label to pricing page 2026-03-27 11:44:38 +01:00
Usman Baig
eb52b7fae6 feat: remove country/VAT inputs from PaymentForm, accept as props 2026-03-27 11:43:19 +01:00
Usman Baig
d9e3f90c27 feat: add VAT breakdown to PlanSummary 2026-03-27 11:41:44 +01:00
Usman Baig
0fcc4866fb feat: lift country/vatId state to CheckoutContent 2026-03-27 11:34:29 +01:00
Usman Baig
5ca24f6c9c feat: add calculateVAT API function 2026-03-27 11:33:48 +01:00
Usman Baig
f4ba6c8a2a Revert "docs: add VAT implementation design"
This reverts commit 4e9439770f.
2026-03-27 11:07:45 +01:00
Usman Baig
4e9439770f docs: add VAT implementation design 2026-03-27 11:07:20 +01:00
Usman Baig
ef83176089 fix: replace browser confirm with in-app modal for cancel subscription 2026-03-27 00:25:23 +01:00
Usman Baig
5cff767e32 fix: org switch now updates auth context immediately — no stale org in header 2026-03-26 23:59:30 +01:00
Usman Baig
342ee1fdf3 fix: add top padding to right column on checkout page 2026-03-26 23:54:00 +01:00
Usman Baig
af1d718a18 fix: move logo to left panel, increase slide interval to 8s, keep mobile logo 2026-03-26 23:50:28 +01:00
Usman Baig
0bfde33050 fix: remove funnel and email report slides from checkout slideshow 2026-03-26 23:39:38 +01:00
Usman Baig
088db2a104 fix: escape apostrophe in slideshow headline 2026-03-26 23:35:09 +01:00
Usman Baig
977425fdb9 feat: break visitor carousel into 5 separate slides with unique titles 2026-03-26 23:33:36 +01:00
Usman Baig
7a44787438 fix: center titles, constrain mockups with overflow hidden instead of scale 2026-03-26 23:31:50 +01:00
Usman Baig
b5150e3b7a fix: remove mockup border, scale down mockups to fit viewport 2026-03-26 23:28:03 +01:00
Usman Baig
4896089463 fix: remove dot indicators from checkout slideshow 2026-03-26 23:27:04 +01:00
Usman Baig
fba1f84ce5 fix: replace window.location.reload with router.refresh on org switch to prevent hydration errors 2026-03-26 23:25:49 +01:00
Usman Baig
7c55e5f763 fix: simplify slideshow to titles only, catch mollie unmount errors 2026-03-26 23:18:58 +01:00
Usman Baig
e5ac1893dc fix: lock left panel, only right side scrolls 2026-03-26 23:17:18 +01:00
Usman Baig
75bf071d98 feat: split checkout layout with auto-cycling feature slideshow 2026-03-26 23:10:14 +01:00
Usman Baig
4c6020535a fix: hide mollie spinners with overflow clip, show static placeholder fields while loading 2026-03-26 22:51:54 +01:00
Usman Baig
3a29fb5a09 fix: use visibility hidden instead of opacity to fully hide mollie loading spinners 2026-03-26 22:47:01 +01:00
Usman Baig
9297e20604 fix: increase card field height and font size for larger card brand logos 2026-03-26 22:45:35 +01:00
Usman Baig
497f0f791a fix: hide mollie spinners, add placeholders, errors only on submit, sliding interval toggle 2026-03-26 22:41:51 +01:00
Usman Baig
48f71ee65b fix: checkout UI polish — brand colors, Pulse Select, logo, touched-only errors, no skeletons 2026-03-26 22:33:30 +01:00
Usman Baig
742c24fa6b fix: prevent auth flash on checkout, skip subscription guard on success return 2026-03-26 22:21:59 +01:00
Usman Baig
f72a140ca6 fix: add required cardHolder component for mollie components 2026-03-26 22:16:20 +01:00
Usman Baig
3e7a32dc91 fix: use correct mollie component types (expiryDate, verificationCode) 2026-03-26 22:11:30 +01:00
Usman Baig
e089640fb9 fix: cast querySelector result to HTMLElement for mollie mount 2026-03-26 22:04:55 +01:00
Usman Baig
22dddc6b6f fix: mount mollie components after DOM ready via useEffect 2026-03-26 22:03:23 +01:00
Usman Baig
512368d79e fix: pass testmode flag to mollie.js based on env var 2026-03-26 21:57:48 +01:00
Usman Baig
0f41eb4df4 fix: allow mollie.js in CSP, hide app header on checkout page 2026-03-26 21:38:47 +01:00
Usman Baig
6be8952fbe fix: checkout page polish, metadata, and typescript fixes 2026-03-26 21:31:56 +01:00
Usman Baig
58ac7b9cc5 feat: pricing and welcome CTAs now redirect to /checkout page 2026-03-26 21:30:17 +01:00
Usman Baig
e23ec2ca40 feat: add payment form with mollie components card fields 2026-03-26 21:26:38 +01:00
Usman Baig
89575c9fcb feat: add checkout page shell with auth guard and success polling 2026-03-26 21:26:32 +01:00
Usman Baig
837f440107 feat: add plan summary component for checkout page 2026-03-26 21:25:51 +01:00
Usman Baig
6ea520e0ed feat: add mollie.js helper and embedded checkout API call 2026-03-26 21:24:27 +01:00
Usman Baig
d419322ab7 refactor: extract shared country list and plan prices 2026-03-26 21:22:12 +01:00
Usman Baig
4e7ad88763 fix: update billing tab for mollie response format, use updatePaymentMethod 2026-03-26 20:46:47 +01:00
Usman Baig
94d0b3498f feat: add country and vat id fields to checkout flow 2026-03-26 20:27:07 +01:00
Usman Baig
704557f233 feat: update frontend billing api for mollie (country, vat_id, payment method update) 2026-03-26 20:24:54 +01:00
Usman
0bf9424200 Merge pull request #70 from ciphera-net/staging
Legacy settings removal, performance improvements, modal polish
2026-03-26 12:04:48 +01:00
Usman Baig
ef3edd963a perf: reduce chart animation from 1100ms to 400ms — shorter main thread block 2026-03-26 11:52:29 +01:00
Usman Baig
952cebc59a perf: lazy-load Chart component — prevents main thread freeze on page load 2026-03-26 11:48:58 +01:00
Usman Baig
c63e72a578 feat: sliding background animation on context switcher 2026-03-26 11:39:46 +01:00
Usman Baig
e7d2ecf50b fix: remove tab content animation — prevents flash on context switch 2026-03-26 11:38:39 +01:00
Usman Baig
012b0d494f perf: lazy-load tabs, cache listSites, faster tab switching animation 2026-03-26 11:33:58 +01:00
Usman Baig
ee1196f061 fix: pass onOpenOrgSettings to UserMenu — no more page navigation for org settings 2026-03-26 11:26:38 +01:00
Usman Baig
c99278e7fa chore: bump @ciphera-net/ui to 0.3.3 2026-03-26 11:21:01 +01:00
Usman Baig
6fef6da468 fix: widen settings modal to max-w-4xl and 90vh height 2026-03-26 11:14:21 +01:00
Usman Baig
97b9486382 fix: org-settings redirect uses router.back() to stay on current page 2026-03-26 11:11:13 +01:00
Usman Baig
b352fa00e4 fix: respect requested context over URL auto-detection + fix backdrop click 2026-03-26 11:06:15 +01:00
Usman Baig
4a950f7070 fix: wrap org-settings redirect in Suspense for useSearchParams 2026-03-26 10:55:55 +01:00
Usman Baig
6b33483c81 fix: remove SettingsModalProvider import and wrapper from layout 2026-03-26 10:53:48 +01:00
Usman Baig
cc3047edba refactor: replace legacy settings pages with redirect handlers + delete unused files
- /sites/:id/settings → redirect handler for GSC OAuth callback + deep links
- /org-settings → redirect handler for tab deep links
- Deleted: OrganizationSettings.tsx, SettingsModalWrapper.tsx, settings-modal-context.tsx
2026-03-26 10:50:36 +01:00
Usman Baig
61a106eed6 refactor: replace all legacy settings links with unified modal openers 2026-03-26 10:47:51 +01:00
Usman
5165b885ff Merge pull request #69 from ciphera-net/staging
Unified settings modal + dashboard shell redesign
2026-03-26 10:15:33 +01:00
Usman Baig
f6e43976d8 feat: whole modal fades in/out together — glass + content as one unit 2026-03-26 00:27:26 +01:00
Usman Baig
ae54e0f10a fix: glass panel fades out on close, snaps in on open — no blur flash 2026-03-26 00:23:47 +01:00
Usman Baig
14695a52dd feat: dropdown-style animation on content — glass stays during exit via onExitComplete 2026-03-26 00:17:58 +01:00
Usman Baig
dc867e84f4 fix: glass instant, content fades in — backdrop and blur separate from animation 2026-03-26 00:10:40 +01:00
Usman Baig
3e603c77a9 feat: fade + scale animation on outer wrapper — glass panel untouched 2026-03-26 00:04:50 +01:00
Usman Baig
2be0841a54 fix: hide all modal content when closed — empty glass box keeps GPU blur warm 2026-03-26 00:01:22 +01:00
Usman Baig
b1254bcad0 fix: instant open/close — no animation prevents glass seep on close 2026-03-25 23:57:26 +01:00
Usman Baig
bd8fae626c fix: use visibility instead of opacity — zero intermediate frames, no blur flash 2026-03-25 23:53:15 +01:00
Usman Baig
67334f1fd6 fix: always-mounted modal — GPU keeps backdrop-filter composited, no blur delay 2026-03-25 23:48:57 +01:00
Usman Baig
b18199aa48 fix: remove backdrop-blur from overlay — single blur layer on modal only 2026-03-25 23:40:36 +01:00
Usman Baig
d97eb77569 fix: smooth close animation — opacity fade on exit only, instant on open 2026-03-25 23:35:18 +01:00
Usman Baig
df286ab0e4 fix: restore glass + remove opacity animation to prevent backdrop-filter flash 2026-03-25 23:29:15 +01:00
Usman Baig
941dcd73ce fix: solid modal background — backdrop-filter causes flash on animated elements 2026-03-25 23:22:51 +01:00
Usman Baig
603e910d40 fix: instant backdrop render prevents chart flash through glass modal 2026-03-25 23:19:27 +01:00
Usman Baig
d13372c864 feat(settings): glassmorphism modal matching ciphera-website header dropdowns 2026-03-25 23:15:27 +01:00
Usman Baig
09181affbb fix: danger zone red background on pending save bar, normal text color 2026-03-25 23:11:51 +01:00
Usman Baig
477a3b4568 fix: don't render ScriptSetupBlock until state initialized from site data 2026-03-25 23:03:53 +01:00
Usman Baig
4af5daa298 fix: wait for integration status to load before rendering cards — prevents flash 2026-03-25 23:01:58 +01:00
Usman Baig
c299b10d19 fix: use LinkBreak icon instead of Trash for disconnect button 2026-03-25 23:00:18 +01:00
Usman Baig
98ba751c2c fix: remove duplicate detail text from integration card titles 2026-03-25 22:58:44 +01:00
Usman Baig
8fb2f603bd debug: log site.script_features on init 2026-03-25 22:57:10 +01:00
Usman Baig
8d894ff92a fix: real BunnyCDN SVG logo + remove duplicate Connected status from grids 2026-03-25 22:55:55 +01:00
Usman Baig
1121a72d63 debug: log script_features at save time 2026-03-25 22:54:01 +01:00
Usman Baig
d819b4bd17 fix: remove card wrapping from bare inputs + fix privacy false-dirty on load 2026-03-25 22:49:25 +01:00
Usman Baig
095b68d769 fix: site name + domain side by side in general tab 2026-03-25 22:46:35 +01:00
Usman Baig
1da71aa1a2 fix(settings): wrap all settings items in card blocks for visual consistency 2026-03-25 22:44:23 +01:00
Usman Baig
e7b8943097 fix(settings): normalize all Workspace tabs to design standards 2026-03-25 22:24:11 +01:00
Usman Baig
9893b283cf fix(settings): normalize Account tabs to design standards 2026-03-25 22:24:02 +01:00
Usman Baig
a3b746deeb fix(settings): normalize Reports + Integrations tabs to design standards 2026-03-25 22:22:21 +01:00
Usman Baig
4d9c3aeabd fix(settings): normalize Visibility, Privacy, BotSpam tabs to design standards 2026-03-25 22:21:41 +01:00
Usman Baig
1cbc8064e2 fix(settings): normalize SiteGeneralTab + SiteGoalsTab to design standards 2026-03-25 22:20:42 +01:00
Usman Baig
db12ad04cf feat(settings): create shared DangerZone component 2026-03-25 22:19:37 +01:00
Usman Baig
1c916bb598 fix: deleteAccount requires password — add password input to delete flow 2026-03-25 22:05:01 +01:00
Usman Baig
712169187b refactor(settings): native profile form replacing SharedProfileSettings — consistent save bar 2026-03-25 22:02:19 +01:00
Usman Baig
cbf2125f0a fix: batch PageSpeed frequency and notification toggles into save flow 2026-03-25 21:59:36 +01:00
Usman Baig
f794696e90 fix: script features save with Save Changes instead of instantly 2026-03-25 21:55:14 +01:00
Usman Baig
6c0061733b fix: neutral save bar background, red text only when pending navigation 2026-03-25 21:39:55 +01:00
Usman Baig
c53152fc68 fix: reset save bar visibility and handler on discard 2026-03-25 21:37:31 +01:00
Usman Baig
3f884fca76 feat(settings): wire WorkspaceGeneralTab into dirty tracking + modal save bar 2026-03-25 21:26:48 +01:00
Usman Baig
5d21a81fad refactor(settings): move save bar to modal level — always flush with modal bottom 2026-03-25 21:24:06 +01:00
Usman Baig
549ac273a1 refactor(settings): move save bar to modal level, remove from tabs 2026-03-25 21:23:42 +01:00
Usman Baig
570dda7bd2 fix: remove -mb-6 from sticky bar — prevents jump at scroll bottom 2026-03-25 21:12:45 +01:00
Usman Baig
8ec9edb126 fix: rose warning bar + proper Discard button sizing 2026-03-25 21:09:37 +01:00
Usman Baig
43005fb9ee fix: red warning bar instead of amber when navigating with unsaved changes 2026-03-25 21:05:52 +01:00
Usman Baig
1c21bf5ff6 fix: amber warning style on sticky bar when navigating with unsaved changes 2026-03-25 21:02:00 +01:00
Usman Baig
81fafcf711 fix: discard button in sticky save bar instead of browser confirm 2026-03-25 20:51:39 +01:00
Usman Baig
7181d68d85 fix: replace amber unsaved changes bar with simple confirm() dialog 2026-03-25 20:42:02 +01:00
Usman Baig
0de8f927a4 chore: remove debug console.logs from privacy tab 2026-03-25 20:37:32 +01:00
Usman Baig
eb3c3b2738 debug: add console.logs to privacy tab dirty tracking 2026-03-25 20:33:46 +01:00
Usman Baig
93401cc1a1 fix: dirty tracking — prevent SWR revalidation from resetting form state 2026-03-25 20:27:47 +01:00
Usman Baig
9dceca765c feat(settings): sticky save bar appears only when dirty, replaces static button 2026-03-25 20:18:26 +01:00
Usman Baig
9a3fab3535 feat(settings): unsaved changes guard with inline confirmation bar 2026-03-25 20:09:11 +01:00
Usman Baig
1ad68943c8 fix: reset tabs to default when switching settings context 2026-03-25 18:23:58 +01:00
Usman Baig
688d268fbf fix: proper Google/Bunny logos and BunnyCDN status grid in unified integrations 2026-03-25 18:21:50 +01:00
Usman Baig
0f5a3388a0 fix: use correct PageSpeedConfig field name (frequency, not check_frequency) 2026-03-25 18:16:05 +01:00
Usman Baig
1fef7b175c feat(settings): add filtering and pagination to unified audit tab 2026-03-25 18:11:01 +01:00
Usman Baig
0cb13e08fd refactor(settings): wire all settings entry points to unified modal 2026-03-25 18:10:42 +01:00
Usman Baig
8bef4b7c9f feat(settings): add member removal and pending invitations to unified members tab 2026-03-25 18:08:22 +01:00
Usman Baig
b64c4c036f feat(settings): add working delete org to unified workspace general tab 2026-03-25 18:07:21 +01:00
Usman Baig
851c607b7a feat(settings): add invoice list to unified billing tab 2026-03-25 18:07:16 +01:00
Usman Baig
b164160d6a feat(settings): add bunny setup flow and GSC details to unified integrations tab 2026-03-25 18:04:07 +01:00
Usman Baig
ce992e331f fix(settings): show delete account in unified profile tab 2026-03-25 18:02:47 +01:00
Usman Baig
7dc6e0daf5 fix(settings): add remove password button to unified visibility tab 2026-03-25 18:02:39 +01:00
Usman Baig
f844751142 feat(settings): add report/alert create & edit modals to unified tab 2026-03-25 17:52:56 +01:00
Usman Baig
b3ccb58431 feat(settings): add session review to unified bot & spam tab 2026-03-25 17:48:39 +01:00
Usman Baig
d0d7a97102 feat(settings): add retention, excluded paths, pagespeed to unified privacy tab 2026-03-25 17:46:06 +01:00
Usman Baig
4e6837a9ee feat(settings): add framework picker and verification to unified general tab 2026-03-25 17:42:34 +01:00
Usman Baig
45a8adff0f feat(settings): add danger zone to unified site general tab 2026-03-25 17:38:47 +01:00
Usman Baig
294629edfe fix: downsize all page h1 headers — top bar now has primary title 2026-03-25 17:17:12 +01:00
Usman Baig
48b404eb37 fix: remove duplicate h1 from uptime page — title now in top bar 2026-03-25 17:11:48 +01:00
Usman Baig
b78f5d4b96 fix: add parens around nullish coalescing mixed with logical OR 2026-03-25 17:07:55 +01:00
Usman Baig
1aeb9cf275 feat: page title in top bar next to collapse toggle 2026-03-25 17:06:21 +01:00
Usman Baig
24858030ba fix: align AppLauncher with collapse toggle — pt-1.5 on both sides 2026-03-25 16:47:42 +01:00
Usman Baig
e39c10d50f fix: reduce top bar padding — pt-1.5 instead of pt-3 2026-03-25 16:44:42 +01:00
Usman Baig
953d828cd9 fix: align collapse toggle with sidebar AppLauncher row (Dokploy-style) 2026-03-25 16:39:53 +01:00
Usman Baig
540c0b51ca fix: remove duplicate realtime indicator from under chart 2026-03-24 23:56:30 +01:00
Usman Baig
9aacd63d1d fix: collapse toggle back in glass top bar, removed from sidebar 2026-03-24 23:53:44 +01:00
Usman Baig
132afa749c fix: collapse toggle as first sidebar item, realtime stays in glass bar
Collapse icon at top of sidebar (aligned with all icons). Glass top
bar now only shows realtime indicator on the right.
2026-03-24 23:44:49 +01:00
Usman Baig
4e5dd6e3f3 fix: collapse icon uses negative margin to align with sidebar icons 2026-03-24 23:40:16 +01:00
Usman Baig
4702bb91b9 fix: top bar spans full width — collapse icon aligns above sidebar 2026-03-24 23:38:13 +01:00
Usman Baig
5eabc52133 fix: sidebar collapse icon bigger (18px) and brighter (neutral-400) 2026-03-24 23:29:37 +01:00
Usman Baig
de10fb5daf fix: use max-w-7xl (1280px) instead of full-width — better readability 2026-03-24 23:21:57 +01:00
Usman Baig
d6627413b8 feat: full-width content — remove max-w-6xl from all site pages and skeletons 2026-03-24 23:16:36 +01:00
Usman Baig
bb55782dba fix: restore scrolling — overflow-clip was blocking overflow-y-auto 2026-03-24 23:06:44 +01:00
Usman Baig
0f462314e2 fix: move collapse toggle + realtime to glass area above content panel
GlassTopBar in the margin strip — SidebarSimple icon (phosphor) on
left, "Live · Xs ago" on right. ContentHeader reverted to mobile-only.
2026-03-24 23:02:52 +01:00
Usman Baig
102551b1ce feat: content header with collapse toggle + realtime indicator
- New SidebarProvider context for shared collapse state
- ContentHeader visible on desktop: collapse icon left, "Live" right
- Collapse button removed from sidebar bottom (moved to header)
- Keyboard shortcut [ handled by context, not sidebar
- Realtime indicator polls every 5s, ticks every 1s for freshness
2026-03-24 22:57:41 +01:00
Usman Baig
b74742e15e fix: thin subtle scrollbar — 6px, white/8% thumb, transparent track 2026-03-24 22:32:40 +01:00
Usman Baig
f3d72c9841 fix: move glassmorphism to shell level, sidebar becomes transparent
Shell now has the glass treatment so sidebar and surrounding area
are one seamless surface. No more visible line between sidebar
and content panel. Desktop sidebar is transparent over the shell.
Mobile sidebar keeps its own glass since it overlays independently.
2026-03-24 22:28:18 +01:00
Usman Baig
505454b7d6 fix: remove gradient behind sidebar 2026-03-24 22:22:44 +01:00
Usman Baig
14e0c9b4dc feat: subtle gradient behind sidebar for glass depth + fix scrollbar clip
- Shell bg changed to neutral-950 (darker, better contrast)
- Warm-to-cool gradient behind sidebar area (orange top, blue bottom)
- Gives the glassmorphic sidebar something to diffuse through
- overflow-clip + isolate on content panel for scrollbar corner clipping
2026-03-24 22:19:43 +01:00
Usman Baig
b607a9a76e fix: site picker opens outside sidebar when collapsed
No longer expands sidebar first. When collapsed, dropdown appears
to the right of the button (like AppLauncher/UserMenu/Notifications).
When expanded, opens below the button.
2026-03-24 22:11:46 +01:00
Usman Baig
441fd9afda fix: remove border-r from desktop sidebar 2026-03-24 22:05:09 +01:00
Usman Baig
441abbd568 fix: portal site picker to document.body to avoid glass-on-glass
Dropdown now uses createPortal + fixed positioning like AppLauncher,
UserMenu and NotificationCenter. Renders over page content instead
of over the glass sidebar, so /65 opacity looks correct.
2026-03-24 22:04:16 +01:00
Usman Baig
71e98d72b4 fix: site picker dropdown matches AppLauncher glassmorphism exactly 2026-03-24 21:59:25 +01:00
Usman Baig
def483cf6d fix: site picker dropdown opacity — more opaque over glass sidebar
Glass-on-glass caused double transparency. Use bg-neutral-900/90
since this dropdown overlays the already-translucent sidebar.
2026-03-24 21:54:44 +01:00
Usman Baig
f686063f0a feat: glassmorphism sidebar matching website header treatment
- Sidebar body: bg-neutral-900/65 + backdrop-blur-3xl + saturate-150
- All borders changed to white/[0.08] and white/[0.06] dividers
- Hover states use white/[0.06] for glass consistency
- Site picker dropdown gets same glass treatment
- Search input uses bg-white/[0.04] + border-white/[0.08]
- Mobile sidebar matches desktop glass effect
2026-03-24 21:51:15 +01:00
Usman Baig
d48479ee5b fix: add open/close animation to NotificationCenter dropdown
Match AppLauncher & UserMenu Framer Motion treatment:
opacity + scale + y-offset with 0.15s transition
2026-03-24 21:45:26 +01:00
Usman Baig
538df57d2b fix: glassmorphism dropdowns + bump @ciphera-net/ui to 0.3.2
- NotificationCenter panel matches website header glass effect
- Bump @ciphera-net/ui for UserMenu & AppLauncher glassmorphism
2026-03-24 21:41:45 +01:00
Usman Baig
5a03e1f9a5 fix: skeleton loading states match actual page layouts
- PageSpeed: show 4 gauge rings, screenshot, legend, metrics grid, trend chart
- Uptime: match real layout with status card, 90-day bar, 4-col detail grid
- Remove duplicate local skeletons in behavior components, use shared library
- Strip light-mode classes from dark-only app
2026-03-24 21:17:21 +01:00
Usman Baig
5dfc3a5636 ci: use self-hosted runner, add filter/date/client tests 2026-03-24 19:58:57 +01:00
Usman Baig
bb4861dbdc fix(settings): remove duplicate comma listener from Sidebar — modal handles it globally 2026-03-24 17:24:45 +01:00
Usman Baig
c48023be9f fix(settings): global comma shortcut works on all authenticated pages 2026-03-24 17:05:21 +01:00
Usman Baig
e12a3661fa fix(settings): lock site context to current URL, rename Workspace to Organization
- Site context is locked to the site from the current URL — no dropdown
  switcher. If not on a site page, defaults to Organization context.
- Renamed "Workspace" to "Organization" in all user-facing text.
- Removed unused CaretDown import and dropdown state.
2026-03-24 16:52:59 +01:00
Usman Baig
ea2c47b53f feat(settings): Phase 2 — all 15 tabs implemented
Site tabs:
- Visibility (public toggle, share link, password protection)
- Privacy (data collection toggles, geo level, retention info)
- Bot & Spam (filtering toggle, stats cards)
- Reports (scheduled reports + alert channels list with test/pause/delete)
- Integrations (GSC + BunnyCDN connect/disconnect cards)

Workspace tabs:
- Members (member list, invite form with role selector)
- Notifications (dynamic toggles from API categories)
- Audit Log (action log with timestamps)

Account tabs:
- Security (wraps existing ProfileSettings security tab)
- Devices (wraps existing TrustedDevicesCard + SecurityActivityCard)

No more "Coming soon" placeholders. All tabs are functional.
2026-03-23 21:29:49 +01:00
Usman Baig
e55a3c4ce4 fix(settings): fixed modal height prevents bottom-edge twitch on context switch 2026-03-23 21:09:24 +01:00
Usman Baig
d050d32d24 fix(settings): remove flicker and scrollbar flash on context switch 2026-03-23 21:04:31 +01:00
Usman Baig
3c17895d64 feat(settings): unified settings modal with context switcher (Phase 1)
New unified settings modal accessible via `,` keyboard shortcut.
Three-context switcher: Site (with site dropdown), Workspace, Account.
Horizontal tabs per context with animated transitions.

Phase 1 tabs implemented:
- Site → General (name, timezone, domain, tracking script with copy)
- Site → Goals (CRUD with inline create/edit)
- Workspace → General (org name, slug, danger zone)
- Workspace → Billing (plan card, usage, cancel/resume, portal)
- Account → Profile (wraps existing ProfileSettings)

Phase 2 tabs show "Coming soon" placeholder:
- Site: Visibility, Privacy, Bot & Spam, Reports, Integrations
- Workspace: Members, Notifications, Audit Log
- Account: Security, Devices

Old settings pages and profile modal remain functional.
2026-03-23 20:57:20 +01:00
Usman
345f4ff4e1 Merge pull request #68 from ciphera-net/staging
PageSpeed monitoring, Polar billing, sidebar polish, frontend consistency audit
2026-03-23 20:07:54 +01:00
Usman Baig
ca2f1ce19d fix(dashboard): content panel as rounded card, sidebar border removed
- Content panel: bg-neutral-950, rounded-2xl, border, margin on top/right/bottom
- Sidebar: removed border-r — content panel's left border acts as separator
- Outer shell: bg-neutral-900 matches sidebar, creating "floating panel" effect
2026-03-23 19:59:56 +01:00
Usman Baig
6521b694f4 fix: replace motion/react imports with framer-motion + rounded content panel
- 4 files imported from 'motion/react' which was the removed 'motion' package.
  Replaced with 'framer-motion' (the package actually installed).
- Dashboard content area now has rounded corners, subtle border, and inset
  margin creating a "panel inside shell" visual separation from the sidebar.
2026-03-23 19:54:44 +01:00
Usman Baig
a3c1af7c95 fix: frontend consistency audit — 55 files cleaned up
Consistency fixes:
- Extract getThisWeekRange/getThisMonthRange to shared lib/utils/dateRanges.ts
  (removed 4 identical copy-pasted definitions)
- Add error boundaries for behavior, cdn, search, pagespeed pages
  (4 new error.tsx files — previously fell through to generic parent error)
- Add "View setup guide" CTA to empty states on journeys and behavior pages
  (previously showed text with no actionable button)
- Fix non-lazy useState initializer in funnel detail page
- Fix Bot & Spam settings header from text-xl to text-2xl (matches all other sections)
- Add useMinimumLoading to PageSpeed skeleton (consistent with all other pages)

Cleanup:
- Remove 438 redundant dark: class prefixes (app is dark-mode only)
  text-neutral-500 dark:text-neutral-400 → text-neutral-400 (206 occurrences)
  text-neutral-900 dark:text-white → text-white (232 occurrences)
- Remove dead @stripe/react-stripe-js and @stripe/stripe-js packages
  (billing migrated to Polar, no code imports Stripe)
- Remove duplicate motion package (framer-motion is the one actually used)
2026-03-23 19:50:16 +01:00
Usman Baig
eca21bf627 feat(billing): update frontend for polar migration
Update billing types, remove invoice preview, replace Stripe invoice
display with Polar orders, update tax ID from array to single object,
remove upcoming invoice amount display.
2026-03-23 16:36:54 +01:00
Usman Baig
cd347ea072 feat: add illustrations to 404, error page, and welcome flow
- 404 page: replace large "404" text with page-not-found illustration
- ErrorDisplay: replace warning icon with server-down illustration
- Welcome step 1 (no orgs): welcome illustration
- Welcome step 4 (add site): website-setup illustration
- Welcome step 5 (done): confirmed illustration
All SVGs dark-themed with brand orange accent.
2026-03-23 15:40:01 +01:00
Usman Baig
21cee4f4ae fix(illustrations): remap SVG colors to dark theme palette
Replace light fills (white, light grays) with dark neutral equivalents
so illustrations blend with Pulse's dark UI.
2026-03-23 15:34:40 +01:00
Usman Baig
c07c020015 feat(home): add illustration to home page empty state
Replace globe icon with setup-analytics illustration on the home page
when no sites are created.
2026-03-23 15:28:52 +01:00
Usman Baig
9510e2da8c feat(sidebar): fix backdrop fade transition, add shimmer to SSR placeholder
Use opacity instead of bg-color swap for proper transition-opacity
animation on mobile backdrop. Add shimmer gradient to the sidebar
loading placeholder in DashboardShell.
2026-03-23 15:28:03 +01:00
Usman Baig
414e112d3d feat(sidebar): mobile exit animation, site picker entrance, hover nudge, CSS tooltips 2026-03-23 15:23:31 +01:00
Usman Baig
645e3e78ef feat(empty-states): add undraw illustrations to empty state screens
Add brand-orange recolored SVG illustrations from undraw to five empty
states: sites list, dashboard chart, funnels, journeys, and behavior.
2026-03-23 15:23:26 +01:00
Usman Baig
d6cef95c4b fix(sidebar): dynamic collapse label, favicon fallback, escape key, remove setTimeout hack 2026-03-23 15:19:52 +01:00
Usman Baig
198bd3b00f feat(sidebar): extract SidebarContent to proper React component
Convert the sidebarContent(isMobile) closure function to a proper
SidebarContent component with explicit props, enabling correct React
reconciliation for both desktop and mobile sidebar instances.
2026-03-23 15:15:28 +01:00
Usman Baig
cbb7445d74 feat(pagespeed): click score gauges to scroll to diagnostics category 2026-03-23 14:55:05 +01:00
Usman Baig
8c3b77e8e5 Revert "fix(pagespeed): make frequency interactive and show next check time"
This reverts commit 01c50ab971.
2026-03-23 14:46:10 +01:00
Usman Baig
01c50ab971 fix(pagespeed): make frequency interactive and show next check time
- Replace dead frequency badge with inline dropdown selector
- Add "Next in Xh" indicator from next_check_at
- Demote "Disable" button to subtle text link (was competing with Run Check)
- Add cursor-pointer to prev/next history arrows
- Narrow filmstrip fade to avoid covering content
2026-03-23 14:43:41 +01:00
Usman Baig
55a08301f4 fix(build): extract FAVICON_SERVICE_URL to prevent server-side createContext error
The share/[id] layout is a server component that imported FAVICON_SERVICE_URL
from icons.tsx, pulling in the entire React icon registry and triggering
createContext on the server. Moved the constant to its own favicon.ts module.
2026-03-23 13:29:53 +01:00
Usman Baig
75bf8acd1e refactor(referrers): unify icon, display name, and favicon into single registry
Replace three separate data structures (getReferrerIcon if-chain,
REFERRER_DISPLAY_OVERRIDES, REFERRER_PREFER_ICON) with a single
REFERRER_REGISTRY. All matching is now exact key/hostname lookup
via resolveReferrer() — no more substring includes() that caused
collisions like t.co matching reddit.com.
2026-03-23 13:21:15 +01:00
Usman Baig
4064f7eabf fix(referrers): prevent t.co substring match on reddit.com
"reddit.com".includes("t.co") was true, causing Reddit to show the
X icon. Use exact match or slash-delimited check instead.
2026-03-23 13:12:57 +01:00
Usman Baig
508bb006a8 fix(referrers): replace low-res Google favicon globe with proper icons
Detect Google's 16x16 default globe fallback via naturalWidth on load
and fall back to Phosphor icons. Add Chrome icon for googlechrome.github.io,
CursorClick for Direct, and abbreviation support (ig, fb, yt).
2026-03-23 12:23:10 +01:00
Usman Baig
31471792f8 feat(pagespeed): move frequency selector to site settings
Revert inline frequency toggle from pagespeed page. Add PageSpeed
Monitoring section to site settings under the Data tab with a Select
dropdown for Daily/Weekly/Monthly. Shows "Not enabled" when PSI is off.
2026-03-23 11:58:09 +01:00
Usman Baig
a0ef570137 feat(pagespeed): inline frequency selector in hero footer
Replace static frequency badge with a pill toggle (Daily/Weekly/Monthly)
matching the Mobile/Desktop tab style. Updates config via API on click.
Read-only badge shown for non-admin users.
2026-03-23 11:51:40 +01:00
Usman Baig
8d9a3f3592 feat(pagespeed): add check history navigation with prev/next arrows
Navigate between historical checks using ◀ ▶ arrows in the hero
footer bar. Shows formatted date when viewing historical data,
"Last checked X ago" when on latest. Fetches full audit data via
getPageSpeedCheck when navigating to a historical check.
2026-03-23 11:34:05 +01:00
Usman Baig
d02d8429e2 fix(pagespeed): contain visx chart within card bounds 2026-03-23 11:26:18 +01:00
Usman Baig
98fcce4647 feat(pagespeed): switch trend chart from Recharts to visx for dashboard consistency 2026-03-23 10:54:09 +01:00
Usman Baig
bba25c722a feat(pagespeed): manual check section, consistent dot indicators
- Add "Additional items to manually check" collapsed section
- Replace triangle/square severity icons with consistent filled circles
- Empty circle (border only) for informative/unscored audits
2026-03-22 23:45:36 +01:00
Usman Baig
354331646b fix(pagespeed): order accessibility sub-groups: names/labels → contrast → best practices 2026-03-22 23:38:58 +01:00
Usman Baig
d232a8a6d1 feat(pagespeed): sort audits by severity + insights before diagnostics
Sort order within each sub-group: red → orange → empty → green.
Sub-groups sorted so insights come before diagnostics.
2026-03-22 23:25:11 +01:00
Usman Baig
9d1d2dbb80 fix(pagespeed): issue count excludes informative/unscored audits
Only audits with a real score (non-null) count toward the issue total.
Informative audits (score: null) are shown but not counted.
2026-03-22 22:11:49 +01:00
Usman Baig
98429f82f5 feat(pagespeed): render audit sub-group headers in diagnostics
Group audits within each category by sub-group (e.g., "Names and
Labels", "Contrast") with small uppercase headers, matching the
pagespeed.web.dev layout.
2026-03-22 22:03:13 +01:00
Usman Baig
a0173636d4 fix(pagespeed): show empty circle for unscored/informative audits
Null scores now show ○ (informative) instead of ▲ (poor), matching
pagespeed.web.dev's "Unscored" indicator for informative audits.
2026-03-22 21:08:50 +01:00
Usman Baig
dfcf6bebde fix(pagespeed): show all 4 category cards including those with 0 issues 2026-03-22 20:59:52 +01:00
Usman Baig
5003175305 redesign(pagespeed): equal gauges in hero + category gauges in diagnostics
- Hero: 4 equal 90px ScoreGauges in a row with screenshot on right
- Diagnostics: each category card gets a 56px gauge header with score
  and issue count, matching pagespeed.web.dev's category sections
- Legend and metadata moved to footer bar in hero card
2026-03-22 20:55:55 +01:00
Usman Baig
ab6008daf9 fix(pagespeed): parse markdown links + handle more audit item fields
- AuditDescription: converts [text](url) to clickable links
- AuditItem: handles href, text/linkText, source.url from PSI API
2026-03-22 20:52:50 +01:00
Usman Baig
8b95620ec1 polish(pagespeed): mini gauges, animated tab switcher, filmstrip title
- Replace compact dot+number scores with 64px ScoreGauge circles
- ScoreGauge scales font/stroke/spacing for small sizes
- Add "Page Load Timeline" header to filmstrip section
- Replace pill toggle with animated underline tabs (matches dashboard)
2026-03-22 20:43:11 +01:00
Usman Baig
783530940e polish(pagespeed): design consistency pass
- Filmstrip: dark mode bg fix, consistent card padding, scroll fade
- Metrics: font-semibold to match uptime page
- Hero: tighter compact scores, smaller legend, centered alignment
- Chart: hide x-axis when single day, height matches uptime (h-40)
- Diagnostics: hide categories with zero failures, muted display values
- Skeleton: matches new hero layout
2026-03-22 20:19:07 +01:00
Usman Baig
dd0700cbea fix(pagespeed): poll silently without triggering SWR re-renders
Use direct API fetch for polling instead of mutateLatest() which was
causing the page to flicker and clear data every 5 seconds. SWR cache
is only updated once when new results arrive.
2026-03-22 19:56:00 +01:00
Usman Baig
8649f37bb9 feat(pagespeed): split diagnostics by category (Performance, Accessibility, Best Practices, SEO)
Each Lighthouse category gets its own card with failing audits sorted
by impact and collapsed passed audits. Matches pagespeed.web.dev layout.
2026-03-22 19:52:49 +01:00
Usman Baig
fcbf21b715 feat(pagespeed): render page load filmstrip between hero and metrics
Horizontal scrollable filmstrip showing page rendering progression
with timing labels. Appears between the score hero and metrics card.
2026-03-22 19:43:44 +01:00
Usman Baig
50960d0556 feat(pagespeed): render element screenshots in expandable audit items
Shows node screenshots, labels, HTML snippets, and URLs in audit
detail rows — matching pagespeed.web.dev's failing elements display.
2026-03-22 19:18:03 +01:00
Usman Baig
6b00b8b04a redesign(pagespeed): full page redesign inspired by pagespeed.web.dev
- Hero card: large performance gauge + compact inline scores + screenshot
- Single metrics card with 2x3 grid and colored status dots
- Flat diagnostics list sorted by impact with severity indicators
- ScoreGauge accepts size prop for flexible gauge sizing
- Unicode severity markers (triangle/square/circle) per audit
2026-03-22 19:10:47 +01:00
Usman Baig
b0e6db36a1 feat(pagespeed): add screenshot display and expandable diagnostics
- Page screenshot thumbnail next to score gauges
- Expandable audit rows with description and detail items table
- Shows URLs, HTML snippets, wasted bytes/ms for each failing element
- AuditRow component replaces flat diagnostic rows
2026-03-22 18:54:45 +01:00
Usman Baig
2fd9bf82f1 fix(pagespeed): poll for results after async check trigger
Backend now returns 202 immediately. Frontend polls every 5s for up
to 2 minutes until new results appear, then shows success toast.
2026-03-22 18:35:17 +01:00
Usman Baig
d1af25266b fix(pagespeed): increase fetch timeout for manual PSI checks to 120s
PSI checks run mobile + desktop sequentially (up to 60s total).
The default 30s client timeout was causing false network errors.
2026-03-22 18:28:06 +01:00
Usman Baig
52906344cf feat(pagespeed): add PageSpeed page with gauges, CWV cards, chart, and diagnostics
- ScoreGauge SVG component with color-coded circular arcs
- Full page: disabled state, score overview, CWV metrics, trend chart
- Diagnostics accordion with opportunities/diagnostics/passed groups
- Mobile/desktop strategy toggle, manual check trigger
- Loading skeleton, frequency selector
2026-03-22 18:13:08 +01:00
Usman Baig
780dd464a1 feat(pagespeed): add API client, SWR hooks, and sidebar navigation
- PageSpeed API client with types for config, checks, and audits
- SWR hooks: usePageSpeedConfig, usePageSpeedLatest, usePageSpeedHistory
- GaugeIcon added to sidebar under Infrastructure group
2026-03-22 18:05:17 +01:00
Usman
b026476311 Merge pull request #67 from ciphera-net/staging
Landing page redesign, dashboard improvements & new settings sections
2026-03-22 17:17:38 +01:00
Usman Baig
6a1698b794 feat: add Notifications section to settings with Reports and Alerts
- Adds purpose field to report schedule API client
- Adds useAlertSchedules SWR hook
- Reorganizes settings: Reports tab becomes Notifications tab
- Groups existing Reports and new Alerts subsections
- Alert channels reuse report delivery infrastructure (email, Slack, Discord, webhooks)
2026-03-22 16:57:04 +01:00
Usman Baig
1d26819727 feat: simplify uptime page to single auto-managed monitor with toggle
Rewrites uptime page from 978 to ~370 lines. Removes all monitor CRUD
UI (modals, monitor list, selection state). Adds enable/disable toggle
and empty state. Reads the single auto-managed monitor.
2026-03-22 16:51:42 +01:00
Usman Baig
5c30043550 feat: remove uptime CRUD functions from API client
Removes create/update/delete/list monitor functions and request types.
Keeps getUptimeStatus and getMonitorChecks for the simplified UI.
2026-03-22 16:47:15 +01:00
Usman Baig
b7e92abb40 feat: persist script feature toggles to backend
Features (scroll, 404, outbound, downloads, frustration, storage, ttl)
are saved to site.script_features JSONB column on every toggle change.
Values are read from the site object on load.
2026-03-22 15:31:45 +01:00
Usman Baig
e626350f14 fix: use UTC hours for intra-day chart labels to match server timezone buckets
Backend returns timestamps already bucketed in site timezone but as
UTC values. Using getUTCHours/getUTCMinutes prevents the browser
from adding its local timezone offset.
2026-03-22 15:16:20 +01:00
Usman Baig
bd023e76f5 fix: use European date/time formats (en-GB) and guard against undefined dateObj 2026-03-22 15:04:11 +01:00
Usman Baig
c85f305f1e fix: show time labels on X-axis and tooltip for intra-day chart views
Added formatLabel prop to XAxis component. When viewing Today (hour
or minute interval), X-axis shows "2:00 PM" instead of "Mar 22".
Tooltip shows time for intra-day, date for multi-day.
2026-03-22 14:59:24 +01:00
Usman Baig
430e6f5d48 feat: use session cookie auth for public dashboard password flow
handlePasswordSubmit now calls POST /public/sites/:id/auth which
sets an HttpOnly cookie. All subsequent API calls authenticate via
cookie automatically — no password in URLs, no captcha state needed
for data fetching. Simplifies share page state management.
2026-03-22 14:45:25 +01:00
Usman Baig
82a201a043 fix: stop password keystrokes from triggering API calls on public dashboard
Used refs for password/captcha values so loadDashboard doesn't
recreate on every keystroke. Password is only sent to API on
explicit form submit. Also fixes stale captcha state in closures.
2026-03-22 13:52:10 +01:00
Usman Baig
ef21004519 fix: skip auth token refresh for public API endpoints
Public dashboard endpoints use password auth, not session tokens.
A 401 on /public/ should surface to the caller (for password prompt),
not trigger a token refresh that fails and shows "Session expired".
2026-03-22 13:47:02 +01:00
Usman Baig
0805bbaeee fix: improve password protection UX with status badge and remove option
- Shows green "Password set" badge when a password is active
- Simplified placeholder to "Enter new password"
- Added helper text explaining current password persists
- Added "Remove password protection" link for easy removal
- Cleaned up dark-mode toggle styling
2026-03-22 13:40:26 +01:00
Usman Baig
3f3d81a41f fix: style bot filter checkboxes with accent-color orange 2026-03-22 13:30:29 +01:00
Usman Baig
0878bde259 fix: redesign session review as card layout instead of cramped table 2026-03-22 13:25:02 +01:00
Usman Baig
42b7363cf9 feat: add Bot & Spam settings tab with session review UI 2026-03-22 13:16:07 +01:00
Usman Baig
6444cec454 fix: use inline styles for Slack SVG fills to prevent CSS override 2026-03-22 01:06:03 +01:00
Usman Baig
5fc1a33745 fix: use official multicolored Slack logo (pink, blue, green, yellow) 2026-03-22 01:01:58 +01:00
Usman Baig
185cb8699f fix: use white color for Slack icon on dark background 2026-03-22 00:52:29 +01:00
Usman Baig
7e48d70411 fix: use real Slack and Discord brand icons in report schedule modal
Replaced generic WebhooksLogo with actual Slack SVG (pink) and
SiDiscord (blurple) in both the channel selector and the report list.
2026-03-22 00:40:02 +01:00
Usman Baig
4043a678db fix: add proper empty state to Peak Hours with icon and description 2026-03-22 00:16:17 +01:00
Usman Baig
5008992f59 feat: replace Phosphor brand icons with real SVG logos
Uses @icons-pack/react-simple-icons for available brands (Google,
Facebook, Instagram, GitHub, YouTube, Reddit, etc.) and inline SVGs
for brands missing from the package (X, LinkedIn, OpenAI, Bing).
All icons now show actual brand logos with correct colors.
2026-03-21 23:38:55 +01:00
Usman Baig
5b0d0e1dc1 fix: use Phosphor icons for all known referrers, skip unreliable favicons
Google's favicon service returns wrong/low-quality icons for known
services. Now all major platforms, search engines, and AI assistants
use their Phosphor icon directly. Favicons only fetched for unknown
domains.
2026-03-21 23:22:31 +01:00
Usman Baig
9d253523e2 fix: remove bar chart toggle, keep area chart only 2026-03-21 23:05:41 +01:00
Usman Baig
d4e4ca819c fix: add numeric Y-axis to bar chart view 2026-03-21 22:59:41 +01:00
Usman Baig
830da49c5f feat: add bar chart toggle to dashboard
Added visx bar chart component with rounded corners and grow animation.
Dashboard now has area/bar toggle buttons next to the export icon.
2026-03-21 22:55:19 +01:00
Usman Baig
9e128c4945 fix: remove pattern fill from dashboard chart, use gradient only 2026-03-21 22:49:43 +01:00
Usman Baig
9c06a845a0 fix: add missing @testing-library/dom dev dependency 2026-03-21 22:46:37 +01:00
Usman Baig
1270aa99a9 feat: add diagonal pattern fill to dashboard area chart 2026-03-21 22:44:14 +01:00
Usman Baig
d37e817cc9 fix: add legacy-peer-deps to .npmrc for visx React 19 compat 2026-03-21 22:40:52 +01:00
Usman Baig
1c7667562c feat: replace Recharts dashboard chart with visx area chart
Integrated 21st.dev AreaChart component with animated crosshair,
spring-based tooltip, and date ticker. Uses brand orange for the
line/fill with dark-only CSS variables.
2026-03-21 22:39:51 +01:00
Usman Baig
24fa01dd25 fix: reduce funnel segment thickness (0.44 -> 0.3) 2026-03-21 22:30:50 +01:00
Usman Baig
028e4e5425 fix: reduce funnel chart height with wider aspect ratio (4:1) 2026-03-21 22:27:37 +01:00
Usman Baig
6098b5e158 feat: replace vertical funnel with horizontal funnel chart
Switched to horizontal orientation with grouped labels for better
readability across multi-step funnels.
2026-03-21 22:23:47 +01:00
Usman Baig
4ef92b9e3a fix: use monotone interpolation for smooth dashboard chart curves 2026-03-21 22:12:30 +01:00
Usman Baig
93347f6454 fix: revert outer container size, increase inner padding to show more bg 2026-03-21 21:05:46 +01:00
Usman Baig
b3bb0685f9 fix: smooth chart curve data, translucent dashboard, smaller demo container 2026-03-21 21:02:35 +01:00
Usman Baig
9ce272d3e5 fix: smooth chart data and increase outer frame padding for more visible gradient 2026-03-21 20:57:10 +01:00
Usman Baig
0bd2f94dd7 fix: fix div nesting in DashboardDemo 2026-03-21 20:53:04 +01:00
Usman Baig
af62532615 fix: showcase bg as outer frame, solid dark background for dashboard content 2026-03-21 20:50:21 +01:00
Usman Baig
39cd1c596c fix: move showcase bg to outer container, increase overlay opacity for card contrast 2026-03-21 20:46:16 +01:00
Usman Baig
941782efe1 fix: remove browser chrome, make dashboard scrollable, add showcase gradient bg 2026-03-21 20:41:02 +01:00
Usman Baig
ca199b59fd feat: replace fake LiveDemo with real dashboard components and fake data 2026-03-21 20:36:55 +01:00
Usman Baig
536bb8c872 feat: add live demo dashboard to landing page hero 2026-03-21 20:26:23 +01:00
Usman Baig
0e8629951c fix: set accent color to neutral gray matching ciphera-website 2026-03-21 20:16:31 +01:00
Usman Baig
911704cff2 feat: port website header with mega-menu, add showcase bg to hero, fix carousel container size 2026-03-21 20:12:01 +01:00
Usman Baig
4afaf32e58 feat: add showcase background image to feature section mockup containers 2026-03-21 20:02:22 +01:00
Usman Baig
74a48299ab fix: replace lucide-react with phosphor-icons in FAQ component 2026-03-21 19:56:30 +01:00
Usman Baig
1e4bb34513 chore: bump @ciphera-net/ui to 0.3.1 2026-03-21 19:54:29 +01:00
Usman Baig
a361649e60 feat: add tabbed FAQ, polish installation code blocks, refine integration styling 2026-03-21 19:52:32 +01:00
Usman Baig
e789fb525b feat: port interactive mockups from website and wire into feature sections 2026-03-21 19:49:19 +01:00
Usman Baig
0b7c4d528a feat: add feature sections, comparison cards, and CTA components for landing page 2026-03-21 19:46:20 +01:00
Usman Baig
acfd532194 feat: redesign landing hero to match website quality 2026-03-21 19:41:51 +01:00
Usman Baig
3710f081a6 feat: dark-only cleanup for marketing pages and authenticated landing view 2026-03-21 19:39:01 +01:00
Usman Baig
7bf7e5cc3d feat: glass treatment + dark-only cleanup for dashboard components and navigation 2026-03-21 19:26:25 +01:00
Usman Baig
64b245caca feat: update card component to glass treatment 2026-03-21 19:13:00 +01:00
Usman Baig
6d253e6d18 chore: bump @ciphera-net/ui to 0.3.0 (dark-only) 2026-03-21 18:54:15 +01:00
Usman Baig
21c68b4334 fix: restore ThemeProvider with forced dark mode to fix build 2026-03-21 18:30:06 +01:00
Usman Baig
ec9d1a2c2d feat: force dark mode and match ciphera-website background 2026-03-21 18:27:35 +01:00
Usman Baig
e6910b77ca fix: remove orange radial gradient from body background 2026-03-21 18:23:12 +01:00
Usman Baig
8fdb8c4a2f fix: remove orange glow orb backgrounds from marketing pages 2026-03-21 18:17:27 +01:00
Usman Baig
4b46bba883 feat: redesign Search dashboard card to match Pulse design language
Add proportional impression bars, color-coded position badges,
animated Queries/Pages tabs, hover percentage reveals, and
searchable expand modal — bringing Search to parity with other
dashboard cards.
2026-03-19 17:23:57 +01:00
Usman
32ca818c3c Merge pull request #66 from ciphera-net/staging
feat: add 5-level intensity heatmap to Peak Hours
2026-03-19 15:17:58 +01:00
Usman Baig
09b4266a49 feat: add 5-level intensity heatmap to Peak Hours
Replace binary on/off coloring with 6 opacity levels (transparent,
0.15, 0.35, 0.60, 0.82, solid) based on percentage of max visitors.
Zero-traffic cells are now visually empty. Adds a GitHub-style
"Less → More" legend strip below the grid.
2026-03-19 15:12:07 +01:00
Usman Baig
ed7d519ed2 Merge branch 'main' into staging 2026-03-19 14:58:31 +01:00
Usman
693c975b24 Merge pull request #65 from ciphera-net/staging
fix: auto-detect domain from hostname for zero-config GTM support
2026-03-19 13:59:05 +01:00
Usman Baig
e6d840abb9 docs: simplify GTM integration guide for auto-detect domain 2026-03-19 13:58:09 +01:00
Usman Baig
ac9e10b436 fix: auto-detect domain from hostname for zero-config GTM support
When data-domain attribute and pulseConfig are both unavailable (common
with GTM which strips data-* attributes), the script now falls back to
location.hostname. This is safe because the backend already validates
Origin/Referer against the registered domain. Strips www. prefix on
auto-detected hostname to match typical Pulse registration patterns.
2026-03-19 13:56:18 +01:00
Usman
8338988471 Merge pull request #64 from ciphera-net/staging
fix: invert macOS and PlayStation icons in dark mode
2026-03-19 13:46:01 +01:00
Usman Baig
73fc47e910 fix: support GTM and tag managers via window.pulseConfig fallback
Script detection now also searches by src URL and supports a global
config object (window.pulseConfig) for environments where data-*
attributes are not preserved on the injected script element.
2026-03-19 13:45:21 +01:00
Usman Baig
bf3097c26e fix: invert macOS and PlayStation icons in dark mode
Both logos are dark/black and disappear against dark backgrounds.
Apply dark:invert CSS filter to flip them to white in dark mode.
2026-03-19 13:19:08 +01:00
Usman
e7c7ab8f9c Merge pull request #63 from ciphera-net/staging
fix: tighten dashboard vertical spacing
2026-03-19 12:15:56 +01:00
Usman Baig
7cbfbc54ca fix: tighten dashboard vertical spacing
Reduce spacing now that top header is removed:
- Main content top padding: pt-6 → pt-4
- Header section: mb-8 → mb-6
- Chart section: mb-8 → mb-6
- Grid sections: mb-8 → mb-6
- Site name margin: mb-2 → mb-1
- Header inner gap: mb-4 → mb-3
2026-03-19 12:10:30 +01:00
Usman
0e759e8fcf Merge pull request #62 from ciphera-net/staging
fix: remove globe tab from locations, default to countries
2026-03-19 12:03:52 +01:00
Usman Baig
dc7bffdf56 fix: remove globe tab from locations, default to countries
Remove the 3D globe visualization tab and set Countries as the
default tab when visiting the dashboard.
2026-03-19 12:01:23 +01:00
Usman
7c3215d662 Merge pull request #61 from ciphera-net/staging
fix: preserve intentional OS name casing (macOS, iOS, webOS)
2026-03-19 11:34:16 +01:00
Usman Baig
6b1e6876c6 fix: preserve intentional OS name casing (macOS, iOS, webOS)
Skip capitalize() for names with mixed casing to prevent
macOS→MacOS, iOS→IOS, webOS→WebOS, ChromeOS→Chromeos etc.
2026-03-19 11:32:36 +01:00
Usman
04b4059392 Merge pull request #60 from ciphera-net/feat/os-icons
Add real browser & OS logo icons
2026-03-19 11:25:17 +01:00
Usman Baig
1696e428ab feat: add real OS logo icons for 16 operating systems
Replace Phosphor generic icons with branded logos from
ngeenx/operating-system-logos. Covers Windows, macOS, Linux, Android,
iOS, ChromeOS, HarmonyOS, KaiOS, Tizen, webOS, FreeBSD, OpenBSD,
NetBSD, PlayStation, Xbox, and Nintendo.
2026-03-19 11:23:29 +01:00
Usman Baig
5287c078bd Merge branch 'staging' 2026-03-19 11:09:26 +01:00
Usman Baig
a7ac2cb9d7 feat: add browser logo icons for all detected browsers
Use real browser logos from alrra/browser-logos (SVG where available,
PNG fallback for archived browsers). Replaces the generic globe icon
with actual Chrome, Firefox, Safari, Edge, Opera, Brave, Vivaldi, Arc,
Samsung Internet, UC Browser, Yandex, Waterfox, Pale Moon, DuckDuckGo,
Maxthon, Silk, Puffin, Tor, and Opera Mini logos.
2026-03-19 11:09:16 +01:00
Usman
f52f98a836 Merge pull request #59 from ciphera-net/staging
Add filtered traffic admin page
2026-03-19 10:27:13 +01:00
Usman Baig
e464b87471 feat: add filtered traffic page to admin dashboard
Add admin page at /admin/filtered-traffic showing domains blocked by the
referrer spam filter with reason badges and date range selector. Helps
operators monitor spam filtering and catch false positives.
2026-03-19 10:11:28 +01:00
Usman
740796dcb7 Merge pull request #58 from ciphera-net/staging
docs: full AGPL-3.0 license text
2026-03-19 01:40:27 +01:00
Usman Baig
a9af5d4593 docs: replace LICENSE summary with full AGPL-3.0 text
Enables GitHub auto-detection of AGPL-3.0 license in repo sidebar.
2026-03-19 01:38:36 +01:00
Usman
39912c5024 Merge pull request #57 from ciphera-net/staging
Sidebar redesign, dropdown fixes, and soft-delete UI
2026-03-19 01:08:16 +01:00
Usman Baig
177c33830c fix: add tooltips to notifications and profile when sidebar collapsed 2026-03-19 01:02:16 +01:00
Usman Baig
dd76aed157 fix: use bottom positioning for notification dropdown
Instead of measuring panel height with unreliable RAF timing, use
CSS bottom positioning when the button is in the lower half of the
viewport. The dropdown grows upward, no measurement needed.
2026-03-19 00:54:50 +01:00
Usman Baig
a31f183b7b fix: recalculate notification dropdown position after content loads
The dropdown position was only clamped on initial render when the panel
was empty. After async notifications loaded and the panel grew taller,
the position was never recalculated. Add an effect that re-clamps when
notifications or loading state changes.
2026-03-19 00:48:46 +01:00
Usman Baig
89343caf65 fix: clamp notification dropdown to viewport, bump ui to 0.2.15
Apply viewport clamping to NotificationCenter dropdown positioning
and bump @ciphera-net/ui to 0.2.15 which clamps AppLauncher and
UserMenu dropdowns the same way.
2026-03-19 00:43:58 +01:00
Usman Baig
1755dcb9dc fix: portal sidebar dropdowns to escape backdrop-filter clipping
Bump @ciphera-net/ui to 0.2.14 which portals AppLauncher and UserMenu
dropdowns via createPortal when anchor="right". Apply the same fix to
NotificationCenter. This escapes the sidebar's backdrop-filter
containing block that was clipping all fixed-positioned dropdowns.
2026-03-19 00:35:51 +01:00
Usman Baig
3e67af5646 fix: sidebar dropdown clipping, settings placement, and theme removal
- Add relative z-10 to sidebar aside to fix dropdowns (AppSwitcher,
  Notifications, UserMenu) rendering behind content area due to
  backdrop-blur-xl creating a stacking context
- Move Site Settings from bottom utility section into Infrastructure
  nav group where it logically belongs
- Remove ThemeToggle from sidebar (available in user settings)
- Rename Settings to Site Settings for clarity
2026-03-19 00:23:08 +01:00
Usman Baig
2fa498fb8f feat: sidebar utility items match NavLink styling
Use variant='sidebar' for ThemeToggle, NotificationCenter, and
compact UserMenu so they render with the same icon+label layout
as nav items. Fixed dropdown positioning uses fixed to escape
sidebar overflow:hidden.
2026-03-18 22:48:52 +01:00
Usman Baig
0b545eaa76 fix: align sidebar utility items with nav item layout
Use left-aligned rows with fading labels for theme, notifications,
and profile — matching the nav items pattern. Fix app switcher
alignment at top to match logo row.
2026-03-18 22:32:56 +01:00
Usman Baig
342e3705e8 fix: stack sidebar utility icons vertically
Horizontal row didn't fit in 64px collapsed sidebar. Stack theme,
notifications, and profile icons vertically like nav items.
2026-03-18 22:27:53 +01:00
Usman Baig
f1fc8facb4 feat: move utility items from header to sidebar
Move theme toggle, notifications, app switcher, and user profile from
the top header bar into the sidebar. App switcher at the top (scope
switch), utilities at the bottom. Header now only shows on mobile for
the hamburger menu.
2026-03-18 22:01:51 +01:00
Usman Baig
66d63f7a3b chore: bump @ciphera-net/ui to ^0.2.12
Adds anchor/compact props for sidebar layout support.
2026-03-18 21:59:41 +01:00
Usman Baig
e8b3227dcf fix: use design system skeleton colors for favicon loading
Match the platform skeleton pattern (bg-neutral-100 dark:bg-neutral-800)
instead of the mismatched bg-neutral-200/700.
2026-03-18 21:23:41 +01:00
Usman Baig
323ed9c137 fix: add skeleton loading for favicon in site picker
Show a pulsing placeholder while the favicon loads from Google's
service instead of an empty container.
2026-03-18 21:20:08 +01:00
Usman Baig
c24a053c07 fix: remove favicon alt text to prevent Firefox flash in site picker
Firefox renders alt text while images load from Google's favicon service,
causing "Ci" to flash briefly in the 28px container before the icon appears.
2026-03-18 21:12:51 +01:00
Usman Baig
6d649d8dc4 fix: reserve sidebar space with placeholder during SSR
With ssr:false, the sidebar rendered nothing in server HTML, so the
content area took full width and page content (site name "Ciphera")
appeared in the sidebar zone. Now the dynamic import has a loading
placeholder — a 64px div with matching border/background that reserves
the sidebar space in the server HTML. Content area never occupies the
sidebar zone. Sidebar replaces the placeholder on client mount.
2026-03-18 20:01:01 +01:00
Usman Baig
7ed04fb85c fix: load Sidebar with ssr:false — zero server-rendered content
The sidebar now uses next/dynamic with ssr:false, meaning it renders
NOTHING in the server HTML. No DOM content = no possible flash of
"Ci" or any text during SSR-to-hydration gap. The sidebar only mounts
on the client where localStorage is immediately available, so
collapsed state is correct from the very first render.
2026-03-18 19:42:14 +01:00
Usman Baig
a63dfa231e fix: render empty sidebar shell until client is ready
Previous opacity-0 approach still rendered DOM content which could
flash during SSR hydration. Now render an empty div (just border +
background, no content) at collapsed width until useEffect fires.
Then swap in the real aside with content. Zero DOM content = zero
possible flash of text or icons.
2026-03-18 18:42:36 +01:00
Usman Baig
137ab4c2ba fix: eliminate sidebar flash on page load for good
The sidebar is now invisible (opacity-0) on the initial render.
In a single useEffect: read collapsed state from localStorage,
then requestAnimationFrame to reveal (opacity-1). React batches
the state update with the reveal, so the sidebar appears at its
correct width with correct label visibility in one frame. No
intermediate states, no hydration mismatch, no transitions on load.
2026-03-18 18:39:17 +01:00
Usman Baig
29127d7ed5 fix: eliminate all loading flashes in sidebar site picker
Root cause 1: hydration mismatch — SSR rendered collapsed=true but
client useState initializer read localStorage synchronously, causing
an immediate state change and visual flash. Fix: always initialize
collapsed=true, read localStorage in useEffect so the transition
is smooth (collapsed→expanded animates cleanly).

Root cause 2: three-phase badge rendering (skeleton→letter→favicon)
caused visible state changes. Fix: just show the empty orange badge
until the favicon arrives. No skeleton, no letter fallback. One state
transition: empty→favicon.
2026-03-18 18:32:50 +01:00
Usman Baig
db055c758c fix: site picker auto-expands collapsed sidebar, fix Ci flash
When clicking the site picker in collapsed mode, the sidebar expands
and opens the dropdown. After selecting a site or clicking outside,
the sidebar re-collapses to its previous state.

Fix "Ci" flash on reload: default collapsed to true on SSR and when
no localStorage value exists, preventing hydration mismatch where
labels briefly render at full opacity in the narrow sidebar.
2026-03-18 18:23:40 +01:00
Usman Baig
c021d8ccf6 fix: show skeleton placeholder while sites load instead of fallback letter 2026-03-18 18:19:40 +01:00
Usman Baig
879df18502 fix: disable site picker dropdown when sidebar is collapsed 2026-03-18 18:17:00 +01:00
Usman Baig
684448159a feat: show site favicon in sidebar site picker
Use Google's favicon service to display the site's actual favicon
instead of the first-letter initial. Falls back to the letter if
the favicon fails to load. Matches the site list dashboard behavior.
2026-03-18 18:15:45 +01:00
Usman Baig
9f8a6606bb fix: add pt-6 top padding to main content area 2026-03-18 17:12:53 +01:00
Usman Baig
7cdbb34f9d style: bigger Pulse logo and text in sidebar (w-9, text-xl) 2026-03-18 17:09:46 +01:00
Usman Baig
9b8ae08460 fix: center all sidebar icons with uniform 28px containers
Every interactive item (logo, site picker, nav links, settings,
collapse) now wraps its icon in a 28px flex container. Combined with
consistent px-2 outer + px-2.5 inner padding, all icon containers
start at exactly 18px from the sidebar edge and center at 32px — the
midpoint of the 64px collapsed sidebar.
2026-03-18 17:07:00 +01:00
Usman Baig
01dfa6954f fix: center site picker hover state in collapsed sidebar
Reduce all sidebar section outer padding from px-3 to px-2 so hover
backgrounds are wider and items center properly in the 64px collapsed
width. All sections now consistent: px-2 outer + px-2.5 inner.
2026-03-18 17:03:08 +01:00
Usman Baig
9773735d2b chore: bump @ciphera-net/ui to 0.2.11 2026-03-18 16:59:44 +01:00
Usman Baig
84c23faa0f style: reduce glass transparency to 90% opacity
Sidebar and content header were too transparent — content bled through.
Bump from bg-*/70 to bg-*/90 with backdrop-blur-xl for a subtle glass
effect that's still readable.
2026-03-18 16:57:34 +01:00
Usman Baig
981eaaff39 fix: center site picker hover state in collapsed sidebar
Match site picker button padding (px-2.5) with nav items so the hover
background aligns consistently. Remove title tooltip.
2026-03-18 16:55:10 +01:00
Usman Baig
b0983e5a3f style: glassy transparency on sidebar and content header
Apply the same backdrop-blur-2xl + semi-transparent bg treatment from
the AppLauncher dropdown to the sidebar and content header. Matches
the Ciphera design language: bg-white/70 dark:bg-neutral-900/70 with
supports-[backdrop-filter] progressive enhancement. Soften all borders
to /60 opacity.
2026-03-18 16:52:40 +01:00
Usman Baig
6fcb6df295 fix: widen collapsed sidebar to 64px, prevent header flash on refresh
Collapsed width 56px→64px to stop clipping site picker badge and icons.
Return null while auth is loading on site pages to prevent brief flash
of the public floating header before the sidebar layout renders.
2026-03-18 16:51:01 +01:00
Usman Baig
5c8f334017 fix: eliminate sidebar jitter on collapse/expand
Root cause: class switching (px-2↔px-3, justify-center↔gap-2.5,
conditional DOM rendering) caused instant layout jumps during the
200ms width transition.

Fix: internal layout is now 100% static — same padding, same gap,
same DOM structure in both states. Only opacity transitions on text
labels (via Label component). The sidebar overflow:hidden + width
transition handles the visual collapse. Collapse icon rotates 180deg
instead of swapping between two icons.
2026-03-18 16:45:39 +01:00
Usman Baig
5807a50092 fix: center icons in collapsed sidebar, eliminate white flash on click
Icons now use justify-center + px-0 when collapsed so they sit
perfectly centered in the 56px rail. Track pending navigation href
optimistically — clicked item shows orange immediately instead of
flashing through the inactive hover state during route transition.
2026-03-18 16:40:00 +01:00
Usman Baig
2474d6558f feat: Linear-style sidebar with explicit toggle
Rewrite sidebar from scratch: 256px expanded, 56px collapsed via
click toggle + [ keyboard shortcut. Two-phase CSS transitions (labels
fade then width contracts). Contextual ContentHeader replaces
UtilityBar (no logo, just actions). Remove framer-motion sidebar
primitive, hover-to-expand, and sidebar-context.
2026-03-18 16:33:35 +01:00
Usman Baig
db5cd4cbcb feat: replace sidebar with 21st.dev hover-to-expand component
Use framer-motion animated sidebar from 21st.dev — collapses to icons,
expands on hover. Phosphor icons instead of lucide. Remove old manual
collapse/expand and sidebar-context. Top bar has Pulse logo + user
actions, sidebar below with site picker and nav groups.
2026-03-18 16:20:32 +01:00
Usman Baig
66a70f676f fix: full-width top bar with logo, sidebar below
Restructure layout: top bar spans full width (Pulse logo left, user
actions right) with continuous bottom border. Sidebar sits below with
no vertical border collision. Remove logo from sidebar. Remove all
transition-colors from nav items to prevent white flash on click.
2026-03-18 16:12:20 +01:00
Usman Baig
d00d2e5592 fix: sidebar polish — logo, scrollbar, utility bar height, icon flash
- Fix stretched logo with object-contain
- Remove border below logo
- Add overflow-hidden to prevent scrollbar flash during transition
- Match utility bar height to old header (py-3.5)
- Remove transition-colors from active nav items to prevent white flash
2026-03-18 16:04:50 +01:00
Usman Baig
1d25368292 feat: Dokploy-style sidebar layout for site pages
Sidebar takes full viewport height with Pulse logo at top. No Header
on site pages — UtilityBar in content area provides theme toggle, app
launcher, notifications, and user menu. Non-site authenticated pages
keep static Header. No footer on dashboard pages.
2026-03-18 15:58:06 +01:00
Usman Baig
7ae5facd0c fix: proper dashboard layout — header + sidebar + content fill viewport
Use h-screen overflow-hidden on the root container for authenticated
views. Sidebar and content fill the remaining height below the header.
Remove footer from dashboard pages. Content scrolls inside its own
container, sidebar stays fixed in place.
2026-03-18 15:38:58 +01:00
Usman Baig
61ce505ee5 fix: pin sidebar to viewport with sticky positioning
Sidebar was scrolling with page content. Fix by adding sticky top-0
h-screen. Widen collapsed width to 68px to prevent icon clipping.
2026-03-18 15:34:48 +01:00
Usman Baig
80ae8311dc feat: static header + collapsible sidebar navigation
Replace floating pill header with static variant for authenticated
views. Add collapsible sidebar with site picker, grouped navigation
(Analytics/Infrastructure), and mobile overlay drawer. Remove
horizontal SiteNav tab bar.
2026-03-18 15:30:27 +01:00
Usman
9f7987fe07 Merge pull request #56 from ciphera-net/feat/funnels-v2
Funnels V2: event steps, edit UI, filters, trends, breakdowns
2026-03-18 14:41:10 +01:00
Usman Baig
94112161f0 docs: update changelog for funnels v2 2026-03-18 14:38:01 +01:00
Usman Baig
4c7ed858f7 feat(funnels): add step-level breakdown drawer with dimension tabs 2026-03-18 14:34:07 +01:00
Usman Baig
efd0c144b5 feat(funnels): add conversion trends line chart with per-step toggles 2026-03-18 14:33:25 +01:00
Usman Baig
585cb4fd88 feat(funnels): add edit funnel page with pre-populated form 2026-03-18 14:27:45 +01:00
Usman Baig
2811945d3e feat(funnels): add filter bar and exit path display to funnel detail 2026-03-18 14:26:26 +01:00
Usman Baig
18e66917d3 feat(funnels): extract reusable FunnelForm with category toggle, property filters, and conversion window 2026-03-18 14:23:25 +01:00
Usman Baig
d5b594d6f9 feat(funnels): update frontend types and API client for funnels v2 2026-03-18 14:20:15 +01:00
Usman
ff58ba5953 Merge pull request #55 from ciphera-net/staging
feat: soft-delete sites with 7-day grace period
2026-03-18 11:26:59 +01:00
Usman Baig
311f546261 fix: improve code quality in soft-delete frontend (loading state, imports, confirm dialog) 2026-03-18 11:15:14 +01:00
Usman Baig
ad1c8c5420 fix: address spec compliance gaps in soft-delete frontend 2026-03-18 11:15:14 +01:00
Usman Baig
51723bea5d feat: replace prompt() delete with DeleteSiteModal on settings page 2026-03-18 11:15:14 +01:00
Usman Baig
d7f374472a feat: integrate delete modal and soft-deleted sites list on dashboard 2026-03-18 11:15:14 +01:00
Usman Baig
7a0f106bc3 feat: add DeleteSiteModal with soft-delete and permanent-delete options 2026-03-18 11:15:14 +01:00
Usman Baig
10ad276c38 feat: add soft-delete API functions and deleted_at to Site type 2026-03-18 11:15:14 +01:00
Usman Baig
e4fa320b39 Merge remote-tracking branch 'origin/main' into staging 2026-03-17 23:10:11 +01:00
Usman Baig
78fed269db fix: replace developer jargon with user-friendly labels in visitor identity settings
Storage/TTL labels used implementation terms (localStorage, sessionStorage, TTL)
that only make sense to developers. Replaced with plain language and added a
description explaining the privacy trade-off.
2026-03-17 23:07:11 +01:00
Usman Baig
90944ce6bd fix: use screen.width fallback in trackCustomEvent to prevent bot filter false positives
window.innerWidth is 0 in hidden/minimized tabs, causing the heuristic bot
scorer (added in #40) to drop legitimate custom events with a score of 5.
Use window.screen.width as fallback, matching the existing trackPageview logic.
2026-03-17 22:32:56 +01:00
Usman Baig
d97818dfd7 fix: use screen.width fallback in trackCustomEvent to prevent bot filter false positives
window.innerWidth is 0 in hidden/minimized tabs, causing the heuristic bot
scorer (added in #40) to drop legitimate custom events with a score of 5.
Use window.screen.width as fallback, matching the existing trackPageview logic.
2026-03-17 22:32:45 +01:00
Usman
fa9fa26a1f Merge pull request #54 from ciphera-net/staging
chore: bump @ciphera-net/ui to ^0.2.8
2026-03-17 22:27:59 +01:00
Usman Baig
3aaf199a19 chore: bump @ciphera-net/ui to ^0.2.8 2026-03-17 19:47:54 +01:00
Usman
431128f4ad Merge pull request #53 from ciphera-net/staging
Slim tracking script: send raw browser state, let server handle normalization
2026-03-17 12:35:57 +01:00
Usman Baig
dedb55b113 docs: add thin client changes to changelog 2026-03-17 12:32:53 +01:00
Usman Baig
c833a759f4 refactor: slim tracking script, move logic server-side 2026-03-17 12:28:23 +01:00
Usman
553b44328e Merge pull request #52 from ciphera-net/staging
BunnyCDN, Search tab, journeys redesign, and dashboard polish
2026-03-17 11:08:26 +01:00
Usman Baig
81e2e8bd6c chore: consolidate unreleased changelog entries
Merge duplicate section headers, remove duplicate visit duration entry,
trim granular sub-features into parent entries, and reorder sections to
follow Keep a Changelog convention (Added → Improved → Removed → Fixed).
2026-03-17 11:03:17 +01:00
Usman Baig
109aca62c0 docs: add bot filtering and script improvements to changelog 2026-03-17 10:24:44 +01:00
Usman Baig
e7ebe2a923 refactor: remove client-side 0x0 screen check, handled server-side
IsSuspiciousEvent already scores 0x0 screens as +5 (bot threshold).
Keeping the check client-side hides bot traffic from analysis and
is trivially bypassable.
2026-03-17 10:20:52 +01:00
Usman Baig
ebd25770b4 revert: remove client-side bot detection from tracking script
Server-side heuristic scoring already catches these patterns via
IsSuspiciousEvent. Client-side checks are trivially bypassable
(script is public) and add payload weight for all real users.
2026-03-17 10:19:29 +01:00
Usman Baig
d45d39aa60 feat: add client-side headless browser detection
Skip events from headless Chrome (empty plugins, missing chrome
runtime), hidden browsers (zero outer dimensions), and known bot
viewports (1024x1024).
2026-03-17 10:17:21 +01:00
Usman Baig
01222bf0a9 fix: bump dark mode inline bar opacity from 25% to 40% — less brown, more orange 2026-03-16 23:14:07 +01:00
Usman Baig
1ba6f6609d fix: step numbering starts at 1 after Entry column 2026-03-16 22:27:11 +01:00
Usman Baig
b16f01bd7f fix: rename Step 1 to Entry in columns view, max depth to 6 2026-03-16 22:08:14 +01:00
Usman Baig
52427fea93 fix: change journey depth default to 4, max to 5 2026-03-16 22:02:29 +01:00
Usman Baig
17f2bdc9e9 feat: rewrite sankey chart with D3 — thin bars, labels beside nodes, proper hover
Replace @nivo/sankey with custom D3 implementation:
- 30px thin node bars with labels positioned beside them
- Links at 0.3 opacity, 0.6 on hover with full path highlighting
- Colors based on first URL segment for visual grouping
- Dynamic height based on tallest column
- Responsive width via ResizeObserver
- Click nodes to filter, hover for tooltips
- Invisible wider hit areas for easier link hovering
- Remove @nivo/sankey dependency, add d3
2026-03-16 21:56:22 +01:00
Usman Baig
4007056e44 feat: redesign sankey to block-style nodes with inside labels
- nodeThickness 8 → 100 (wide blocks like Rybbit)
- Labels inside nodes with white text instead of outside
- Margins 90px → 16px (labels no longer need outside space)
- Dynamic chart height based on max nodes per step
- Tighter nodeSpacing (4px) and subtle link opacity
- nodeBorderRadius 4 for rounded block corners
2026-03-16 21:45:08 +01:00
Usman Baig
bec61c599e fix: reduce sankey margins from 160px to 90px — less wasted space 2026-03-16 21:30:09 +01:00
Usman Baig
40f223cf38 fix: make sankey chart responsive — no horizontal scrolling
Replace fixed numSteps*250 width calculation with ResizeObserver
that measures the container and fits the chart within it.
2026-03-16 21:25:21 +01:00
Usman Baig
e9ec86b10b fix: polish ScriptSetupBlock — orange accent bar, terminal dots, tighter storage/TTL, framework icons
- Add gradient orange accent bar and macOS-style terminal dots to code block
- Copy button uses brand-orange styling instead of plain gray
- Storage and TTL selects sit side-by-side tightly instead of spread apart
- TTL options shortened (24h, 48h, 7d, 30d)
- Replace framework dropdown with compact icon+label buttons (logos visible)
- Add "All integrations" link in section header
2026-03-16 17:20:27 +01:00
Usman Baig
16020a166c feat: redesign ScriptSetupBlock with feature toggles and dynamic script builder
Replace framework grid + static code block with:
- Dark terminal-style code block that updates in real-time
- Feature toggle switches (scroll depth, 404, outbound, downloads)
- Frustration tracking toggle (visually distinct as add-on)
- Storage mode + TTL dropdowns (TTL hides when per-tab selected)
- Compact framework dropdown replacing 10-button grid
2026-03-16 17:14:13 +01:00
Usman Baig
e444985295 refactor: extract frustration tracking into separate add-on script
Move rage click and dead click detection (35% of script.js) into
script.frustration.js as an optional add-on. Core script drops from
8.1KB to 5.7KB gzipped. Add-on auto-discovers core via window.pulse
polling and supports opt-out via data-no-rage/data-no-dead attributes.

- Expose cleanPath on window.pulse for add-on consumption
- Add script.frustration.js to middleware PUBLIC_ROUTES
- Update integration guides, ScriptSetupBlock, and FrustrationTable
  empty state to reference the add-on script
2026-03-16 16:59:37 +01:00
Usman Baig
f797d89131 fix: restyle sankey to match reference - thinner nodes, all labels, scrollable
- Switch to fixed-width Sankey with horizontal scroll (250px per step)
- Thinner nodes (8px), tighter spacing (8px)
- Labels on all columns, not just first/last
- Lower link opacity (0.15) for cleaner look
- Increased node cap to 25 per step
2026-03-16 14:22:06 +01:00
Usman Baig
1aace48d73 fix: cap sankey height at 500px, show labels for first/last steps only 2026-03-16 14:15:10 +01:00
Usman Baig
d3f5e6b361 fix: disable sankey labels, reduce margins, dynamic height
Labels were overlapping badly with many nodes. Rely on hover
tooltips instead. Chart height now scales with node count
(400-700px range).
2026-03-16 14:08:08 +01:00
Usman Baig
6f42d4d3de feat: add Columns/Flow view toggle to journeys page 2026-03-16 14:01:18 +01:00
Usman Baig
71f922976d feat: add SankeyJourney component with data transformation and interactivity 2026-03-16 14:00:12 +01:00
Usman Baig
bb1ed9d8b5 chore: add @nivo/sankey dependency 2026-03-16 13:57:06 +01:00
Usman Baig
47ea6fa6f6 feat: add micro-animations to journey chart
- Connection lines draw-in with staggered stroke-dashoffset
- Bar widths grow from zero on mount with row stagger
- Columns fade + slide in from left with 50ms delay each
- Hover lift (-1px translate + shadow) on page rows
- Exit card fades in from top
- Drop-off percentages count up with eased animation
2026-03-16 13:28:13 +01:00
Usman Baig
3b09758881 fix: cap inline bar chart max width at 75%
Prevents the top item from spanning full width, making bars
read more clearly as proportional indicators.
2026-03-16 12:44:32 +01:00
Usman Baig
4f419f8b04 fix: increase inline bar chart opacity for better brand visibility
Light mode: 5% → 15%, dark mode: 10% → 25%
2026-03-16 12:40:01 +01:00
Usman Baig
336520e401 feat: show brief success state before closing export modal
Progress bar turns green at 100%, button shows "Done", then modal
auto-closes after 600ms. Gives visual confirmation without fake delay.
2026-03-16 11:48:47 +01:00
Usman Baig
ec9f72455a chore: remove polish-audit.md from tracking 2026-03-16 11:40:32 +01:00
Usman Baig
be1d9a2f46 feat: add shimmer bar when dashboard is refetching after filter change
Shows a thin animated orange bar below the filter bar while SWR
revalidates, so users know their filter was applied. Hidden on
initial load where the skeleton already provides feedback.
2026-03-16 11:39:28 +01:00
Usman Baig
e4291c44a8 feat: add progress bar to export modal
Show step-by-step progress during PDF/XLSX exports with percentage,
stage label, and animated orange bar. Yields to UI thread between
stages so the browser can repaint.
2026-03-16 11:32:17 +01:00
Usman Baig
ed865c9a6f chore: remove unused axios dependency
All HTTP calls use the native fetch-based apiRequest client.
2026-03-16 11:27:31 +01:00
Usman Baig
8287a38b43 chore: add 429 errors 2026-03-16 11:06:41 +01:00
Usman Baig
2e444849ef fix: make step 1 clicks show connector lines like other steps
Previously clicking a step 1 block would set it as an entry point filter
instead of showing connection lines. Now all steps behave consistently —
clicking any step toggles selection and draws connector lines to the next
column. Entry point filtering remains available via the dropdown.
2026-03-16 09:42:59 +01:00
Usman Baig
df10d4e747 feat: add actionable CTAs to all dashboard empty states
- Campaigns: "Build a UTM URL" opens UTM builder modal directly
- Pages/Referrers/Locations/Technology: "Install tracking script"
  links to /installation
- Matches existing CTA pattern from GoalStats
2026-03-15 22:00:58 +01:00
Usman Baig
c21d7b9073 feat: add animated number transitions to dashboard stats
Numbers smoothly count up/down when switching date ranges,
applying filters, or as real-time visitor count changes.
Uses framer-motion useSpring for natural spring physics.
2026-03-15 21:37:11 +01:00
Usman Baig
df2b3cadd7 feat: add inline bar charts to all dashboard list components
Add proportional background bars (brand-orange) to Pages,
Referrers, Locations, Technology, and Campaigns tables.
Bars scale relative to the top item in each list.
2026-03-15 20:39:25 +01:00
Usman Baig
4f4f2f4f9a refactor: redesign top paths table to match Pulse patterns
- Replace card-per-row with compact list rows + background bars
- Drop rank badges (order already communicates rank)
- Inline path sequence + stats into single row
- Truncate sequences longer than 7 steps (first 3 + … + last 2)
- Duration shows on hover with slide-in animation
- Use brand-orange bars proportional to top path count
2026-03-15 20:31:57 +01:00
Usman Baig
d864d951f9 fix: rebuild depth slider from scratch
- Use min=2 max=10 directly on range input (no array index mapping)
- Debounce via useEffect instead of manual timer refs
- Always render Reset button with opacity transition (no layout shift)
- Immediate setCommittedDepth on reset for instant response
2026-03-15 20:08:15 +01:00
Usman Baig
d5b48ac985 fix: rebuild depth slider with single state + debounce
Replace dual-state (depth/displayDepth) with a single depth state
and a debounced value for API calls. Eliminates glitchy reset button
and simplifies the slider to just onChange.
2026-03-15 20:04:36 +01:00
Usman Baig
3c9d5b47be fix: show reset button while dragging depth slider 2026-03-15 20:00:51 +01:00
Usman Baig
0ea9b31b63 style: make journey exit row a full red block matching other rows 2026-03-15 19:53:10 +01:00
Usman Baig
25f4cd5eb9 fix: move border-b inside scrollable nav to prevent orange indicator clipping 2026-03-15 19:45:42 +01:00
Usman Baig
2068f839fd fix: restore brand orange tab indicator clipped by overflow-x-auto 2026-03-15 19:41:28 +01:00
Usman Baig
76248233b9 fix: revalidate funnels list after creating a new funnel
Mutate the SWR funnels cache key before navigating back so the
list page shows the newly created funnel without requiring a refresh.
2026-03-15 18:38:51 +01:00
Usman Baig
849986edf1 fix: restore active tab indicator in scrollable SiteNav
Move overflow-x-auto to the outer border-b container and use min-w-max
on the nav so the framer-motion layoutId indicator is not clipped.
2026-03-15 18:37:40 +01:00
Usman Baig
220d3905be fix: use 0-based step order when creating funnels
Backend expects sequential order starting from 0 (0, 1, 2, ...),
but the frontend was sending 1-based order (1, 2, 3, ...).
2026-03-15 18:33:30 +01:00
Usman Baig
6d6c1ee8f6 fix: prevent grid children from overflowing on mobile
Add [&>*]:min-w-0 to lg:grid-cols-2 grids so widget cards
respect the grid track width instead of expanding to content size.
2026-03-15 18:20:08 +01:00
Usman Baig
24c71f7991 fix: mobile responsiveness across all pages
- SiteNav: add horizontal scroll for 8 tabs on mobile
- NotificationCenter: full-width dropdown on mobile
- ContentStats/Locations/TechSpecs: scrollable tab bars
- FrustrationTable: fix selector text overflow
- FrustrationByPageTable: horizontal scroll on mobile
- CDN: better stat card grid breakpoints
- Home: reduce stat card height, prevent button wrap
- Billing: shorter invoice labels on mobile
- Bump @ciphera-net/ui to 0.2.6 (AppLauncher mobile fix)
2026-03-15 18:15:06 +01:00
Usman Baig
7103a39273 fix: increase column padding for bar chart breathing room 2026-03-15 13:48:19 +01:00
Usman Baig
3c8904ffe4 fix: remove overflow-hidden clipping bar chart left rounding 2026-03-15 13:45:29 +01:00
Usman Baig
aba67592bb fix: bar chart left rounding by using width calc instead of scaleX 2026-03-15 13:42:41 +01:00
Usman Baig
e7907d68bf fix: default depth 10, bar rounding, exit row height, connection line reach 2026-03-15 13:39:41 +01:00
Usman Baig
342bf46946 fix: bar chart overflow by using scaleX instead of width percentage 2026-03-15 13:33:10 +01:00
Usman Baig
de16991bb3 fix: inset bar chart so left rounding is visible 2026-03-15 13:30:20 +01:00
Usman Baig
3954ee0a97 refactor: restyle journey columns to match Pulse native patterns 2026-03-15 13:27:20 +01:00
Usman Baig
b000d0e1f7 fix: replace horizontal dashed lines with solid vertical column dividers 2026-03-15 13:20:57 +01:00
Usman Baig
58272f3fb5 fix: remove scroll fade gradient that was overlapping column content 2026-03-15 13:15:42 +01:00
Usman Baig
722b5de88d feat: polish journey columns with bar charts, count pills, colored selection, dotted connectors 2026-03-15 13:12:17 +01:00
Usman Baig
ada2c65d8f fix: show exit as red card in next column instead of SVG text hack 2026-03-15 13:03:06 +01:00
Usman Baig
b10abd38fc feat: show exit count when selecting a page, fix scroll fade overlay 2026-03-15 12:56:59 +01:00
Usman Baig
9f9f4286b7 fix: selections only show connection lines, no longer filter column data 2026-03-15 12:46:48 +01:00
Usman Baig
a7e9f7c998 fix: cascade column selection filter downstream, trim empty columns, add scroll fade 2026-03-15 12:42:49 +01:00
Usman Baig
4103014cdb fix: restyle journey columns to match Pirsch card-based design 2026-03-15 12:28:43 +01:00
Usman Baig
9528eca443 fix: handle 204 No Content responses in API client
Prevent error toasts on successful delete operations by checking for
204 status before attempting to parse response body as JSON.
2026-03-15 12:23:05 +01:00
Usman Baig
e8f00e06ec feat: replace sankey chart with column-based journey visualization 2026-03-15 12:17:48 +01:00
Usman Baig
1e147c955b fix: improve visit duration reliability with pagehide fallback and dedup guard 2026-03-15 11:58:33 +01:00
Usman Baig
7e30d04df3 feat: redesign top paths as breadcrumb cards with icons 2026-03-15 11:47:52 +01:00
Usman Baig
47d884e47b fix: remove focus ring from depth slider, debounce API calls until drag ends 2026-03-15 11:43:04 +01:00
Usman Baig
f858bb7811 refactor: remake journeys page with pricing-style slider, remove top paths table 2026-03-15 11:39:33 +01:00
Usman Baig
302e683b32 feat: increase journey depth slider max from 5 to 10 2026-03-15 11:27:18 +01:00
Usman Baig
20cda8d464 docs: add CDN refresh interval changelog entry 2026-03-14 23:38:46 +01:00
Usman Baig
bc2534a22b fix: reduce false positives in rage click and dead click detection
- Skip rage clicks when text is selected (triple-click to select)
- Exclude tabindex="-1" elements from dead click interactive selector
- Observe document.body for DOM changes (modals, drawers, overlays)
- Listen for popstate/hashchange to detect SPA navigations
2026-03-14 23:32:31 +01:00
Usman Baig
b305b5345b refactor: remove performance insights (Web Vitals) feature entirely
Remove Performance tab, PerformanceStats component, settings toggle,
Web Vitals observers from tracking script, and all related API types
and SWR hooks. Duration tracking is preserved.
2026-03-14 22:47:33 +01:00
Usman Baig
7247281ce2 feat: move performance to dedicated tab, fix 0/99999 metrics bug
Performance metrics moved from dashboard into a new Performance tab.
Fixed null handling so "No data" shows instead of misleading zeros.
Script no longer sends INP=0 when no interaction occurred.
2026-03-14 22:01:44 +01:00
Usman Baig
f278aada7a fix: use flag icons, show per-datacenter dots on map, format tooltip as bytes 2026-03-14 21:35:26 +01:00
Usman Baig
1e61926bc6 fix: parse bunnycdn datacenter codes to ISO country codes for map dots and flags 2026-03-14 21:26:51 +01:00
Usman Baig
77b280341b fix: use official bunnycdn logo, redesign traffic distribution with map and country grid 2026-03-14 21:21:53 +01:00
Usman Baig
d9c01b9b06 feat: add traffic distribution dotted map to CDN tab 2026-03-14 21:12:07 +01:00
Usman Baig
2512be0d57 fix: bunnycdn logo and api key security 2026-03-14 21:08:42 +01:00
Usman Baig
fb85c431f0 feat: add BunnyCDN integration 2026-03-14 20:46:26 +01:00
Usman Baig
a8fe171c8c fix: use ShieldCheck icon for Data & Privacy settings tab 2026-03-14 18:30:41 +01:00
Usman Baig
4ceb33b946 feat: add header icons to all dashboard panels
Consistent icon treatment across Pages, Referrers, Locations,
Technology, Campaigns, Peak Hours, Goals, and Search panels.
2026-03-14 18:27:12 +01:00
Usman Baig
4d869d8cb1 fix: place Search and Goals side by side in two-column grid 2026-03-14 18:13:07 +01:00
Usman Baig
a3f50dc38f fix: restore Peak Hours layout and hide empty Search panel
Peak Hours back in its original grid position next to Campaigns.
Search panel now placed below as a standalone row, and hides
entirely when there's no GSC data instead of showing zeros.
2026-03-14 18:09:07 +01:00
Usman Baig
8f00193e0f feat: add Search panel to dashboard and enrich Search tab
Dashboard: compact Search Performance panel showing top 5 queries,
clicks, impressions, and avg position alongside Campaigns.

Search tab: clicks/impressions trend chart, top query position
tracker cards, and new queries badge.
2026-03-14 18:05:05 +01:00
Usman Baig
af29bb77cd fix: stop retrying rate-limited and auth-failed requests
SWR was retrying 429/401/403 responses with exponential backoff,
which cascaded into a flood of failed requests when the tab regained
focus. Now skips retries entirely for these status codes.
2026-03-14 17:16:23 +01:00
Usman Baig
34c705549b feat: add Google Search Console integration UI
Search Console page with overview cards, top queries/pages tables,
and query↔page drill-down. Integrations tab in Settings for
connect/disconnect flow. New Search tab in site navigation.
2026-03-14 15:36:37 +01:00
Usman
9b7781115f Merge pull request #50 from ciphera-net/staging
feat: track time-on-page via unload ping for accurate visit durations
2026-03-14 14:26:39 +01:00
Usman Baig
cf0b6b8a68 feat: track time-on-page via unload ping for accurate visit durations
Sends duration to the metrics endpoint on visibilitychange and SPA
navigation. Also lowers journey min_sessions to 1 for low-traffic sites.
2026-03-14 14:07:54 +01:00
Usman
8e7c273ebc Merge pull request #49 from ciphera-net/staging
feat: centralise date/time formatting with European conventions
2026-03-14 13:49:20 +01:00
Usman Baig
11ef95ef45 fix: use full day names in Peak Hours busiest-time callout 2026-03-14 13:40:42 +01:00
Usman Baig
19db02e945 fix: lower min_sessions to 1 for journey data visibility
Journeys page showed empty state on low-traffic sites because
min_sessions=2 filtered out all single-occurrence transitions.
2026-03-14 13:40:40 +01:00
Usman Baig
2242a159c7 fix: use 24-hour time in Peak Hours heatmap
Axis labels, bucket ranges, and busiest-time callout now use
24-hour format (00:00, 06:00, 12:00, 18:00) instead of AM/PM.
2026-03-14 13:38:51 +01:00
Usman Baig
25210013d3 feat: centralise date/time formatting with European conventions
All dates now use day-first ordering (14 Mar 2025) and 24-hour time
(14:30) via a single formatDate.ts module, replacing scattered inline
toLocaleDateString/toLocaleTimeString calls across 12 files.
2026-03-14 13:31:30 +01:00
Usman Baig
7ba5e063ca feat: add free plan to pricing page and enforce 1-site limit
Show the free tier (€0, 1 site, 5k pageviews, 6 months retention)
as the first card on the pricing page. Enforce a 1-site limit for
free plan users in the frontend.
2026-03-13 21:28:04 +01:00
Usman Baig
ed80290431 Merge branch 'staging' 2026-03-13 20:35:11 +01:00
Usman Baig
d6d42b5759 fix: portal delete modal to body so backdrop-blur covers header
The modal was rendered inside <main> which is a sibling of the
fixed header. Browser compositing didn't apply backdrop-blur
across the header's separate GPU layer. Using createPortal to
render at document.body matches how the delete account modal
works (rendered as sibling to header via SettingsModalWrapper).
2026-03-13 20:35:04 +01:00
Usman Baig
3619a1e644 Merge branch 'staging' 2026-03-13 20:29:47 +01:00
Usman Baig
618c4fd5fe fix: bump delete modal z-index to z-[100] to cover fixed header
Header uses fixed z-50 with backdrop-blur which creates its own
stacking context. z-[60] wasn't enough, z-[100] matches the
pattern used by VerificationModal.
2026-03-13 20:29:47 +01:00
Usman Baig
68536ed71a Merge branch 'staging' 2026-03-13 20:10:24 +01:00
Usman Baig
39e06183c3 fix: delete modal overlay z-index above navbar 2026-03-13 20:10:23 +01:00
Usman Baig
cad588da52 fix: delete modal overlay now covers navbar
Bumped z-index from z-50 to z-[60] so the backdrop-blur
renders above the sticky navbar.
2026-03-13 20:10:02 +01:00
Usman Baig
6c31f3fc60 fix: show org name in delete modal and fix subscription card visibility
Modal now says "Delete Ciphera?" instead of generic "Delete Organization?".
Subscription cancellation card now shows for any paid plan (solo/team/business)
regardless of subscription_status, which could be 'active' or 'trialing'.
2026-03-13 19:58:01 +01:00
Usman Baig
86077557a8 style: redesign org delete modal to match delete account style
Red border, frosted overlay, destruction items as prominent
red-bordered cards with warning icons instead of small bullet
points. Button text changed to "Delete Forever".
2026-03-13 19:44:03 +01:00
Usman Baig
e86021caf8 fix: await subscription fetch before opening delete modal
The delete button fired loadSubscription() without awaiting it,
so the modal opened with subscription=null and the destruction
summary (sites count, active subscription) never rendered.
2026-03-13 19:35:12 +01:00
Usman Baig
0dd1f00095 fix: fetch subscription data when delete modal opens
Subscription was only loaded on the Billing tab, so the delete
modal showed no destruction summary when opened from General tab.
2026-03-13 18:25:34 +01:00
Usman Baig
84312ebf59 feat: show destruction summary in org delete modal
Lists what will be deleted: site count with analytics data,
member count, active subscription, and notification settings.
Replaces the generic warning with specific impact details.
2026-03-13 18:15:40 +01:00
Usman
91f4743f48 Merge pull request #48 from ciphera-net/staging
Site verification status UI
2026-03-13 17:20:12 +01:00
Usman Baig
f7bd61187a style: make verified state match button shape of unverified state 2026-03-13 16:46:51 +01:00
Usman Baig
344838e0cd style: use subtle inline text for verified status in settings 2026-03-13 16:44:00 +01:00
Usman Baig
e336d2c7e5 feat: show verification status in site settings page 2026-03-13 16:40:37 +01:00
Usman Baig
8f06c9168a feat: show verified/unverified badge on site cards 2026-03-13 16:32:26 +01:00
Usman Baig
66a9ac1f31 fix: include all 76 integrations in sitemap instead of only 4 2026-03-13 14:58:27 +01:00
Usman
98c08e3996 Merge pull request #47 from ciphera-net/staging
fix: exclude sitemap.xml, robots.txt, llms.txt from auth middleware
2026-03-13 14:45:30 +01:00
Usman Baig
dc422b5920 fix: exclude sitemap.xml, robots.txt, llms.txt from auth middleware 2026-03-13 14:44:52 +01:00
Usman
f976fbdb2e Merge pull request #46 from ciphera-net/staging
Chart UX improvements, behavior page polish, and SEO setup
2026-03-13 14:42:01 +01:00
Usman Baig
2a2a64f6d7 feat: add sitemap.xml, robots.txt, and llms.txt for SEO 2026-03-13 14:40:41 +01:00
Usman Baig
b5d408b4e8 style: add skeleton loading & fade transition to behavior page 2026-03-13 14:30:01 +01:00
Usman Baig
00d232ab3f fix: switch from natural to bump interpolation to prevent overshoot 2026-03-13 13:53:38 +01:00
Usman Baig
87f5905bd6 fix: clip chart overflow from natural spline overshoot 2026-03-13 13:50:27 +01:00
Usman Baig
58f42f945c style: smooth chart curves with natural spline and add area fill
Switch from monotone to natural interpolation for rounder peaks.
Add transparent orange gradient area fill beneath the line.
2026-03-13 13:47:26 +01:00
Usman Baig
570a84889a fix: increase hover hitbox on map location markers 2026-03-13 13:43:44 +01:00
Usman
2cc120ca3f Merge pull request #45 from ciphera-net/staging
Polish dashboard UX, loading states, and tracking accuracy
2026-03-13 13:35:08 +01:00
Usman Baig
fcfa4bfed9 fix: use allowlist for query params to prevent path fragmentation
Switch from blocklist (strip known-bad params) to allowlist (only keep
UTM/attribution params). Eliminates cache-busters like _t and _ from
page paths without maintaining an ever-growing blocklist.
2026-03-13 13:33:11 +01:00
Usman Baig
969887cc67 style: use CartesianGrid for horizontal lines aligned with Y-axis ticks
Replace the CSS overlay with Recharts CartesianGrid (horizontal only)
so lines align perfectly with the Y-axis values.
2026-03-13 13:08:28 +01:00
Usman Baig
453a596eaf style: replace animated grid with subtle horizontal lines in chart
Simple repeating horizontal lines at 40px intervals with 50% opacity,
faded at top/bottom edges via CSS mask. No extra components needed.
2026-03-13 13:05:24 +01:00
Usman Baig
9a54d93c79 style: replace static grid with animated grid pattern in chart
Use AnimatedGridPattern from 21st.dev with subtle fading squares.
Scoped to CardContent only so the metric tabs and annotation footer
stay clean.
2026-03-13 13:01:03 +01:00
Usman Baig
eb0dc4a27b style: replace dotted chart background with grid line pattern
Swap the old dot grid overlay inside the Recharts SVG for a GridPattern
component rendered behind the chart card. Uses a vertical mask gradient
to fade edges for a cleaner look.
2026-03-13 12:55:20 +01:00
Usman Baig
8c4bb8f861 style: add fade-in transition from skeleton to content
Smooth out the jarring visual pop when loading skeletons are replaced
by real content. Only animates after an actual skeleton was shown —
cached data still renders instantly with no delay.
2026-03-13 12:45:48 +01:00
Usman Baig
0abc5cd4a8 style: unify all dashboard chart colors to brand orange 2026-03-13 12:32:57 +01:00
Usman Baig
3bda7215db fix: stat label invisible on light mode when selected
The active metric label (e.g. UNIQUE VISITORS) was white on a
near-white background. Switch to brand orange for visibility in
both themes.
2026-03-13 12:29:05 +01:00
Usman Baig
6380f216aa perf: migrate Settings, Funnels, and Uptime to SWR data fetching
Replace manual useState/useEffect fetch patterns with SWR hooks so
cached data renders instantly on tab revisit. Skeleton loading now
only appears on the initial cold load, not every navigation.

New hooks: useFunnels, useUptimeStatus, useGoals, useReportSchedules,
useSubscription — all with background revalidation.
2026-03-13 12:21:55 +01:00
Usman Baig
b6a7c642f2 feat: add skeleton loading to Journeys page
Use JourneysSkeleton with useMinimumLoading hook, matching the
loading pattern used on Dashboard, Funnels, Uptime, and Settings.
2026-03-13 11:58:35 +01:00
Usman
6a13e5480a Merge pull request #44 from ciphera-net/staging
fix: pass CSRF token to switch-context call in refresh route
2026-03-13 11:41:03 +01:00
Usman Baig
57e43b1b4f Merge branch 'main' into staging 2026-03-13 11:30:05 +01:00
Usman Baig
c0ad0cfb7a fix: pass CSRF token to switch-context call in refresh route
The auth API requires CSRF tokens on POST requests. The switch-context
call was failing silently with 403, causing refreshed tokens to lack
org_id.
2026-03-13 11:30:00 +01:00
Usman Baig
2d3388546f fix: pass CSRF token to switch-context call in refresh route
The auth API requires CSRF tokens on POST requests. The switch-context
call was failing silently with 403, causing refreshed tokens to lack
org_id.
2026-03-13 11:29:45 +01:00
Usman Baig
34c80d0857 fix: restore org context during token refresh
After refreshing the base token, call switch-context to get an
org-scoped token. This prevents 403 errors on Pulse API requests
when the access token is refreshed mid-session.
2026-03-13 11:18:26 +01:00
Usman Baig
1c26e4cc6c fix: resolve intermittent auth errors when navigating between tabs
Token refresh race condition: when multiple requests got 401 simultaneously,
queued retries reused stale headers and the initiator fell through without
throwing on retry failure. Now retries regenerate headers (fresh request ID
and CSRF token), and both retry failure and refresh failure throw explicitly.

SWR cache is now invalidated after token refresh so stale error responses
are not served from cache.
2026-03-13 10:52:02 +01:00
Usman Baig
f7340fa763 fix: include URL in outbound/download events, exclude form inputs from dead clicks 2026-03-13 09:11:23 +01:00
Usman Baig
6e213539ea feat: filter headless browsers and zero-screen bots client-side 2026-03-13 09:07:49 +01:00
Usman Baig
f69248ecfa fix: strip utm_id from page paths to prevent fragmentation 2026-03-13 08:58:33 +01:00
Usman Baig
360d6e7e71 fix: preserve UTM params for backend attribution, only strip ad click IDs 2026-03-13 08:43:55 +01:00
Usman Baig
63144a136e fix: only attribute referrer to landing page and strip Meta ad params 2026-03-13 01:31:14 +01:00
Usman Baig
1d71a13df4 fix: normalize page paths — strip UTM params and trailing slashes 2026-03-13 01:19:23 +01:00
Usman Baig
6edd5ac0b6 fix: skip pageview tracking for prerendered pages 2026-03-13 01:17:12 +01:00
Usman Baig
a57ed871f1 fix: screen size shadowing, popstate double pageview, custom event self-referrals 2026-03-13 01:09:45 +01:00
Usman Baig
765f8ec63e fix: strip self-referrals from tracking script 2026-03-13 01:06:14 +01:00
Usman Baig
aae1714b02 fix: deduplicate pageviews on page refresh 2026-03-13 00:50:13 +01:00
270 changed files with 27550 additions and 12784 deletions

View File

@@ -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
View File

@@ -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
View File

@@ -1,2 +1,3 @@
@ciphera-net:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}
legacy-peer-deps=true

View File

@@ -4,6 +4,69 @@ All notable changes to Pulse (frontend and product) are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and Pulse uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) with a **0.x.y** version scheme while in initial development. The leading `0` indicates that the public API and behaviour may change until we release **1.0.0**.
## [Unreleased]
### 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 23 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

648
LICENSE
View File

@@ -1,7 +1,638 @@
Copyright (C) 2024-2026 Ciphera
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2024-2026 Ciphera
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.
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
@@ -15,3 +646,18 @@ 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/>.

View File

@@ -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 mattervisitors, pageviews, and sourceswhile 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.

View 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>
)
}

View File

@@ -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>

View File

@@ -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">Customer ID:</span>
<span className="font-mono text-xs">{org.billing_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">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">

View File

@@ -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">

View File

@@ -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>
)
}

View File

@@ -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 })
}

View File

@@ -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
View 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
View 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&apos;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>
)
}

View File

@@ -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-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:ring-offset-2"
>
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white pr-4">
{faq.question}
</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 (
<>
@@ -95,28 +35,8 @@ export default function FAQPage() {
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>

View File

@@ -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&apos;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>
@@ -193,10 +192,10 @@ export default function FeaturesPage() {
{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&apos; 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">

View File

@@ -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 &lt;head&gt; 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">&gt;&lt;/script&gt;</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(&apos;event_name&apos;)</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(&apos;event_name&apos;)</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" />

View File

@@ -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>
</>
)

View File

@@ -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>
)
}

View File

@@ -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 &ldquo;{query}&rdquo;</>}
@@ -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-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible: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 &ldquo;{query}&rdquo;.
</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&apos;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-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible: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,12 +350,12 @@ 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&apos;d like to see next.
</p>
<a

View File

@@ -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>&lt;head&gt;</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>
)
}

View 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>&lt;head&gt;</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> &mdash; your site&apos;s domain as shown in your Pulse dashboard (e.g. <code>example.com</code>), without <code>https://</code></li>
<li><code>defer</code> &mdash; loads the script without blocking page rendering</li>
</ul>
<h2>Where to paste the script</h2>
<p>
Most platforms have a &ldquo;Custom Code&rdquo;, &ldquo;Code Injection&rdquo;, or &ldquo;Header Scripts&rdquo;
section in their settings. Look for one of these:
</p>
<ul>
<li><strong>Squarespace:</strong> Settings &rarr; Developer Tools &rarr; Code Injection &rarr; Header</li>
<li><strong>Wix:</strong> Settings &rarr; Custom Code &rarr; Head</li>
<li><strong>Webflow:</strong> Project Settings &rarr; Custom Code &rarr; Head Code</li>
<li><strong>Ghost:</strong> Settings &rarr; Code Injection &rarr; Site Header</li>
<li><strong>Any HTML site:</strong> Paste directly into your <code>&lt;head&gt;</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>
</>
)
}

View File

@@ -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>&lt;head&gt;</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>
)
}

View File

@@ -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 &gt; 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>&lt;/head&gt;</code> tag.</li>
</ol>
</div>
</div>
</div>
)
}

View File

@@ -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 (
<>
{auth.user && <OfflineBanner isOnline={isOnline} />}
{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={handleCreateOrganization}
onCreateOrganization={() => router.push('/onboarding')}
allowPersonalOrganization={false}
showFaq={false}
showSecurity={false}
showPricing={true}
topOffset={showOfflineBar ? `${barHeightRem}rem` : undefined}
rightSideActions={auth.user ? <NotificationCenter /> : null}
showPricing={false}
rightSideActions={<NotificationCenter />}
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>
)}
</>
}
onOpenSettings={() => openUnifiedSettings({ context: 'account', tab: 'profile' })}
/>
<main
className={`flex-1 pb-8 ${showOfflineBar ? '' : 'pt-24'}`}
style={showOfflineBar ? { paddingTop: `${mainTopPaddingRem}rem` } : undefined}
>
<main className="flex-1 pb-8">
{children}
</main>
<UnifiedSettingsModal />
</div>
)
}
// Public/marketing: sticky header + footer
return (
<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>
)
}

View File

@@ -1,4 +1,4 @@
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'
@@ -45,15 +45,15 @@ 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">
<html lang="en" className={`${plusJakartaSans.variable} dark`} suppressHydrationWarning>
<body className="antialiased min-h-screen flex flex-col bg-neutral-950 text-neutral-100">
<SWRProvider>
<ThemeProviders>
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem={false}>
<AuthProvider>
<LayoutContent>{children}</LayoutContent>
<Toaster />
</AuthProvider>
</ThemeProviders>
</ThemeProvider>
</SWRProvider>
</body>
</html>

View File

@@ -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>

View File

@@ -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)}

View File

@@ -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">

View File

@@ -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',
}
export default function OrgSettingsPage() {
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])
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>
)
}

View File

@@ -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&apos; 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) => (
{/* Live Dashboard Demo */}
<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"
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"
>
<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>
<DashboardDemo />
</motion.div>
))}
</div>
{/* * NEW: COMPARISON SECTION */}
<ComparisonSection />
{/* * NEW: CTA BOTTOM */}
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
className="text-center mb-20"
>
<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>
</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,17 +250,18 @@ 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>
<div className="flex items-center gap-3">
<span className="text-sm font-medium text-neutral-500 dark:text-neutral-400 bg-neutral-100 dark:bg-neutral-800 px-3 py-1.5 rounded-lg border border-neutral-200 dark:border-neutral-700">
<span className="text-sm font-medium text-neutral-400 bg-neutral-800 px-3 py-1.5 rounded-lg border border-neutral-700">
Limit reached ({sites.length}/{siteLimit})
</span>
<Link href="/pricing">
@@ -396,108 +270,31 @@ export default function HomePage() {
</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-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:rounded">
Manage billing
</Link>
) : (
<Link href="/pricing" className="text-sm font-medium text-brand-orange hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible: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,7 +306,55 @@ export default function HomePage() {
)}
{(sitesLoading || sites.length > 0) && (
<SiteList sites={sites} siteStats={siteStats} loading={sitesLoading} onDelete={handleDelete} />
<SiteList sites={sites} siteStats={siteStats} loading={sitesLoading} />
)}
<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>
)

View File

@@ -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
View 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',
}
}

View File

@@ -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'

View File

@@ -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,6 +40,8 @@ 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('')
@@ -92,57 +93,30 @@ 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)
})()
])
@@ -150,23 +124,11 @@ export default function PublicDashboardPage() {
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>
@@ -256,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 || []
@@ -274,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">
@@ -286,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}
@@ -394,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
View 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,
})),
]
}

View File

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

View 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}
/>
)
}

View File

@@ -2,7 +2,7 @@
import { useCallback, useEffect, useState } from 'react'
import { useParams } from 'next/navigation'
import { getDateRange, formatDate } from '@ciphera-net/ui'
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'
@@ -11,23 +11,10 @@ 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'))
function getThisWeekRange(): { start: string; end: string } {
const today = new Date()
const dayOfWeek = today.getDay()
const monday = new Date(today)
monday.setDate(today.getDate() - (dayOfWeek === 0 ? 6 : dayOfWeek - 1))
return { start: formatDate(monday), end: formatDate(today) }
}
function getThisMonthRange(): { start: string; end: string } {
const today = new Date()
const firstOfMonth = new Date(today.getFullYear(), today.getMonth(), 1)
return { start: formatDate(firstOfMonth), end: formatDate(today) }
}
export default function BehaviorPage() {
const params = useParams()
const siteId = params.id as string
@@ -42,6 +29,9 @@ export default function BehaviorPage() {
// 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'
@@ -63,15 +53,17 @@ export default function BehaviorPage() {
const deadClicks = behavior?.dead_clicks ?? { items: [], total: 0 }
const byPage = behavior?.by_page ?? []
if (showSkeleton) return <BehaviorSkeleton />
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
<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-2xl font-bold text-neutral-900 dark:text-white mb-1">
<h1 className="text-lg font-semibold text-neutral-200 mb-1">
Behavior
</h1>
<p className="text-sm text-neutral-500 dark:text-neutral-400">
<p className="text-sm text-neutral-400">
Frustration signals and user engagement patterns
</p>
</div>
@@ -117,7 +109,7 @@ export default function BehaviorPage() {
<FrustrationSummaryCards data={summary} loading={loading} />
{/* Rage clicks + Dead clicks side by side */}
<div className="grid gap-6 lg:grid-cols-2 mb-8">
<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"
@@ -144,7 +136,7 @@ export default function BehaviorPage() {
{/* Scroll depth + Frustration trend — hide when data failed to load */}
{!behaviorError && (
<div className="grid gap-6 lg:grid-cols-2 mb-8">
<div className="grid gap-6 lg:grid-cols-2 mb-8 [&>*]:min-w-0">
<ScrollDepth
goalCounts={dashboard?.goal_counts ?? []}
totalPageviews={dashboard?.stats?.pageviews ?? 0}

View 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
View 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>
)
}

View 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}`}
/>
)
}

View File

@@ -1,14 +1,20 @@
'use client'
import { useCallback, useEffect, 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 { 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 { FunnelDetailSkeleton, useMinimumLoading } from '@/components/skeletons'
import { PencilSimple } from '@phosphor-icons/react'
import { FunnelDetailSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
import Link from 'next/link'
import { FunnelChart } from '@/components/ui/funnel-chart'
import { getDateRange } from '@ciphera-net/ui'
import BreakdownDrawer from '@/components/funnels/BreakdownDrawer'
import { ResponsiveContainer, LineChart, Line, XAxis, YAxis, Tooltip, CartesianGrid } from 'recharts'
export default function FunnelReportPage() {
const params = useParams()
@@ -19,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')
@@ -43,7 +57,7 @@ export default function FunnelReportPage() {
} finally {
setLoading(false)
}
}, [siteId, funnelId, dateRange])
}, [siteId, funnelId, dateRange, filters])
useEffect(() => {
loadData()
@@ -62,6 +76,7 @@ export default function FunnelReportPage() {
}
const showSkeleton = useMinimumLoading(loading && !funnel)
const fadeClass = useSkeletonFade(showSkeleton)
if (showSkeleton) {
return <FunnelDetailSkeleton />
@@ -69,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 pb-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>
)
@@ -77,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 pb-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">
@@ -90,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 pb-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
@@ -101,7 +116,7 @@ export default function FunnelReportPage() {
if (!funnel || !stats) {
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-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>
)
@@ -112,8 +127,23 @@ export default function FunnelReportPage() {
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 pb-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">
@@ -124,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 && (
@@ -156,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"
@@ -166,48 +203,148 @@ 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>
<FunnelChart
data={chartData}
orientation="vertical"
orientation="horizontal"
color="var(--chart-1)"
layers={3}
className="mx-auto max-w-md"
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">
<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-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>
<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>
</td>
<td className="px-6 py-4 text-right">
<span className="font-medium text-neutral-900 dark:text-white">
<span className="font-medium text-white">
{step.visitors.toLocaleString()}
</span>
</td>
@@ -230,6 +367,35 @@ export default function FunnelReportPage() {
</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>
@@ -237,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)}

View File

@@ -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 pb-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}
<FunnelForm
siteId={siteId}
onSubmit={handleSubmit}
submitLabel={saving ? 'Creating...' : 'Create Funnel'}
cancelHref={`/sites/${siteId}/funnels`}
/>
{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>
)
}

View File

@@ -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,24 +22,25 @@ 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 pb-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>
<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">
@@ -71,11 +56,16 @@ export default function FunnelsPage() {
</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">
@@ -99,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 && (

View File

@@ -2,11 +2,13 @@
import { useEffect, useState } from 'react'
import { useParams } from 'next/navigation'
import { getDateRange, formatDate } from '@ciphera-net/ui'
import { motion } from 'framer-motion'
import { getDateRange, formatDate, getThisWeekRange, getThisMonthRange } from '@/lib/utils/dateRanges'
import { Select, DatePicker } from '@ciphera-net/ui'
import SankeyDiagram from '@/components/journeys/SankeyDiagram'
import ColumnJourney from '@/components/journeys/ColumnJourney'
import SankeyJourney from '@/components/journeys/SankeyJourney'
import TopPathsTable from '@/components/journeys/TopPathsTable'
import { SkeletonCard } from '@/components/skeletons'
import { JourneysSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
import {
useDashboard,
useJourneyTransitions,
@@ -14,19 +16,7 @@ import {
useJourneyEntryPoints,
} from '@/lib/swr/dashboard'
function getThisWeekRange(): { start: string; end: string } {
const today = new Date()
const dayOfWeek = today.getDay()
const monday = new Date(today)
monday.setDate(today.getDate() - (dayOfWeek === 0 ? 6 : dayOfWeek - 1))
return { start: formatDate(monday), end: formatDate(today) }
}
function getThisMonthRange(): { start: string; end: string } {
const today = new Date()
const firstOfMonth = new Date(today.getFullYear(), today.getMonth(), 1)
return { start: formatDate(firstOfMonth), end: formatDate(today) }
}
const DEFAULT_DEPTH = 4
export default function JourneysPage() {
const params = useParams()
@@ -35,14 +25,29 @@ export default function JourneysPage() {
const [period, setPeriod] = useState('30')
const [dateRange, setDateRange] = useState(() => getDateRange(30))
const [isDatePickerOpen, setIsDatePickerOpen] = useState(false)
const [depth, setDepth] = useState(3)
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, depth, 2, entryPath || undefined
siteId, dateRange.start, dateRange.end, committedDepth, 1, entryPath || undefined
)
const { data: topPaths, isLoading: topPathsLoading } = useJourneyTopPaths(
siteId, dateRange.start, dateRange.end, 20, 2, entryPath || undefined
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)
@@ -52,6 +57,9 @@ export default function JourneysPage() {
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) => ({
@@ -60,15 +68,19 @@ export default function JourneysPage() {
})),
]
if (showSkeleton) return <JourneysSkeleton />
const totalSessions = transitionsData?.total_sessions ?? 0
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
<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-2xl font-bold text-neutral-900 dark:text-white mb-1">
<h1 className="text-lg font-semibold text-neutral-200 mb-1">
Journeys
</h1>
<p className="text-sm text-neutral-500 dark:text-neutral-400">
<p className="text-sm text-neutral-400">
How visitors navigate through your site
</p>
</div>
@@ -110,22 +122,35 @@ export default function JourneysPage() {
/>
</div>
{/* Controls */}
<div className="flex flex-wrap items-center gap-4 mb-6">
<div className="flex items-center gap-3">
<label className="text-sm text-neutral-500 dark:text-neutral-400">Depth</label>
{/* 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={5}
max={6}
step={1}
value={depth}
onChange={(e) => setDepth(Number(e.target.value))}
className="w-32 accent-brand-orange"
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"
/>
<span className="text-sm font-medium text-neutral-900 dark:text-white w-4">{depth}</span>
</div>
{/* Entry point + Reset */}
<div className="flex items-center gap-3 shrink-0">
<Select
variant="input"
className="min-w-[180px]"
@@ -133,35 +158,76 @@ export default function JourneysPage() {
onChange={(value) => setEntryPath(value)}
options={entryPointOptions}
/>
{(depth !== 3 || entryPath) && (
<button
onClick={() => { setDepth(3); setEntryPath('') }}
className="text-sm text-neutral-500 hover:text-neutral-900 dark:hover:text-white transition-colors"
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>
{/* Sankey Diagram */}
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 mb-6">
{transitionsLoading ? (
<div className="h-[400px] flex items-center justify-center">
<SkeletonCard className="w-full h-full" />
{/* 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>
) : (
<SankeyDiagram
</div>
{/* Journey Chart */}
<div className="p-6">
{viewMode === 'columns' ? (
<ColumnJourney
transitions={transitionsData?.transitions ?? []}
totalSessions={transitionsData?.total_sessions ?? 0}
depth={depth}
onNodeClick={(path) => setEntryPath(path)}
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

View File

@@ -5,7 +5,6 @@ import { logger } from '@/lib/utils/logger'
import { useCallback, useEffect, useRef, useState, useMemo } from 'react'
import { useParams, useRouter, useSearchParams } from 'next/navigation'
import {
getPerformanceByPage,
getTopPages,
getTopReferrers,
getCountries,
@@ -18,24 +17,24 @@ import {
type Stats,
type DailyStat,
} from '@/lib/api/stats'
import { getDateRange, formatDate } 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 dynamic from 'next/dynamic'
import { DashboardSkeleton, useMinimumLoading } from '@/components/skeletons'
import { DashboardSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
import FilterBar from '@/components/dashboard/FilterBar'
import AddFilterDropdown, { type FilterSuggestion, type FilterSuggestions } from '@/components/dashboard/AddFilterDropdown'
import Chart from '@/components/dashboard/Chart'
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'
const PerformanceStats = dynamic(() => import('@/components/dashboard/PerformanceStats'))
const GoalStats = dynamic(() => import('@/components/dashboard/GoalStats'))
const Campaigns = dynamic(() => import('@/components/dashboard/Campaigns'))
const PeakHours = dynamic(() => import('@/components/dashboard/PeakHours'))
const 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'
@@ -64,19 +63,6 @@ function loadSavedSettings(): {
}
}
function getThisWeekRange(): { start: string; end: string } {
const today = new Date()
const dayOfWeek = today.getDay()
const monday = new Date(today)
monday.setDate(today.getDate() - (dayOfWeek === 0 ? 6 : dayOfWeek - 1))
return { start: formatDate(monday), end: formatDate(today) }
}
function getThisMonthRange(): { start: string; end: string } {
const today = new Date()
const firstOfMonth = new Date(today.getFullYear(), today.getMonth(), 1)
return { start: formatDate(firstOfMonth), end: formatDate(today) }
}
function getInitialDateRange(): { start: string; end: string } {
const settings = loadSavedSettings()
@@ -235,10 +221,10 @@ export default function SiteDashboardPage() {
return { start: prevStart.toISOString().split('T')[0], end: prevEnd.toISOString().split('T')[0] }
}, [dateRange])
// Single dashboard request replaces 7 focused hooks (overview, pages, locations,
// devices, referrers, performance, goals). The backend runs all queries in parallel
// and caches the result in Redis, reducing requests from 12 to 6 per refresh cycle.
const { data: dashboard, isLoading: dashboardLoading, error: dashboardError } = useDashboard(siteId, dateRange.start, dateRange.end, interval, filtersParam || undefined)
// 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)
@@ -423,6 +409,7 @@ export default function SiteDashboardPage() {
// 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 />
@@ -430,19 +417,19 @@ export default function SiteDashboardPage() {
if (!site) {
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-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 (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<div className={`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">
@@ -531,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}
@@ -554,21 +548,7 @@ export default function SiteDashboardPage() {
/>
</div>
{/* Performance Stats - Only show if enabled */}
{site.enable_performance_insights && (
<div className="mb-8">
<PerformanceStats
stats={dashboard?.performance ?? { lcp: 0, cls: 0, inp: 0 }}
performanceByPage={dashboard?.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={dashboard?.top_pages ?? []}
entryPages={dashboard?.entry_pages ?? []}
@@ -588,7 +568,7 @@ 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={dashboard?.countries ?? []}
cities={dashboard?.cities ?? []}
@@ -611,12 +591,12 @@ 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="mb-8">
<div className="grid gap-6 lg:grid-cols-2 mb-6 [&>*]:min-w-0">
<SearchPerformance siteId={siteId} dateRange={dateRange} />
<GoalStats
goalCounts={(dashboard?.goal_counts ?? []).filter(g => !/^scroll_\d+$/.test(g.event_name))}
onSelectEvent={setSelectedEvent}

View 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}
/>
)
}

View 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&apos;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&apos;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&ndash;49</span>
<span className="flex items-center gap-1"><span className="inline-block w-2 h-2 rounded-full bg-amber-500" />50&ndash;89</span>
<span className="flex items-center gap-1"><span className="inline-block w-2 h-2 rounded-full bg-emerald-500" />90&ndash;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>
)
}

View 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}
/>
)
}

View 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

View File

@@ -1,27 +1,21 @@
'use client'
import { useAuth } from '@/lib/auth/context'
import { useEffect, useState, useCallback, useRef } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { motion, AnimatePresence } from 'framer-motion'
import { getSite, type Site } from '@/lib/api/sites'
import { useEffect, useState } from 'react'
import { useParams } from 'next/navigation'
import { useSite, useUptimeStatus } from '@/lib/swr/dashboard'
import { updateSite, type Site } from '@/lib/api/sites'
import {
getUptimeStatus,
createUptimeMonitor,
updateUptimeMonitor,
deleteUptimeMonitor,
getMonitorChecks,
type UptimeStatusResponse,
type MonitorStatus,
type UptimeCheck,
type UptimeDailyStat,
type CreateMonitorRequest,
} from '@/lib/api/uptime'
import { toast } from '@ciphera-net/ui'
import { useTheme } from '@ciphera-net/ui'
import { getAuthErrorMessage } from '@ciphera-net/ui'
import { Button, Modal } from '@ciphera-net/ui'
import { UptimeSkeleton, ChecksSkeleton, useMinimumLoading } from '@/components/skeletons'
import { Button } from '@ciphera-net/ui'
import { UptimeSkeleton, ChecksSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
import { formatDateFull, formatTime, formatDateTimeShort } from '@/lib/utils/formatDate'
import {
AreaChart,
Area,
@@ -106,7 +100,7 @@ function getOverallStatusTextColor(status: string): string {
case 'down':
return 'text-red-600 dark:text-red-400'
default:
return 'text-neutral-500 dark:text-neutral-400'
return 'text-neutral-400'
}
}
@@ -166,11 +160,7 @@ function StatusBarTooltip({
}) {
if (!visible) return null
const formattedDate = new Date(date + 'T00:00:00').toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
})
const formattedDate = formatDateFull(new Date(date + 'T00:00:00'))
return (
<div
@@ -178,22 +168,22 @@ function StatusBarTooltip({
style={{ left: position.x, top: position.y - 10, transform: 'translate(-50%, -100%)' }}
>
<div className="bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-xl shadow-lg transition-shadow duration-300 px-3 py-2.5 text-xs min-w-40">
<div className="font-semibold text-neutral-900 dark:text-white mb-1.5">{formattedDate}</div>
<div className="font-semibold text-white mb-1.5">{formattedDate}</div>
{stat && stat.total_checks > 0 ? (
<div className="space-y-1">
<div className="flex justify-between gap-4">
<span className="text-neutral-500 dark:text-neutral-400">Uptime</span>
<span className="font-medium text-neutral-900 dark:text-white">
<span className="text-neutral-400">Uptime</span>
<span className="font-medium text-white">
{formatUptime(stat.uptime_percentage)}
</span>
</div>
<div className="flex justify-between gap-4">
<span className="text-neutral-500 dark:text-neutral-400">Checks</span>
<span className="font-medium text-neutral-900 dark:text-white">{stat.total_checks}</span>
<span className="text-neutral-400">Checks</span>
<span className="font-medium text-white">{stat.total_checks}</span>
</div>
<div className="flex justify-between gap-4">
<span className="text-neutral-500 dark:text-neutral-400">Avg Response</span>
<span className="font-medium text-neutral-900 dark:text-white">
<span className="text-neutral-400">Avg Response</span>
<span className="font-medium text-white">
{formatMs(Math.round(stat.avg_response_time_ms))}
</span>
</div>
@@ -276,10 +266,7 @@ function ResponseTimeChart({ checks }: { checks: UptimeCheck[] }) {
.reverse()
.filter((c) => c.response_time_ms !== null)
.map((c) => ({
time: new Date(c.checked_at).toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
}),
time: formatTime(new Date(c.checked_at)),
ms: c.response_time_ms as number,
status: c.status,
}))
@@ -288,7 +275,7 @@ function ResponseTimeChart({ checks }: { checks: UptimeCheck[] }) {
return (
<div className="mt-4">
<h4 className="text-xs font-semibold text-neutral-500 dark:text-neutral-400 uppercase tracking-wider mb-3">
<h4 className="text-xs font-semibold text-neutral-400 uppercase tracking-wider mb-3">
Response Time
</h4>
<ChartContainer config={responseTimeChartConfig} className="h-40">
@@ -342,34 +329,34 @@ function ResponseTimeChart({ checks }: { checks: UptimeCheck[] }) {
)
}
// * Component: Monitor card (matches the reference image design)
function MonitorCard({
monitorStatus,
expanded,
onToggle,
onEdit,
onDelete,
canEdit,
siteId,
}: {
monitorStatus: MonitorStatus
expanded: boolean
onToggle: () => void
onEdit: () => void
onDelete: () => void
canEdit: boolean
siteId: string
}) {
const { monitor, daily_stats, overall_uptime } = monitorStatus
// * Main uptime page
export default function UptimePage() {
const { user } = useAuth()
const canEdit = user?.role === 'owner' || user?.role === 'admin'
const params = useParams()
const siteId = params.id as string
const { data: site, mutate: mutateSite } = useSite(siteId)
const { data: uptimeData, isLoading, mutate: mutateUptime } = useUptimeStatus(siteId)
const [toggling, setToggling] = useState(false)
const [checks, setChecks] = useState<UptimeCheck[]>([])
const [loadingChecks, setLoadingChecks] = useState(false)
// * Single monitor from the auto-managed uptime system
const monitor = uptimeData?.monitors?.[0] ?? null
const overallUptime = uptimeData?.overall_uptime ?? 100
const overallStatus = uptimeData?.status ?? 'operational'
// * Fetch recent checks when we have a monitor
useEffect(() => {
if (expanded && checks.length === 0) {
if (!monitor) {
setChecks([])
return
}
const fetchChecks = async () => {
setLoadingChecks(true)
try {
const data = await getMonitorChecks(siteId, monitor.id, 50)
const data = await getMonitorChecks(siteId, monitor.monitor.id, 20)
setChecks(data)
} catch {
// * Silent fail for check details
@@ -378,104 +365,187 @@ function MonitorCard({
}
}
fetchChecks()
}
}, [expanded, siteId, monitor.id, checks.length])
}, [siteId, monitor?.monitor.id])
const handleToggleUptime = async (enabled: boolean) => {
if (!site) return
setToggling(true)
try {
await updateSite(site.id, {
name: site.name,
timezone: site.timezone,
is_public: site.is_public,
excluded_paths: site.excluded_paths,
uptime_enabled: enabled,
})
mutateSite()
mutateUptime()
toast.success(enabled ? 'Uptime monitoring enabled' : 'Uptime monitoring disabled')
} catch {
toast.error('Failed to update uptime monitoring')
} finally {
setToggling(false)
}
}
useEffect(() => {
if (site?.domain) document.title = `Uptime · ${site.domain} | Pulse`
}, [site?.domain])
const showSkeleton = useMinimumLoading(isLoading && !uptimeData)
const fadeClass = useSkeletonFade(showSkeleton)
if (showSkeleton) return <UptimeSkeleton />
if (!site) return <div className="p-8 text-neutral-500">Site not found</div>
const uptimeEnabled = site.uptime_enabled
// * Disabled state — show empty state with enable toggle
if (!uptimeEnabled) {
return (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden">
<div className={`w-full max-w-7xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
{/* Header */}
<button
onClick={onToggle}
className="w-full p-5 flex items-center justify-between hover:bg-neutral-50 dark:hover:bg-neutral-800/50 transition-colors"
>
<div className="flex items-center gap-3">
{/* Status indicator */}
<div className={`w-3 h-3 rounded-full ${getStatusDotColor(monitor.last_status)} shrink-0`} />
<span className="font-semibold text-neutral-900 dark:text-white">
{monitor.name}
</span>
<span className="text-sm text-neutral-500 dark:text-neutral-400 hidden sm:inline">
{monitor.url}
</span>
<div className="mb-8">
<h1 className="text-lg font-semibold text-neutral-200 mb-1">
Uptime
</h1>
<p className="text-sm text-neutral-400">
Monitor your site&apos;s availability and response time
</p>
</div>
<div className="flex items-center gap-4">
{monitor.last_response_time_ms !== null && (
<span className="text-sm text-neutral-500 dark:text-neutral-400 hidden sm:inline">
{formatMs(monitor.last_response_time_ms)}
</span>
)}
<span className="text-sm font-semibold text-neutral-900 dark:text-white">
{formatUptime(overall_uptime)} uptime
</span>
<svg
className={`w-4 h-4 text-neutral-400 transition-transform ${expanded ? 'rotate-180' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
{/* 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="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</button>
<h3 className="font-semibold text-white mb-2">
Uptime monitoring is disabled
</h3>
<p className="text-sm text-neutral-400 mb-6 max-w-md mx-auto">
Enable uptime monitoring to track your site&apos;s availability and response time around the clock.
</p>
{canEdit && (
<Button
onClick={() => handleToggleUptime(true)}
disabled={toggling}
>
{toggling ? 'Enabling...' : 'Enable Uptime Monitoring'}
</Button>
)}
</div>
</div>
)
}
{/* Status bar */}
<div className="px-5 pb-4">
<UptimeStatusBar dailyStats={daily_stats} />
// * Enabled state — show uptime dashboard
return (
<div className={`w-full max-w-7xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
{/* Header + action */}
<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">
Uptime
</h1>
<p className="text-sm text-neutral-400">
Monitor your site&apos;s availability and response time
</p>
</div>
{canEdit && (
<Button
variant="secondary"
onClick={() => handleToggleUptime(false)}
disabled={toggling}
className="text-sm"
>
{toggling ? 'Disabling...' : 'Disable Monitoring'}
</Button>
)}
</div>
{/* Overall status card */}
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-5 mb-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`w-3.5 h-3.5 rounded-full ${getStatusDotColor(overallStatus)}`} />
<div>
<span className="font-semibold text-white text-lg">
{site.name}
</span>
<span className={`text-sm font-medium ml-3 ${getOverallStatusTextColor(overallStatus)}`}>
{getOverallStatusText(overallStatus)}
</span>
</div>
</div>
<div className="text-right">
<span className="text-sm font-semibold text-white">
{formatUptime(overallUptime)} uptime
</span>
{monitor && (
<div className="text-xs text-neutral-400">
Last checked {formatTimeAgo(monitor.monitor.last_checked_at)}
</div>
)}
</div>
</div>
</div>
{/* 90-day uptime bar */}
{monitor && (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-5 mb-6">
<h3 className="text-xs font-semibold text-neutral-400 uppercase tracking-wider mb-3">
90-Day Availability
</h3>
<UptimeStatusBar dailyStats={monitor.daily_stats} />
<div className="flex justify-between mt-1.5 text-xs text-neutral-400 dark:text-neutral-500">
<span>90 days ago</span>
<span>Today</span>
</div>
</div>
)}
{/* Expanded details */}
<AnimatePresence>
{expanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="px-5 pb-5 border-t border-neutral-200 dark:border-neutral-800 pt-4">
{/* Response time chart + Recent checks */}
{monitor && (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-5">
{/* Monitor details grid */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-5">
<div>
<div className="text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider mb-1">
<div className="text-xs font-medium text-neutral-400 uppercase tracking-wider mb-1">
Status
</div>
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${getStatusDotColor(monitor.last_status)}`} />
<span className="text-sm font-medium text-neutral-900 dark:text-white">
{getStatusLabel(monitor.last_status)}
<div className={`w-2 h-2 rounded-full ${getStatusDotColor(monitor.monitor.last_status)}`} />
<span className="text-sm font-medium text-white">
{getStatusLabel(monitor.monitor.last_status)}
</span>
</div>
</div>
<div>
<div className="text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider mb-1">
<div className="text-xs font-medium text-neutral-400 uppercase tracking-wider mb-1">
Response Time
</div>
<span className="text-sm font-medium text-neutral-900 dark:text-white">
{formatMs(monitor.last_response_time_ms)}
<span className="text-sm font-medium text-white">
{formatMs(monitor.monitor.last_response_time_ms)}
</span>
</div>
<div>
<div className="text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider mb-1">
<div className="text-xs font-medium text-neutral-400 uppercase tracking-wider mb-1">
Check Interval
</div>
<span className="text-sm font-medium text-neutral-900 dark:text-white">
{monitor.check_interval_seconds >= 60
? `${Math.floor(monitor.check_interval_seconds / 60)}m`
: `${monitor.check_interval_seconds}s`}
<span className="text-sm font-medium text-white">
{monitor.monitor.check_interval_seconds >= 60
? `${Math.floor(monitor.monitor.check_interval_seconds / 60)}m`
: `${monitor.monitor.check_interval_seconds}s`}
</span>
</div>
<div>
<div className="text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider mb-1">
Last Checked
<div className="text-xs font-medium text-neutral-400 uppercase tracking-wider mb-1">
Overall Uptime
</div>
<span className="text-sm font-medium text-neutral-900 dark:text-white">
{formatTimeAgo(monitor.last_checked_at)}
<span className="text-sm font-medium text-white">
{formatUptime(monitor.overall_uptime)}
</span>
</div>
</div>
@@ -489,7 +559,7 @@ function MonitorCard({
{/* Recent checks */}
<div className="mt-5">
<h4 className="text-xs font-semibold text-neutral-500 dark:text-neutral-400 uppercase tracking-wider mb-3">
<h4 className="text-xs font-semibold text-neutral-400 uppercase tracking-wider mb-3">
Recent Checks
</h4>
<div className="space-y-1.5 max-h-48 overflow-y-auto">
@@ -501,17 +571,12 @@ function MonitorCard({
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${getStatusDotColor(check.status)}`} />
<span className="text-neutral-600 dark:text-neutral-300 text-xs">
{new Date(check.checked_at).toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
{formatDateTimeShort(new Date(check.checked_at))}
</span>
</div>
<div className="flex items-center gap-3">
{check.status_code && (
<span className="text-xs text-neutral-500 dark:text-neutral-400">
<span className="text-xs text-neutral-400">
{check.status_code}
</span>
)}
@@ -525,498 +590,8 @@ function MonitorCard({
</div>
</>
) : null}
{/* Actions */}
{canEdit && (
<div className="flex gap-2 mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-800">
<Button
onClick={(e: React.MouseEvent) => { e.stopPropagation(); onEdit() }}
variant="secondary"
className="text-sm"
>
Edit
</Button>
<Button
onClick={(e: React.MouseEvent) => { e.stopPropagation(); onDelete() }}
variant="secondary"
className="text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20"
>
Delete
</Button>
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}
// * Main uptime page
export default function UptimePage() {
const { user } = useAuth()
const canEdit = user?.role === 'owner' || user?.role === 'admin'
const params = useParams()
const router = useRouter()
const siteId = params.id as string
const [site, setSite] = useState<Site | null>(null)
const [loading, setLoading] = useState(true)
const [uptimeData, setUptimeData] = useState<UptimeStatusResponse | null>(null)
const [expandedMonitor, setExpandedMonitor] = useState<string | null>(null)
const [showAddModal, setShowAddModal] = useState(false)
const [showEditModal, setShowEditModal] = useState(false)
const [editingMonitor, setEditingMonitor] = useState<MonitorStatus | null>(null)
const [formData, setFormData] = useState<CreateMonitorRequest>({
name: '',
url: '',
check_interval_seconds: 300,
expected_status_code: 200,
timeout_seconds: 30,
})
const [saving, setSaving] = useState(false)
const loadData = useCallback(async () => {
try {
const [siteData, statusData] = await Promise.all([
getSite(siteId),
getUptimeStatus(siteId),
])
setSite(siteData)
setUptimeData(statusData)
} catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || 'Failed to load uptime monitors')
} finally {
setLoading(false)
}
}, [siteId])
useEffect(() => {
loadData()
}, [loadData])
// * Auto-refresh every 30 seconds; show toast on failure (e.g. network loss or auth expiry)
useEffect(() => {
const interval = setInterval(async () => {
try {
const statusData = await getUptimeStatus(siteId)
setUptimeData(statusData)
} catch {
toast.error('Could not refresh uptime data. Check your connection or sign in again.')
}
}, 30000)
return () => clearInterval(interval)
}, [siteId])
const handleAddMonitor = async () => {
if (!formData.name || !formData.url) {
toast.error('Name and URL are required')
return
}
setSaving(true)
try {
await createUptimeMonitor(siteId, formData)
toast.success('Monitor created successfully')
setShowAddModal(false)
setFormData({ name: '', url: '', check_interval_seconds: 300, expected_status_code: 200, timeout_seconds: 30 })
await loadData()
} catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || 'Failed to create monitor')
} finally {
setSaving(false)
}
}
const handleEditMonitor = async () => {
if (!editingMonitor || !formData.name || !formData.url) return
setSaving(true)
try {
await updateUptimeMonitor(siteId, editingMonitor.monitor.id, {
name: formData.name,
url: formData.url,
check_interval_seconds: formData.check_interval_seconds,
expected_status_code: formData.expected_status_code,
timeout_seconds: formData.timeout_seconds,
enabled: editingMonitor.monitor.enabled,
})
toast.success('Monitor updated successfully')
setShowEditModal(false)
setEditingMonitor(null)
await loadData()
} catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || 'Failed to update monitor')
} finally {
setSaving(false)
}
}
const handleDeleteMonitor = async (monitorId: string) => {
if (!window.confirm('Are you sure you want to delete this monitor? All historical data will be lost.')) return
try {
await deleteUptimeMonitor(siteId, monitorId)
toast.success('Monitor deleted')
await loadData()
} catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || 'Failed to delete monitor')
}
}
const openEditModal = (ms: MonitorStatus) => {
setEditingMonitor(ms)
setFormData({
name: ms.monitor.name,
url: ms.monitor.url,
check_interval_seconds: ms.monitor.check_interval_seconds,
expected_status_code: ms.monitor.expected_status_code,
timeout_seconds: ms.monitor.timeout_seconds,
})
setShowEditModal(true)
}
useEffect(() => {
if (site?.domain) document.title = `Uptime · ${site.domain} | Pulse`
}, [site?.domain])
const showSkeleton = useMinimumLoading(loading)
if (showSkeleton) return <UptimeSkeleton />
if (!site) return <div className="p-8 text-neutral-500">Site not found</div>
const monitors = Array.isArray(uptimeData?.monitors) ? uptimeData.monitors : []
const overallUptime = uptimeData?.overall_uptime ?? 100
const overallStatus = uptimeData?.status ?? 'operational'
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
{/* Header */}
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-1">
Uptime
</h1>
<p className="text-sm text-neutral-500 dark:text-neutral-400">
Monitor your endpoints and track availability over time
</p>
</div>
{canEdit && (
<Button
onClick={() => {
setFormData({ name: '', url: '', check_interval_seconds: 300, expected_status_code: 200, timeout_seconds: 30 })
setShowAddModal(true)
}}
>
Add Monitor
</Button>
)}
</div>
{/* Overall status card */}
{monitors.length > 0 && (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-5 mb-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`w-3.5 h-3.5 rounded-full ${getStatusDotColor(overallStatus)}`} />
<div>
<span className="font-semibold text-neutral-900 dark:text-white text-lg">
{site.name}
</span>
<span className={`text-sm font-medium ml-3 ${getOverallStatusTextColor(overallStatus)}`}>
{getOverallStatusText(overallStatus)}
</span>
</div>
</div>
<div className="text-right">
<span className="text-sm font-semibold text-neutral-900 dark:text-white">
{formatUptime(overallUptime)} uptime
</span>
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{monitors.length} {monitors.length === 1 ? 'component' : 'components'}
</div>
</div>
</div>
</div>
)}
{/* Monitor list */}
{monitors.length > 0 ? (
<div className="space-y-4">
{monitors.map((ms) => (
<MonitorCard
key={ms.monitor.id}
monitorStatus={ms}
expanded={expandedMonitor === ms.monitor.id}
onToggle={() => setExpandedMonitor(
expandedMonitor === ms.monitor.id ? null : ms.monitor.id
)}
onEdit={() => openEditModal(ms)}
onDelete={() => handleDeleteMonitor(ms.monitor.id)}
canEdit={canEdit}
siteId={siteId}
/>
))}
</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-500 dark:text-neutral-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<h3 className="font-semibold text-neutral-900 dark:text-white mb-2">
No monitors yet
</h3>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-6 max-w-md mx-auto">
Add a monitor to start tracking the uptime and response time of your endpoints. You can monitor APIs, websites, and any HTTP endpoint.
</p>
{canEdit && (
<Button
onClick={() => {
setFormData({ name: '', url: '', check_interval_seconds: 300, expected_status_code: 200, timeout_seconds: 30 })
setShowAddModal(true)
}}
>
Add Your First Monitor
</Button>
)}
</div>
)}
{/* Add Monitor Modal */}
<Modal isOpen={showAddModal} onClose={() => setShowAddModal(false)} title="Add Monitor">
<MonitorForm
formData={formData}
setFormData={setFormData}
onSubmit={handleAddMonitor}
onCancel={() => setShowAddModal(false)}
saving={saving}
submitLabel="Create Monitor"
siteDomain={site.domain}
/>
</Modal>
{/* Edit Monitor Modal */}
<Modal isOpen={showEditModal} onClose={() => setShowEditModal(false)} title="Edit Monitor">
<MonitorForm
formData={formData}
setFormData={setFormData}
onSubmit={handleEditMonitor}
onCancel={() => setShowEditModal(false)}
saving={saving}
submitLabel="Save Changes"
siteDomain={site.domain}
/>
</Modal>
</div>
)
}
// * Monitor creation/edit form
function MonitorForm({
formData,
setFormData,
onSubmit,
onCancel,
saving,
submitLabel,
siteDomain,
}: {
formData: CreateMonitorRequest
setFormData: (data: CreateMonitorRequest) => void
onSubmit: () => void
onCancel: () => void
saving: boolean
submitLabel: string
siteDomain: string
}) {
// * Derive protocol from formData.url so edit modal shows the monitor's actual scheme (no desync)
const protocol: 'https://' | 'http://' = formData.url.startsWith('http://') ? 'http://' : 'https://'
const [showProtocolDropdown, setShowProtocolDropdown] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null)
// * Extract the path portion from the full URL
const getPath = (): string => {
const url = formData.url
if (!url) return ''
try {
const parsed = new URL(url)
const pathAndRest = parsed.pathname + parsed.search + parsed.hash
return pathAndRest === '/' ? '' : pathAndRest
} catch {
// ? If not a valid full URL, try stripping the protocol prefix
if (url.startsWith('https://')) return url.slice(8 + siteDomain.length)
if (url.startsWith('http://')) return url.slice(7 + siteDomain.length)
return url
}
}
const handlePathChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const path = e.target.value
const safePath = path.startsWith('/') || path === '' ? path : `/${path}`
setFormData({ ...formData, url: `${protocol}${siteDomain}${safePath}` })
}
const handleProtocolChange = (proto: 'https://' | 'http://') => {
setShowProtocolDropdown(false)
const path = getPath()
setFormData({ ...formData, url: `${proto}${siteDomain}${path}` })
}
// * Initialize URL if empty
useEffect(() => {
if (!formData.url) {
setFormData({ ...formData, url: `${protocol}${siteDomain}` })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// * Close dropdown on outside click
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setShowProtocolDropdown(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
return (
<div className="space-y-4">
{/* Name */}
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
Name
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="e.g. API, Website, CDN"
autoFocus
maxLength={100}
className="w-full px-3 py-2 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-white placeholder-neutral-400 focus:outline-none focus:ring-2 focus:ring-brand-orange focus:border-transparent text-sm"
/>
{formData.name.length > 80 && (
<span className={`text-xs tabular-nums mt-1 ${formData.name.length > 90 ? 'text-amber-500' : 'text-neutral-400'}`}>{formData.name.length}/100</span>
)}
</div>
{/* URL with protocol dropdown + domain prefix */}
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
URL
</label>
<div className="flex rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 focus-within:ring-2 focus-within:ring-brand-orange focus-within:border-transparent overflow-hidden">
{/* Protocol dropdown */}
<div ref={dropdownRef} className="relative">
<button
type="button"
onClick={() => setShowProtocolDropdown(!showProtocolDropdown)}
className="h-full px-3 flex items-center gap-1 bg-neutral-100 dark:bg-neutral-700 text-neutral-600 dark:text-neutral-300 text-sm border-r border-neutral-300 dark:border-neutral-600 hover:bg-neutral-200 dark:hover:bg-neutral-600 transition-colors select-none whitespace-nowrap"
>
{protocol}
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
{showProtocolDropdown && (
<div className="absolute top-full left-0 mt-1 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg shadow-lg transition-shadow duration-300 z-10 min-w-[100px]">
<button
type="button"
onClick={() => handleProtocolChange('https://')}
className={`w-full text-left px-3 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors rounded-t-lg ${protocol === 'https://' ? 'text-brand-orange font-medium' : 'text-neutral-700 dark:text-neutral-300'}`}
>
https://
</button>
<button
type="button"
onClick={() => handleProtocolChange('http://')}
className={`w-full text-left px-3 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors rounded-b-lg ${protocol === 'http://' ? 'text-brand-orange font-medium' : 'text-neutral-700 dark:text-neutral-300'}`}
>
http://
</button>
</div>
)}
</div>
{/* Domain prefix */}
<span className="flex items-center px-1.5 text-sm text-neutral-500 dark:text-neutral-400 select-none whitespace-nowrap bg-neutral-100 dark:bg-neutral-700 border-r border-neutral-300 dark:border-neutral-600">
{siteDomain}
</span>
{/* Path input */}
<input
type="text"
value={getPath()}
onChange={handlePathChange}
placeholder="/api/health"
className="flex-1 min-w-0 px-3 py-2 bg-transparent text-neutral-900 dark:text-white placeholder-neutral-400 focus:outline-none text-sm"
/>
</div>
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
Add a specific path (e.g. /api/health) or leave empty for the root domain
</p>
</div>
{/* Check interval */}
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
Check Interval
</label>
<select
value={formData.check_interval_seconds}
onChange={(e) => setFormData({ ...formData, check_interval_seconds: parseInt(e.target.value) })}
className="w-full px-3 py-2 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-orange focus:border-transparent text-sm"
>
<option value={60}>Every 1 minute</option>
<option value={120}>Every 2 minutes</option>
<option value={300}>Every 5 minutes</option>
<option value={600}>Every 10 minutes</option>
<option value={900}>Every 15 minutes</option>
<option value={1800}>Every 30 minutes</option>
<option value={3600}>Every 1 hour</option>
</select>
</div>
{/* Expected status code */}
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
Expected Status Code
</label>
<input
type="number"
value={formData.expected_status_code}
onChange={(e) => setFormData({ ...formData, expected_status_code: parseInt(e.target.value) || 200 })}
min={100}
max={599}
className="w-full px-3 py-2 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-orange focus:border-transparent text-sm [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
</div>
{/* Timeout */}
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
Timeout (seconds)
</label>
<input
type="number"
value={formData.timeout_seconds}
onChange={(e) => setFormData({ ...formData, timeout_seconds: parseInt(e.target.value) || 30 })}
min={5}
max={60}
className="w-full px-3 py-2 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-orange focus:border-transparent text-sm [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
</div>
{/* Actions */}
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={onCancel}>
Cancel
</Button>
<Button onClick={onSubmit} disabled={saving || !formData.name || !formData.url}>
{saving ? 'Saving...' : submitLabel}
</Button>
</div>
</div>
)
}

View File

@@ -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">
@@ -137,7 +137,7 @@ export default function NewSitePage() {
>
<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

View File

@@ -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('')
try {
trackWelcomePlanContinue()
const intent = JSON.parse(raw)
const { url } = await createCheckoutSession({
plan_id: intent.planId,
interval: intent.interval || 'month',
limit: intent.limit ?? 100000,
})
try {
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-visible:outline-none focus-visible:ring-2 focus-visible: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-visible:outline-none focus-visible:ring-2 focus-visible: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>
@@ -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-visible:outline-none focus-visible:ring-2 focus-visible: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">
@@ -754,7 +731,7 @@ function WelcomeContent() {
>
<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>

View File

@@ -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">

View File

@@ -48,7 +48,7 @@ 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">
@@ -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>
@@ -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}>
@@ -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}>
@@ -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}>
@@ -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>

View File

@@ -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>

View File

@@ -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 youre ready.')
@@ -166,49 +166,25 @@ export default function PricingSection() {
}
}
const handleSubscribe = async (planId: string, options?: { interval?: string, limit?: number }) => {
try {
setLoadingPlan(planId)
const handleSubscribe = (planId: string, options?: { interval?: string, limit?: number }) => {
// 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
sliderIndex,
isYearly
}
localStorage.setItem('pulse_pending_checkout', JSON.stringify(intent))
initiateOAuthFlow()
return
}
// 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)
}
// 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-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
@@ -281,8 +257,8 @@ export default function PricingSection() {
aria-checked={isYearly}
className={`min-w-[88px] px-4 py-2 rounded-lg text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange ${
isYearly
? '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>
)
}

View File

@@ -3,30 +3,13 @@
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
}
function SkeletonRows() {
return (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="animate-pulse flex items-center justify-between h-9 px-2">
<div className="h-4 w-40 bg-neutral-200 dark:bg-neutral-700 rounded" />
<div className="flex gap-6">
<div className="h-4 w-10 bg-neutral-200 dark:bg-neutral-700 rounded" />
<div className="h-4 w-10 bg-neutral-200 dark:bg-neutral-700 rounded" />
<div className="h-4 w-10 bg-neutral-200 dark:bg-neutral-700 rounded" />
<div className="h-4 w-10 bg-neutral-200 dark:bg-neutral-700 rounded" />
</div>
</div>
))}
</div>
)
}
export default function FrustrationByPageTable({ pages, loading }: FrustrationByPageTableProps) {
const hasData = pages.length > 0
const maxTotal = Math.max(...pages.map(p => p.total), 1)
@@ -34,18 +17,18 @@ export default function FrustrationByPageTable({ pages, loading }: FrustrationBy
return (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 mb-8">
<div className="flex items-center justify-between mb-1">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
<h3 className="text-lg font-semibold text-white">
Frustration by Page
</h3>
</div>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-4">
<p className="text-sm text-neutral-400 mb-4">
Pages with the most frustration signals
</p>
{loading ? (
<SkeletonRows />
<TableSkeleton rows={5} cols={5} />
) : hasData ? (
<div>
<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>
@@ -60,7 +43,7 @@ export default function FrustrationByPageTable({ pages, loading }: FrustrationBy
{/* Rows */}
<div className="space-y-0.5">
{pages.map((page) => {
const barWidth = (page.total / maxTotal) * 100
const barWidth = (page.total / maxTotal) * 75
return (
<div
key={page.page_path}
@@ -68,11 +51,11 @@ export default function FrustrationByPageTable({ pages, loading }: FrustrationBy
>
{/* Background bar */}
<div
className="absolute inset-y-0 left-0 bg-brand-orange/5 dark:bg-brand-orange/10 rounded-lg transition-all"
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-neutral-900 dark:text-white truncate max-w-[300px]"
className="relative text-sm text-white truncate max-w-[200px] sm:max-w-[300px]"
title={page.page_path}
>
{page.page_path}
@@ -84,7 +67,7 @@ export default function FrustrationByPageTable({ pages, loading }: FrustrationBy
<span className="w-12 text-right text-sm tabular-nums text-neutral-600 dark:text-neutral-400">
{formatNumber(page.dead_clicks)}
</span>
<span className="w-12 text-right text-sm font-semibold tabular-nums text-neutral-900 dark:text-white">
<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">
@@ -99,14 +82,17 @@ export default function FrustrationByPageTable({ pages, loading }: FrustrationBy
) : (
<div className="flex flex-col items-center justify-center text-center px-6 py-8 gap-4 min-h-[200px]">
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
<Files className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
<Files 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 frustration signals detected
</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">
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>

View File

@@ -1,6 +1,7 @@
'use client'
import type { FrustrationSummary } from '@/lib/api/stats'
import { StatCardSkeleton } from '@/components/skeletons'
interface FrustrationSummaryCardsProps {
data: FrustrationSummary | null
@@ -31,7 +32,7 @@ function ChangeIndicator({ change }: { change: ReturnType<typeof pctChange> }) {
? 'text-red-600 dark:text-red-400'
: isDown
? 'text-green-600 dark:text-green-400'
: 'text-neutral-500 dark:text-neutral-400'
: 'text-neutral-400'
}`}
>
{isUp ? '+' : ''}{change.value}%
@@ -39,25 +40,13 @@ function ChangeIndicator({ change }: { change: ReturnType<typeof pctChange> }) {
)
}
function SkeletonCard() {
return (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
<div className="animate-pulse space-y-3">
<div className="h-4 w-24 bg-neutral-200 dark:bg-neutral-700 rounded" />
<div className="h-8 w-16 bg-neutral-200 dark:bg-neutral-700 rounded" />
<div className="h-3 w-32 bg-neutral-200 dark:bg-neutral-700 rounded" />
</div>
</div>
)
}
export default function FrustrationSummaryCards({ data, loading }: FrustrationSummaryCardsProps) {
if (loading || !data) {
return (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-8">
<SkeletonCard />
<SkeletonCard />
<SkeletonCard />
<StatCardSkeleton />
<StatCardSkeleton />
<StatCardSkeleton />
</div>
)
}
@@ -71,11 +60,11 @@ export default function FrustrationSummaryCards({ data, loading }: FrustrationSu
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-8">
{/* Rage Clicks */}
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
<p className="text-sm font-medium text-neutral-500 dark:text-neutral-400 mb-1">
<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-neutral-900 dark:text-white tabular-nums">
<span className="text-2xl font-bold text-white tabular-nums">
{data.rage_clicks.toLocaleString()}
</span>
<ChangeIndicator change={rageChange} />
@@ -87,11 +76,11 @@ export default function FrustrationSummaryCards({ data, loading }: FrustrationSu
{/* Dead Clicks */}
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
<p className="text-sm font-medium text-neutral-500 dark:text-neutral-400 mb-1">
<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-neutral-900 dark:text-white tabular-nums">
<span className="text-2xl font-bold text-white tabular-nums">
{data.dead_clicks.toLocaleString()}
</span>
<ChangeIndicator change={deadChange} />
@@ -103,10 +92,10 @@ export default function FrustrationSummaryCards({ data, loading }: FrustrationSu
{/* Total Frustration Signals */}
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
<p className="text-sm font-medium text-neutral-500 dark:text-neutral-400 mb-1">
<p className="text-sm font-medium text-neutral-400 mb-1">
Total Signals
</p>
<span className="text-2xl font-bold text-neutral-900 dark:text-white tabular-nums">
<span className="text-2xl font-bold text-white tabular-nums">
{totalSignals.toLocaleString()}
</span>
{topPage ? (

View File

@@ -53,7 +53,7 @@ function SelectorCell({ selector }: { selector: string }) {
className="flex items-center gap-1 min-w-0 group/copy cursor-pointer"
title={selector}
>
<span className="text-sm font-mono text-neutral-900 dark:text-white truncate">
<span className="text-sm font-mono text-white truncate">
{selector}
</span>
<span className="opacity-0 group-hover/copy:opacity-100 transition-opacity shrink-0">
@@ -81,7 +81,7 @@ function Row({
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">
<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"
@@ -145,7 +145,7 @@ export default function FrustrationTable({
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
<h3 className="text-lg font-semibold text-white">
{title}
</h3>
{showViewAll && (
@@ -159,7 +159,7 @@ export default function FrustrationTable({
)}
</div>
</div>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-4">
<p className="text-sm text-neutral-400 mb-4">
{description}
</p>
@@ -177,15 +177,23 @@ export default function FrustrationTable({
</>
) : (
<div className="h-full flex flex-col items-center justify-center text-center px-6 py-8 gap-4">
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
<CursorClick className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
</div>
<h4 className="font-semibold text-neutral-900 dark:text-white">
<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-500 dark:text-neutral-400 max-w-md">
{description}. Data will appear here once frustration signals are detected on your site.
<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>
@@ -209,7 +217,7 @@ export default function FrustrationTable({
))}
</div>
) : (
<p className="text-sm text-neutral-500 dark:text-neutral-400 py-8 text-center">
<p className="text-sm text-neutral-400 py-8 text-center">
No data available
</p>
)}

View File

@@ -8,26 +8,13 @@ import {
type ChartConfig,
} from '@/components/charts'
import type { FrustrationSummary } from '@/lib/api/stats'
import { WidgetSkeleton } from '@/components/skeletons'
interface FrustrationTrendProps {
summary: FrustrationSummary | null
loading: boolean
}
function SkeletonCard() {
return (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
<div className="animate-pulse space-y-3 mb-4">
<div className="h-5 w-36 bg-neutral-200 dark:bg-neutral-700 rounded" />
<div className="h-4 w-48 bg-neutral-200 dark:bg-neutral-700 rounded" />
</div>
<div className="flex-1 min-h-[270px] animate-pulse flex items-center justify-center">
<div className="w-[200px] h-[200px] rounded-full bg-neutral-200 dark:bg-neutral-700" />
</div>
</div>
)
}
const LABELS: Record<string, string> = {
rage_clicks: 'Rage Clicks',
dead_clicks: 'Dead Clicks',
@@ -59,7 +46,7 @@ function CustomTooltip({ active, payload }: { active?: boolean; payload?: Array<
className="h-2.5 w-2.5 shrink-0 rounded-full"
style={{ backgroundColor: item.fill }}
/>
<span className="text-neutral-500 dark:text-neutral-400">
<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">
@@ -70,7 +57,7 @@ function CustomTooltip({ active, payload }: { active?: boolean; payload?: Array<
}
export default function FrustrationTrend({ summary, loading }: FrustrationTrendProps) {
if (loading || !summary) return <SkeletonCard />
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
@@ -93,21 +80,21 @@ export default function FrustrationTrend({ summary, loading }: FrustrationTrendP
return (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
<div className="flex items-center justify-between mb-1">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
<h3 className="text-lg font-semibold text-white">
Frustration Trend
</h3>
</div>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-4">
<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-500 dark:text-neutral-400" />
<TrendUp 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 trend 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">
Frustration trend data will appear here once rage clicks or dead clicks are detected on your site.
</p>
</div>
@@ -118,11 +105,11 @@ export default function FrustrationTrend({ summary, loading }: FrustrationTrendP
return (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
<div className="flex items-center justify-between mb-1">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
<h3 className="text-lg font-semibold text-white">
Frustration Trend
</h3>
</div>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-4">
<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'}

View 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>
)
}

View 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&apos;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>
</>
)
}

View 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>&euro;{vatResult.base_amount}</span>
</div>
{vatResult.vat_exempt ? (
<div className="flex justify-between text-neutral-500 text-xs">
<span>{vatResult.vat_reason}</span>
<span>&euro;0.00</span>
</div>
) : (
<div className="flex justify-between text-neutral-400">
<span>VAT {vatResult.vat_rate}%</span>
<span>&euro;{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>&euro;{vatResult.total_amount}</span>
</div>
{isYearly && (
<p className="text-xs text-neutral-500">&euro;{(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>&euro;{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>&euro;{baseDisplay.toFixed(2)}</span>
</div>
{isYearly && (
<p className="text-xs text-neutral-500">&euro;{(baseDisplay / 12).toFixed(2)}/mo &middot; Save 1 month</p>
)}
</div>
)}
</div>
</div>
)
}

View File

@@ -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 &ldquo;{search.trim()}&rdquo;
</button>

View File

@@ -124,16 +124,17 @@ 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">
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
<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-100 dark:hover:bg-neutral-800 transition-all cursor-pointer rounded-lg"
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" />
@@ -154,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}>
@@ -173,11 +180,11 @@ 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>
@@ -190,22 +197,22 @@ export default function Campaigns({ siteId, dateRange, filters, onFilter }: Camp
</>
) : (
<div className="h-full flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
<Megaphone className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
<div 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-visible:outline-none focus-visible:ring-2 focus-visible: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>
@@ -223,7 +230,7 @@ export default function Campaigns({ siteId, dateRange, filters, onFilter }: Camp
value={modalSearch}
onChange={(e) => setModalSearch(e.target.value)}
placeholder="Search campaigns..."
className="w-full px-3 py-2 mb-3 text-sm bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg text-neutral-900 dark:text-white placeholder-neutral-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-brand-orange/50"
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]">
@@ -255,12 +262,12 @@ export default function Campaigns({ siteId, dateRange, filters, onFilter }: Camp
<div
key={`${item.source}|${item.medium}|${item.campaign}`}
onClick={() => { if (onFilter) { onFilter({ dimension: 'utm_source', operator: 'is', values: [item.source] }); setIsModalOpen(false) } }}
className={`flex items-center justify-between py-2 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 transition-colors${onFilter ? ' cursor-pointer' : ''}`}
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-neutral-900 dark:text-white font-medium truncate text-sm" title={item.source}>
<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">
@@ -274,7 +281,7 @@ export default function Campaigns({ siteId, dateRange, filters, onFilter }: Camp
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
{modalTotal > 0 ? `${Math.round((item.visitors / modalTotal) * 100)}%` : ''}
</span>
<span className="font-semibold text-neutral-900 dark:text-white">
<span className="font-semibold text-white">
{formatNumber(item.visitors)}
</span>
<span className="text-neutral-400 dark:text-neutral-500 w-16 text-right">

View File

@@ -2,15 +2,16 @@
import { useState, useMemo, useRef, useCallback, useEffect } from 'react'
import { useTheme } from '@ciphera-net/ui'
import { Line, LineChart, XAxis, YAxis, ReferenceLine } from 'recharts'
import { ChartContainer, ChartTooltip, type ChartConfig } from '@/components/ui/line-charts-6'
import { AreaChart as VisxAreaChart, Area as VisxArea, Grid as VisxGrid, XAxis as VisxXAxis, YAxis as VisxYAxis, ChartTooltip as VisxChartTooltip, type TooltipRow } from '@/components/ui/area-chart'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { formatNumber, formatDuration, formatUpdatedAgo, DatePicker } from '@ciphera-net/ui'
import { Select, DownloadIcon, PlusIcon, XIcon } from '@ciphera-net/ui'
import { Checkbox } from '@ciphera-net/ui'
import { ArrowUpRight, ArrowDownRight } from '@phosphor-icons/react'
import { motion } from 'framer-motion'
import { AnimatedNumber } from '@/components/ui/animated-number'
import { cn } from '@/lib/utils'
import { formatTime, formatDateShort, formatDate } from '@/lib/utils/formatDate'
const ANNOTATION_COLORS: Record<string, string> = {
deploy: '#3b82f6',
@@ -84,8 +85,7 @@ type MetricType = 'pageviews' | 'visitors' | 'bounce_rate' | 'avg_duration'
// ─── Helpers ─────────────────────────────────────────────────────────
function formatEU(dateStr: string): string {
const [y, m, d] = dateStr.split('-')
return `${d}/${m}/${y}`
return formatDate(new Date(dateStr + 'T00:00:00'))
}
// ─── Metric configurations ──────────────────────────────────────────
@@ -102,40 +102,11 @@ const METRIC_CONFIGS: {
{ key: 'avg_duration', label: 'Visit Duration', format: (v) => formatDuration(v) },
]
const chartConfig = {
visitors: { label: 'Unique Visitors', color: '#FD5E0F' },
pageviews: { label: 'Total Pageviews', color: '#3b82f6' },
bounce_rate: { label: 'Bounce Rate', color: '#a855f7' },
avg_duration: { label: 'Visit Duration', color: '#10b981' },
} satisfies ChartConfig
// ─── Custom Tooltip ─────────────────────────────────────────────────
interface TooltipProps {
active?: boolean
payload?: Array<{ dataKey: string; value: number; color: string }>
label?: string
metric: MetricType
}
function CustomTooltip({ active, payload, metric }: TooltipProps) {
if (active && payload && payload.length) {
const entry = payload[0]
const config = METRIC_CONFIGS.find((m) => m.key === metric)
if (config) {
return (
<div className="rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-800 p-3 shadow-sm shadow-black/5 min-w-[120px]">
<div className="flex items-center gap-2 text-sm">
<div className="size-1.5 rounded-full" style={{ backgroundColor: entry.color }}></div>
<span className="text-neutral-500 dark:text-neutral-400">{config.label}:</span>
<span className="font-semibold text-neutral-900 dark:text-white">{config.format(entry.value)}</span>
</div>
</div>
)
}
}
return null
const CHART_COLORS: Record<MetricType, string> = {
visitors: '#FD5E0F',
pageviews: '#FD5E0F',
bounce_rate: '#FD5E0F',
avg_duration: '#FD5E0F',
}
// ─── Chart Component ─────────────────────────────────────────────────
@@ -216,19 +187,17 @@ export default function Chart({
const chartData = useMemo(() => data.map((item) => {
let formattedDate: string
if (interval === 'minute') {
formattedDate = new Date(item.date).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
formattedDate = formatTime(new Date(item.date))
} else if (interval === 'hour') {
const d = new Date(item.date)
const isMidnight = d.getHours() === 0 && d.getMinutes() === 0
formattedDate = isMidnight
? d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + ' 12:00 AM'
: d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + ', ' + d.toLocaleTimeString('en-US', { hour: 'numeric', minute: 'numeric' })
formattedDate = formatDateShort(d) + ', ' + formatTime(d)
} else {
formattedDate = new Date(item.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
formattedDate = formatDateShort(new Date(item.date))
}
return {
date: formattedDate,
dateObj: new Date(item.date),
originalDate: item.date,
pageviews: item.pageviews,
visitors: item.visitors,
@@ -351,9 +320,9 @@ export default function Chart({
metric === m.key && 'bg-neutral-50 dark:bg-neutral-800/40',
)}
>
<div className={cn('text-[10px] font-semibold uppercase tracking-widest mb-2', metric === m.key ? 'text-white' : 'text-neutral-400 dark:text-neutral-500')}>{m.label}</div>
<div className={cn('text-[10px] font-semibold uppercase tracking-widest mb-2', metric === m.key ? 'text-brand-orange' : 'text-neutral-400 dark:text-neutral-500')}>{m.label}</div>
<div className="flex items-baseline gap-2">
<span className="text-2xl font-bold text-neutral-900 dark:text-white">{m.format(m.value)}</span>
<AnimatedNumber value={m.value} format={m.format} className="text-2xl font-bold text-white" />
{m.change !== null && (
<span className={cn('flex items-center gap-0.5 text-sm font-semibold', m.isPositive ? 'text-[#10B981]' : 'text-[#EF4444]')}>
{m.isPositive ? <ArrowUpRight weight="bold" className="size-3.5" /> : <ArrowDownRight weight="bold" className="size-3.5" />}
@@ -388,7 +357,7 @@ export default function Chart({
{/* Toolbar */}
<div className="flex items-center justify-between gap-3 mb-4 px-2">
<div className="flex items-center gap-3">
<span className="text-xs font-medium text-neutral-500 dark:text-neutral-400">
<span className="text-xs font-medium text-neutral-400">
{METRIC_CONFIGS.find((m) => m.key === metric)?.label}
</span>
</div>
@@ -445,105 +414,73 @@ export default function Chart({
</div>
{!hasData || !hasAnyNonZero ? (
<div className="flex h-96 flex-col items-center justify-center gap-2">
<div className="flex h-96 flex-col items-center justify-center gap-3">
<img
src="/illustrations/no-data.svg"
alt="No data available"
className="w-48 h-auto mb-2"
/>
<p className="text-sm text-neutral-400 dark:text-neutral-500">
{!hasData ? 'No data for this period' : `No ${METRIC_CONFIGS.find((m) => m.key === metric)?.label.toLowerCase()} recorded`}
</p>
</div>
) : (
<div className="w-full" onContextMenu={handleChartContextMenu}>
<ChartContainer
config={chartConfig}
className="h-96 w-full overflow-visible [&_.recharts-curve.recharts-tooltip-cursor]:stroke-[initial]"
<VisxAreaChart
data={chartData as Record<string, unknown>[]}
xDataKey="dateObj"
aspectRatio="2.5 / 1"
margin={{ top: 20, right: 20, bottom: 40, left: 50 }}
animationDuration={400}
>
<LineChart
data={chartData}
margin={{ top: 20, right: 20, left: 5, bottom: 20 }}
style={{ overflow: 'visible' }}
>
<defs>
<pattern id="dotGrid" x="0" y="0" width="20" height="20" patternUnits="userSpaceOnUse">
<circle cx="10" cy="10" r="1" fill="var(--chart-grid)" fillOpacity="1" />
</pattern>
<filter id="lineShadow" x="-100%" y="-100%" width="300%" height="300%">
<feDropShadow
dx="4"
dy="6"
stdDeviation="25"
floodColor={`${chartConfig[metric]?.color}60`}
/>
</filter>
<filter id="dotShadow" x="-50%" y="-50%" width="200%" height="200%">
<feDropShadow dx="2" dy="2" stdDeviation="3" floodColor="rgba(0,0,0,0.5)" />
</filter>
</defs>
<XAxis
dataKey="date"
axisLine={false}
tickLine={false}
tick={{ fontSize: 11, fill: 'var(--chart-axis)' }}
tickMargin={10}
minTickGap={32}
/>
<YAxis
axisLine={false}
tickLine={false}
tick={{ fontSize: 11, fill: 'var(--chart-axis)' }}
tickMargin={10}
tickCount={6}
tickFormatter={(value) => {
const config = METRIC_CONFIGS.find((m) => m.key === metric)
return config ? config.format(value) : value.toString()
}}
/>
<ChartTooltip content={<CustomTooltip metric={metric} />} cursor={{ strokeDasharray: '3 3', stroke: '#9ca3af' }} />
{/* Background dot grid pattern */}
<rect
x="60px"
y="-20px"
width="calc(100% - 75px)"
height="calc(100% - 10px)"
fill="url(#dotGrid)"
style={{ pointerEvents: 'none' }}
/>
{/* Annotation reference lines */}
{visibleAnnotationMarkers.map((marker) => {
const primaryCategory = marker.annotations[0].category
const color = ANNOTATION_COLORS[primaryCategory] || ANNOTATION_COLORS.other
return (
<ReferenceLine
key={`ann-${marker.x}`}
x={marker.x}
stroke={color}
strokeDasharray="4 4"
strokeWidth={1.5}
strokeOpacity={0.6}
/>
)
})}
<Line
type="monotone"
<VisxGrid horizontal vertical={false} stroke="var(--chart-grid)" strokeDasharray="4,4" />
<VisxArea
dataKey={metric}
stroke={chartConfig[metric]?.color}
fill={CHART_COLORS[metric]}
fillOpacity={0.15}
stroke={CHART_COLORS[metric]}
strokeWidth={2}
filter="url(#lineShadow)"
dot={false}
activeDot={{
r: 6,
fill: chartConfig[metric]?.color,
stroke: 'white',
strokeWidth: 2,
filter: 'url(#dotShadow)',
gradientToOpacity={0}
/>
<VisxXAxis
numTicks={6}
formatLabel={interval === 'minute' || interval === 'hour'
? (d) => `${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')}`
: (d) => d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })
}
/>
<VisxYAxis
numTicks={6}
formatValue={(v) => {
const config = METRIC_CONFIGS.find((m) => m.key === metric)
return config ? config.format(v) : v.toString()
}}
/>
</LineChart>
</ChartContainer>
<VisxChartTooltip
content={({ point }) => {
const dateObj = point.dateObj instanceof Date ? point.dateObj : new Date(point.dateObj as string || Date.now())
const config = METRIC_CONFIGS.find((m) => m.key === metric)
const value = point[metric] as number
const title = interval === 'minute' || interval === 'hour'
? `${String(dateObj.getUTCHours()).padStart(2, '0')}:${String(dateObj.getUTCMinutes()).padStart(2, '0')}`
: dateObj.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short' })
return (
<div className="px-3 py-2.5">
<div className="mb-2 font-medium text-neutral-400 text-xs">{title}</div>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ backgroundColor: CHART_COLORS[metric] }} />
<span className="text-neutral-400 text-sm">{config?.label || metric}</span>
</div>
<span className="font-medium text-white text-sm tabular-nums">
{config ? config.format(value) : value}
</span>
</div>
</div>
)
}}
/>
</VisxAreaChart>
</div>
)}
</CardContent>
@@ -590,7 +527,7 @@ export default function Chart({
<span className="font-medium text-neutral-400 dark:text-neutral-500">
{ANNOTATION_LABELS[a.category] || 'Note'} &middot; {formatEU(a.date)}{a.time ? ` at ${a.time}` : ''}
</span>
<p className="text-neutral-900 dark:text-white">{a.text}</p>
<p className="text-white">{a.text}</p>
</div>
</div>
))}
@@ -607,16 +544,6 @@ export default function Chart({
</>
)}
</div>
{/* Live indicator right */}
{lastUpdatedAt != null && (
<div className="flex items-center gap-1.5 text-[11px] text-neutral-400 dark:text-neutral-500">
<span className="relative flex h-1.5 w-1.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-500 opacity-75" />
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-green-500" />
</span>
Live · {formatUpdatedAgo(lastUpdatedAt)}
</div>
)}
</div>
)}
</Card>
@@ -657,16 +584,16 @@ export default function Chart({
{annotationForm.visible && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-black/20 dark:bg-black/40 rounded-2xl">
<div className="bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-xl shadow-xl p-5 w-[340px] max-w-[90%]">
<h3 className="text-sm font-semibold text-neutral-900 dark:text-white mb-3">
<h3 className="text-sm font-semibold text-white mb-3">
{annotationForm.editingId ? 'Edit annotation' : 'Add annotation'}
</h3>
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-1">Date</label>
<label className="block text-xs font-medium text-neutral-400 mb-1">Date</label>
<button
type="button"
onClick={() => setCalendarOpen(true)}
className="w-full px-3 py-1.5 text-sm rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-orange/30 text-left flex items-center justify-between"
className="w-full px-3 py-1.5 text-sm rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 text-white focus:outline-none focus:ring-2 focus:ring-brand-orange/30 text-left flex items-center justify-between"
>
<span>{annotationForm.date ? formatEU(annotationForm.date) : 'Select date'}</span>
<svg className="w-4 h-4 text-neutral-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -675,7 +602,7 @@ export default function Chart({
</button>
</div>
<div>
<label className="block text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-1">
<label className="block text-xs font-medium text-neutral-400 mb-1">
Time <span className="text-neutral-400 dark:text-neutral-500">(optional)</span>
</label>
<div className="flex items-center gap-2">
@@ -683,7 +610,7 @@ export default function Chart({
type="time"
value={annotationForm.time}
onChange={(e) => setAnnotationForm((f) => ({ ...f, time: e.target.value }))}
className="flex-1 px-3 py-1.5 text-sm rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-orange/30"
className="flex-1 px-3 py-1.5 text-sm rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 text-white focus:outline-none focus:ring-2 focus:ring-brand-orange/30"
/>
{annotationForm.time && (
<button
@@ -698,20 +625,20 @@ export default function Chart({
</div>
</div>
<div>
<label className="block text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-1">Note</label>
<label className="block text-xs font-medium text-neutral-400 mb-1">Note</label>
<input
type="text"
value={annotationForm.text}
onChange={(e) => setAnnotationForm((f) => ({ ...f, text: e.target.value.slice(0, 200) }))}
placeholder="e.g. Launched new homepage"
maxLength={200}
className="w-full px-3 py-1.5 text-sm rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-orange/30"
className="w-full px-3 py-1.5 text-sm rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 text-white focus:outline-none focus:ring-2 focus:ring-brand-orange/30"
autoFocus
/>
<span className="text-[10px] text-neutral-400 mt-0.5 block text-right">{annotationForm.text.length}/200</span>
</div>
<div>
<label className="block text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-1">Category</label>
<label className="block text-xs font-medium text-neutral-400 mb-1">Category</label>
<Select
value={annotationForm.category}
onChange={(v) => setAnnotationForm((f) => ({ ...f, category: v }))}
@@ -739,7 +666,7 @@ export default function Chart({
<button
type="button"
onClick={() => setAnnotationForm({ visible: false, date: '', time: '', text: '', category: 'other' })}
className="px-3 py-1.5 text-xs font-medium text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200 cursor-pointer"
className="px-3 py-1.5 text-xs font-medium text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200 cursor-pointer"
>
Cancel
</button>
@@ -747,7 +674,7 @@ export default function Chart({
type="button"
disabled={!annotationForm.text.trim() || !annotationForm.date || saving}
onClick={handleSaveAnnotation}
className="px-3 py-1.5 text-xs font-medium text-white bg-brand-orange hover:bg-brand-orange/90 rounded-lg disabled:opacity-50 cursor-pointer"
className="px-3 py-1.5 text-xs font-medium text-white bg-brand-orange-button hover:bg-brand-orange-button-hover rounded-lg disabled:opacity-50 cursor-pointer"
>
{saving ? 'Saving...' : annotationForm.editingId ? 'Save' : 'Add'}
</button>

View 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>
)
}

View File

@@ -6,8 +6,9 @@ import { logger } from '@/lib/utils/logger'
import { formatNumber } from '@ciphera-net/ui'
import { useTabListKeyboard } from '@/lib/hooks/useTabListKeyboard'
import { TopPage, getTopPages, getEntryPages, getExitPages } from '@/lib/api/stats'
import { FrameCornersIcon } from '@phosphor-icons/react'
import { Modal, ArrowUpRightIcon, LayoutDashboardIcon } from '@ciphera-net/ui'
import 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'
@@ -98,23 +99,24 @@ 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">
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
<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-100 dark:hover:bg-neutral-800 transition-all cursor-pointer rounded-lg"
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" role="tablist" aria-label="Pages view tabs" onKeyDown={handleTabKeyDown}>
<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}
@@ -123,8 +125,8 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain,
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-neutral-900 dark:text-white'
: '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)}
@@ -143,17 +145,24 @@ 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) => (
{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={`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' : ''}`}
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="flex-1 truncate text-neutral-900 dark:text-white flex items-center">
<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}`}
@@ -165,31 +174,39 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain,
<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="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((page.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(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" />
))}
</>
) : (
<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>
@@ -207,7 +224,7 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain,
value={modalSearch}
onChange={(e) => setModalSearch(e.target.value)}
placeholder="Search pages..."
className="w-full px-3 py-2 mb-3 text-sm bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg text-neutral-900 dark:text-white placeholder-neutral-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-brand-orange/50"
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]">
@@ -229,16 +246,16 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain,
<div
key={page.path}
onClick={() => { if (canFilter) { onFilter({ dimension: 'page', operator: 'is', values: [page.path] }); setIsModalOpen(false) } }}
className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 transition-colors${canFilter ? ' cursor-pointer' : ''}`}
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-neutral-900 dark:text-white flex items-center">
<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-600 dark:text-neutral-400">
<span className="text-sm font-semibold text-neutral-400">
{formatNumber(page.pageviews)}
</span>
</div>

View 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>
)
}

View File

@@ -57,6 +57,8 @@ const BASE_DOTS_PATH = (() => {
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 {
@@ -68,7 +70,7 @@ function getCountryName(code: string): string {
}
}
export default function DottedMap({ data, className }: DottedMapProps) {
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(() => {
@@ -118,41 +120,41 @@ export default function DottedMap({ data, className }: DottedMapProps) {
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 (
<circle
cx={marker.x + offsetX}
cy={marker.y}
r={marker.size ?? DOT_RADIUS}
fill="#FD5E0F"
filter="url(#marker-glow)"
className="cursor-pointer"
<g
key={`marker-${marker.x}-${marker.y}-${index}`}
className="cursor-pointer"
onMouseEnter={(e) => {
if (info) {
const rect = (e.target as SVGCircleElement).closest('svg')!.getBoundingClientRect()
const svgX = marker.x + offsetX
const svgY = marker.y
const rect = (e.target as SVGElement).closest('svg')!.getBoundingClientRect()
setTooltip({
x: rect.left + (svgX / MAP_WIDTH) * rect.width,
y: rect.top + (svgY / MAP_HEIGHT) * rect.height,
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-900 dark:bg-neutral-800 border border-neutral-700 rounded-lg shadow-lg pointer-events-none -translate-x-1/2 -translate-y-full -mt-2"
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">{formatNumber(tooltip.pageviews)}</span>
<span className="ml-1.5 text-brand-orange font-bold">{formatValue(tooltip.pageviews)}</span>
</div>
)}
</div>

View File

@@ -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}%` }}

View File

@@ -7,6 +7,7 @@ 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,9 +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,
@@ -62,8 +65,24 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to
setSelectedFields((prev) => ({ ...prev, [field]: checked }))
}
const finishExport = useCallback(() => {
setExportDone(true)
setIsExporting(false)
setTimeout(() => {
setExportDone(false)
onClose()
}, 600)
}, [onClose])
// 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))
}, [])
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 () => {
@@ -99,6 +118,7 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to
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')
@@ -124,12 +144,15 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
onClose()
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()
// Header Section
await updateProgress(++currentStep, totalSteps, 'Building header...')
try {
// Logo
const logoData = await loadImage('/pulse_icon_no_margins.png')
@@ -153,9 +176,9 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to
// Metadata (Top Right)
doc.setFontSize(9)
doc.setTextColor(150, 150, 150)
const generatedDate = new Date().toLocaleDateString()
const generatedDate = formatDate(new Date())
const dateRange = data.length > 0
? `${new Date(data[0].date).toLocaleDateString()} - ${new Date(data[data.length - 1].date).toLocaleDateString()}`
? `${formatDate(new Date(data[0].date))} - ${formatDate(new Date(data[data.length - 1].date))}`
: generatedDate
const pageWidth = doc.internal.pageSize.width
@@ -194,6 +217,7 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to
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]
@@ -202,9 +226,7 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to
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()
return isHourly ? formatDateTime(date) : formatDate(date)
}
if (typeof val === 'number') {
if (field === 'bounce_rate') return `${Math.round(val)}%`
@@ -259,6 +281,7 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to
// 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()
@@ -287,6 +310,7 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to
// 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()
@@ -316,6 +340,7 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to
// Campaigns Table
if (campaigns && campaigns.length > 0) {
await updateProgress(++currentStep, totalSteps, 'Adding campaigns...')
if (finalY + 40 > doc.internal.pageSize.height) {
doc.addPage()
finalY = 20
@@ -342,8 +367,9 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to
})
}
await updateProgress(totalSteps, totalSteps, 'Saving PDF...')
doc.save(`${filename || 'export'}.pdf`)
onClose()
finishExport()
return
} else {
content = JSON.stringify(exportData, null, 2)
@@ -360,7 +386,7 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to
link.click()
document.body.removeChild(link)
onClose()
finishExport()
} catch (e) {
console.error('Export failed:', e)
} finally {
@@ -451,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} disabled={isExporting}>
Cancel
</Button>
<Button variant="primary" onClick={handleExport} disabled={isExporting}>
{isExporting ? 'Exporting...' : 'Export Data'}
<Button variant="primary" onClick={handleExport} disabled={isExporting || exportDone}>
{exportDone ? '✓ Done' : isExporting ? 'Exporting...' : 'Export Data'}
</Button>
</div>
</div>

View File

@@ -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>

View File

@@ -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'
@@ -19,12 +20,15 @@ export default function GoalStats({ goalCounts, onSelectEvent }: GoalStatsProps)
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">
<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="flex-1 min-h-[270px]">
@@ -32,10 +36,10 @@ export default function GoalStats({ goalCounts, onSelectEvent }: GoalStatsProps)
<div
key={row.event_name}
onClick={() => onSelectEvent?.(row.event_name)}
className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors${onSelectEvent ? ' cursor-pointer' : ''}`}
className={`flex items-center justify-between h-9 group hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors${onSelectEvent ? ' cursor-pointer' : ''}`}
>
<div className="flex items-center flex-1 min-w-0">
<span className="text-sm font-medium text-neutral-900 dark:text-white truncate">
<span className="text-sm font-medium text-white truncate">
{row.display_name ?? row.event_name.replace(/_/g, ' ')}
</span>
</div>
@@ -43,7 +47,7 @@ export default function GoalStats({ goalCounts, onSelectEvent }: GoalStatsProps)
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
{total > 0 ? `${Math.round((row.count / total) * 100)}%` : ''}
</span>
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400 tabular-nums">
<span className="text-sm font-semibold text-neutral-400 tabular-nums">
{formatNumber(row.count)}
</span>
</div>
@@ -55,14 +59,14 @@ export default function GoalStats({ goalCounts, onSelectEvent }: GoalStatsProps)
</div>
) : (
<div className="flex-1 min-h-[270px] flex flex-col items-center justify-center text-center px-6 py-8 gap-4">
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
<BookOpenIcon className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
<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(&apos;event_name&apos;)</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(&apos;event_name&apos;)</code> where actions happen on your site, then see counts here. Check our guide for step-by-step instructions.
</p>
<Link
href="/installation"

View File

@@ -10,11 +10,11 @@ import * as Flags from 'country-flag-icons/react/3x2'
import iso3166 from 'iso-3166-2'
const DottedMap = dynamic(() => import('./DottedMap'), { ssr: false })
const Globe = dynamic(() => import('./Globe'), { ssr: false })
import { Modal, GlobeIcon } from '@ciphera-net/ui'
import Link from 'next/link'
import { Modal, GlobeIcon, ArrowRightIcon } from '@ciphera-net/ui'
import { ListSkeleton } from '@/components/skeletons'
import VirtualList from './VirtualList'
import { ShieldCheck, Detective, Broadcast, FrameCornersIcon } from '@phosphor-icons/react'
import { ShieldCheck, Detective, Broadcast, MapPin, FrameCornersIcon } from '@phosphor-icons/react'
import { getCountries, getCities, getRegions } from '@/lib/api/stats'
import { type DimensionFilter } from '@/lib/filters'
@@ -28,14 +28,14 @@ interface LocationProps {
onFilter?: (filter: DimensionFilter) => void
}
type Tab = 'map' | 'globe' | 'countries' | 'regions' | 'cities'
type Tab = 'map' | 'countries' | 'regions' | 'cities'
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('')
@@ -90,13 +90,13 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
case 'T1':
return <ShieldCheck className="w-5 h-5 text-purple-600 dark:text-purple-400" />
case 'A1':
return <Detective 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 <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]
@@ -193,7 +193,7 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
})
}
const isVisualTab = activeTab === 'map' || activeTab === 'globe'
const isVisualTab = activeTab === 'map'
const rawData = isVisualTab ? [] : getData()
const data = filterUnknown(rawData)
const totalPageviews = data.reduce((sum, item) => sum + item.pageviews, 0)
@@ -216,24 +216,25 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
return (
<>
<div ref={containerRef} className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
<div 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">
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
<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-100 dark:hover:bg-neutral-800 transition-all cursor-pointer rounded-lg"
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" role="tablist" aria-label="Location view tabs" onKeyDown={handleTabKeyDown}>
{(['map', 'globe', 'countries', 'regions', 'cities'] as Tab[]).map((tab) => (
<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)}
@@ -241,8 +242,8 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
aria-selected={activeTab === tab}
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
? 'text-neutral-900 dark:text-white'
: '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}
@@ -261,24 +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>
) : isVisualTab ? (
hasData ? (
activeTab === 'globe'
? (inView ? <Globe data={filterUnknown(countries) as { country: string; pageviews: number }[]} /> : null)
: (inView ? <DottedMap data={filterUnknown(countries) as { country: string; pageviews: number }[]} /> : null)
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>
)
) : (
@@ -288,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 ?? '') :
@@ -302,11 +314,11 @@ 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>
@@ -319,13 +331,13 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
</>
) : (
<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>
@@ -346,7 +358,7 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
value={modalSearch}
onChange={(e) => setModalSearch(e.target.value)}
placeholder="Search locations..."
className="w-full px-3 py-2 mb-3 text-sm bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg text-neutral-900 dark:text-white placeholder-neutral-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-brand-orange/50"
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]">
@@ -375,9 +387,9 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
<div
key={`${item.country ?? ''}-${item.region ?? ''}-${item.city ?? ''}`}
onClick={() => { if (canFilter) { onFilter({ dimension: dim, operator: 'is', values: [filterValue!] }); setIsModalOpen(false) } }}
className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 transition-colors${canFilter ? ' cursor-pointer' : ''}`}
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-neutral-900 dark:text-white flex items-center gap-3">
<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 ?? '') :
@@ -389,7 +401,7 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
{modalTotal > 0 ? `${Math.round((item.pageviews / modalTotal) * 100)}%` : ''}
</span>
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
<span className="text-sm font-semibold text-neutral-400">
{formatNumber(item.pageviews)}
</span>
</div>

View File

@@ -1,6 +1,7 @@
'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'
@@ -12,36 +13,38 @@ interface PeakHoursProps {
}
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=12am, 3=6am, 6=12pm, 9=6pm
const BUCKET_LABELS: Record<number, string> = { 0: '12am', 3: '6am', 6: '12pm', 9: '6pm' }
// 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 = [
'rgba(253,94,15,0.18)',
'rgba(253,94,15,0.38)',
'rgba(253,94,15,0.62)',
'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
if (hour === 0) return '12am2am'
if (hour === 12) return '12pm2pm'
return hour < 12 ? `${hour}am${hour + 2}am` : `${hour - 12}pm${hour - 10}pm`
const end = hour + 2
return `${String(hour).padStart(2, '0')}:00${String(end).padStart(2, '0')}:00`
}
function formatHour(hour: number): string {
if (hour === 0) return '12am'
if (hour === 12) return '12pm'
return hour < 12 ? `${hour}am` : `${hour - 12}pm`
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.6) return HIGHLIGHT_COLORS[2]
return HIGHLIGHT_COLORS[3]
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) {
@@ -128,11 +131,14 @@ export default function PeakHours({ siteId, dateRange }: PeakHoursProps) {
}
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-1">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">Peak Hours</h3>
<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>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-5">
</div>
<p className="text-sm text-neutral-400 mb-5">
When your visitors are most active
</p>
@@ -140,8 +146,8 @@ export default function PeakHours({ siteId, dateRange }: PeakHoursProps) {
<div className="flex-1 min-h-[270px] flex flex-col justify-center gap-1.5">
{Array.from({ length: 7 }).map((_, i) => (
<div key={i} className="flex items-center gap-1">
<div className="w-7 h-3 rounded bg-neutral-200 dark:bg-neutral-800 animate-pulse" />
<div className="flex-1 h-5 rounded bg-neutral-200 dark:bg-neutral-800 animate-pulse" />
<div 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>
@@ -168,7 +174,7 @@ export default function PeakHours({ siteId, dateRange }: PeakHoursProps) {
key={`${animKey}-${dayIdx}-${bucket}`}
className={[
'aspect-square w-full rounded-[4px] border cursor-default transition-transform duration-100',
'border-neutral-200 dark:border-neutral-800',
'border-neutral-800',
isActive ? 'animate-cell-highlight' : '',
isHoveredCell ? 'scale-110 z-10 relative' : '',
isBestCell && !isHoveredCell ? 'ring-1 ring-brand-orange/40' : '',
@@ -195,21 +201,34 @@ export default function PeakHours({ siteId, dateRange }: PeakHoursProps) {
{Object.entries(BUCKET_LABELS).map(([b, label]) => (
<span
key={b}
className="absolute text-[10px] text-neutral-400 dark:text-neutral-600 -translate-x-1/2"
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-400 dark:text-neutral-600 -translate-x-full"
className="absolute text-[10px] text-neutral-600 -translate-x-full"
style={{ left: '100%' }}
>
12am
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 && (
@@ -226,7 +245,7 @@ export default function PeakHours({ siteId, dateRange }: PeakHoursProps) {
transform: 'translate(-50%, -100%)',
}}
>
<div className="bg-neutral-900 dark:bg-neutral-800 border border-neutral-700 text-white text-xs px-3 py-2 rounded-lg shadow-xl whitespace-nowrap">
<div className="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>
@@ -250,19 +269,25 @@ export default function PeakHours({ siteId, dateRange }: PeakHoursProps) {
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.6 }}
className="mt-4 text-xs text-neutral-500 dark:text-neutral-400 text-center"
className="mt-4 text-xs text-neutral-400 text-center"
>
Your busiest time is{' '}
<span className="text-brand-orange font-medium">
{DAYS[bestTime.day]}s at {formatHour(bestTime.bucket * 2)}
{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 gap-3">
<p className="text-sm text-neutral-500 dark:text-neutral-400">
No data available for this period
<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>
)}

View File

@@ -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 Googles “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-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-400 dark:focus-visible:ring-neutral-500 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-neutral-900"
aria-expanded={mainExpanded}
>
<div className="flex items-center gap-2 min-w-0">
<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-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-400 dark:focus-visible:ring-neutral-500 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-neutral-900"
aria-expanded={worstPagesOpen}
>
<ChevronDownIcon
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>
)
}

View File

@@ -1,3 +1,7 @@
'use client'
import { AnimatedNumber } from '@/components/ui/animated-number'
interface RealtimeVisitorsProps {
count: number
}
@@ -5,16 +9,16 @@ interface RealtimeVisitorsProps {
export default function RealtimeVisitors({ count }: RealtimeVisitorsProps) {
return (
<div
className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6"
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>
)

View File

@@ -28,13 +28,13 @@ export default function ScrollDepth({ goalCounts, totalPageviews }: ScrollDepthP
}))
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-1">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
<h3 className="text-lg font-semibold text-white">
Scroll Depth
</h3>
</div>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-4">
<p className="text-sm text-neutral-400 mb-4">
% of visitors who scrolled this far
</p>
@@ -73,13 +73,13 @@ export default function ScrollDepth({ goalCounts, totalPageviews }: ScrollDepthP
</div>
) : (
<div className="flex-1 min-h-[270px] flex flex-col items-center justify-center text-center px-6 py-8 gap-4">
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
<BarChartIcon className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
<div 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>

View 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>
</>
)
}

View 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>
</>
)}
</>
)
}

View File

@@ -4,7 +4,6 @@ import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { motion } from 'framer-motion'
import { useTabListKeyboard } from '@/lib/hooks/useTabListKeyboard'
import { useAuth } from '@/lib/auth/context'
interface SiteNavProps {
siteId: string
@@ -13,16 +12,15 @@ interface SiteNavProps {
export default function SiteNav({ siteId }: SiteNavProps) {
const pathname = usePathname()
const handleTabKeyDown = useTabListKeyboard()
const { user } = useAuth()
const canEdit = user?.role === 'owner' || user?.role === 'admin'
const tabs = [
{ label: 'Dashboard', href: `/sites/${siteId}` },
{ label: 'Journeys', href: `/sites/${siteId}/journeys` },
{ label: 'Funnels', href: `/sites/${siteId}/funnels` },
{ label: 'Behavior', href: `/sites/${siteId}/behavior` },
{ label: 'Search', href: `/sites/${siteId}/search` },
{ label: 'CDN', href: `/sites/${siteId}/cdn` },
{ label: 'Uptime', href: `/sites/${siteId}/uptime` },
...(canEdit ? [{ label: 'Settings', href: `/sites/${siteId}/settings` }] : []),
]
const isActive = (href: string) => {
@@ -33,8 +31,8 @@ export default function SiteNav({ siteId }: SiteNavProps) {
}
return (
<div className="border-b border-neutral-200 dark:border-neutral-800 mb-6">
<nav className="flex gap-1" role="tablist" aria-label="Site navigation" onKeyDown={handleTabKeyDown}>
<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}
@@ -42,9 +40,9 @@ export default function SiteNav({ siteId }: SiteNavProps) {
role="tab"
aria-selected={isActive(tab.href)}
tabIndex={isActive(tab.href) ? 0 : -1}
className={`relative px-3 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange rounded-t cursor-pointer -mb-px ${
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-neutral-900 dark:text-white'
? 'text-white'
: 'text-neutral-400 dark:text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300'
}`}
>

View File

@@ -6,8 +6,9 @@ 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 { Monitor, FrameCornersIcon } from '@phosphor-icons/react'
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'
@@ -29,6 +30,8 @@ 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)
}
@@ -128,23 +131,24 @@ 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">
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
<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-100 dark:hover:bg-neutral-800 transition-all cursor-pointer rounded-lg"
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" role="tablist" aria-label="Technology view tabs" onKeyDown={handleTabKeyDown}>
<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}
@@ -153,8 +157,8 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
aria-selected={activeTab === tab}
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
? 'text-neutral-900 dark:text-white'
: '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}
@@ -173,28 +177,34 @@ 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">{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>
@@ -207,15 +217,22 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
</>
) : (
<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>
@@ -233,7 +250,7 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
value={modalSearch}
onChange={(e) => setModalSearch(e.target.value)}
placeholder="Search technology..."
className="w-full px-3 py-2 mb-3 text-sm bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg text-neutral-900 dark:text-white placeholder-neutral-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-brand-orange/50"
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]">
@@ -256,9 +273,9 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
<div
key={item.name}
onClick={() => { if (canFilter) { onFilter({ dimension: dim, operator: 'is', values: [item.name] }); setIsModalOpen(false) } }}
className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 transition-colors${canFilter ? ' cursor-pointer' : ''}`}
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-neutral-900 dark:text-white flex items-center gap-3">
<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>
@@ -266,7 +283,7 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
{modalTotal > 0 ? `${Math.round((item.pageviews / modalTotal) * 100)}%` : ''}
</span>
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
<span className="text-sm font-semibold text-neutral-400">
{formatNumber(item.pageviews)}
</span>
</div>

View File

@@ -2,11 +2,11 @@
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 { FrameCornersIcon } from '@phosphor-icons/react'
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'
@@ -47,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))
}
}}
/>
)
}
@@ -89,7 +96,8 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
<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 && (
@@ -107,21 +115,28 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
<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) => (
{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.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' : ''}`}
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="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">
{renderReferrerIcon(ref.referrer)}
<span className="truncate" title={ref.referrer}>{getReferrerDisplayName(ref.referrer)}</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((ref.pageviews / totalPageviews) * 100)}%` : ''}
</span>
@@ -130,7 +145,8 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
</span>
</div>
</div>
))}
)
})}
{Array.from({ length: emptySlots }).map((_, i) => (
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
))}
@@ -138,14 +154,21 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
) : (
<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>
@@ -163,7 +186,7 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
value={modalSearch}
onChange={(e) => setModalSearch(e.target.value)}
placeholder="Search referrers..."
className="w-full px-3 py-2 mb-3 text-sm bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg text-neutral-900 dark:text-white placeholder-neutral-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-brand-orange/50"
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]">
@@ -185,7 +208,7 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
onClick={() => { if (onFilter) { onFilter({ dimension: 'referrer', operator: 'is', values: [ref.referrer] }); setIsModalOpen(false) } }}
className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 transition-colors${onFilter ? ' cursor-pointer' : ''}`}
>
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
<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>

View File

@@ -0,0 +1,111 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { getFunnelBreakdown, type FunnelBreakdown } from '@/lib/api/funnels'
import { DIMENSION_LABELS } from '@/lib/filters'
const BREAKDOWN_DIMENSIONS = [
'device', 'country', 'browser', 'os',
'utm_source', 'utm_medium', 'utm_campaign'
]
interface BreakdownDrawerProps {
siteId: string
funnelId: string
stepIndex: number
stepName: string
startDate: string
endDate: string
filters?: string
onClose: () => void
}
export default function BreakdownDrawer({ siteId, funnelId, stepIndex, stepName, startDate, endDate, filters, onClose }: BreakdownDrawerProps) {
const [activeDimension, setActiveDimension] = useState('device')
const [breakdown, setBreakdown] = useState<FunnelBreakdown | null>(null)
const [loading, setLoading] = useState(true)
const loadBreakdown = useCallback(async () => {
setLoading(true)
try {
const data = await getFunnelBreakdown(siteId, funnelId, stepIndex, activeDimension, startDate, endDate, filters)
setBreakdown(data)
} catch {
setBreakdown(null)
} finally {
setLoading(false)
}
}, [siteId, funnelId, stepIndex, activeDimension, startDate, endDate, filters])
useEffect(() => {
loadBreakdown()
}, [loadBreakdown])
return (
<>
{/* Backdrop */}
<div className="fixed inset-0 z-40 bg-black/20" onClick={onClose} />
<div className="fixed inset-y-0 right-0 z-50 w-96 max-w-full bg-white dark:bg-neutral-900 border-l border-neutral-200 dark:border-neutral-800 shadow-xl flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-neutral-200 dark:border-neutral-800">
<div>
<h3 className="font-semibold text-white">Step Breakdown</h3>
<p className="text-sm text-neutral-500">{stepName}</p>
</div>
<button onClick={onClose} className="p-2 text-neutral-400 hover:text-neutral-600 rounded-lg">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Dimension tabs */}
<div className="flex overflow-x-auto gap-1 px-6 py-3 border-b border-neutral-200 dark:border-neutral-800">
{BREAKDOWN_DIMENSIONS.map(dim => (
<button
key={dim}
onClick={() => setActiveDimension(dim)}
className={`px-3 py-1.5 text-xs font-medium rounded-lg whitespace-nowrap transition-colors ${
activeDimension === dim
? 'bg-brand-orange-button text-white'
: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-500 hover:bg-neutral-200 dark:hover:bg-neutral-700'
}`}
>
{DIMENSION_LABELS[dim] || dim}
</button>
))}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto px-6 py-4">
{loading ? (
<div className="space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-10 bg-neutral-100 dark:bg-neutral-800 rounded-lg animate-pulse" />
))}
</div>
) : !breakdown || breakdown.entries.length === 0 ? (
<p className="text-sm text-neutral-500">No data for this dimension</p>
) : (
<div className="space-y-2">
{breakdown.entries.map(entry => (
<div key={entry.value} className="flex items-center justify-between py-2 px-3 rounded-lg hover:bg-neutral-50 dark:hover:bg-neutral-800/50">
<span className="text-sm text-white truncate mr-4">
{entry.value || '(unknown)'}
</span>
<div className="flex items-center gap-4 text-sm shrink-0">
<span className="text-neutral-500">{entry.visitors}</span>
<span className="text-green-600 dark:text-green-400 font-medium w-16 text-right">
{Math.round(entry.conversion)}%
</span>
</div>
</div>
))}
</div>
)}
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,519 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { Input, Button, ChevronLeftIcon, ChevronDownIcon, PlusIcon, TrashIcon } from '@ciphera-net/ui'
import { CaretUp } from '@phosphor-icons/react'
import type { FunnelStep, StepPropertyFilter, CreateFunnelRequest } from '@/lib/api/funnels'
type StepWithoutOrder = Omit<FunnelStep, 'order'>
interface FunnelFormProps {
siteId: string
initialData?: {
name: string
description: string
steps: StepWithoutOrder[]
conversion_window_value: number
conversion_window_unit: 'hours' | 'days'
}
onSubmit: (data: CreateFunnelRequest) => Promise<void>
submitLabel: string
cancelHref: string
}
function isValidRegex(pattern: string): boolean {
try {
new RegExp(pattern)
return true
} catch {
return false
}
}
const WINDOW_PRESETS = [
{ label: '1h', value: 1, unit: 'hours' as const },
{ label: '24h', value: 24, unit: 'hours' as const },
{ label: '7d', value: 7, unit: 'days' as const },
{ label: '14d', value: 14, unit: 'days' as const },
{ label: '30d', value: 30, unit: 'days' as const },
]
const OPERATOR_OPTIONS: { value: StepPropertyFilter['operator']; label: string }[] = [
{ value: 'is', label: 'is' },
{ value: 'is_not', label: 'is not' },
{ value: 'contains', label: 'contains' },
{ value: 'not_contains', label: 'does not contain' },
]
const MAX_STEPS = 8
const MAX_FILTERS = 10
export default function FunnelForm({ siteId, initialData, onSubmit, submitLabel, cancelHref }: FunnelFormProps) {
const [name, setName] = useState(initialData?.name ?? '')
const [description, setDescription] = useState(initialData?.description ?? '')
const [steps, setSteps] = useState<StepWithoutOrder[]>(
initialData?.steps ?? [
{ name: 'Step 1', value: '/', type: 'exact' },
{ name: 'Step 2', value: '', type: 'exact' },
]
)
const [windowValue, setWindowValue] = useState(initialData?.conversion_window_value ?? 7)
const [windowUnit, setWindowUnit] = useState<'hours' | 'days'>(initialData?.conversion_window_unit ?? 'days')
const handleAddStep = () => {
if (steps.length >= MAX_STEPS) return
setSteps([...steps, { name: `Step ${steps.length + 1}`, value: '', type: 'exact' }])
}
const handleRemoveStep = (index: number) => {
if (steps.length <= 1) return
setSteps(steps.filter((_, i) => i !== index))
}
const handleUpdateStep = (index: number, field: string, value: string) => {
const newSteps = [...steps]
const step = { ...newSteps[index] }
if (field === 'category') {
step.category = value as 'page' | 'event'
// Reset fields when switching category
if (value === 'event') {
step.type = 'exact'
step.value = ''
} else {
step.value = ''
step.property_filters = undefined
}
} else {
;(step as Record<string, unknown>)[field] = value
}
newSteps[index] = step
setSteps(newSteps)
}
const moveStep = (index: number, direction: -1 | 1) => {
const targetIndex = index + direction
if (targetIndex < 0 || targetIndex >= steps.length) return
const newSteps = [...steps]
const temp = newSteps[index]
newSteps[index] = newSteps[targetIndex]
newSteps[targetIndex] = temp
setSteps(newSteps)
}
// Property filter handlers
const addPropertyFilter = (stepIndex: number) => {
const newSteps = [...steps]
const step = { ...newSteps[stepIndex] }
const filters = [...(step.property_filters || [])]
if (filters.length >= MAX_FILTERS) return
filters.push({ key: '', operator: 'is', value: '' })
step.property_filters = filters
newSteps[stepIndex] = step
setSteps(newSteps)
}
const updatePropertyFilter = (stepIndex: number, filterIndex: number, field: keyof StepPropertyFilter, value: string) => {
const newSteps = [...steps]
const step = { ...newSteps[stepIndex] }
const filters = [...(step.property_filters || [])]
filters[filterIndex] = { ...filters[filterIndex], [field]: value }
step.property_filters = filters
newSteps[stepIndex] = step
setSteps(newSteps)
}
const removePropertyFilter = (stepIndex: number, filterIndex: number) => {
const newSteps = [...steps]
const step = { ...newSteps[stepIndex] }
const filters = [...(step.property_filters || [])]
filters.splice(filterIndex, 1)
step.property_filters = filters.length > 0 ? filters : undefined
newSteps[stepIndex] = step
setSteps(newSteps)
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!name.trim()) {
const { toast } = await import('@ciphera-net/ui')
toast.error('Please enter a funnel name')
return
}
if (steps.some(s => !s.name.trim())) {
const { toast } = await import('@ciphera-net/ui')
toast.error('Please enter a name for all steps')
return
}
// Validate based on category
for (const step of steps) {
const category = step.category || 'page'
if (!step.value.trim()) {
const { toast } = await import('@ciphera-net/ui')
toast.error(category === 'event'
? `Please enter an event name for step: ${step.name}`
: `Please enter a path for step: ${step.name}`)
return
}
if (category === 'page' && step.type === 'regex' && !isValidRegex(step.value)) {
const { toast } = await import('@ciphera-net/ui')
toast.error(`Invalid regex pattern in step: ${step.name}`)
return
}
if (category === 'event' && step.property_filters) {
for (const filter of step.property_filters) {
if (!filter.key.trim()) {
const { toast } = await import('@ciphera-net/ui')
toast.error(`Property filter key is required in step: ${step.name}`)
return
}
}
}
}
const funnelSteps = steps.map((s, i) => ({
...s,
order: i,
}))
await onSubmit({
name,
description,
steps: funnelSteps,
conversion_window_value: windowValue,
conversion_window_unit: windowUnit,
})
}
const selectClass = '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'
return (
<div className="w-full max-w-3xl mx-auto px-4 sm:px-6 pb-8">
<div className="mb-8">
<Link
href={cancelHref}
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-white mb-2">
{initialData ? 'Edit Funnel' : '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}>
{/* Name & Description */}
<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>
{/* Steps */}
<div className="space-y-4 mb-6">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-white">
Funnel Steps
</h3>
</div>
{steps.map((step, index) => {
const category = step.category || 'page'
return (
<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">
{/* Step number + reorder */}
<div className="mt-3 text-neutral-400 flex items-center gap-1.5">
<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 className="flex flex-col gap-0.5">
<button
type="button"
onClick={() => moveStep(index, -1)}
disabled={index === 0}
className="p-0.5 text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 disabled:opacity-30 transition-colors"
>
<CaretUp className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => moveStep(index, 1)}
disabled={index === steps.length - 1}
className="p-0.5 text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 disabled:opacity-30 transition-colors"
>
<ChevronDownIcon className="w-3.5 h-3.5" />
</button>
</div>
</div>
<div className="flex-1">
{/* Category toggle */}
<div className="flex gap-1 mb-3">
<button
type="button"
onClick={() => handleUpdateStep(index, 'category', 'page')}
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${
category === 'page'
? 'bg-brand-orange-button 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'
}`}
>
Page Visit
</button>
<button
type="button"
onClick={() => handleUpdateStep(index, 'category', 'event')}
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${
category === 'event'
? 'bg-brand-orange-button 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'
}`}
>
Custom Event
</button>
</div>
<div className="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>
{category === 'page' ? (
<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 ${selectClass}`}
>
<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>
<label className="block text-xs font-medium text-neutral-500 uppercase mb-1">
Event Name
</label>
<Input
value={step.value}
onChange={(e) => handleUpdateStep(index, 'value', e.target.value)}
placeholder="e.g. signup, purchase"
/>
</div>
)}
</div>
{/* Property filters (event steps only) */}
{category === 'event' && (
<div className="mt-3">
{step.property_filters && step.property_filters.length > 0 && (
<div className="space-y-2 mb-2">
{step.property_filters.map((filter, filterIndex) => (
<div key={filterIndex} className="flex gap-2 items-center">
<Input
value={filter.key}
onChange={(e) => updatePropertyFilter(index, filterIndex, 'key', e.target.value)}
placeholder="key"
className="flex-1"
/>
<select
value={filter.operator}
onChange={(e) => updatePropertyFilter(index, filterIndex, 'operator', e.target.value)}
className={selectClass}
>
{OPERATOR_OPTIONS.map(op => (
<option key={op.value} value={op.value}>{op.label}</option>
))}
</select>
<Input
value={filter.value}
onChange={(e) => updatePropertyFilter(index, filterIndex, 'value', e.target.value)}
placeholder="value"
className="flex-1"
/>
<button
type="button"
onClick={() => removePropertyFilter(index, filterIndex)}
className="p-1.5 text-neutral-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
>
<TrashIcon className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
{(!step.property_filters || step.property_filters.length < MAX_FILTERS) && (
<button
type="button"
onClick={() => addPropertyFilter(index)}
className="text-xs text-neutral-500 hover:text-neutral-900 dark:hover:text-white flex items-center gap-1 transition-colors"
>
<PlusIcon className="w-3.5 h-3.5" />
Add property filter
</button>
)}
</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>
)
})}
{steps.length < MAX_STEPS ? (
<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>
) : (
<p className="text-center text-sm text-neutral-400">Maximum 8 steps</p>
)}
</div>
{/* Conversion Window */}
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 mb-6">
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-3">
Conversion Window
</h3>
<p className="text-xs text-neutral-500 mb-4">
Visitors must complete all steps within this time to count as converted.
</p>
{/* Quick presets */}
<div className="flex flex-wrap gap-2 mb-4">
{WINDOW_PRESETS.map(preset => (
<button
key={preset.label}
type="button"
onClick={() => {
setWindowValue(preset.value)
setWindowUnit(preset.unit)
}}
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${
windowValue === preset.value && windowUnit === preset.unit
? 'bg-brand-orange-button 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'
}`}
>
{preset.label}
</button>
))}
</div>
{/* Custom input */}
<div className="flex gap-2 items-center">
<Input
type="number"
min={1}
max={2160}
value={windowValue}
onChange={(e) => setWindowValue(Math.max(1, parseInt(e.target.value) || 1))}
className="w-20"
/>
<select
value={windowUnit}
onChange={(e) => setWindowUnit(e.target.value as 'hours' | 'days')}
className={selectClass}
>
<option value="hours">hours</option>
<option value="days">days</option>
</select>
</div>
</div>
<div className="flex justify-end gap-4">
<Link href={cancelHref}>
<Button variant="secondary">
Cancel
</Button>
</Link>
<Button
type="submit"
variant="primary"
>
{submitLabel}
</Button>
</div>
</form>
</div>
)
}

View File

@@ -0,0 +1,624 @@
'use client'
import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { TreeStructure } from '@phosphor-icons/react'
import type { PathTransition } from '@/lib/api/journeys'
// ─── Types ──────────────────────────────────────────────────────────
interface ColumnJourneyProps {
transitions: PathTransition[]
totalSessions: number
depth: number
}
interface ColumnPage {
path: string
sessionCount: number
}
interface Column {
index: number
totalSessions: number
dropOffPercent: number
pages: ColumnPage[]
}
interface LineDef {
sourceY: number
destY: number
sourceX: number
destX: number
weight: number
}
// ─── Constants ──────────────────────────────────────────────────────
const COLUMN_COLORS = [
'#FD5E0F', '#3B82F6', '#10B981', '#F59E0B', '#8B5CF6',
'#EC4899', '#06B6D4', '#EF4444', '#84CC16', '#F97316', '#6366F1',
]
const MAX_NODES_PER_COLUMN = 10
function colorForColumn(col: number): string {
return COLUMN_COLORS[col % COLUMN_COLORS.length]
}
// ─── Helpers ────────────────────────────────────────────────────────
function smartLabel(path: string): string {
if (path === '/' || path === '(other)') return path
const segments = path.replace(/\/$/, '').split('/')
if (segments.length <= 2) return path
return `…/${segments[segments.length - 1]}`
}
// ─── Animated count hook ────────────────────────────────────────────
function useAnimatedCount(target: number, duration = 400): number {
const [display, setDisplay] = useState(0)
const prevTarget = useRef(target)
useEffect(() => {
const from = prevTarget.current
prevTarget.current = target
if (from === target) {
setDisplay(target)
return
}
const start = performance.now()
let raf: number
const tick = (now: number) => {
const t = Math.min((now - start) / duration, 1)
const eased = 1 - Math.pow(1 - t, 3) // ease-out cubic
setDisplay(Math.round(from + (target - from) * eased))
if (t < 1) raf = requestAnimationFrame(tick)
}
raf = requestAnimationFrame(tick)
return () => cancelAnimationFrame(raf)
}, [target, duration])
return display
}
// ─── Data transformation ────────────────────────────────────────────
function buildColumns(
transitions: PathTransition[],
depth: number,
): Column[] {
const numCols = depth + 1
const columns: Column[] = []
for (let col = 0; col < numCols; col++) {
const pageMap = new Map<string, number>()
if (col === 0) {
for (const t of transitions) {
if (t.step_index === 0) {
pageMap.set(t.from_path, (pageMap.get(t.from_path) ?? 0) + t.session_count)
}
}
} else {
for (const t of transitions) {
if (t.step_index === col - 1) {
pageMap.set(t.to_path, (pageMap.get(t.to_path) ?? 0) + t.session_count)
}
}
}
let pages = Array.from(pageMap.entries())
.map(([path, sessionCount]) => ({ path, sessionCount }))
.sort((a, b) => b.sessionCount - a.sessionCount)
if (pages.length > MAX_NODES_PER_COLUMN) {
const kept = pages.slice(0, MAX_NODES_PER_COLUMN)
const otherCount = pages
.slice(MAX_NODES_PER_COLUMN)
.reduce((sum, p) => sum + p.sessionCount, 0)
kept.push({ path: '(other)', sessionCount: otherCount })
pages = kept
}
const totalSessions = pages.reduce((sum, p) => sum + p.sessionCount, 0)
const prevTotal = col > 0 ? columns[col - 1].totalSessions : totalSessions
const dropOffPercent =
col === 0 || prevTotal === 0
? 0
: Math.round(((totalSessions - prevTotal) / prevTotal) * 100)
columns.push({ index: col, totalSessions, dropOffPercent, pages })
}
// Trim empty trailing columns
while (columns.length > 1 && columns[columns.length - 1].pages.length === 0) {
columns.pop()
}
return columns
}
// ─── Sub-components ─────────────────────────────────────────────────
function AnimatedDropOff({ percent }: { percent: number }) {
const displayed = useAnimatedCount(percent)
if (displayed === 0 && percent === 0) return null
return (
<span
className={`text-xs font-medium ${
percent < 0 ? 'text-red-500' : 'text-emerald-500'
}`}
>
{percent > 0 ? '+' : displayed < 0 ? '' : ''}
{displayed}%
</span>
)
}
function ColumnHeader({
column,
}: {
column: Column
}) {
return (
<div className="flex flex-col items-center gap-0.5 mb-4">
<span className="text-xs font-medium text-neutral-400 dark:text-neutral-500 uppercase tracking-wider">
{column.index === 0 ? 'Entry' : `Step ${column.index}`}
</span>
<div className="flex items-baseline gap-1.5">
<span className="text-sm font-semibold text-white tabular-nums">
{column.totalSessions.toLocaleString()} visitors
</span>
{column.dropOffPercent !== 0 && (
<AnimatedDropOff percent={column.dropOffPercent} />
)}
</div>
</div>
)
}
function PageRow({
page,
colIndex,
rowIndex,
columnTotal,
maxCount,
isSelected,
isOther,
isMounted,
onClick,
}: {
page: ColumnPage
colIndex: number
rowIndex: number
columnTotal: number
maxCount: number
isSelected: boolean
isOther: boolean
isMounted: boolean
onClick: () => void
}) {
const pct = columnTotal > 0 ? Math.round((page.sessionCount / columnTotal) * 100) : 0
const barWidth = maxCount > 0 ? (page.sessionCount / maxCount) * 100 : 0
return (
<button
type="button"
disabled={isOther}
onClick={onClick}
title={page.path}
data-col={colIndex}
data-path={page.path}
className={`
group flex items-center justify-between w-full relative
h-9 px-3 rounded-lg text-left transition-all duration-200
${isOther ? 'cursor-default' : 'cursor-pointer'}
${isSelected
? 'bg-brand-orange/10 dark:bg-brand-orange/10'
: isOther
? ''
: 'hover:bg-neutral-50 dark:hover:bg-neutral-800/50 hover:-translate-y-px hover:shadow-sm'
}
`}
>
{/* Background bar — animates width on mount */}
{!isOther && barWidth > 0 && (
<div
className="absolute top-0.5 bottom-0.5 left-0.5 rounded-md transition-all duration-500 ease-out"
style={{
width: isMounted ? `calc(${barWidth}% - 4px)` : '0%',
transitionDelay: `${rowIndex * 30}ms`,
backgroundColor: isSelected ? 'rgba(253, 94, 15, 0.15)' : 'rgba(253, 94, 15, 0.08)',
}}
/>
)}
<span
className={`relative flex-1 truncate text-sm ${
isSelected
? 'text-white font-medium'
: isOther
? 'italic text-neutral-400 dark:text-neutral-500'
: 'text-white'
}`}
>
{isOther ? page.path : smartLabel(page.path)}
</span>
<div className="relative flex items-center gap-2 ml-2 shrink-0">
{!isOther && (
<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">
{pct}%
</span>
)}
<span
className={`text-sm tabular-nums font-semibold ${
isOther
? 'text-neutral-400 dark:text-neutral-500'
: 'text-neutral-600 dark:text-neutral-400'
}`}
>
{page.sessionCount.toLocaleString()}
</span>
</div>
</button>
)
}
function JourneyColumn({
column,
selectedPath,
exitCount,
onSelect,
}: {
column: Column
selectedPath: string | undefined
exitCount: number
onSelect: (path: string) => void
}) {
// Animation #2 & #3: trigger bar grow after mount
const [isMounted, setIsMounted] = useState(false)
useEffect(() => {
const raf = requestAnimationFrame(() => setIsMounted(true))
return () => {
cancelAnimationFrame(raf)
setIsMounted(false)
}
}, [column.pages])
if (column.pages.length === 0 && exitCount === 0) {
return (
<div
className="w-56 shrink-0"
style={{
animation: `col-enter 300ms ease-out ${column.index * 50}ms backwards`,
}}
>
<ColumnHeader column={column} />
<div className="flex items-center justify-center h-16 px-2">
<span className="text-xs text-neutral-400 dark:text-neutral-500">
No onward traffic
</span>
</div>
</div>
)
}
const maxCount = Math.max(...column.pages.map((p) => p.sessionCount), 0)
return (
<div
className="w-56 shrink-0 px-3"
style={{
animation: `col-enter 300ms ease-out ${column.index * 50}ms backwards`,
}}
>
<ColumnHeader column={column} />
<div className="space-y-0.5 max-h-[500px] overflow-y-auto">
{column.pages.map((page, rowIndex) => {
const isOther = page.path === '(other)'
return (
<PageRow
key={page.path}
page={page}
colIndex={column.index}
rowIndex={rowIndex}
columnTotal={column.totalSessions}
maxCount={maxCount}
isSelected={selectedPath === page.path}
isOther={isOther}
isMounted={isMounted}
onClick={() => {
if (!isOther) onSelect(page.path)
}}
/>
)
})}
{/* Animation #5: exit card slides in */}
{exitCount > 0 && (
<div
data-col={column.index}
data-path="(exit)"
className="flex items-center justify-between w-full relative h-9 px-3 rounded-lg bg-red-500/15 dark:bg-red-500/15"
style={{ animation: 'exit-reveal 300ms ease-out backwards' }}
>
<div
className="absolute top-0.5 bottom-0.5 left-0.5 rounded-md"
style={{
width: `calc(100% - 4px)`,
backgroundColor: 'rgba(239, 68, 68, 0.15)',
}}
/>
<span className="relative text-sm text-red-500 dark:text-red-400 font-medium">
(exit)
</span>
<span className="relative text-sm tabular-nums font-semibold text-red-500 dark:text-red-400">
{exitCount.toLocaleString()}
</span>
</div>
)}
</div>
</div>
)
}
// ─── Connection Lines ───────────────────────────────────────────────
function ConnectionLines({
containerRef,
selections,
columns,
transitions,
}: {
containerRef: React.RefObject<HTMLDivElement | null>
selections: Map<number, string>
columns: Column[]
transitions: PathTransition[]
}) {
const [lines, setLines] = useState<(LineDef & { color: string; length: number })[]>([])
const [dimensions, setDimensions] = useState({ width: 0, height: 0 })
useLayoutEffect(() => {
const container = containerRef.current
if (!container || selections.size === 0) {
setLines([])
return
}
const containerRect = container.getBoundingClientRect()
setDimensions({
width: container.scrollWidth,
height: container.scrollHeight,
})
const newLines: (LineDef & { color: string; length: number })[] = []
for (const [colIdx, selectedPath] of selections) {
const nextCol = columns[colIdx + 1]
if (!nextCol) continue
const sourceEl = container.querySelector(
`[data-col="${colIdx}"][data-path="${CSS.escape(selectedPath)}"]`
) as HTMLElement | null
if (!sourceEl) continue
const sourceRect = sourceEl.getBoundingClientRect()
const sourceY =
sourceRect.top + sourceRect.height / 2 - containerRect.top + container.scrollTop
const sourceX = sourceRect.right - containerRect.left + container.scrollLeft + 4
const relevantTransitions = transitions.filter(
(t) => t.step_index === colIdx && t.from_path === selectedPath
)
const color = colorForColumn(colIdx)
const maxCount = relevantTransitions.length > 0
? Math.max(...relevantTransitions.map((rt) => rt.session_count))
: 1
for (const t of relevantTransitions) {
const destEl = container.querySelector(
`[data-col="${colIdx + 1}"][data-path="${CSS.escape(t.to_path)}"]`
) as HTMLElement | null
if (!destEl) continue
const destRect = destEl.getBoundingClientRect()
const destY =
destRect.top + destRect.height / 2 - containerRect.top + container.scrollTop
const destX = destRect.left - containerRect.left + container.scrollLeft - 4
const weight = Math.max(1, Math.min(4, (t.session_count / maxCount) * 4))
// Approximate bezier curve length for animation
const dx = destX - sourceX
const dy = destY - sourceY
const length = Math.sqrt(dx * dx + dy * dy) * 1.2
newLines.push({ sourceY, destY, sourceX, destX, weight, color, length })
}
// Draw line to exit card if it exists
const exitEl = container.querySelector(
`[data-col="${colIdx + 1}"][data-path="(exit)"]`
) as HTMLElement | null
if (exitEl) {
const exitRect = exitEl.getBoundingClientRect()
const exitY =
exitRect.top + exitRect.height / 2 - containerRect.top + container.scrollTop
const exitX = exitRect.left - containerRect.left + container.scrollLeft
const dx = exitX - sourceX
const dy = exitY - sourceY
const length = Math.sqrt(dx * dx + dy * dy) * 1.2
newLines.push({ sourceY, destY: exitY, sourceX, destX: exitX, weight: 1, color: '#ef4444', length })
}
}
setLines(newLines)
}, [selections, columns, transitions, containerRef])
if (lines.length === 0) return null
return (
<svg
className="absolute top-0 left-0 pointer-events-none"
width={dimensions.width}
height={dimensions.height}
style={{ overflow: 'visible' }}
>
{lines.map((line, i) => {
const midX = (line.sourceX + line.destX) / 2
return (
<path
key={i}
d={`M ${line.sourceX},${line.sourceY} C ${midX},${line.sourceY} ${midX},${line.destY} ${line.destX},${line.destY}`}
stroke={line.color}
strokeWidth={line.weight}
strokeOpacity={0.35}
fill="none"
strokeDasharray={line.length}
strokeDashoffset={line.length}
style={{
animation: `draw-line 400ms ease-out ${i * 50}ms forwards`,
}}
/>
)
})}
<style>
{`@keyframes draw-line {
to { stroke-dashoffset: 0; }
}`}
</style>
</svg>
)
}
// ─── Exit count helper ──────────────────────────────────────────────
function getExitCount(
colIdx: number,
selectedPath: string,
columns: Column[],
transitions: PathTransition[],
): number {
const col = columns[colIdx]
const page = col?.pages.find((p) => p.path === selectedPath)
if (!page) return 0
const outbound = transitions
.filter((t) => t.step_index === colIdx && t.from_path === selectedPath)
.reduce((sum, t) => sum + t.session_count, 0)
return Math.max(0, page.sessionCount - outbound)
}
// ─── Main Component ─────────────────────────────────────────────────
export default function ColumnJourney({
transitions,
totalSessions,
depth,
}: ColumnJourneyProps) {
const [selections, setSelections] = useState<Map<number, string>>(new Map())
const containerRef = useRef<HTMLDivElement>(null)
// Clear selections when data changes
const transitionsKey = useMemo(
() => transitions.length + '-' + depth,
[transitions.length, depth]
)
const prevKeyRef = useRef(transitionsKey)
if (prevKeyRef.current !== transitionsKey) {
prevKeyRef.current = transitionsKey
if (selections.size > 0) setSelections(new Map())
}
const columns = useMemo(
() => buildColumns(transitions, depth),
[transitions, depth]
)
const handleSelect = useCallback(
(colIndex: number, path: string) => {
setSelections((prev) => {
const next = new Map(prev)
if (next.get(colIndex) === path) {
next.delete(colIndex)
} else {
next.set(colIndex, path)
}
for (const key of Array.from(next.keys())) {
if (key > colIndex) next.delete(key)
}
return next
})
},
[]
)
// ─── Empty state ────────────────────────────────────────────────
if (!transitions.length) {
return (
<div className="h-[400px] flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
<img
src="/illustrations/journey.svg"
alt="No journey data"
className="w-52 h-auto mb-2"
/>
<h4 className="font-semibold text-white">
No journey data yet
</h4>
<p className="text-sm text-neutral-400 max-w-xs">
Navigation flows will appear here as visitors browse through your site.
</p>
<a href="/installation" target="_blank" rel="noopener noreferrer" className="mt-2 text-sm font-medium text-brand-orange hover:underline">
View setup guide
</a>
</div>
)
}
return (
<div className="relative">
<style>
{`@keyframes col-enter {
from { opacity: 0; transform: translateX(-8px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes exit-reveal {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}`}
</style>
<div
ref={containerRef}
className="overflow-x-auto -mx-6 px-6 pb-2 relative"
>
<div className="flex min-w-fit py-2">
{columns.map((col, i) => {
const prevSelection = selections.get(col.index - 1)
const exitCount = prevSelection
? getExitCount(col.index - 1, prevSelection, columns, transitions)
: 0
return (
<Fragment key={col.index}>
{i > 0 && (
<div className="w-px shrink-0 mx-3 bg-neutral-100 dark:bg-neutral-800" />
)}
<JourneyColumn
column={col}
selectedPath={selections.get(col.index)}
exitCount={exitCount}
onSelect={(path) => handleSelect(col.index, path)}
/>
</Fragment>
)
})}
</div>
<ConnectionLines
containerRef={containerRef}
selections={selections}
columns={columns}
transitions={transitions}
/>
</div>
</div>
)
}

View File

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

View File

@@ -0,0 +1,554 @@
'use client'
import * as d3 from 'd3'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { TreeStructure, X } from '@phosphor-icons/react'
import type { PathTransition } from '@/lib/api/journeys'
// ─── Types ──────────────────────────────────────────────────────────
interface SankeyJourneyProps {
transitions: PathTransition[]
totalSessions: number
depth: number
}
interface SNode {
id: string
name: string
step: number
height: number
x: number
y: number
count: number
inLinks: SLink[]
outLinks: SLink[]
}
interface SLink {
source: string
target: string
value: number
sourceY?: number
targetY?: number
}
// ─── Constants ──────────────────────────────────────────────────────
const NODE_WIDTH = 30
const NODE_GAP = 20
const MIN_NODE_HEIGHT = 2
const MAX_LINK_HEIGHT = 100
const LINK_OPACITY = 0.3
const LINK_HOVER_OPACITY = 0.6
const MAX_NODES_PER_STEP = 25
const COLOR_PALETTE = [
'hsl(160, 45%, 40%)', 'hsl(220, 45%, 50%)', 'hsl(270, 40%, 50%)',
'hsl(25, 50%, 50%)', 'hsl(340, 40%, 50%)', 'hsl(190, 45%, 45%)',
'hsl(45, 45%, 50%)', 'hsl(0, 45%, 50%)',
]
// ─── Helpers ────────────────────────────────────────────────────────
function pathFromId(id: string): string {
const idx = id.indexOf(':')
return idx >= 0 ? id.slice(idx + 1) : id
}
function stepFromId(id: string): number {
const idx = id.indexOf(':')
return idx >= 0 ? parseInt(id.slice(0, idx), 10) : 0
}
function firstSegment(path: string): string {
const parts = path.split('/').filter(Boolean)
return parts.length > 0 ? `/${parts[0]}` : path
}
function smartLabel(path: string): string {
if (path === '/' || path === '(other)') return path
const segments = path.replace(/\/$/, '').split('/')
if (segments.length <= 2) return path
return `.../${segments[segments.length - 1]}`
}
// ─── Data Transformation ────────────────────────────────────────────
function buildData(
transitions: PathTransition[],
filterPath?: string,
): { nodes: SNode[]; links: SLink[] } {
if (transitions.length === 0) return { nodes: [], links: [] }
// Group transitions by step, count per path per step
const stepPaths = new Map<number, Map<string, number>>()
for (const t of transitions) {
if (!stepPaths.has(t.step_index)) stepPaths.set(t.step_index, new Map())
const fromMap = stepPaths.get(t.step_index)!
fromMap.set(t.from_path, (fromMap.get(t.from_path) ?? 0) + t.session_count)
const nextStep = t.step_index + 1
if (!stepPaths.has(nextStep)) stepPaths.set(nextStep, new Map())
const toMap = stepPaths.get(nextStep)!
toMap.set(t.to_path, (toMap.get(t.to_path) ?? 0) + t.session_count)
}
// Keep top N per step, rest → (other)
const topPaths = new Map<number, Set<string>>()
for (const [step, pm] of stepPaths) {
const sorted = Array.from(pm.entries()).sort((a, b) => b[1] - a[1])
topPaths.set(step, new Set(sorted.slice(0, MAX_NODES_PER_STEP).map(([p]) => p)))
}
// Build links
const linkMap = new Map<string, number>()
for (const t of transitions) {
const fromTop = topPaths.get(t.step_index)!
const toTop = topPaths.get(t.step_index + 1)!
const fp = fromTop.has(t.from_path) ? t.from_path : '(other)'
const tp = toTop.has(t.to_path) ? t.to_path : '(other)'
if (fp === '(other)' && tp === '(other)') continue
const src = `${t.step_index}:${fp}`
const tgt = `${t.step_index + 1}:${tp}`
const key = `${src}|${tgt}`
linkMap.set(key, (linkMap.get(key) ?? 0) + t.session_count)
}
let links: SLink[] = Array.from(linkMap.entries()).map(([k, v]) => {
const [source, target] = k.split('|')
return { source, target, value: v }
})
// Collect node IDs
const nodeIdSet = new Set<string>()
for (const l of links) { nodeIdSet.add(l.source); nodeIdSet.add(l.target) }
let nodes: SNode[] = Array.from(nodeIdSet).map((id) => ({
id,
name: pathFromId(id),
step: stepFromId(id),
height: 0, x: 0, y: 0, count: 0,
inLinks: [], outLinks: [],
}))
// Filter by path (BFS forward + backward)
if (filterPath) {
const matchIds = nodes.filter((n) => n.name === filterPath).map((n) => n.id)
if (matchIds.length === 0) return { nodes: [], links: [] }
const fwd = new Map<string, Set<string>>()
const bwd = new Map<string, Set<string>>()
for (const l of links) {
if (!fwd.has(l.source)) fwd.set(l.source, new Set())
fwd.get(l.source)!.add(l.target)
if (!bwd.has(l.target)) bwd.set(l.target, new Set())
bwd.get(l.target)!.add(l.source)
}
const reachable = new Set<string>(matchIds)
let queue = [...matchIds]
while (queue.length > 0) {
const next: string[] = []
for (const id of queue) {
for (const nb of fwd.get(id) ?? []) {
if (!reachable.has(nb)) { reachable.add(nb); next.push(nb) }
}
}
queue = next
}
queue = [...matchIds]
while (queue.length > 0) {
const next: string[] = []
for (const id of queue) {
for (const nb of bwd.get(id) ?? []) {
if (!reachable.has(nb)) { reachable.add(nb); next.push(nb) }
}
}
queue = next
}
links = links.filter((l) => reachable.has(l.source) && reachable.has(l.target))
const kept = new Set<string>()
for (const l of links) { kept.add(l.source); kept.add(l.target) }
nodes = nodes.filter((n) => kept.has(n.id))
}
return { nodes, links }
}
// ─── Component ──────────────────────────────────────────────────────
export default function SankeyJourney({
transitions,
totalSessions,
depth,
}: SankeyJourneyProps) {
const [filterPath, setFilterPath] = useState<string | null>(null)
const [isDark, setIsDark] = useState(false)
const svgRef = useRef<SVGSVGElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const [containerWidth, setContainerWidth] = useState(900)
// Detect dark mode
useEffect(() => {
const el = document.documentElement
setIsDark(el.classList.contains('dark'))
const obs = new MutationObserver(() => setIsDark(el.classList.contains('dark')))
obs.observe(el, { attributes: true, attributeFilter: ['class'] })
return () => obs.disconnect()
}, [])
// Measure container
useEffect(() => {
const el = containerRef.current
if (!el) return
const measure = () => setContainerWidth(el.clientWidth)
measure()
const obs = new ResizeObserver(measure)
obs.observe(el)
return () => obs.disconnect()
}, [])
const data = useMemo(
() => buildData(transitions, filterPath ?? undefined),
[transitions, filterPath],
)
// Clear filter on data change
const transKey = transitions.length + '-' + depth
const [prevKey, setPrevKey] = useState(transKey)
if (prevKey !== transKey) {
setPrevKey(transKey)
if (filterPath !== null) setFilterPath(null)
}
const handleNodeClick = useCallback((path: string) => {
if (path === '(other)') return
setFilterPath((prev) => (prev === path ? null : path))
}, [])
// ─── D3 Rendering ──────────────────────────────────────────────
useEffect(() => {
if (!svgRef.current || data.nodes.length === 0) return
const svg = d3.select(svgRef.current)
svg.selectAll('*').remove()
const { nodes, links } = data
const linkColor = isDark ? 'rgba(163,163,163,0.5)' : 'rgba(82,82,82,0.5)'
const textColor = isDark ? '#e5e5e5' : '#171717'
// Wire up node ↔ link references
for (const n of nodes) { n.inLinks = []; n.outLinks = []; n.count = 0 }
const nodeMap = new Map(nodes.map((n) => [n.id, n]))
for (const l of links) {
const src = nodeMap.get(l.source)
const tgt = nodeMap.get(l.target)
if (src) src.outLinks.push(l)
if (tgt) tgt.inLinks.push(l)
}
for (const n of nodes) {
const inVal = n.inLinks.reduce((s, l) => s + l.value, 0)
const outVal = n.outLinks.reduce((s, l) => s + l.value, 0)
n.count = n.step === 0 ? outVal : Math.max(inVal, outVal)
}
// Calculate node heights (proportional to value)
const maxVal = d3.max(links, (l) => l.value) || 1
const heightScale = d3.scaleLinear().domain([0, maxVal]).range([0, MAX_LINK_HEIGHT])
for (const n of nodes) {
const inVal = n.inLinks.reduce((s, l) => s + l.value, 0)
const outVal = n.outLinks.reduce((s, l) => s + l.value, 0)
n.height = Math.max(heightScale(Math.max(inVal, outVal)), MIN_NODE_HEIGHT)
}
// Group by step, determine layout
const byStep = d3.group(nodes, (n) => n.step)
const numSteps = byStep.size
const width = containerWidth
const stepWidth = width / numSteps
// Calculate chart height from tallest column
const stepHeights = Array.from(byStep.values()).map(
(ns) => ns.reduce((s, n) => s + n.height, 0) + (ns.length - 1) * NODE_GAP,
)
const height = Math.max(200, Math.max(...stepHeights) + 20)
// Position nodes in columns, aligned from top
byStep.forEach((stepNodes, step) => {
let cy = 0
for (const n of stepNodes) {
n.x = step * stepWidth
n.y = cy + n.height / 2
cy += n.height + NODE_GAP
}
})
// Calculate link y-positions (stacked within each node)
for (const n of nodes) {
n.outLinks.sort((a, b) => b.value - a.value)
n.inLinks.sort((a, b) => b.value - a.value)
let outY = n.y - n.height / 2
for (const l of n.outLinks) {
const lh = heightScale(l.value)
l.sourceY = outY + lh / 2
outY += lh
}
let inY = n.y - n.height / 2
for (const l of n.inLinks) {
const lh = heightScale(l.value)
l.targetY = inY + lh / 2
inY += lh
}
}
// Color by first path segment
const segCounts = new Map<string, number>()
for (const n of nodes) {
const seg = firstSegment(n.name)
segCounts.set(seg, (segCounts.get(seg) ?? 0) + 1)
}
const segColors = new Map<string, string>()
let ci = 0
segCounts.forEach((count, seg) => {
if (count > 1) { segColors.set(seg, COLOR_PALETTE[ci % COLOR_PALETTE.length]); ci++ }
})
const defaultColor = isDark ? 'hsl(0, 0%, 50%)' : 'hsl(0, 0%, 45%)'
const nodeColor = (n: SNode) => segColors.get(firstSegment(n.name)) ?? defaultColor
const linkSourceColor = (l: SLink) => {
const src = nodeMap.get(l.source)
return src ? nodeColor(src) : linkColor
}
// Link path generator
const linkPath = (l: SLink) => {
const src = nodeMap.get(l.source)
const tgt = nodeMap.get(l.target)
if (!src || !tgt) return ''
const sy = l.sourceY ?? src.y
const ty = l.targetY ?? tgt.y
const sx = src.x + NODE_WIDTH
const tx = tgt.x
const gap = tx - sx
const c1x = sx + gap / 3
const c2x = tx - gap / 3
return `M ${sx},${sy} C ${c1x},${sy} ${c2x},${ty} ${tx},${ty}`
}
svg.attr('width', width).attr('height', height)
const g = svg.append('g')
// ── Draw links ────────────────────────────────────────
g.selectAll('.link')
.data(links)
.join('path')
.attr('class', 'link')
.attr('d', linkPath)
.attr('fill', 'none')
.attr('stroke', (d) => linkSourceColor(d))
.attr('stroke-width', (d) => heightScale(d.value))
.attr('opacity', LINK_OPACITY)
.attr('data-source', (d) => d.source)
.attr('data-target', (d) => d.target)
.style('pointer-events', 'none')
// ── Tooltip ───────────────────────────────────────────
const tooltip = d3.select('body').append('div')
.style('position', 'absolute')
.style('visibility', 'hidden')
.style('background', isDark ? '#262626' : '#f5f5f5')
.style('border', `1px solid ${isDark ? '#404040' : '#d4d4d4'}`)
.style('border-radius', '8px')
.style('padding', '8px 12px')
.style('font-size', '12px')
.style('color', isDark ? '#fff' : '#171717')
.style('pointer-events', 'none')
.style('z-index', '9999')
.style('box-shadow', '0 4px 12px rgba(0,0,0,0.15)')
// ── Draw nodes ────────────────────────────────────────
const nodeGs = g.selectAll('.node')
.data(nodes)
.join('g')
.attr('class', 'node')
.attr('transform', (d) => `translate(${d.x},${d.y - d.height / 2})`)
.style('cursor', 'pointer')
// Node bars
nodeGs.append('rect')
.attr('class', 'node-rect')
.attr('width', NODE_WIDTH)
.attr('height', (d) => d.height)
.attr('fill', (d) => nodeColor(d))
.attr('rx', 2)
.attr('ry', 2)
// Node labels
nodeGs.append('text')
.attr('class', 'node-text')
.attr('x', NODE_WIDTH + 6)
.attr('y', (d) => d.height / 2 + 4)
.text((d) => smartLabel(d.name))
.attr('font-size', '12px')
.attr('fill', textColor)
.attr('text-anchor', 'start')
// ── Hover: find all connected paths ───────────────────
const findConnected = (startLink: SLink, dir: 'fwd' | 'bwd') => {
const result: SLink[] = []
const visited = new Set<string>()
const queue = [startLink]
while (queue.length > 0) {
const cur = queue.shift()!
const lid = `${cur.source}|${cur.target}`
if (visited.has(lid)) continue
visited.add(lid)
result.push(cur)
if (dir === 'fwd') {
const tgt = nodeMap.get(cur.target)
if (tgt) tgt.outLinks.forEach((l) => queue.push(l))
} else {
const src = nodeMap.get(cur.source)
if (src) src.inLinks.forEach((l) => queue.push(l))
}
}
return result
}
const highlightPaths = (nodeId: string) => {
const connectedLinks: SLink[] = []
const connectedNodes = new Set<string>([nodeId])
const directLinks = links.filter((l) => l.source === nodeId || l.target === nodeId)
for (const dl of directLinks) {
connectedLinks.push(dl, ...findConnected(dl, 'fwd'), ...findConnected(dl, 'bwd'))
}
const connectedLinkIds = new Set(connectedLinks.map((l) => `${l.source}|${l.target}`))
connectedLinks.forEach((l) => { connectedNodes.add(l.source); connectedNodes.add(l.target) })
g.selectAll<SVGPathElement, SLink>('.link')
.attr('opacity', function () {
const s = d3.select(this).attr('data-source')
const t = d3.select(this).attr('data-target')
return connectedLinkIds.has(`${s}|${t}`) ? LINK_HOVER_OPACITY : 0.05
})
g.selectAll<SVGRectElement, SNode>('.node-rect')
.attr('opacity', (d) => connectedNodes.has(d.id) ? 1 : 0.15)
g.selectAll<SVGTextElement, SNode>('.node-text')
.attr('opacity', (d) => connectedNodes.has(d.id) ? 1 : 0.2)
}
const resetHighlight = () => {
g.selectAll('.link').attr('opacity', LINK_OPACITY)
.attr('stroke', (d: unknown) => linkSourceColor(d as SLink))
g.selectAll('.node-rect').attr('opacity', 1)
g.selectAll('.node-text').attr('opacity', 1)
tooltip.style('visibility', 'hidden')
}
// Node hover
nodeGs
.on('mouseenter', function (event, d) {
tooltip.style('visibility', 'visible')
.html(`<div style="font-weight:600;margin-bottom:2px">${d.name}</div><div style="opacity:0.7">${d.count.toLocaleString()} sessions</div>`)
.style('top', `${event.pageY - 10}px`).style('left', `${event.pageX + 12}px`)
highlightPaths(d.id)
})
.on('mousemove', (event) => {
tooltip.style('top', `${event.pageY - 10}px`).style('left', `${event.pageX + 12}px`)
})
.on('mouseleave', resetHighlight)
.on('click', (_, d) => handleNodeClick(d.name))
// Link hit areas (wider invisible paths for easier hovering)
g.selectAll('.link-hit')
.data(links)
.join('path')
.attr('class', 'link-hit')
.attr('d', linkPath)
.attr('fill', 'none')
.attr('stroke', 'transparent')
.attr('stroke-width', (d) => Math.max(heightScale(d.value), 14))
.attr('data-source', (d) => d.source)
.attr('data-target', (d) => d.target)
.style('cursor', 'pointer')
.on('mouseenter', function (event, d) {
const src = nodeMap.get(d.source)
const tgt = nodeMap.get(d.target)
tooltip.style('visibility', 'visible')
.html(`<div style="font-weight:600;margin-bottom:2px">${src?.name ?? '?'}${tgt?.name ?? '?'}</div><div style="opacity:0.7">${d.value.toLocaleString()} sessions</div>`)
.style('top', `${event.pageY - 10}px`).style('left', `${event.pageX + 12}px`)
// Highlight this link's connected paths
const all = [d, ...findConnected(d, 'fwd'), ...findConnected(d, 'bwd')]
const lids = new Set(all.map((l) => `${l.source}|${l.target}`))
const nids = new Set<string>()
all.forEach((l) => { nids.add(l.source); nids.add(l.target) })
g.selectAll<SVGPathElement, SLink>('.link')
.attr('opacity', function () {
const s = d3.select(this).attr('data-source')
const t = d3.select(this).attr('data-target')
return lids.has(`${s}|${t}`) ? LINK_HOVER_OPACITY : 0.05
})
g.selectAll<SVGRectElement, SNode>('.node-rect')
.attr('opacity', (nd) => nids.has(nd.id) ? 1 : 0.15)
g.selectAll<SVGTextElement, SNode>('.node-text')
.attr('opacity', (nd) => nids.has(nd.id) ? 1 : 0.2)
})
.on('mousemove', (event) => {
tooltip.style('top', `${event.pageY - 10}px`).style('left', `${event.pageX + 12}px`)
})
.on('mouseleave', resetHighlight)
return () => { tooltip.remove() }
}, [data, containerWidth, isDark, handleNodeClick])
// ─── Empty state ────────────────────────────────────────────────
if (!transitions.length || data.nodes.length === 0) {
return (
<div className="h-[400px] flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
<TreeStructure className="w-8 h-8 text-neutral-400" />
</div>
<h4 className="font-semibold text-white">
No journey data yet
</h4>
<p className="text-sm text-neutral-400 max-w-xs">
Navigation flows will appear here as visitors browse through your site.
</p>
<a href="/installation" target="_blank" rel="noopener noreferrer" className="mt-2 text-sm font-medium text-brand-orange hover:underline">
View setup guide
</a>
</div>
)
}
return (
<div>
{filterPath && (
<div className="flex items-center gap-2 mb-3 px-3 py-2 rounded-lg bg-brand-orange/10 text-sm">
<span className="text-neutral-700 dark:text-neutral-300">
Showing flows through{' '}
<span className="font-medium text-white">
{filterPath}
</span>
</span>
<button
type="button"
onClick={() => setFilterPath(null)}
className="ml-auto flex items-center gap-1 text-xs font-medium text-brand-orange hover:text-brand-orange/80 transition-colors"
>
<X weight="bold" className="w-3.5 h-3.5" />
Reset
</button>
</div>
)}
<div ref={containerRef} className="w-full overflow-hidden">
<svg ref={svgRef} className="w-full" />
</div>
</div>
)
}

View File

@@ -2,7 +2,7 @@
import type { TopPath } from '@/lib/api/journeys'
import { TableSkeleton } from '@/components/skeletons'
import { Path } from '@phosphor-icons/react'
import { Path, ArrowRight, Clock } from '@phosphor-icons/react'
interface TopPathsTableProps {
paths: TopPath[]
@@ -17,67 +17,108 @@ function formatDuration(seconds: number): string {
return `${m}m ${s}s`
}
function smartLabel(path: string): string {
if (path === '/') return '/'
const segments = path.replace(/\/$/, '').split('/')
if (segments.length <= 2) return path
return `…/${segments[segments.length - 1]}`
}
function truncateSequence(seq: string[], max: number): (string | null)[] {
if (seq.length <= max) return seq
const head = seq.slice(0, 3)
const tail = seq.slice(-2)
return [...head, null, ...tail]
}
export default function TopPathsTable({ paths, loading }: TopPathsTableProps) {
const hasData = paths.length > 0
const maxCount = hasData ? paths[0].session_count : 0
return (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
<div className="flex items-center justify-between mb-1">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
<div className="mb-1">
<h3 className="text-lg font-semibold text-white">
Top Paths
</h3>
</div>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-4">
<p className="text-sm text-neutral-400 mb-5">
Most common navigation paths across sessions
</p>
{loading ? (
<TableSkeleton rows={7} cols={4} />
) : hasData ? (
<div>
{/* Header */}
<div className="flex items-center px-2 -mx-2 mb-2 text-xs font-medium text-neutral-400 dark:text-neutral-500 uppercase tracking-wider">
<span className="w-8 text-right shrink-0">#</span>
<span className="flex-1 ml-3">Path</span>
<span className="w-20 text-right shrink-0">Sessions</span>
<span className="w-16 text-right shrink-0">Dur.</span>
</div>
{/* Rows */}
<div className="space-y-0.5">
{paths.map((path, i) => (
{paths.map((path, i) => {
const barWidth = maxCount > 0 ? (path.session_count / maxCount) * 75 : 0
const displaySeq = truncateSequence(path.page_sequence, 7)
return (
<div
key={i}
className="flex items-center h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800/50 rounded-lg px-2 -mx-2 transition-colors"
className="relative flex items-center h-10 group hover:bg-neutral-50 dark:hover:bg-neutral-800/50 rounded-lg px-3 -mx-3 transition-colors"
>
<span className="w-8 text-right shrink-0 text-sm tabular-nums text-neutral-400">
{i + 1}
{/* Background bar */}
<div
className="absolute inset-y-0.5 left-0.5 bg-brand-orange/15 dark:bg-brand-orange/25 rounded-md transition-all"
style={{ width: `${barWidth}%` }}
/>
{/* Content */}
<div className="relative flex items-center justify-between w-full min-w-0">
{/* Path sequence */}
<div className="flex items-center min-w-0 gap-1.5 flex-1 overflow-hidden">
{displaySeq.map((page, j) => (
<div key={j} className="flex items-center gap-1.5 shrink-0">
{j > 0 && (
<ArrowRight
weight="bold"
className="w-2.5 h-2.5 text-neutral-300 dark:text-neutral-600 shrink-0"
/>
)}
{page === null ? (
<span className="text-xs text-neutral-400 dark:text-neutral-500">
</span>
) : (
<span
className="flex-1 ml-3 text-sm text-neutral-900 dark:text-white truncate"
title={path.page_sequence.join(' → ')}
className="text-sm text-white truncate"
title={page}
>
{path.page_sequence.join(' → ')}
</span>
<span className="w-20 text-right shrink-0 text-sm tabular-nums text-neutral-600 dark:text-neutral-400">
{path.session_count.toLocaleString()}
</span>
<span className="w-16 text-right shrink-0 text-sm tabular-nums text-neutral-600 dark:text-neutral-400">
{formatDuration(path.avg_duration)}
{smartLabel(page)}
</span>
)}
</div>
))}
</div>
{/* Stats */}
<div className="relative flex items-center gap-4 ml-4 shrink-0">
{path.avg_duration > 0 && (
<span className="hidden sm:flex items-center gap-1 text-xs text-neutral-400 dark:text-neutral-500 opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
<Clock weight="bold" className="w-3 h-3" />
{formatDuration(path.avg_duration)}
</span>
)}
<span className="text-sm tabular-nums font-semibold text-neutral-600 dark:text-neutral-400">
{path.session_count.toLocaleString()}
</span>
</div>
</div>
</div>
)
})}
</div>
) : (
<div className="flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
<Path className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
<Path 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 path 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">
Common navigation paths will appear here as visitors browse your site.
</p>
</div>

View File

@@ -0,0 +1,47 @@
'use client'
import { motion } from 'framer-motion'
import { ArrowRight } from '@phosphor-icons/react'
import { Button } from '@ciphera-net/ui'
import { initiateOAuthFlow } from '@/lib/api/oauth'
import Link from 'next/link'
export default function CTASection() {
return (
<section className="py-20 lg:py-32">
<div className="container mx-auto px-6">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
className="relative overflow-hidden rounded-xl border border-white/[0.06] bg-neutral-900/80 px-6 py-20 sm:px-10 sm:py-24 max-w-6xl mx-auto"
>
{/* Atmosphere inside card */}
<div className="absolute inset-0 -z-10 pointer-events-none">
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[500px] h-[500px] bg-brand-orange/5 rounded-full blur-[150px]" />
</div>
<div className="relative z-10 text-center max-w-2xl mx-auto">
<h2 className="text-3xl sm:text-4xl font-bold text-white mb-4">
Start tracking with privacy.
</h2>
<p className="text-lg text-neutral-300 mb-10">
Join thousands of developers who respect their users&apos; privacy while getting the insights they need.
</p>
<div className="flex flex-row gap-3 justify-center flex-wrap">
<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>
<Link href="/pricing">
<Button variant="secondary" className="px-6 py-3">
View Pricing
</Button>
</Link>
</div>
</div>
</motion.div>
</div>
</section>
)
}

View File

@@ -0,0 +1,111 @@
'use client'
import { motion } from 'framer-motion'
import Image from 'next/image'
import { Check, X } from '@phosphor-icons/react'
const pulseFeatures = [
{ label: 'No cookies required', has: true },
{ label: 'GDPR compliant by default', has: true },
{ label: 'No consent banner needed', has: true },
{ label: 'Open source client', has: true },
{ label: 'Script under 2KB', has: true },
{ label: 'Swiss infrastructure', has: true },
{ label: 'No cross-site tracking', has: true },
{ label: 'Free tier available', has: true },
{ label: 'Real-time dashboard', has: true },
]
const gaFeatures = [
{ label: 'Requires cookies', has: false },
{ label: 'GDPR requires configuration', has: false },
{ label: 'Consent banner required', has: false },
{ label: 'Closed source', has: false },
{ label: 'Script over 45KB', has: false },
{ label: 'US infrastructure', has: false },
{ label: 'Cross-site tracking', has: false },
{ label: 'Free tier available', has: true },
{ label: 'Real-time dashboard', has: true },
]
export default function ComparisonCards() {
return (
<section className="py-20 lg:py-32 border-t border-white/[0.04]">
<div className="container mx-auto px-6">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
className="text-center mb-16"
>
<h2 className="text-3xl sm:text-4xl md:text-5xl font-bold text-white leading-tight mb-4">
How Pulse compares.
</h2>
<p className="text-lg text-neutral-400 max-w-2xl mx-auto">
Privacy-first analytics doesn&apos;t mean less insight. See how Pulse stacks up.
</p>
</motion.div>
<div className="grid md:grid-cols-2 gap-6 max-w-4xl mx-auto">
{/* Pulse — highlighted */}
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.1 }}
className="rounded-xl border border-brand-orange/20 bg-neutral-900/80 p-8 relative overflow-hidden"
>
<div className="absolute top-0 left-0 right-0 h-[3px] bg-brand-orange" />
<div className="flex items-center gap-3 mb-6">
<Image src="/pulse_icon_no_margins.png" alt="Pulse" width={40} height={40} className="rounded-lg" unoptimized />
<div>
<h3 className="text-xl font-bold text-white">Pulse</h3>
<p className="text-xs text-brand-orange">Privacy-first analytics</p>
</div>
</div>
<ul className="space-y-4">
{pulseFeatures.map((f) => (
<li key={f.label} className="flex items-center gap-3">
<Check weight="bold" className="w-5 h-5 text-brand-orange shrink-0" />
<span className="text-neutral-300 text-sm">{f.label}</span>
</li>
))}
</ul>
</motion.div>
{/* Google Analytics — muted */}
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.2 }}
className="rounded-xl border border-white/[0.08] bg-neutral-900/80 p-8"
>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-lg bg-neutral-800 flex items-center justify-center text-lg">
📊
</div>
<div>
<h3 className="text-xl font-bold text-white">Google Analytics</h3>
<p className="text-xs text-neutral-500">Traditional tracking</p>
</div>
</div>
<ul className="space-y-4">
{gaFeatures.map((f) => (
<li key={f.label} className="flex items-center gap-3">
{f.has ? (
<Check weight="bold" className="w-5 h-5 text-green-500 shrink-0" />
) : (
<X weight="bold" className="w-5 h-5 text-red-500 shrink-0" />
)}
<span className="text-neutral-400 text-sm">{f.label}</span>
</li>
))}
</ul>
</motion.div>
</div>
</div>
</section>
)
}

View File

@@ -0,0 +1,273 @@
'use client'
import Chart from '@/components/dashboard/Chart'
import ContentStats from '@/components/dashboard/ContentStats'
import TopReferrers from '@/components/dashboard/TopReferrers'
import Locations from '@/components/dashboard/Locations'
import TechSpecs from '@/components/dashboard/TechSpecs'
import { useState } from 'react'
// ─── Fake Data ───────────────────────────────────────────────────────
const FAKE_STATS = { pageviews: 8432, visitors: 2847, bounce_rate: 42, avg_duration: 154 }
const FAKE_PREV_STATS = { pageviews: 7821, visitors: 2543, bounce_rate: 45, avg_duration: 134 }
const FAKE_DAILY_STATS = [
{ date: '2026-03-21 00:00:00', pageviews: 42, visitors: 26, bounce_rate: 46, avg_duration: 118 },
{ date: '2026-03-21 01:00:00', pageviews: 38, visitors: 24, bounce_rate: 47, avg_duration: 115 },
{ date: '2026-03-21 02:00:00', pageviews: 35, visitors: 22, bounce_rate: 47, avg_duration: 112 },
{ date: '2026-03-21 03:00:00', pageviews: 34, visitors: 21, bounce_rate: 48, avg_duration: 110 },
{ date: '2026-03-21 04:00:00', pageviews: 36, visitors: 23, bounce_rate: 47, avg_duration: 112 },
{ date: '2026-03-21 05:00:00', pageviews: 45, visitors: 29, bounce_rate: 46, avg_duration: 116 },
{ date: '2026-03-21 06:00:00', pageviews: 62, visitors: 40, bounce_rate: 45, avg_duration: 122 },
{ date: '2026-03-21 07:00:00', pageviews: 95, visitors: 62, bounce_rate: 43, avg_duration: 132 },
{ date: '2026-03-21 08:00:00', pageviews: 148, visitors: 98, bounce_rate: 41, avg_duration: 145 },
{ date: '2026-03-21 09:00:00', pageviews: 215, visitors: 145, bounce_rate: 39, avg_duration: 155 },
{ date: '2026-03-21 10:00:00', pageviews: 285, visitors: 192, bounce_rate: 38, avg_duration: 162 },
{ date: '2026-03-21 11:00:00', pageviews: 338, visitors: 228, bounce_rate: 37, avg_duration: 168 },
{ date: '2026-03-21 12:00:00', pageviews: 355, visitors: 240, bounce_rate: 38, avg_duration: 165 },
{ date: '2026-03-21 13:00:00', pageviews: 372, visitors: 252, bounce_rate: 37, avg_duration: 170 },
{ date: '2026-03-21 14:00:00', pageviews: 390, visitors: 265, bounce_rate: 36, avg_duration: 175 },
{ date: '2026-03-21 15:00:00', pageviews: 385, visitors: 260, bounce_rate: 36, avg_duration: 173 },
{ date: '2026-03-21 16:00:00', pageviews: 362, visitors: 245, bounce_rate: 37, avg_duration: 168 },
{ date: '2026-03-21 17:00:00', pageviews: 325, visitors: 218, bounce_rate: 38, avg_duration: 162 },
{ date: '2026-03-21 18:00:00', pageviews: 282, visitors: 190, bounce_rate: 40, avg_duration: 155 },
{ date: '2026-03-21 19:00:00', pageviews: 238, visitors: 160, bounce_rate: 41, avg_duration: 148 },
{ date: '2026-03-21 20:00:00', pageviews: 195, visitors: 132, bounce_rate: 42, avg_duration: 140 },
{ date: '2026-03-21 21:00:00', pageviews: 155, visitors: 105, bounce_rate: 43, avg_duration: 132 },
{ date: '2026-03-21 22:00:00', pageviews: 112, visitors: 75, bounce_rate: 44, avg_duration: 125 },
{ date: '2026-03-21 23:00:00', pageviews: 72, visitors: 46, bounce_rate: 45, avg_duration: 120 },
]
const FAKE_TOP_PAGES = [
{ path: '/', pageviews: 2341, visits: 1892 },
{ path: '/products/pulse', pageviews: 1567, visits: 1234 },
{ path: '/products/drop', pageviews: 987, visits: 812 },
{ path: '/pricing', pageviews: 876, visits: 723 },
{ path: '/blog/privacy-first-analytics', pageviews: 654, visits: 543 },
{ path: '/about', pageviews: 432, visits: 367 },
{ path: '/docs/getting-started', pageviews: 389, visits: 312 },
{ path: '/blog/end-to-end-encryption', pageviews: 345, visits: 289 },
{ path: '/contact', pageviews: 287, visits: 234 },
{ path: '/careers', pageviews: 198, visits: 167 },
]
const FAKE_ENTRY_PAGES = [
{ path: '/', pageviews: 1987, visits: 1654 },
{ path: '/products/pulse', pageviews: 1123, visits: 987 },
{ path: '/blog/privacy-first-analytics', pageviews: 567, visits: 489 },
{ path: '/products/drop', pageviews: 534, visits: 456 },
{ path: '/pricing', pageviews: 423, visits: 378 },
{ path: '/docs/getting-started', pageviews: 312, visits: 267 },
{ path: '/about', pageviews: 234, visits: 198 },
{ path: '/blog/end-to-end-encryption', pageviews: 198, visits: 167 },
{ path: '/careers', pageviews: 145, visits: 123 },
{ path: '/contact', pageviews: 112, visits: 98 },
]
const FAKE_EXIT_PAGES = [
{ path: '/pricing', pageviews: 1456, visits: 1234 },
{ path: '/', pageviews: 1234, visits: 1087 },
{ path: '/contact', pageviews: 876, visits: 756 },
{ path: '/products/drop', pageviews: 654, visits: 543 },
{ path: '/products/pulse', pageviews: 567, visits: 478 },
{ path: '/docs/getting-started', pageviews: 432, visits: 367 },
{ path: '/about', pageviews: 345, visits: 289 },
{ path: '/blog/privacy-first-analytics', pageviews: 298, visits: 245 },
{ path: '/careers', pageviews: 234, visits: 198 },
{ path: '/blog/end-to-end-encryption', pageviews: 178, visits: 145 },
]
const FAKE_REFERRERS = [
{ referrer: 'google.com', pageviews: 3421 },
{ referrer: '(direct)', pageviews: 2100 },
{ referrer: 'twitter.com', pageviews: 876 },
{ referrer: 'github.com', pageviews: 654 },
{ referrer: 'reddit.com', pageviews: 432 },
{ referrer: 'producthunt.com', pageviews: 312 },
{ referrer: 'news.ycombinator.com', pageviews: 267 },
{ referrer: 'linkedin.com', pageviews: 198 },
{ referrer: 'duckduckgo.com', pageviews: 112 },
{ referrer: 'dev.to', pageviews: 78 },
]
const FAKE_COUNTRIES = [
{ country: 'CH', pageviews: 2534 },
{ country: 'DE', pageviews: 1856 },
{ country: 'US', pageviews: 1234 },
{ country: 'FR', pageviews: 876 },
{ country: 'GB', pageviews: 654 },
{ country: 'NL', pageviews: 432 },
{ country: 'AT', pageviews: 312 },
{ country: 'SE', pageviews: 198 },
{ country: 'JP', pageviews: 156 },
{ country: 'CA', pageviews: 134 },
]
const FAKE_CITIES = [
{ city: 'Zurich', country: 'CH', pageviews: 1234 },
{ city: 'Geneva', country: 'CH', pageviews: 678 },
{ city: 'Berlin', country: 'DE', pageviews: 567 },
{ city: 'Munich', country: 'DE', pageviews: 432 },
{ city: 'San Francisco', country: 'US', pageviews: 345 },
{ city: 'Paris', country: 'FR', pageviews: 312 },
{ city: 'London', country: 'GB', pageviews: 289 },
{ city: 'Amsterdam', country: 'NL', pageviews: 234 },
{ city: 'Vienna', country: 'AT', pageviews: 198 },
{ city: 'New York', country: 'US', pageviews: 178 },
]
const FAKE_REGIONS = [
{ region: 'Zurich', country: 'CH', pageviews: 1567 },
{ region: 'Geneva', country: 'CH', pageviews: 734 },
{ region: 'Bavaria', country: 'DE', pageviews: 523 },
{ region: 'Berlin', country: 'DE', pageviews: 489 },
{ region: 'California', country: 'US', pageviews: 456 },
{ region: 'Ile-de-France', country: 'FR', pageviews: 345 },
{ region: 'England', country: 'GB', pageviews: 312 },
{ region: 'North Holland', country: 'NL', pageviews: 267 },
{ region: 'Bern', country: 'CH', pageviews: 234 },
{ region: 'New York', country: 'US', pageviews: 198 },
]
const FAKE_BROWSERS = [
{ browser: 'Chrome', pageviews: 5234 },
{ browser: 'Firefox', pageviews: 1518 },
{ browser: 'Safari', pageviews: 987 },
{ browser: 'Edge', pageviews: 456 },
{ browser: 'Brave', pageviews: 178 },
{ browser: 'Arc', pageviews: 59 },
]
const FAKE_OS = [
{ os: 'macOS', pageviews: 3421 },
{ os: 'Windows', pageviews: 2567 },
{ os: 'Linux', pageviews: 1234 },
{ os: 'iOS', pageviews: 756 },
{ os: 'Android', pageviews: 454 },
]
const FAKE_DEVICES = [
{ device: 'Desktop', pageviews: 5876 },
{ device: 'Mobile', pageviews: 1987 },
{ device: 'Tablet', pageviews: 569 },
]
const FAKE_SCREEN_RESOLUTIONS = [
{ screen_resolution: '1920x1080', pageviews: 2345 },
{ screen_resolution: '1440x900', pageviews: 1567 },
{ screen_resolution: '2560x1440', pageviews: 1234 },
{ screen_resolution: '1366x768', pageviews: 876 },
{ screen_resolution: '3840x2160', pageviews: 654 },
{ screen_resolution: '1536x864', pageviews: 432 },
{ screen_resolution: '390x844', pageviews: 312 },
{ screen_resolution: '393x873', pageviews: 234 },
]
// ─── Component ───────────────────────────────────────────────────────
export default function DashboardDemo() {
const [todayInterval, setTodayInterval] = useState<'minute' | 'hour'>('hour')
const [multiDayInterval, setMultiDayInterval] = useState<'hour' | 'day'>('day')
const today = new Date().toISOString().split('T')[0]
const dateRange = { start: today, end: today }
const noop = () => {}
return (
<div className="relative">
{/* Orange glow behind */}
<div className="absolute -inset-8 bg-brand-orange/8 rounded-[2.5rem] blur-3xl" />
{/* Outer frame with showcase bg */}
<div className="relative rounded-3xl border border-white/[0.08] overflow-hidden p-5 sm:p-8 lg:p-10">
<img src="/pulse-showcase-bg.png" alt="" className="absolute inset-0 w-full h-full object-cover" />
<div className="absolute inset-0 bg-black/40" />
{/* Inner dashboard — solid background */}
<div className="relative rounded-2xl bg-neutral-950/80 backdrop-blur-sm p-4 sm:p-6">
{/* Dashboard header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
<div>
<h2 className="text-xl font-bold text-white">Ciphera</h2>
<p className="text-sm text-neutral-400">ciphera.net</p>
</div>
<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 className="relative inline-flex rounded-full h-2 w-2 bg-green-500" />
</span>
<span className="text-sm font-medium text-green-400">12 current visitors</span>
</div>
</div>
<div className="px-4 py-2 rounded-lg bg-neutral-900/80 border border-white/[0.08] text-sm text-neutral-300">
Today
</div>
</div>
{/* Chart with stats */}
<div className="mb-6">
<Chart
data={FAKE_DAILY_STATS}
stats={FAKE_STATS}
prevStats={FAKE_PREV_STATS}
interval={todayInterval}
dateRange={dateRange}
period="today"
todayInterval={todayInterval}
setTodayInterval={setTodayInterval}
multiDayInterval={multiDayInterval}
setMultiDayInterval={setMultiDayInterval}
/>
</div>
{/* 2-col grid: Pages + Referrers */}
<div className="grid gap-6 lg:grid-cols-2 mb-6 [&>*]:min-w-0">
<ContentStats
topPages={FAKE_TOP_PAGES}
entryPages={FAKE_ENTRY_PAGES}
exitPages={FAKE_EXIT_PAGES}
domain="ciphera.net"
collectPagePaths={true}
siteId="demo"
dateRange={dateRange}
onFilter={noop}
/>
<TopReferrers
referrers={FAKE_REFERRERS}
collectReferrers={true}
siteId="demo"
dateRange={dateRange}
onFilter={noop}
/>
</div>
{/* 2-col grid: Locations + Tech */}
<div className="grid gap-6 lg:grid-cols-2 [&>*]:min-w-0">
<Locations
countries={FAKE_COUNTRIES}
cities={FAKE_CITIES}
regions={FAKE_REGIONS}
geoDataLevel="full"
siteId="demo"
dateRange={dateRange}
onFilter={noop}
/>
<TechSpecs
browsers={FAKE_BROWSERS}
os={FAKE_OS}
devices={FAKE_DEVICES}
screenResolutions={FAKE_SCREEN_RESOLUTIONS}
collectDeviceInfo={true}
collectScreenResolution={true}
siteId="demo"
dateRange={dateRange}
onFilter={noop}
/>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,169 @@
'use client';
import React, { useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { Plus } from '@phosphor-icons/react';
import { cn } from '@/lib/utils';
interface FAQItem {
question: string;
answer: string;
}
interface FAQProps extends React.HTMLAttributes<HTMLElement> {
title?: string;
subtitle?: string;
categories: Record<string, string>;
faqData: Record<string, FAQItem[]>;
}
export const FAQ = ({
title = "FAQs",
subtitle = "Frequently Asked Questions",
categories,
faqData,
className,
...props
}: FAQProps) => {
const categoryKeys = Object.keys(categories);
const [selectedCategory, setSelectedCategory] = useState(categoryKeys[0]);
return (
<section
className={cn(
"relative overflow-hidden bg-background px-4 py-12 text-foreground",
className
)}
{...props}
>
<FAQHeader title={title} subtitle={subtitle} />
<FAQTabs
categories={categories}
selected={selectedCategory}
setSelected={setSelectedCategory}
/>
<FAQList
faqData={faqData}
selected={selectedCategory}
/>
</section>
);
};
const FAQHeader = ({ title, subtitle }: { title: string; subtitle: string }) => (
<div className="relative z-10 flex flex-col items-center justify-center">
<span className="mb-8 bg-gradient-to-r from-primary to-primary/60 bg-clip-text font-medium text-transparent">
{subtitle}
</span>
<h2 className="mb-8 text-5xl font-bold">{title}</h2>
</div>
);
const FAQTabs = ({ categories, selected, setSelected }: { categories: Record<string, string>; selected: string; setSelected: (key: string) => void }) => (
<div className="relative z-10 flex flex-wrap items-center justify-center gap-4">
{Object.entries(categories).map(([key, label]) => (
<button
key={key}
onClick={() => setSelected(key)}
className={cn(
"relative overflow-hidden whitespace-nowrap rounded-md border px-3 py-1.5 text-sm font-medium transition-colors duration-500",
selected === key
? "border-primary text-background"
: "border-border bg-transparent text-muted-foreground hover:text-foreground"
)}
>
<span className="relative z-10">{label}</span>
<AnimatePresence>
{selected === key && (
<motion.span
initial={{ y: "100%" }}
animate={{ y: "0%" }}
exit={{ y: "100%" }}
transition={{ duration: 0.5, ease: "backIn" }}
className="absolute inset-0 z-0 bg-gradient-to-r from-primary to-primary/80"
/>
)}
</AnimatePresence>
</button>
))}
</div>
);
const FAQList = ({ faqData, selected }: { faqData: Record<string, FAQItem[]>; selected: string }) => (
<div className="mx-auto mt-12 max-w-3xl">
<AnimatePresence mode="wait">
{Object.entries(faqData).map(([category, questions]) => {
if (selected === category) {
return (
<motion.div
key={category}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
transition={{ duration: 0.5, ease: "backIn" }}
className="space-y-4"
>
{questions.map((faq, index) => (
<FAQItemComponent key={index} {...faq} />
))}
</motion.div>
);
}
return null;
})}
</AnimatePresence>
</div>
);
const FAQItemComponent = ({ question, answer }: FAQItem) => {
const [isOpen, setIsOpen] = useState(false);
return (
<motion.div
animate={isOpen ? "open" : "closed"}
className={cn(
"rounded-xl border transition-colors",
isOpen ? "bg-muted/50" : "bg-card"
)}
>
<button
onClick={() => setIsOpen(!isOpen)}
className="flex w-full items-center justify-between gap-4 p-4 text-left"
>
<span
className={cn(
"text-lg font-medium transition-colors",
isOpen ? "text-foreground" : "text-muted-foreground"
)}
>
{question}
</span>
<motion.span
variants={{
open: { rotate: "45deg" },
closed: { rotate: "0deg" },
}}
transition={{ duration: 0.2 }}
>
<Plus
className={cn(
"h-5 w-5 transition-colors",
isOpen ? "text-foreground" : "text-muted-foreground"
)}
/>
</motion.span>
</button>
<motion.div
initial={false}
animate={{
height: isOpen ? "auto" : "0px",
marginBottom: isOpen ? "16px" : "0px"
}}
transition={{ duration: 0.3, ease: "easeInOut" }}
className="overflow-hidden px-4"
>
<p className="text-muted-foreground">{answer}</p>
</motion.div>
</motion.div>
);
};

View File

@@ -0,0 +1,210 @@
'use client'
import { motion } from 'framer-motion'
import { Check } from '@phosphor-icons/react'
import { PulseMockup } from './mockups/pulse-mockup'
import { PulseFeaturesCarousel } from './mockups/pulse-features-carousel'
import { FunnelMockup } from './mockups/funnel-mockup'
import { EmailReportMockup } from './mockups/email-report-mockup'
// Section wrapper component for reuse
function FeatureSection({
id,
heading,
description,
features,
mockup,
reverse = false,
showBg = true,
}: {
id: string
heading: string
description: string
features: string[]
mockup: React.ReactNode
reverse?: boolean
showBg?: boolean
}) {
return (
<section id={id} className="container mx-auto px-6 scroll-mt-28">
<div className={`grid lg:grid-cols-2 gap-12 lg:gap-20 items-center`}>
{/* Text */}
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
className={reverse ? 'lg:order-last' : ''}
>
<h2 className="text-3xl sm:text-4xl md:text-5xl font-bold text-white leading-tight mb-6">
{heading}
</h2>
<p className="text-lg text-neutral-400 leading-relaxed mb-6">
{description}
</p>
<ul className="space-y-3 mb-8">
{features.map((item) => (
<li key={item} className="flex gap-3 text-neutral-300">
<Check weight="bold" className="w-5 h-5 text-brand-orange mt-0.5 shrink-0" />
<span>{item}</span>
</li>
))}
</ul>
</motion.div>
{/* Mockup container */}
<motion.div
initial={{ opacity: 0, y: 40 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.1 }}
className={`relative ${reverse ? 'lg:order-first' : ''}`}
>
{showBg && <div className="absolute -inset-8 bg-brand-orange/8 rounded-[2.5rem] blur-3xl" />}
<div className={`relative rounded-3xl overflow-hidden border border-white/[0.08] ${showBg ? '' : 'bg-neutral-900/80'}`}>
{showBg && (
<>
<img src="/pulse-showcase-bg.png" alt="" className="absolute inset-0 w-full h-full object-cover" />
<div className="absolute inset-0 bg-black/30" />
</>
)}
<div className="relative">
{mockup}
</div>
</div>
</motion.div>
</div>
</section>
)
}
export default function FeatureSections() {
return (
<div className="py-20 lg:py-32 space-y-28">
{/* Section 1: Dashboard — text left, mockup right */}
<FeatureSection
id="dashboard"
heading="Your traffic, at a glance."
description="Get a clear, real-time overview of your website's performance without the clutter of traditional analytics tools."
features={[
'Live visitor count with real-time updates',
'Hourly, daily, weekly, and monthly trends',
'Referrer sources and UTM campaign tracking',
'Country-level geographic breakdown',
]}
mockup={
<div className="p-6 sm:p-10">
<PulseMockup />
</div>
}
/>
{/* Section 2: Visitors — mockup left, text right */}
<FeatureSection
id="visitors"
heading="Everything you need to know about your visitors."
description="Understand where your traffic comes from, what content resonates, and how visitors interact with your site — all without compromising their privacy."
features={[
'Top pages ranked by views and unique visitors',
'Referrer breakdown with source attribution',
'Browser, OS, and device analytics',
'Peak hours heatmap for optimal publishing',
]}
reverse
mockup={
<div className="p-6 sm:p-10 min-h-[500px] flex items-center">
<div className="w-full">
<PulseFeaturesCarousel />
</div>
</div>
}
/>
{/* Section 3: Funnels — text left, mockup right */}
<FeatureSection
id="funnels"
heading="See where visitors drop off."
description="Build custom conversion funnels to understand your user journey. Identify bottlenecks and optimize your conversion flow."
features={[
'Multi-step funnels with conversion rates',
'Drop-off analysis between each step',
'Conversion trends over time',
'Breakdown by device, country, or referrer',
'Configurable conversion window (up to 90 days)',
]}
mockup={
<div className="p-6 sm:p-10">
<FunnelMockup />
</div>
}
/>
{/* Section 4: Reports — mockup left, text right */}
<FeatureSection
id="reports"
heading="Reports delivered to your inbox."
description="Get automated summaries of your site's performance without logging into a dashboard. Stay informed effortlessly."
features={[
'Daily, weekly, or monthly email summaries',
'Key metrics with period-over-period comparison',
'Top pages, referrers, and country breakdown',
'Webhook delivery for custom integrations',
'Multiple recipients per report',
]}
reverse
mockup={
<div className="p-6 sm:p-10">
<EmailReportMockup />
</div>
}
/>
{/* Section 5: Script — text left, code block right (no showcase bg) */}
<FeatureSection
id="script"
showBg={false}
heading="One script tag. That's it."
description="No npm packages, no build steps, no configuration files. Add a single line to your HTML and start collecting privacy-respecting analytics instantly."
features={[
'Under 2KB gzipped — 20x smaller than Google Analytics',
'Async loading with defer — never blocks rendering',
'Works with any framework or static site',
]}
mockup={
<div className="p-0">
{/* Code block with browser chrome */}
<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" />
<div className="w-3 h-3 rounded-full bg-yellow-500/20" />
<div className="w-3 h-3 rounded-full bg-green-500/20" />
</div>
<span className="ml-4 text-xs text-neutral-400 font-mono">index.html</span>
</div>
<pre className="p-6 overflow-x-auto">
<code className="font-mono text-sm text-neutral-300">
<span className="text-neutral-500">{'<!-- Add before </head> -->'}</span>{'\n'}
<span className="text-blue-400">{'<'}</span>
<span className="text-blue-400">script</span>{'\n'}
{' '}<span className="text-sky-300">defer</span>{'\n'}
{' '}<span className="text-sky-300">data-domain</span>=<span className="text-orange-300">&quot;yoursite.com&quot;</span>{'\n'}
{' '}<span className="text-sky-300">src</span>=<span className="text-orange-300">&quot;https://pulse.ciphera.net/js/script.js&quot;</span>{'\n'}
<span className="text-blue-400">{'>'}</span>
<span className="text-blue-400">{'</'}</span>
<span className="text-blue-400">script</span>
<span className="text-blue-400">{'>'}</span>
</code>
</pre>
<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>
)
}

View File

@@ -0,0 +1,309 @@
'use client';
import React from 'react';
import Link from 'next/link';
import Image from 'next/image';
import { Button } from '@/components/ui/button-website';
import { cn } from '@/lib/utils';
import { MenuToggleIcon } from '@/components/ui/menu-toggle-icon';
import { createPortal } from 'react-dom';
import {
NavigationMenu,
NavigationMenuContent,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
} from '@/components/ui/navigation-menu';
import { LucideIcon } from 'lucide-react';
import {
BarChart3,
Eye,
Funnel,
Send,
FileText,
Puzzle,
HelpCircle,
} from 'lucide-react';
type LinkItem = {
title: string;
href: string;
icon?: LucideIcon;
description?: string;
};
const featureLinks: LinkItem[] = [
{
title: 'Dashboard',
href: '/features#dashboard',
icon: BarChart3,
description: 'Real-time traffic overview',
},
{
title: 'Visitor Insights',
href: '/features#visitors',
icon: Eye,
description: 'Browser, device & geo data',
},
{
title: 'Conversion Funnels',
href: '/features#funnels',
icon: Funnel,
description: 'Multi-step drop-off analysis',
},
{
title: 'Email Reports',
href: '/features#reports',
icon: Send,
description: 'Scheduled inbox summaries',
},
];
const resourceLinks: LinkItem[] = [
{
title: 'Installation',
href: '/installation',
icon: FileText,
description: 'Setup guides & code snippets',
},
{
title: 'Integrations',
href: '/integrations',
icon: Puzzle,
description: '75+ framework guides',
},
{
title: 'FAQ',
href: '/faq',
icon: HelpCircle,
description: 'Common questions answered',
},
];
export function Header() {
const [open, setOpen] = React.useState(false);
const scrolled = useScroll(10);
React.useEffect(() => {
if (open) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
};
}, [open]);
return (
<header
className={cn('sticky top-0 z-50 w-full border-b border-transparent', {
'border-white/[0.06]': scrolled,
})}
>
<div className={cn("absolute inset-0 -z-10 transition-opacity duration-300", scrolled ? "opacity-100 backdrop-blur-xl bg-neutral-950/60 supports-[backdrop-filter]:bg-neutral-950/50" : "opacity-0")} />
<nav className="mx-auto flex h-16 w-full max-w-6xl items-center justify-between px-6 my-3">
<div className="flex items-center gap-5">
<Link href="/" className="hover:bg-accent rounded-md p-2 flex items-center gap-2">
<Image
src="/pulse_icon_no_margins.png"
alt="Pulse"
width={36}
height={36}
priority
className="object-contain w-8 h-8"
unoptimized
/>
<span className="text-xl font-bold text-foreground tracking-tight">
Pulse
</span>
</Link>
<NavigationMenu className="hidden md:flex">
<NavigationMenuList>
{/* Features dropdown */}
<NavigationMenuItem>
<NavigationMenuTrigger className="bg-transparent">Features</NavigationMenuTrigger>
<NavigationMenuContent className="bg-transparent p-1 pr-1.5">
<ul className="grid w-[32rem] grid-cols-2 gap-2 rounded-md border border-white/[0.06] bg-white/[0.04] p-2">
{featureLinks.map((item, i) => (
<li key={i}>
<ListItem title={item.title} href={item.href} icon={item.icon} description={item.description} />
</li>
))}
</ul>
</NavigationMenuContent>
</NavigationMenuItem>
{/* Resources dropdown */}
<NavigationMenuItem>
<NavigationMenuTrigger className="bg-transparent">Resources</NavigationMenuTrigger>
<NavigationMenuContent className="bg-transparent p-1 pr-1.5 pb-1.5">
<div className="grid w-[32rem] grid-cols-2 gap-2">
<ul className="space-y-2 rounded-md border border-white/[0.06] bg-white/[0.04] p-2">
{resourceLinks.map((item, i) => (
<li key={i}>
<ListItem {...item} />
</li>
))}
</ul>
<div className="flex flex-col justify-center gap-3 p-4">
<p className="text-sm font-medium text-foreground">Need help?</p>
<p className="text-xs text-muted-foreground leading-relaxed">
Questions about setup, integrations, or billing we typically respond within 24-48 hours.
</p>
<a href="mailto:support@ciphera.net" className="text-sm font-medium text-brand-orange hover:underline">
support@ciphera.net &rarr;
</a>
</div>
</div>
</NavigationMenuContent>
</NavigationMenuItem>
{/* Pricing standalone link */}
<NavigationMenuItem>
<NavigationMenuLink asChild>
<Link href="/pricing" className="group inline-flex h-9 w-max items-center justify-center rounded-md bg-transparent px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none">
Pricing
</Link>
</NavigationMenuLink>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
</div>
<div className="hidden items-center gap-2 md:flex">
<Button variant="outline" asChild>
<a href="https://pulse.ciphera.net">Sign In</a>
</Button>
<Button asChild>
<a href="https://pulse.ciphera.net">Get Started</a>
</Button>
</div>
<div className="flex items-center gap-2 md:hidden">
<Button
size="icon"
variant="outline"
onClick={() => setOpen(!open)}
aria-expanded={open}
aria-controls="mobile-menu"
aria-label="Toggle menu"
>
<MenuToggleIcon open={open} className="size-5" duration={300} />
</Button>
</div>
</nav>
<MobileMenu open={open} className="flex flex-col justify-between gap-2 overflow-y-auto">
<NavigationMenu className="max-w-full">
<div className="flex w-full flex-col gap-y-2">
<span className="text-sm">Features</span>
{featureLinks.map((link) => (
<ListItem key={link.title} title={link.title} href={link.href} icon={link.icon} description={link.description} />
))}
<span className="text-sm">Resources</span>
{resourceLinks.map((link) => (
<ListItem key={link.title} {...link} />
))}
<Link
href="/pricing"
className="flex flex-row gap-x-2 rounded-sm p-2 transition-colors hover:bg-white/[0.06]"
>
<div className="flex aspect-square size-12 items-center justify-center rounded-md border border-white/[0.08] bg-white/[0.05] shadow-sm p-2">
<BarChart3 className="text-foreground size-5" />
</div>
<div className="flex flex-col items-start justify-center">
<span className="text-sm font-medium">Pricing</span>
<span className="text-muted-foreground text-xs">Plans & billing</span>
</div>
</Link>
</div>
</NavigationMenu>
<div className="flex flex-col gap-2">
<Button variant="outline" className="w-full bg-transparent" asChild>
<a href="https://pulse.ciphera.net">
Sign In
</a>
</Button>
<Button className="w-full" asChild>
<a href="https://pulse.ciphera.net">
Get Started
</a>
</Button>
</div>
</MobileMenu>
</header>
);
}
type MobileMenuProps = React.ComponentProps<'div'> & {
open: boolean;
};
function MobileMenu({ open, children, className, ...props }: MobileMenuProps) {
if (!open || typeof window === 'undefined') return null;
return createPortal(
<div
id="mobile-menu"
className={cn(
'bg-background/95 supports-[backdrop-filter]:bg-background/50 backdrop-blur-lg',
'fixed top-16 right-0 bottom-0 left-0 z-40 flex flex-col overflow-hidden border-y md:hidden',
)}
>
<div
data-slot={open ? 'open' : 'closed'}
className={cn(
'data-[slot=open]:animate-in data-[slot=open]:zoom-in-95 ease-out',
'size-full p-4',
className,
)}
{...props}
>
{children}
</div>
</div>,
document.body,
);
}
function ListItem({
title,
description,
icon: Icon,
className,
href,
...props
}: React.ComponentProps<typeof NavigationMenuLink> & LinkItem) {
return (
<NavigationMenuLink className={cn('w-full flex flex-row gap-x-2 data-[active=true]:focus:bg-white/[0.06] data-[active=true]:hover:bg-white/[0.06] data-[active=true]:text-accent-foreground hover:bg-white/[0.06] hover:text-accent-foreground focus:bg-white/[0.06] focus:text-accent-foreground rounded-sm p-2 transition-colors', className)} {...props} asChild>
<Link href={href || '#'}>
<div className="flex aspect-square size-12 items-center justify-center rounded-md border border-white/[0.08] bg-white/[0.05] shadow-sm p-2">
{Icon ? (
<Icon className="text-foreground size-5" />
) : null}
</div>
<div className="flex flex-col items-start justify-center">
<span className="text-sm font-medium">{title}</span>
<span className="text-muted-foreground text-xs">{description}</span>
</div>
</Link>
</NavigationMenuLink>
);
}
function useScroll(threshold: number) {
const [scrolled, setScrolled] = React.useState(false);
const onScroll = React.useCallback(() => {
setScrolled(window.scrollY > threshold);
}, [threshold]);
React.useEffect(() => {
window.addEventListener('scroll', onScroll);
return () => window.removeEventListener('scroll', onScroll);
}, [onScroll]);
React.useEffect(() => {
onScroll();
}, [onScroll]);
return scrolled;
}

Some files were not shown because too many files have changed in this diff Show More