946 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
Usman
484300c307 Merge pull request #43 from ciphera-net/staging
Release 0.15.0-alpha
2026-03-13 00:13:41 +01:00
Usman Baig
9fb19c18e8 chore: release 0.15.0-alpha 2026-03-13 00:12:13 +01:00
Usman Baig
0112004457 fix: depth default 3 max 5, min_sessions 2, 5 nodes per column, stricter labels 2026-03-12 23:58:56 +01:00
Usman Baig
063a21adeb feat: cap nodes per column, dynamic SVG height, smart labels, thinner exit node 2026-03-12 23:46:24 +01:00
Usman Baig
90de83ad6d fix: lower min_sessions from 3 to 1 for journey data 2026-03-12 23:30:12 +01:00
Usman Baig
a3fa48732a fix: correct ribbon y-offset — d3-sankey y0/y1 are center, not top 2026-03-12 23:20:33 +01:00
Usman Baig
a637d32446 revert: remove frontend same-page filter, backend fix handles this 2026-03-12 23:12:12 +01:00
Usman Baig
df394b85ef fix: filter out same-page transitions (reloads) from Sankey 2026-03-12 23:08:30 +01:00
Usman Baig
4e7c495160 fix: use SVG-level onMouseMove with data attrs for reliable hover 2026-03-12 23:04:08 +01:00
Usman Baig
9c8943d1e3 fix: rewrite hover state to single object, fix link dimming on node hover 2026-03-12 22:56:13 +01:00
Usman Baig
e7debdeb41 fix: consolidate exit nodes into single (exit) node 2026-03-12 22:52:24 +01:00
Usman Baig
3df93bb227 fix: link color from source node, fix hover dimming, labels trigger hover 2026-03-12 22:48:31 +01:00
Usman Baig
3bde3fd4e1 fix: only highlight links on node hover, not other nodes 2026-03-12 22:40:40 +01:00
Usman Baig
5cdf353233 feat: add node hover highlighting with connection dimming 2026-03-12 22:37:40 +01:00
Usman Baig
683bbce817 fix: thick node bars, multi-hue palette, higher link opacity, more padding 2026-03-12 22:31:05 +01:00
Usman Baig
828e930a69 fix: Sankey visual overhaul — lower link opacity, column color gradient, breathing room
- Links: 18% opacity default (was 60%), 45% on hover, grey for exit links
- Nodes: column-based orange gradient (bright entry → dark deep), stroke outline
- Labels: larger font, better padding, higher contrast backgrounds
- Layout: more vertical padding, wider node gap (24px)
2026-03-12 22:23:52 +01:00
Usman Baig
54daf14c6a feat: replace MUI X Charts Pro with d3-sankey custom Sankey
Remove paid MUI dependency. Use d3-sankey (MIT, ~5KB) for layout
algorithm + custom SVG rendering. Same visual quality: smooth bezier
ribbon links, proper node spacing via sankeyJustify, label backgrounds,
hover dimming, exit nodes.
2026-03-12 22:17:16 +01:00
Usman Baig
281a9f237a feat: replace custom Sankey SVG with MUI X Charts Pro Sankey 2026-03-12 22:07:20 +01:00
Usman Baig
4b10f8c1fc fix: refine Sankey visual — thinner nodes, subtle links, orange on hover 2026-03-12 21:58:50 +01:00
Usman Baig
31286c45f4 fix: use brand orange for Sankey diagram nodes and links 2026-03-12 21:51:07 +01:00
Usman Baig
908606ade2 fix: make journey empty states consistent with dashboard blocks 2026-03-12 21:49:31 +01:00
Usman Baig
4cd9544672 fix(journeys): use correct session_count property in entry point dropdown 2026-03-12 21:39:31 +01:00
Usman Baig
49aa8aae60 docs: update frontend changelog for user journeys 2026-03-12 21:37:07 +01:00
Usman Baig
b3e335ec6c feat(journeys): add skeleton and error boundary 2026-03-12 21:36:27 +01:00
Usman Baig
e7e76bb3db feat(journeys): add Journeys tab to site navigation 2026-03-12 21:35:43 +01:00
Usman Baig
dc1030036c feat(journeys): add Journeys page with controls and layout 2026-03-12 21:35:05 +01:00
Usman Baig
0fa6c4aaf4 feat(journeys): add top paths table component 2026-03-12 21:32:18 +01:00
Usman Baig
c669035718 feat(journeys): add Sankey diagram SVG component 2026-03-12 21:29:45 +01:00
Usman Baig
7336f9126e feat(journeys): add frontend API client and SWR hooks 2026-03-12 21:24:39 +01:00
Usman Baig
6964be9610 refactor: remove realtime visitors detail page
Remove the individual session journey page and make the live visitor
count a static indicator. Prepares for the new aggregated User Journeys
feature (v0.17).
2026-03-12 20:45:58 +01:00
Usman Baig
bae492e8d9 style: show only percentage badge on hover in frustration tables 2026-03-12 20:31:21 +01:00
Usman Baig
03e3f41e48 refactor: use bundled /behavior endpoint via useBehavior SWR hook
Replaces 4 separate frustration API calls with single useBehavior hook.
Removes manual fetchData, loading/error state, and refresh interval—SWR
handles caching, revalidation, and error state automatically.
2026-03-12 20:24:28 +01:00
Usman Baig
eb17e8e8d6 fix: hide scroll depth and trend chart when rate-limited
Frustration APIs and dashboard API are separate — when frustration
calls fail, Scroll Depth still rendered from cached SWR data,
creating a broken mixed state. Now tracks error state and hides
the bottom section entirely on failure.
2026-03-12 18:35:36 +01:00
Usman Baig
540c774100 fix: custom tooltip with inline fill color, dynamic subtitle
- Replace ChartTooltipContent with custom tooltip that reads fill
  directly from payload — guaranteed to show the slice color
- Subtitle adapts: shows 'current and previous period' only when
  previous period data exists, otherwise 'rage vs dead breakdown'
- Filter out zero-count slices from chart data
2026-03-12 18:29:43 +01:00
Usman Baig
3bf832af92 style: use transparent orange tones for frustration pie chart
Rage clicks: warm orange at 70% opacity
Dead clicks: darker amber at 70% opacity
Previous period: same hues at 35% opacity
2026-03-12 18:27:57 +01:00
Usman Baig
5050422a60 refactor: match frustration tables to dashboard pattern
- Remove column headers for cleaner look
- Show secondary info (avg, sessions, last seen) on hover
- Add orange percentage badge that slides in on hover
- Add empty row padding for consistent card height
2026-03-12 18:27:20 +01:00
Usman Baig
13f6f53868 fix: tooltip indicator dot not showing slice color
bg-[--color-bg] doesn't resolve in Tailwind v4 — needs var() wrapper.
Changed to bg-[var(--color-bg)] and border-[var(--color-border)].
2026-03-12 18:25:15 +01:00
Usman Baig
bf7fe87120 fix: use direct hex colors for pie chart tooltip and distinct color palette 2026-03-12 18:21:19 +01:00
Usman Baig
d4dc45e82b fix: align table headers with row data using CSS grid
- Switch FrustrationTable from flex to grid columns so headers
  and row cells share the same column widths
- Replace bar chart with pie chart for frustration trend
- Remove Card wrapper borders, footer line, and total signals text
- Change dead clicks color from yellow to darker orange
2026-03-12 18:16:12 +01:00
Usman Baig
0889079372 refactor: replace bar chart with pie chart for frustration trend 2026-03-12 18:09:34 +01:00
Usman Baig
2f01be1c67 feat: polish behavior page UI with 8 improvements
- Add column headers to rage/dead click tables
- Rich empty states with icons matching dashboard pattern
- Add frustration trend comparison chart (current vs previous period)
- Show "New" badge instead of misleading "+100%" when previous period is 0
- Click-to-copy on CSS selectors with toast feedback
- Normalize min-height to 270px for consistent card sizing
- Fix page title to include site domain (Behavior · domain | Pulse)
- Add "last seen" column with relative timestamps
2026-03-12 18:03:22 +01:00
Usman Baig
585f37f444 docs: add rage click and dead click detection to changelog 2026-03-12 17:06:36 +01:00
Usman Baig
1f64bec46d fix: correct summary card label and skip MutationObserver on html/body 2026-03-12 17:02:52 +01:00
Usman Baig
9179e058f7 refactor: move scroll depth from dashboard to behavior tab 2026-03-12 16:56:26 +01:00
Usman Baig
d5aafdc48a feat: add behavior page shell 2026-03-12 16:56:00 +01:00
Usman Baig
062d0a2b44 feat: add frustration by page breakdown component 2026-03-12 16:55:01 +01:00
Usman Baig
46084b71a6 feat: add frustration table component with view-all modal 2026-03-12 16:54:38 +01:00
Usman Baig
a00042c557 feat: add frustration summary cards component 2026-03-12 16:53:47 +01:00
Usman Baig
c17a856224 feat: add Behavior tab to site navigation 2026-03-12 16:53:12 +01:00
Usman Baig
953762075b feat: add frustration signal API types and fetch functions 2026-03-12 16:53:05 +01:00
Usman Baig
fb47716711 fix: use hasAttribute for data-no-rage and data-no-dead opt-out checks 2026-03-12 16:50:20 +01:00
Usman Baig
247a0b3460 feat: add dead click detection to tracking script 2026-03-12 16:47:53 +01:00
Usman Baig
9e6e2a2214 feat: add rage click detection to tracking script 2026-03-12 16:47:15 +01:00
Usman Baig
b05f7bbcf6 feat: add element identifier function for frustration tracking 2026-03-12 16:46:32 +01:00
Usman Baig
1417c952c6 docs: update changelog with time-of-day report scheduling 2026-03-12 16:10:07 +01:00
Usman Baig
a22333bbc2 fix: bump @ciphera-net/ui to 0.2.5 2026-03-12 15:25:05 +01:00
Usman Baig
27a9836d5a feat: add time-of-day controls to scheduled reports UI
Add send hour, day of week/month selectors to report schedule modal.
Schedule cards now show descriptive delivery times like
"Every Monday at 9:00 AM (UTC)". Timezone picker moved into modal.
2026-03-12 15:17:46 +01:00
Usman Baig
c6ec4671a4 fix: match report_schedules JSON key from backend response 2026-03-12 14:50:51 +01:00
Usman Baig
acf7b16dde docs: add scheduled reports to changelog 2026-03-12 14:44:06 +01:00
Usman Baig
31aff95552 feat: add Reports tab to site settings with schedule CRUD 2026-03-12 14:33:05 +01:00
Usman Baig
d728b49f67 feat: add report schedules API client module 2026-03-12 14:33:05 +01:00
Usman Baig
eeb46affda docs: add region name fix to changelog 2026-03-12 13:37:36 +01:00
Usman
cf5fbb6f8e Merge pull request #42 from ciphera-net/staging
Release 0.14.0-alpha
2026-03-12 13:12:03 +01:00
Usman Baig
bb9e907a50 chore: bump version to 0.14.0-alpha and finalize changelog
Move all unreleased entries into 0.14.0-alpha. Recategorize
Instagram attribution and OS parsing fixes from Improved to Fixed.
2026-03-12 13:05:40 +01:00
Usman Baig
7fe8c3818f docs: add changelog entries for Instagram attribution and OS parsing fixes 2026-03-12 12:36:13 +01:00
Usman Baig
3fc0dec9d9 fix: show branded icons for UA-inferred referrers instead of broken favicons
Plain name referrers like "Instagram" or "WhatsApp" (inferred from
User-Agent) were being passed to the favicon service as invalid
domains, returning a generic globe. Now skips favicon fetch for
any referrer without a dot, falling through to Phosphor icons.
2026-03-12 12:31:21 +01:00
Usman Baig
7bd922a012 feat: add shared link referrer icon and new social platform icons
Add branded icons for WhatsApp, Telegram, Snapchat, Pinterest, Threads.
Add link icon for new "Shared Link" referrer category. Update changelog.
2026-03-12 12:08:07 +01:00
Usman Baig
7e91e08532 feat: 2-hour bucket grid for larger square cells 2026-03-12 00:33:56 +01:00
Usman Baig
cb6c03432c fix: use CSS grid with aspect-square for square heatmap cells 2026-03-12 00:30:23 +01:00
Usman Baig
bc299fe9a0 fix: increase Peak Hours grid row height 2026-03-12 00:27:17 +01:00
Usman Baig
632530af7f feat: replace heatmap grid with CommitsGrid-style animated cells 2026-03-12 00:20:04 +01:00
Usman Baig
ffbfcf342f feat: fix cell visibility, add thermal blob mode & peak cell pulse 2026-03-12 00:13:47 +01:00
Usman Baig
602f7350b8 fix: remove row/column dim highlight on Peak Hours hover 2026-03-12 00:09:09 +01:00
Usman Baig
c15737b9c6 feat: interactive Peak Hours heatmap
- Row + column highlight on cell hover with dim effect
- Cell-anchored tooltip showing pageviews and % of week's traffic
- Best time callout: "Your busiest time is Xdays at Ypm"
- Staggered entrance animation on data load
2026-03-12 00:05:46 +01:00
Usman Baig
a189952fad feat: add Peak Hours heatmap dashboard panel 2026-03-11 23:59:22 +01:00
Usman Baig
428a6fd18d chore: bump @ciphera-net/ui to 0.2.4 2026-03-11 23:48:27 +01:00
Usman Baig
136ceff962 feat: add dividers to period selector dropdown 2026-03-11 23:47:52 +01:00
Usman Baig
eb872dbc5a docs: add missing changelog entries for block height fix and goals panel correction 2026-03-11 23:42:49 +01:00
Usman Baig
956cfbcf35 feat: animate active metric indicator with spring slide 2026-03-11 23:38:04 +01:00
Usman Baig
b5dd5e7082 feat: add This week / This month period options and fix comparison labels 2026-03-11 23:33:24 +01:00
Usman Baig
34eca64967 fix: correct off-by-one in comparison period label 2026-03-11 23:23:39 +01:00
Usman Baig
1c5ca7fa54 fix: active metric label white and slightly smaller 2026-03-11 23:15:59 +01:00
Usman Baig
275503ae8f fix: show dynamic comparison period label in stat headers 2026-03-11 23:14:35 +01:00
Usman Baig
73db65c0b2 feat: redesign chart stat headers and fix badge semantic colors 2026-03-11 23:10:16 +01:00
Usman Baig
0754cb0e4f fix: align Goals & Events and Scroll Depth block height with other dashboard blocks 2026-03-11 22:52:14 +01:00
Usman Baig
1ba6bf6a84 fix: add subtitle to scroll depth radar chart 2026-03-11 22:49:54 +01:00
Usman Baig
72011dea5c fix: enlarge scroll depth radar chart 2026-03-11 22:48:37 +01:00
Usman Baig
7431f2b78d fix: increase radar fill opacity to cover grid lines 2026-03-11 22:45:36 +01:00
Usman Baig
bf37add366 revert: restore radar chart for scroll depth (4 axes, no 0% anchor) 2026-03-11 22:42:32 +01:00
Usman Baig
ca60379e5e feat: replace radar with clean bar chart for scroll depth 2026-03-11 22:36:55 +01:00
Usman Baig
b30619e6b4 fix: add 0% baseline axis to scroll depth radar for pentagon shape 2026-03-11 22:30:19 +01:00
Usman Baig
0f5d5338f3 fix: make scroll depth block half-width and enlarge radar chart 2026-03-11 22:26:15 +01:00
Usman Baig
faa2f50d6e feat: replace scroll depth bar chart with radar chart 2026-03-11 22:22:59 +01:00
Usman Baig
55bf20c58d fix: remove rank numbers from Goals & Events panel 2026-03-11 22:19:39 +01:00
Usman Baig
2fa3540a48 feat: polish Goals & Events dashboard panel
Align visual style with Pages, Referrers, and Locations panels — flat
rows with hover state, rank numbers, muted count colors, and a
slide-in percentage on hover. Add empty slots for consistent height.
2026-03-11 22:15:59 +01:00
Usman Baig
c2d5935394 security: send X-CSRF-Token on all state-changing API requests (F-01) 2026-03-11 21:54:24 +01:00
Usman Baig
8136268988 fix: bump ciphera-ui to 0.2.3 and allow blob: in worker-src CSP
Adds blob: to worker-src so the captcha PoW web worker can run.
2026-03-11 11:53:04 +01:00
Usman Baig
15d41f5bd9 fix: bump @ciphera-net/ui to 0.2.2 (PoW difficulty fix) 2026-03-11 11:39:16 +01:00
Usman Baig
37eb49eb37 feat: action-scoped captcha tokens for share access and org settings
Captcha on shared dashboard and organization settings now passes
action-specific identifiers. Bumps @ciphera-net/ui to 0.2.1.
2026-03-11 11:30:21 +01:00
Usman Baig
3d12f35331 chore: bump @ciphera-net/ui to 0.1.4
PasswordInput now forwards the required attribute to the underlying
input element, enabling HTML5 form validation on password fields.
2026-03-10 23:55:47 +01:00
Usman Baig
205cdf314c perf: bound SWR cache, clean stale storage, cap annotations
Add LRU cache provider (200 entries) to prevent unbounded SWR memory
growth. Clean up stale PKCE localStorage keys on app init. Cap chart
annotations to 20 visible reference lines with overflow indicator.
2026-03-10 21:19:33 +01:00
Usman Baig
502f4952fc perf: lazy-load globe/map and update changelog
Globe and DottedMap now only render when the Locations section enters
the viewport via IntersectionObserver. Added changelog entries for
rate limit fallback, buffer improvements, and lazy loading.
2026-03-10 20:57:55 +01:00
Usman Baig
f10b903a80 perf: add export loading state and virtual scrolling for large lists
Export modal now shows a loading indicator and doesn't freeze the UI.
Large list modals use virtual scrolling for smooth performance.
2026-03-10 20:45:49 +01:00
Usman Baig
848bde237f docs: add faster entry/exit page stats to changelog 2026-03-10 20:25:57 +01:00
Usman Baig
835c284a6b docs: add smarter caching improvement to changelog 2026-03-10 20:13:09 +01:00
Usman Baig
beee87bd2e docs: add query timeout improvement to changelog 2026-03-10 18:45:52 +01:00
Usman Baig
bcaa5c25f8 perf: replace real-time polling with SSE streaming
Replace 5-second setInterval polling with EventSource connection to the
new /realtime/stream SSE endpoint. The server pushes visitor updates
instead of each client independently polling. Auto-reconnects on
connection drops.
2026-03-10 18:33:17 +01:00
Usman Baig
d863004d5f perf: consolidate 7 dashboard hooks into single batch request
Replace useDashboardOverview, useDashboardPages, useDashboardLocations,
useDashboardDevices, useDashboardReferrers, useDashboardPerformance, and
useDashboardGoals with a single useDashboard hook that calls the existing
/dashboard batch endpoint. This endpoint runs all queries in parallel on
the backend and caches the result in Redis (30s TTL).

Reduces dashboard requests from 12 to 6 per refresh cycle (50% reduction).
At 1,000 concurrent users: ~6,000 req/min instead of 12,000.
2026-03-10 17:55:29 +01:00
Usman Baig
00d8656ad2 Fix modal titles, hover rounding, search focus, and page filter dimension
- Remove redundant prefixes from modal titles
- Remove -mx-2 from modal rows so hover rounds evenly on both sides
- Fix page filter using wrong dimension name (path -> page)
- Bump @ciphera-net/ui to 0.1.3 (fixes search input losing focus)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 01:46:31 +01:00
Usman Baig
64a8652423 Add search bar to expanded panel modals 2026-03-10 01:34:05 +01:00
Usman Baig
a99d13309f Improve expanded modals: wider, taller, hover percentage, click-to-filter
- Widen modals from max-w-lg to max-w-2xl
- Increase max height from 60vh to 80vh
- Add hover percentage on each row (matching card behavior)
- Click any row to filter dashboard and close modal
2026-03-10 01:32:00 +01:00
Usman Baig
7aa809c8a0 Move expand icon to the right of panel titles 2026-03-10 01:25:46 +01:00
Usman Baig
ca71c1646d Move expand icon to the left of panel titles 2026-03-10 01:22:11 +01:00
Usman Baig
3587f93645 Scope 1s tick interval to Chart component to eliminate page-level re-renders
The setInterval that drives the "Live · Xs ago" display was at the page level,
forcing all 10+ dashboard components to re-render every second. Now it lives
inside Chart — the only consumer — so the rest of the dashboard is unaffected.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 01:19:34 +01:00
Usman Baig
e07fd3f0e8 Move expand icon to top-right corner of panels, make it larger
Reposition FrameCornersIcon from next to the title to the far right
of each card header (after tabs). Increase size from w-3.5 to w-4
and add hover background for better visibility and discoverability.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 01:13:03 +01:00
Usman Baig
05d13bff81 Use FrameCornersIcon for expand buttons in dashboard panels
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 01:06:56 +01:00
Usman Baig
a9f42acbf6 Use ref for lastUpdatedAt to avoid extra re-render on mount
When navigating to dashboard with cached SWR data, setLastUpdatedAt
triggered a second full render of the entire dashboard immediately
after the first. Using a ref instead avoids this — the value still
updates and Chart reads it on the next 1-second tick re-render.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 01:03:12 +01:00
Usman Baig
a60efeb6a7 Replace 'View all' buttons with expand icon in block headers
Add ArrowsOutSimpleIcon next to each panel title (Pages, Referrers,
Locations, Technology, Campaigns) that opens the full-data modal.
Remove the old text-based 'View all' button from the bottom of lists.
Update changelog with performance and UI improvements.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 01:00:09 +01:00
Usman Baig
88f02a244b Hoist DottedMap constants to module scope, static-import above-fold components
DottedMap: move createMap, stagger helpers, and base dots path to module
scope so they compute once on module load and survive unmount/remount
cycles — eliminates all recomputation when switching tabs.

Dashboard: restore static imports for the 5 above-fold components
(Chart, ContentStats, TopReferrers, Locations, TechSpecs) now that
their heavy computations are memoized. Keeps below-fold components
(PerformanceStats, GoalStats, ScrollDepth, Campaigns, etc.) dynamic.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 00:51:32 +01:00
Usman Baig
8c5b452f73 Batch 8000 SVG circles into single path element in DottedMap
Instead of rendering 8000 individual <circle> elements (each a React
node to reconcile), batch all map dots into a single <path d="...">
string. Reduces DOM nodes from ~8000 to 1 for the base map layer.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 00:47:27 +01:00
Usman Baig
5f797112ec Memoize expensive computations in Chart and Globe components
Chart: wrap chartData (Date formatting on every data point) and
metricsWithTrends in useMemo — these ran on every render.
Globe: memoize marker computation (Math.max + filter + map on every render).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 00:44:19 +01:00
Usman Baig
ae0f6b8ffa Fix dashboard and map tab lag with memoization and code splitting
Memoize createMap() in DottedMap (was regenerating 8000 SVG dots every
render) and convert 11 heavy dashboard components to next/dynamic imports
so the page shell renders instantly instead of blocking on one massive
render pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 00:39:35 +01:00
Usman Baig
4babbc7555 fix: skip skeleton when SWR has cached data + lazy-load Map and Globe 2026-03-10 00:32:07 +01:00
Usman Baig
01f6d8d065 fix: remove content crossfade animation that caused lag on heavy pages
Also configure PWA service worker to use network-first for JS chunks
to prevent ChunkLoadError after deploys.
2026-03-10 00:26:50 +01:00
Usman Baig
628749a416 feat: opacity-only page transition + sliding indicator on all sub-tabs 2026-03-10 00:18:52 +01:00
Usman Baig
b88f4d438b fix: use popLayout mode so heavy pages animate in without delay 2026-03-10 00:12:59 +01:00
Usman Baig
2776c803f1 fix: use focus-visible for all button/tab/link focus rings across app
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 00:08:09 +01:00
Usman Baig
c46d463533 fix: use focus-visible for tab nav ring so it only shows on keyboard 2026-03-09 23:45:41 +01:00
Usman Baig
6f964f38f3 feat: add sliding tab indicator and content crossfade animations
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 23:41:34 +01:00
Usman Baig
330cc134aa feat: instant tab navigation by moving SiteNav to shared layout
SiteNav now lives in the [id] layout instead of each page, so it stays
mounted during route transitions. Switching between Dashboard, Uptime,
Funnels, and Settings no longer flashes a full-page skeleton.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 23:35:06 +01:00
Usman Baig
92fae83772 chore: move site nav tabs above site header and update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 23:21:12 +01:00
Usman Baig
242c76b763 fix: reduce funnel chart size with max-w-md constraint 2026-03-09 23:12:37 +01:00
Usman Baig
9f2032fc32 Replace custom FunnelChart with 21st.dev funnel-chart component
Drops in the exact 21st.dev FunnelChart component with motion/react,
curved bezier segments, layered rings, and spring hover animations.
Removes the previous custom SVG implementation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 23:06:31 +01:00
Usman Baig
cc4f924fb8 fix: put annotations and live indicator on same row in chart footer 2026-03-09 23:03:58 +01:00
Usman Baig
5625703168 fix: move annotations and live indicator inside Card component 2026-03-09 23:00:44 +01:00
Usman Baig
7175de44af Fix metric cards grid to use md: breakpoint instead of @container queries
Tailwind 3 doesn't support container queries without a plugin.
2026-03-09 22:56:53 +01:00
Usman Baig
033d735c3a Replace dashboard BarChart with 21st.dev LineChart component
Swap the main site dashboard chart from a bar chart to a line chart
using 21st.dev's line-charts-6 component with dot grid background,
glow shadow, and animated active dots. Add Badge trend indicators
on metric cards using Phosphor icons. All existing features preserved
(annotations, comparison, export, live indicator, interval controls).

New UI primitives: line-charts-6, badge-2, card, button-1, avatar.
Added shadcn-compatible CSS variables and Tailwind color mappings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 22:53:35 +01:00
Usman Baig
5721d25291 Rewrite FunnelChart as proper SVG funnel with curved sides
Replaces crude clip-path divs with SVG bezier paths matching the 21st.dev
reference: smooth curved sides, background glow, divider lines, centered
percentage pills, and labels on both sides.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 22:20:55 +01:00
Usman Baig
536aebc086 Add FunnelChart visualization to replace BarChart on funnel detail page
Replaces the recharts BarChart with a custom funnel component using clip-path
trapezoid segments, framer-motion animations, and hover interactions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 22:12:34 +01:00
Usman Baig
8c9c711296 Make Globe drag more responsive (damping 3000→800) 2026-03-09 21:56:38 +01:00
Usman Baig
652c93cbd0 Update changelog with Globe, DottedMap, theme toggle, and chart restyle 2026-03-09 16:22:41 +01:00
Usman Baig
2d7e13b098 Rewrite Globe with pure refs, remove framer-motion dependency
- Remove useMotionValue/useSpring which caused effect re-runs
  and globe destroy/recreate cycles (source of glitches)
- All state tracked via refs (phi, drag offset, pointer position)
- Effect only re-runs on theme change, not on every spring tick
- Direct delta tracking for drag instead of spring physics
- Simpler, more stable WebGL lifecycle
2026-03-09 16:18:24 +01:00
Usman Baig
58c151e2b0 Fix Globe glitches: stop resizing buffer every frame
- Set canvas size once on mount instead of every render frame
- Use actual devicePixelRatio (capped at 2) instead of hardcoded 2
- Remove redundant width*2 doubling (was 4x with devicePixelRatio:2)
- Increase spring damping 50→60, reduce stiffness 80→60 for smoother drag
2026-03-09 16:15:38 +01:00
Usman Baig
1a75b44c68 Move Globe up to top of container to fill space 2026-03-09 16:10:19 +01:00
Usman Baig
9629a5788c Add very slow auto-rotation to Globe (pauses on drag) 2026-03-09 16:07:52 +01:00
Usman Baig
464a361094 Center Globe horizontally and move up to show more surface 2026-03-09 16:05:44 +01:00
Usman Baig
12ae1a9175 Zoom in Globe and slow drag speed
- Position globe lower with overflow-hidden for cropped zoomed look
- Globe at 140% width pushed down 30% so only top portion visible
- Add radial gradient overlay at bottom for depth
- Increase movement damping 1400→3000 for slower drag
2026-03-09 16:02:47 +01:00
Usman Baig
3268a70baa Fix Globe: reduce mapBrightness to fix glitches, brighten base color
mapBrightness 6→2 fixes overblown dot artifacts, baseColor 0.3→0.5
makes the sphere visible against the dark card background
2026-03-09 15:57:33 +01:00
Usman Baig
9dba2cf2e2 Fix Globe: remove auto-spin, brighten dark mode, reduce jitter 2026-03-09 15:51:01 +01:00
Usman Baig
efd647d856 Add interactive 3D Globe tab to Locations using cobe WebGL
- Magic UI Globe component with auto-rotation and drag interaction
- Dark/light mode reactive (base color, glow, brightness)
- Country markers from visitor data using existing centroids
- Brand orange (#FD5E0F) marker color matching DottedMap

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 15:46:29 +01:00
Usman Baig
df2f38eb83 Scale DottedMap SVG to fill full card height 2026-03-09 15:34:23 +01:00
Usman Baig
c065853800 Make map landmass dots more prominent in both themes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 15:05:28 +01:00
Usman Baig
f58154f18d Bump @ciphera-net/ui to ^0.1.2 2026-03-09 14:43:35 +01:00
Usman Baig
31416f0eb2 Polish DottedMap: glow effect, tooltips, better fill
- SVG filter for orange glow behind markers
- Hover tooltips showing country name + pageview count
- Reduced viewBox height (75→68) to fill card better
- Bumped mapSamples to 8000 for crisper landmass
- Centered map vertically with flexbox wrapper

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 14:23:48 +01:00
Usman Baig
6ccc26ab48 Replace WorldMap with Magic UI DottedMap for visitor locations
- New DottedMap component using svg-dotted-map with country centroid markers
- Marker size scales by pageview proportion (brand orange)
- Static country-centroids.ts lookup (~200 ISO codes)
- Remove react-simple-maps, i18n-iso-countries, world-atlas CDN dependency
2026-03-09 14:17:35 +01:00
Usman Baig
cbf48318ce Update package-lock.json for @ciphera-net/ui ^0.1.1 2026-03-09 13:59:07 +01:00
Usman Baig
874ff61a46 Bump @ciphera-net/ui to ^0.1.1 2026-03-09 13:58:30 +01:00
Usman Baig
0dfd0ccb3c Fix ChartContainer CSS to work without ShadCN theme, match ShadCN bar chart exactly
- ChartContainer: replace stroke-border/fill-muted (ShadCN tokens we
  don't have) with var(--chart-grid) so recharts CSS overrides actually work
- Dashboard chart: remove YAxis (ShadCN interactive bar has none)
- Remove unused formatAxisValue, formatAxisDuration, tick calculations
- Exact ShadCN structure: CartesianGrid vertical={false}, XAxis with
  tickMargin/minTickGap, single Bar with fill var(--color-{metric})

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 13:45:13 +01:00
Usman Baig
56225bb1ad Match ShadCN interactive bar chart style
- Remove cursor={false} to enable hover highlight
- Remove rounded bar corners for flat ShadCN look
- Remove explicit stroke/color on grid and axes (inherit from CSS)
- Use var(--color-{metric}) for bar fill
- Reduce chart height to 250px (ShadCN default)
- Simplify bar props to match ShadCN minimal pattern

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 13:37:00 +01:00
Usman Baig
ad747b1772 Switch main dashboard chart from AreaChart to BarChart
ShadCN-style bar chart with rounded corners, solid fill,
clean grid, and translucent comparison bars.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 13:31:07 +01:00
Usman Baig
3f81cb0e48 feat: adopt ShadCN chart primitives
Add ChartContainer, ChartConfig, ChartTooltip, ChartTooltipContent
primitives ported from ShadCN's chart pattern. Refactor all 3 chart
locations (dashboard, funnels, uptime) to use CSS variable-driven
theming instead of duplicated CHART_COLORS_LIGHT/DARK objects.

- Add --chart-1 through --chart-5, --chart-grid, --chart-axis CSS vars
- Remove duplicated color objects from 3 files (-223 lines)
- Add accessibilityLayer to all charts
- Rounded bar corners on funnel chart
- Tooltips use Tailwind dark classes instead of imperative style props

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 13:24:29 +01:00
Usman Baig
86c11dc16f chore: bump @ciphera-net/ui to ^0.1.0
ShadCN-quality restyle of shared UI components.
2026-03-09 13:02:06 +01:00
Usman Baig
5fc6f183db feat: annotation UX improvements
- Custom calendar (DatePicker) instead of native date input
- Custom dropdown (Select) instead of native select
- EU date format (DD/MM/YYYY) in tooltips and form
- Right-click context menu on chart to add annotations
- Optional time field (HH:MM) for precise timestamps
- Escape key to dismiss, loading state on save
- Bump @ciphera-net/ui to 0.0.95
2026-03-09 04:17:58 +01:00
Usman Baig
4d99334bcf feat: add chart annotations
Inline annotation markers on the dashboard chart with create/edit/delete UI.
Color-coded categories: deploy, campaign, incident, other.
2026-03-09 03:44:05 +01:00
Usman Baig
3002c4f58c docs: add hide unknown locations to changelog 2026-03-09 02:37:28 +01:00
Usman Baig
a05e2e94b8 feat: add hide unknown locations toggle in site settings
Adds toggle in Data & Privacy > Filtering section to exclude entries
where geographic data could not be resolved from location stats.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 02:26:15 +01:00
Usman Baig
7ff5be7c4e fix: pre-render Phosphor icons in capabilities array
The /features page mixed inline SVG JSX with Phosphor component
references in the capabilities array. The typeof check couldn't
distinguish them, causing a prerender crash:
"Objects are not valid as a React child (found: object with keys
{$$typeof, render, displayName})".

Pre-rendering Share2Icon and GlobeIcon as JSX makes all entries
consistent and eliminates the conditional entirely.
2026-03-09 02:00:32 +01:00
Usman Baig
f516c59d32 chore: regenerate package-lock.json for @ciphera-net/ui 0.0.94
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:43:24 +01:00
Usman Baig
b6199e8a3a chore: bump @ciphera-net/ui to ^0.0.94
Picks up the Phosphor icon migration in ciphera-ui.
2026-03-09 01:39:40 +01:00
Usman Baig
7f9ad0e977 refactor: switch icons from react-icons to Phosphor
Replace react-icons and @radix-ui/react-icons with @phosphor-icons/react
for a consistent icon style across all dashboard panels.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 00:23:31 +01:00
Usman Baig
397a5afef9 fix: capitalize technology labels in dashboard 2026-03-09 00:07:12 +01:00
Usman Baig
6f1956b740 fix: chart no longer shows tomorrow's date on 7/30-day views
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 23:40:49 +01:00
Usman
831fd86f67 Merge pull request #41 from ciphera-net/staging
fix: add Cache-Control no-cache for HTML pages to prevent stale CDN content
2026-03-07 22:04:22 +01:00
Usman Baig
2f5bcf479a fix: add Cache-Control no-cache for HTML pages to prevent stale CDN content
Bunny CDN was caching HTML pages, so after deploys the browser kept
loading old JS bundles with expired Server Action hashes. This header
tells the CDN to always revalidate with the origin. Static assets
(/_next/static/*) are excluded since they are content-hashed.
2026-03-07 20:12:11 +01:00
Usman Baig
ad806e0427 fix: remove reload-based stale build recovery to stop login loop
window.location.reload() causes infinite loops when the CDN keeps
serving cached assets. Instead, silently treat Server Action failures
as no-session — the OAuth flow uses full navigations (window.location.href)
which naturally fetch fresh content from the server on return.
2026-03-07 20:02:58 +01:00
Usman Baig
6338d1dfe7 fix: prevent infinite reload loop on stale build recovery
Use sessionStorage guard so the hard reload only fires once. If the
reload doesn't fix it (CDN still serving stale JS), fall through
gracefully instead of looping forever.
2026-03-07 19:55:16 +01:00
Usman Baig
d2dfe62993 fix: recover gracefully from stale Server Action hashes after deployment
Wrap all Server Action calls (getSessionAction, exchangeAuthCode,
logoutAction) in try-catch so a cached browser bundle with old action
IDs triggers a hard reload instead of an infinite loading spinner.
2026-03-07 19:37:41 +01:00
Usman Baig
cc268c320e feat: replace ghost buttons with underline tab bar for site navigation
Dashboard, Uptime, Funnels, and Settings now use a consistent
underline tab bar with orange active indicator, matching the
existing panel tab design language.
2026-03-07 19:10:23 +01:00
Usman
985978dd8f Merge pull request #40 from ciphera-net/staging
Dashboard filtering, automatic tracking, chart rebuild & settings modal
2026-03-07 01:21:04 +01:00
Usman Baig
8ebd8ba9e1 style: rename Content to Pages, Top Referrers to Referrers, consolidate changelog
Rename dashboard panel titles for clarity. Merge 0.13.1-alpha into
0.13.0-alpha and clean up changelog entries — remove developer-facing
notes, simplify language, and update panel names throughout.
2026-03-07 01:13:53 +01:00
Usman Baig
dd8e101f69 fix: resolve CSS var for chart PNG export background color
html-to-image cannot resolve CSS custom properties. Use
getComputedStyle to get the actual background color from the DOM.
2026-03-07 00:56:22 +01:00
Usman Baig
ece8cda334 style: remove avg reference line and badge from chart
Remove the dashed average line from the chart area and the Avg badge
from the toolbar for a cleaner look.
2026-03-07 00:54:11 +01:00
Usman Baig
74ee64a560 fix: remove tabular-nums causing font fallback on KPI numbers
Plus Jakarta Sans doesn't fully support the tnum OpenType feature,
causing browsers to fall back to system fonts for numeric glyphs.
2026-03-07 00:50:53 +01:00
Usman Baig
641a3deebb refactor: rebuild Chart component from scratch
- Remove sparklines from stat cards (redundant with main chart)
- Widen Y-axis to 40px, add allowDecimals=false for count metrics
- Move avg label from inside chart to toolbar badge
- Lighter grid lines, simpler gradient, thinner strokes
- Streamline toolbar: inline controls, icon-only export, no trailing separator
- Move live indicator from absolute to proper flow element
- Cleaner empty states without dashed border boxes
- Extract TrendBadge component, add tabular-nums for aligned numbers
2026-03-07 00:31:05 +01:00
Usman Baig
77dc61e7d0 fix: round chart average label, update changelog
Fix formatAxisValue to show 1 decimal place for floats instead of raw
floating-point numbers. Update changelog with all UI improvements from
this session.
2026-03-07 00:26:36 +01:00
Usman Baig
dee7089925 style: always show UTM medium/campaign, clean up dead code
Show medium and campaign on every row with em-dash when empty so UTM
attributes are always visible. Remove unused getSecondaryLabel function.
2026-03-07 00:20:21 +01:00
Usman Baig
2acfd90bbd style: move Campaigns to 2-col grid, show UTM details on second line
Pair Campaigns with Goals & Events in a half-width grid to avoid empty
space. Show medium and campaign on a visible second line under source
name instead of subtle inline text.
2026-03-07 00:01:24 +01:00
Usman Baig
34e59894af style: redesign Campaigns panel to match dashboard layout, use filter icon
Rebuild Campaigns from table layout to simple row-based design matching
Content, Referrers, Locations, and Technology panels. Add click-to-filter
by utm_source. Change filter button icon from plus to funnel.
2026-03-06 23:52:47 +01:00
Usman Baig
7fc40f2a83 style: move View All to bottom of list, clean up panel headers
Remove expand icon from all panel headers. Add a subtle "View all ›"
link at the bottom of each data list that appears when there's more
data than shown. Headers now only contain title and tabs.
2026-03-06 23:46:21 +01:00
Usman Baig
068943974e style: replace View All buttons with expand icon, switch to underline tabs
- Replace "View All" text buttons with a subtle expand/fullscreen icon
  across all 5 dashboard panels
- Convert pill-style tab switchers to underline tabs with brand-orange
  active indicator in Content, Locations, and Technology panels
2026-03-06 23:38:46 +01:00
Usman Baig
2c82c1a52a fix: load full filter suggestions (up to 100) and fix Direct referrer duplicate
Filter dropdowns previously only showed ~10 values from cached dashboard
data. Now lazy-loads up to 100 values per dimension when the dropdown
opens. Also removes duplicate "Direct" entry from referrer suggestions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:27:54 +01:00
Usman Baig
b046978256 style: unify filter bar design — kill FILTERS label, solid orange pills
- Remove redundant "FILTERS" uppercase label
- Change pills from washed-out orange/10 border to solid brand-orange
  with white text for strong contrast in both light and dark mode
- Pills now use rounded-lg to match the Filter button shape
- FilterBar renders fragments (no wrapper div) so everything flows
  naturally in the parent flex row
2026-03-06 23:11:36 +01:00
Usman Baig
7be30b57b5 refactor: condense filter chips into single button with dropdown
Replaces the 7-chip row with a single "+ Filter" button that opens a
compact popover: dimension list → operator + search + values. Much
cleaner default state, same functionality.
2026-03-06 23:02:35 +01:00
Usman Baig
386b4a8c44 feat: replace filter modal with chip-based dimension filter bar
Dimension chips (Page, Referrer, Country, Browser, OS, Device + More)
with popover dropdowns showing real values from current dashboard data
with counts. Operators inline, search/type custom values, click to apply.
2026-03-06 22:53:00 +01:00
Usman Baig
34053004c0 style: use brand orange for hover percentage indicators 2026-03-06 22:43:05 +01:00
Usman Baig
0809c37067 fix: prevent duplicate filters, support Direct referrer, pass filters to Campaigns
- Deduplicate filters so clicking the same item twice doesn't stack identical pills
- Normalize "Direct" referrer to empty string so direct traffic filtering works
- Pass active filters through to Campaigns component so it respects dashboard filters
2026-03-06 22:40:57 +01:00
Usman Baig
ec96fa8a0d feat: add hover percentage indicator on dashboard panel rows
Shows percentage of total pageviews on hover with a slide-in animation from right to left across all panels (Content, Locations, Technology, Top Referrers).
2026-03-06 22:25:15 +01:00
Usman Baig
0865774686 feat: replace filter dropdown with modal, add click-to-filter on all panels
- Filter button is now a solid pill that opens a centered modal with
  dimension grid and operator/value selection
- Clicking any row in TopReferrers, TechSpecs, Locations, or ContentStats
  adds an "is" filter for that dimension and value
- ContentStats preserves the external link icon separately via stopPropagation
2026-03-06 21:15:27 +01:00
Usman Baig
5677f30f3b feat: add dashboard dimension filtering and custom event properties
Dashboard filtering: FilterBar pills, AddFilterDropdown with dimension/
operator/value steps, URL-serialized filters, all SWR hooks filter-aware.

Custom event properties: pulse.track() accepts props object, EventProperties
panel with auto-discovered key tabs and value bar charts, clickable goal rows.

Updated changelog with both features under v0.13.0-alpha.
2026-03-06 21:02:14 +01:00
Usman Baig
8b1d196812 feat: add automatic 404 detection, scroll depth tracking, and scroll depth dashboard card
- 404 detection: checks document.title for "404" or "not found", fires custom event, SPA-aware
- Scroll depth: passive scroll listener fires events at 25/50/75/100% thresholds
- ScrollDepth dashboard card: progress bar visualization showing % of visitors reaching each threshold
- Scroll events filtered out of GoalStats to avoid duplication
- Both features on by default, opt-out via data-no-404 / data-no-scroll
2026-03-06 20:00:22 +01:00
Usman Baig
53a0341925 feat: automatic outbound link and file download tracking
Adds a single click listener in the tracking script that detects
external link clicks and file download clicks, firing outbound_link
and file_download custom events. On by default, opt-out via
data-no-outbound / data-no-downloads attributes.
2026-03-06 19:41:11 +01:00
Usman Baig
e72e6f2ec5 feat: add AI traffic source identification
Display proper brand icons and names for AI referrers (ChatGPT,
Perplexity, Claude, Gemini, Copilot, DeepSeek, Grok, Meta AI,
You.com, Phind) in Top Referrers panel.
2026-03-06 19:25:05 +01:00
Usman Baig
acede8ca54 feat: rename section to Account, move Danger Zone to own sidebar item
- Rename "Pulse Settings" to "Account"
- Add hideDangerZone prop to hide it from Profile tab
- Add standalone "Danger Zone" item under Account section
- Bump @ciphera-net/ui to ^0.0.92
2026-03-06 12:23:00 +01:00
Usman Baig
6d360cf1ac feat: settings modal improvements — borderless profile, remove descriptions, bump ui to 0.0.91
- Add borderless prop passthrough to ProfileSettings
- Remove section descriptions from SettingsModalWrapper sidebar
- Bump @ciphera-net/ui to ^0.0.91
2026-03-06 11:44:30 +01:00
Usman Baig
7865b41722 refactor: use shared settings components, fix sections
- Use NotificationToggleList and BellIcon from @ciphera-net/ui
- Remove duplicated inline SVG icons and toggle code
- Remove double card borders from content areas
- Remove broken Ciphera Account external links
- Add dedicated Security section (Trusted Devices, Security Activity)
- Bump @ciphera-net/ui to ^0.0.89
2026-03-06 00:20:08 +01:00
Usman Baig
48cf9a1f62 feat: replace settings page with SettingsModal
- Add SettingsModalProvider context and SettingsModalWrapper
- Wire Header onOpenSettings callback via LayoutInner pattern
- Remove old /settings page and SettingsPageClient
- Bump @ciphera-net/ui to ^0.0.88
2026-03-05 22:16:36 +01:00
Usman Baig
f469d0d755 chore: bump @ciphera-net/ui to ^0.0.80 2026-03-04 21:09:20 +01:00
Usman
88956879de Merge pull request #39 from ciphera-net/staging
Release 0.13.0-alpha
2026-03-02 23:51:52 +01:00
Usman Baig
564c853f7f Bump version to 0.13.0-alpha and move unreleased changelog items 2026-03-02 23:31:55 +01:00
Usman Baig
c9fd949ae1 chore: bump @ciphera-net/ui to ^0.0.79 2026-03-02 21:58:34 +01:00
Usman Baig
70f46ba63c docs: changelog entries for backend API cleanup (B-32, B-13, B-12) 2026-03-01 21:37:27 +01:00
Usman Baig
7d3f1cb10a refactor: reduce stats.ts duplication with factory pattern (F-10, F-21)
Introduce buildQuery helper and createListFetcher factory to eliminate
near-identical param building and list endpoint boilerplate. File reduced
from ~600 to ~310 lines (~48% reduction) with identical runtime behavior.
2026-03-01 21:25:05 +01:00
Usman Baig
fd1386b80d fix: replace index-based React keys with stable data keys (F-9)
Use page paths, referrer URLs, item names, and composite location
keys instead of array indices. Prevents stale-row glitches when
lists are filtered or reordered.
2026-03-01 21:15:09 +01:00
Usman Baig
501932849b fix: ESM import for next-pwa, changelog updates (F-5)
- Convert require() to ESM import in next.config.ts
- Remove skipWaiting (defaults to true in Workbox)
2026-03-01 21:09:10 +01:00
Usman Baig
b7426d6128 fix: login loading overlay, deduplicate getCookieDomain (F-18, F-11)
- Login page shows LoadingOverlay during redirect instead of blank screen
- Extract getCookieDomain() to shared lib/utils/cookies.ts
2026-03-01 21:02:28 +01:00
Usman Baig
dfa887147a fix: stabilize auth context effect deps and batch uptime cleanup
Extract stable primitives (isAuthenticated, userOrgId) from user object
for the checkOrg effect dependency array to prevent unnecessary re-runs
on every render. Batch uptime cleanup deletion (1000 rows/batch) to
avoid lock contention and WAL bloat.
2026-03-01 19:56:14 +01:00
Usman Baig
4de4e05ccb fix: standardize funnel date format to YYYY-MM-DD and update changelog
Funnel stats API now uses start_date/end_date params consistent with
all other endpoints. Removed RFC3339 conversion helper. Added changelog
entries for audit fixes (B-7, B-11, B-23, B-38, B-42).
2026-03-01 19:36:38 +01:00
Usman Baig
d7eb10e815 docs: update changelog with backend reliability improvements 2026-03-01 19:25:59 +01:00
Usman Baig
8a7076ee1b refactor: migrate dashboard to SWR hooks, eliminate all any[] state
Replace 22 manual useState + useEffect + setInterval polling with 11
focused SWR hooks. Removes ~85 lines of polling/visibility logic that
SWR handles natively. All any[] types replaced with proper interfaces
(TopPage, CountryStat, BrowserStat, etc.). Organization state in layout
typed as OrganizationMember[].

Resolves F-7, F-8, F-15 from audit report.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 18:42:14 +01:00
Usman Baig
67c9bdd3e0 docs: add realtime rate limit fix to changelog 2026-03-01 18:07:22 +01:00
Usman Baig
3ecd2abf63 docs: update changelog for event ingestion fix 2026-03-01 17:55:25 +01:00
Usman Baig
baceb6e8a8 docs: add funnel stats fix to changelog 2026-03-01 17:51:01 +01:00
Usman Baig
fba1fd99c2 fix: add favicon domains to connect-src for service worker compatibility
The PWA service worker (workbox) fetches images via the Fetch API, which
is governed by connect-src, not img-src. Add www.google.com, *.gstatic.com,
and ciphera.net to connect-src so favicon and app icon fetches succeed.
2026-03-01 15:44:10 +01:00
Usman Baig
c9123832a5 fix: fix broken images from CSP, remove dead code, upgrade React types
- Add ciphera.net and *.gstatic.com to CSP img-src (fixes app switcher
  icons and site favicons blocked by Content Security Policy)
- Delete 6 unused component/utility files and orphaned test
- Upgrade @types/react and @types/react-dom to v19 (matches React 19 runtime)
- Fix logger test to use vi.stubEnv for React 19 type compatibility
2026-03-01 15:33:37 +01:00
Usman Baig
95920e4724 fix: update changelog with Phase 2 audit fixes 2026-03-01 15:18:56 +01:00
Usman Baig
67f6690258 fix: enhance security with stricter Content Security Policy and input validation 2026-03-01 15:02:22 +01:00
Usman Baig
5b388808b6 fix: update changelog with recent fixes and remove unused icon files 2026-03-01 14:43:25 +01:00
Usman Baig
27158f7bfc fix: enhance billing operations and session management in API 2026-03-01 14:33:28 +01:00
Usman Baig
bc5e20ab7b fix: add note on lower resource usage under load to changelog 2026-03-01 14:29:29 +01:00
Usman Baig
6bb23bc22a fix: add service health reporting fix to changelog for clarity 2026-03-01 14:16:08 +01:00
Usman
3da2472c86 Merge pull request #38 from ciphera-net/staging
Settings page overhaul, auth resilience, and automated testing
2026-03-01 14:05:56 +01:00
Usman Baig
29e84e3a4f fix: remove outdated fixes from changelog for clarity 2026-03-01 14:02:31 +01:00
Usman Baig
b3a303d6df fix: improve session management and UI highlights 2026-03-01 13:53:54 +01:00
Usman Baig
ac1ed58127 fix: improve reliability of background processing across multiple Pulse servers 2026-03-01 13:45:00 +01:00
Usman Baig
805617a290 chore: update version to 0.12.0-alpha and document automated testing in changelog 2026-03-01 00:29:57 +01:00
Usman Baig
6bb356697b feat: update test workflow to use PKG_READ_TOKEN for NODE_AUTH_TOKEN 2026-03-01 00:20:17 +01:00
Usman Baig
9a39745323 feat: add NODE_AUTH_TOKEN environment variable for dependency installation 2026-03-01 00:13:57 +01:00
Usman Baig
b5f83ce582 feat: add unit tests and CI configuration 2026-03-01 00:11:54 +01:00
Usman Baig
bce56fa64d feat: implement refresh token functionality and update local storage management 2026-02-28 23:02:43 +01:00
Usman Baig
5ef6eafc63 feat: update notification preferences to include granular security alerts 2026-02-28 21:18:57 +01:00
Usman Baig
15f82eee00 feat: add email notification preferences and update settings page structure 2026-02-28 20:36:53 +01:00
Usman Baig
7053cf5d5e feat: add security activity and trusted devices management to settings page 2026-02-28 19:58:49 +01:00
Usman Baig
c4e95268fe feat: enhance settings page with account management and sidebar navigation 2026-02-28 19:13:09 +01:00
Usman Baig
fcd36dcaeb chore: update package-lock.json
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 17:57:39 +01:00
Usman Baig
c436680876 feat: add expandable sidebar navigation to settings page
Replace direct SharedProfileSettings rendering with an expandable sidebar
that shows Profile, Security, and Preferences as collapsible sub-items
under Profile & Preferences. Matches the new settings pattern across
all Ciphera frontends.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 17:56:06 +01:00
Usman Baig
cba6347d70 Merge main into staging: remove sw 2.js, gitignore hygiene 2026-02-28 15:32:31 +01:00
Usman Baig
ba24c24f41 chore: remove duplicate sw 2.js and add to gitignore
- Remove public/sw 2.js (misnamed PWA build artifact)
- Ignore public/sw 2.js in .gitignore to prevent re-add

Made-with: Cursor
2026-02-28 15:32:25 +01:00
Usman Baig
22bc18a7cc chore: update CHANGELOG.md to include Request ID tracing for debugging, enhancing request tracking across services, and update API client to propagate Request ID in headers 2026-02-27 17:26:08 +01:00
Usman Baig
a928d2577b chore: update CHANGELOG.md to include consistent app order in the App Switcher for improved navigation experience, and update @ciphera-net/ui dependency to version 0.0.74 in package.json and package-lock.json 2026-02-27 15:03:44 +01:00
Usman Baig
8589842f16 chore: update CHANGELOG.md to include session expiration warning feature, enhancing user awareness, and update @ciphera-net/ui dependency to version 0.0.73 in package.json and package-lock.json 2026-02-27 14:24:07 +01:00
Usman Baig
3ff5ee4b6c chore: update CHANGELOG.md to include session synchronization across tabs feature, enhancing user experience, and update @ciphera-net/ui dependency to version 0.0.72 in package.json and package-lock.json 2026-02-27 14:15:40 +01:00
Usman Baig
67dcca660e chore: update @ciphera-net/ui dependency to version 0.0.71 in package.json and package-lock.json 2026-02-27 13:51:24 +01:00
Usman Baig
d14911baf9 chore: update @ciphera-net/ui dependency to version 0.0.70 in package.json and package-lock.json 2026-02-27 13:38:40 +01:00
Usman
4e140c853f Merge pull request #37 from ciphera-net/staging
Admin Dashboard enhancements, OAuth session fixes, and tracking script improvements
2026-02-27 13:27:36 +01:00
Usman
335cfc1a00 Merge branch 'main' into staging 2026-02-27 13:26:49 +01:00
Usman Baig
052c49ace2 chore: update @ciphera-net/ui dependency to version 0.0.69 in package.json and package-lock.json 2026-02-27 13:22:36 +01:00
Usman Baig
f933c2fb71 chore: update @ciphera-net/ui dependency to version 0.0.68 and update icon URLs in layout-content.tsx for improved asset management 2026-02-27 13:02:30 +01:00
Usman Baig
908b8c0900 chore: update CHANGELOG.md to include the addition of an App Switcher in the User Menu for easier navigation between Ciphera products, along with dependency updates for @ciphera-net/ui 2026-02-27 12:50:05 +01:00
Usman Baig
e5ad4cf2f6 chore: update CHANGELOG.md to reflect improvements in authentication flow, including seamless sign-in from the Ciphera portal and enhanced cookie management for better security and user experience 2026-02-27 12:05:49 +01:00
Usman Baig
b4b1348a94 chore: update CHANGELOG.md to include improvements in authentication flow, addressing CSRF handling and cookie management for seamless sign-in and enhanced security 2026-02-27 11:52:20 +01:00
Usman Baig
0022e7b335 chore: update CHANGELOG.md to clarify improvements in visitor tracking accuracy, ensuring unique identifiers for analytics during high traffic periods 2026-02-27 10:07:09 +01:00
Usman Baig
a9aaf24456 chore: update CHANGELOG.md to include multiple performance enhancements, such as faster billing page loading, improved funnel analysis, and more reliable database connections under heavy load 2026-02-27 10:04:13 +01:00
Usman Baig
e7e217777a chore: update CHANGELOG.md to include faster analytics processing for improved daily stats updates across multiple sites 2026-02-27 09:34:43 +01:00
Usman Baig
704a38f3df chore: update CHANGELOG.md to include lighter dashboard data transfers for improved loading times and new focused dashboard endpoints for efficient data retrieval 2026-02-27 09:24:01 +01:00
Usman Baig
4cff0c621d feat: implement request deduplication and caching in API client for improved performance 2026-02-27 09:17:51 +01:00
Usman Baig
36774cc995 chore: update CHANGELOG.md to include smarter data fetching with request deduplication and caching for improved performance 2026-02-27 09:13:29 +01:00
Usman Baig
3efd23b386 chore: update CHANGELOG.md to include enhancements for dashboard performance, including smarter updates, real-time visitor tracking, and faster event processing 2026-02-27 09:10:08 +01:00
Usman Baig
3aa0d7ae7c chore: update CHANGELOG.md to include faster dashboard statistics feature using pre-computed daily summaries for improved loading times 2026-02-27 08:49:23 +01:00
Usman Baig
faa0bfe64a chore: update CHANGELOG.md to include smarter database indexing for improved query performance and reduced storage overhead 2026-02-27 08:47:22 +01:00
Usman Baig
209ec1608a chore: update CHANGELOG.md to include better data management for long-term performance, enhancing analytics data storage and retrieval 2026-02-27 08:41:02 +01:00
Usman Baig
bcc02c93a0 chore: update CHANGELOG.md to highlight faster dashboard loading feature with intelligent caching for improved performance 2026-02-27 08:04:46 +01:00
Usman Baig
f994141d64 fix: improve Pulse tracking script for embedded sites like Shopify and WooCommerce 2026-02-26 14:44:24 +01:00
Usman Baig
86cc27a10c fix: resolve OAuth session flow issue when opening Pulse from the Ciphera hub 2026-02-26 14:40:42 +01:00
Usman Baig
1edd78672e fix: treat session-flow callback (no state) as valid when coming from auth hub
Made-with: Cursor
2026-02-26 14:26:54 +01:00
Usman Baig
40fe34014c style: update admin layout for improved responsiveness 2026-02-26 12:18:32 +01:00
Usman Baig
c89d9ce485 fix: add click-to-copy for org ID, show site name fallback in admin list
Made-with: Cursor
2026-02-26 12:16:07 +01:00
Usman Baig
72745bd41a [Fix] Admin organizations list - document org visibility fix
Made-with: Cursor
2026-02-25 22:18:21 +01:00
Usman Baig
30b450cdb6 Fix admin dashboard build: remove date-fns, replace Card with native divs, fix Button props
Made-with: Cursor
2026-02-25 22:02:53 +01:00
Usman Baig
3fe20a4b1b [Feat] Pulse Admin Dashboard - manually grant plans to organizations
- Add admin layout with /api/admin/me check and access denial
- Add admin pages: dashboard, orgs list, org detail with grant form
- Add lib/api/admin.ts client for admin endpoints

Made-with: Cursor
2026-02-25 21:58:23 +01:00
Usman Baig
b0c15d6464 Fix: allow script.js to load without auth for embedded sites (Shopify)
- Add /script.js to PUBLIC_ROUTES in middleware
- Fixes 307 redirect to /login when tracking script loads from third-party sites

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-25 21:15:09 +01:00
Usman
892ba4cb11 Merge pull request #36 from ciphera-net/staging
Performance insights, Goals & Events, 2FA improvements, auth fixes
2026-02-25 20:41:06 +01:00
Usman Baig
2cb8ffddec chore: update CHANGELOG.md to include new features, improvements, and fixes for performance insights, goals tracking, and enhanced error handling 2026-02-25 12:41:18 +01:00
Usman Baig
801dc1d773 chore: add @simplewebauthn/browser dependency to package.json and package-lock.json for WebAuthn support 2026-02-23 20:18:18 +01:00
Usman Baig
1484ade717 chore: update @ciphera-net/ui dependency to version 0.0.64 in package.json and package-lock.json 2026-02-23 19:33:49 +01:00
Usman Baig
ef041d9a01 chore: update @ciphera-net/ui dependency to version 0.0.63 in package.json 2026-02-23 19:10:07 +01:00
Usman Baig
6fb4da5a69 chore: update @ciphera-net/ui dependency to version 0.0.62 in package.json and package-lock.json 2026-02-23 19:00:08 +01:00
Usman Baig
3cb5416251 fix: implement automatic token refresh to prevent frequent re-logins, enhancing user experience during inactivity 2026-02-23 18:57:03 +01:00
Usman Baig
f62d142adb fix: resolve sign-in issue after inactivity by ensuring only valid access tokens trigger redirects, improving user experience 2026-02-23 18:46:46 +01:00
Usman Baig
dd9d4c5ac2 chore: update @ciphera-net/ui to version 0.0.61 for improved functionality 2026-02-23 18:04:10 +01:00
Usman Baig
27b3aa8380 feat: add 2FA recovery codes regeneration and backup functionality, enhancing account security 2026-02-23 11:43:57 +01:00
Usman Baig
b54af6c03a fix: require password confirmation to disable 2FA, enhancing security against session hijacking 2026-02-23 11:35:02 +01:00
Usman Baig
2889b0bb0a chore: update @ciphera-net/ui to 0.0.59 for improved functionality 2026-02-23 10:57:11 +01:00
Usman Baig
bd17bb45c4 chore: update CHANGELOG.md for version 0.11.1-alpha, highlighting secure sign-in improvements and update package version 2026-02-23 10:35:08 +01:00
Usman
91ec37be53 Merge pull request #35 from ciphera-net/staging
[PULSE-60] Frontend hardening, UX polish, and security
2026-02-22 22:43:06 +01:00
Usman Baig
31de661888 chore: update CHANGELOG.md to reflect recent fixes in Content Security Policy and date range validation, enhancing clarity and accuracy 2026-02-22 22:41:49 +01:00
Usman Baig
43a0954e5f chore: update dashboard preview image to version 2, replacing the old file for improved design consistency 2026-02-22 22:21:23 +01:00
Usman Baig
93028efa0d chore: increase dashboard preview image height for better visibility and update the image file to reflect design improvements 2026-02-22 22:16:37 +01:00
Usman Baig
414908b6ce chore: update dashboard preview image to enhance visual representation and align with recent design changes 2026-02-22 22:11:26 +01:00
Usman Baig
14ca762305 refactor: remove mock data and streamline DashboardPreview component for improved performance and maintainability 2026-02-22 22:06:22 +01:00
Usman Baig
6545b006de fix: enhance landing page dashboard preview and resolve logout redirect loop, improving user experience and visual consistency 2026-02-22 21:56:30 +01:00
Usman Baig
19df3c6c75 fix: resolve logout redirect loop by directing users to the Pulse homepage after signing out, improving user experience 2026-02-22 21:48:33 +01:00
Usman Baig
c1325bc573 fix: resolve Content Security Policy issue by ensuring the backend CSP header is set correctly, preventing captcha integration failures 2026-02-22 21:43:59 +01:00
Usman Baig
7215eb17b0 feat: introduce a limit of 50 excluded paths for sites to enhance event processing efficiency and prevent performance issues 2026-02-22 21:41:27 +01:00
Usman Baig
e53d37a388 fix: add date range validation for analytics, funnel, and uptime queries to prevent invalid inputs and enhance data integrity 2026-02-22 21:37:27 +01:00
Usman Baig
bd19288f52 fix: safer error messages by preventing exposure of internal details in server responses, enhancing security and user experience 2026-02-22 21:31:45 +01:00
Usman Baig
270b970f43 fix: improve audit log reliability by logging failed writes to the server, enabling detection of gaps in the audit trail 2026-02-22 21:25:19 +01:00
Usman Baig
65e5c727de feat: implement database connection pooling to limit and recycle connections, improving performance under load 2026-02-22 21:20:33 +01:00
Usman Baig
a1e9a6b8f7 feat: implement graceful server shutdown to ensure in-flight requests and background tasks are completed before deployment termination 2026-02-22 21:08:06 +01:00
Usman Baig
19be64c43a feat: optimize icon imports for smaller page downloads by enabling tree-shaking in the build process 2026-02-22 21:04:05 +01:00
Usman Baig
39eac4100e feat: update favicon retrieval to use a centralized service URL for consistency across the application 2026-02-22 21:02:11 +01:00
Usman Baig
b88a31c612 feat: add character limits to site name and domain input fields to enhance form validation and user experience 2026-02-22 20:59:31 +01:00
Usman Baig
2d0307d328 fix: enhance error logging by replacing console.error with a centralized logger across the application to improve security and maintainability 2026-02-22 20:57:21 +01:00
Usman Baig
837c677b51 fix: update dark mode support for uptime chart tooltips to align with user theme preferences 2026-02-22 20:53:21 +01:00
Usman Baig
c73c300620 feat: improve organization switching experience with a branded loading overlay and session management for smoother transitions 2026-02-22 20:48:09 +01:00
Usman Baig
8007900940 feat: enhance accessibility across the application by improving keyboard navigation and screen reader support for various components 2026-02-22 20:39:18 +01:00
Usman Baig
06f54176f1 refactor: enhance type safety by replacing any types with stricter types across the codebase, improving error handling and reducing potential bugs 2026-02-22 20:29:16 +01:00
Usman Baig
1947c6a886 fix: remove debug logs from authentication and organization switching to enhance security and prevent sensitive information leakage 2026-02-22 20:18:06 +01:00
Usman Baig
18d9f59e5d fix: correct organization context switching to ensure secure session storage when switching away from deleted organizations 2026-02-22 20:14:18 +01:00
Usman Baig
acac536590 feat: enforce tighter character limits for site, funnel, and monitor names to improve UI consistency and usability 2026-02-22 20:07:00 +01:00
Usman Baig
da0366603e feat: improve form usability with auto-focus, character limits, and unsaved changes warnings for better user experience 2026-02-22 20:02:50 +01:00
Usman Baig
5d234b30d6 feat: implement security headers for enhanced protection against clickjacking, MIME-sniffing, and other vulnerabilities 2026-02-22 19:55:52 +01:00
Usman Baig
e0bae5a728 feat: add graceful error recovery with user-friendly error screens and retry options for improved user experience 2026-02-22 19:49:27 +01:00
Usman Baig
ca805c9790 feat: implement faster login redirects to improve user experience when accessing dashboards and settings 2026-02-22 19:42:29 +01:00
Usman Baig
5c148a0547 feat: enhance page titles and link previews for improved user experience and sharing capabilities 2026-02-22 19:40:00 +01:00
Usman Baig
94fb7c60e0 feat: optimize favicon loading across the application using Next.js image component for better performance and caching 2026-02-22 19:21:28 +01:00
Usman Baig
156d9986df fix: improve error messaging for various components to provide clearer feedback on failures 2026-02-22 19:17:20 +01:00
Usman Baig
ac6a9429d4 chore: release version 0.11.0-alpha with enhanced loading experience and layout stability improvements 2026-02-22 19:14:58 +01:00
Usman Baig
d571b6156f refactor: integrate useMinimumLoading hook for enhanced loading state management across multiple pages 2026-02-22 18:38:35 +01:00
Usman Baig
c100277955 refactor: replace loading overlays with skeleton components for improved user experience across various pages 2026-02-22 18:01:45 +01:00
Usman Baig
574462a275 style: update loading state background colors to brand colors for enhanced visual consistency 2026-02-22 00:49:33 +01:00
Usman Baig
afa0cec88b style: update loading state background colors for improved visual consistency 2026-02-22 00:46:17 +01:00
Usman Baig
b124fa49ef style: enhance layout stability by adding min-height to overview cards and improving loading state visuals 2026-02-22 00:42:44 +01:00
Usman Baig
a2419d681c refactor: simplify site statistics fetching by removing daily stats and updating related components 2026-02-22 00:25:36 +01:00
Usman Baig
ccefdcc384 fix: handle rejected site statistics fetches by providing default empty stats 2026-02-22 00:22:02 +01:00
Usman Baig
2aedc656d7 feat: implement site statistics fetching and display in SiteList component 2026-02-22 00:20:54 +01:00
Usman
20959683e5 Merge pull request #34 from ciphera-net/staging
[PULSE-59] Design consistency audit fixes
2026-02-22 00:09:41 +01:00
Usman Baig
1a970279b5 chore: release version 0.10.0-alpha with design consistency improvements across various components 2026-02-22 00:06:26 +01:00
Usman Baig
ee25d87097 chore: update package versions and dependencies for improved functionality 2026-02-21 23:58:39 +01:00
Usman Baig
4dead4b399 style: standardize gap sizes across multiple components for improved visual consistency 2026-02-21 23:48:03 +01:00
Usman Baig
aada06c207 style: update domain name in OrganizationSettings component for consistency with new branding 2026-02-21 23:45:51 +01:00
Usman Baig
947e37168d style: update background colors and border styles in integration and installation pages for improved visual consistency 2026-02-21 23:45:05 +01:00
Usman Baig
d08c8f00a0 style: add transition effects to shadow properties across multiple components for improved visual feedback 2026-02-21 23:42:12 +01:00
Usman Baig
0b68db58be style: standardize min-width values across multiple components for improved layout consistency 2026-02-21 23:39:29 +01:00
Usman Baig
fb47cb0c86 style: update padding in integration pages and IntegrationGuide component for improved layout consistency 2026-02-21 23:36:54 +01:00
Usman Baig
8f8761ed3d style: standardize padding across multiple components for improved layout consistency 2026-02-21 23:29:50 +01:00
Usman Baig
fb3490feb9 refactor: replace anchor tag with Button component in PricingSection for improved styling and consistency 2026-02-21 23:25:00 +01:00
Usman Baig
65ba7ccba2 style: enhance dark mode support by updating text colors across multiple components for improved readability 2026-02-21 23:13:52 +01:00
Usman Baig
f1e6d5a48e style: refactor chart color variables across multiple components to use CSS variables for improved theming consistency 2026-02-21 23:09:34 +01:00
Usman Baig
72c06816fe style: update layout of multiple pages to use consistent max-width and padding for improved responsiveness 2026-02-21 22:53:26 +01:00
Usman Baig
23ba5f77a9 refactor: replace button elements with a unified Button component in SiteSettingsPage and VerificationModal for consistency and improved styling 2026-02-21 22:41:43 +01:00
Usman Baig
e8e304e238 style: update heading sizes across various pages for improved typography consistency 2026-02-21 22:29:26 +01:00
Usman
4ffd61963c Merge pull request #33 from ciphera-net/staging
[PULSE-58] Data retention settings in Site Settings
2026-02-21 20:03:25 +01:00
Usman Baig
d1d82f5b3c feat: refine data retention adjustment logic in SiteSettingsPage to snap to nearest valid option upon subscription load 2026-02-21 19:58:48 +01:00
Usman Baig
98eef9c366 feat: adjust default data retention to 6 months in SiteSettingsPage and add error handling for subscription loading failures 2026-02-21 19:50:27 +01:00
Usman Baig
5c0babe273 feat: implement data retention clamping in SiteSettingsPage to ensure user settings align with subscription plan limits 2026-02-21 19:45:35 +01:00
Usman Baig
22b2c036ac chore: update CHANGELOG and package version to 0.9.0-alpha, adding data retention features and settings for site owners 2026-02-21 19:40:33 +01:00
Usman Baig
1e41bedc86 fix: update maximum data retention for business plan from 60 to 36 months and adjust retention options accordingly 2026-02-21 18:28:56 +01:00
Usman Baig
1ae20dba4c feat: add data retention settings to SiteSettingsPage, including subscription-based options and UI updates for user interaction 2026-02-21 18:21:43 +01:00
Usman
42ed7d91dd Merge pull request #32 from ciphera-net/staging
[PULSE-57] Billing UX: renewal display, design fixes, React crash fix
2026-02-20 18:32:33 +01:00
Usman Baig
b8cb7e177e chore: update CHANGELOG for version 0.8.0-alpha, adding new features, changes, and fixes related to billing and subscription management 2026-02-20 18:32:12 +01:00
Usman Baig
fa3982001d feat: enhance HomePage and OrganizationSettings to display detailed subscription information and improve user interaction with invoice links 2026-02-20 18:05:59 +01:00
Usman Baig
6817f0c9fa fix: streamline invoice preview logic in OrganizationSettings to improve performance and user feedback during plan changes 2026-02-20 17:50:46 +01:00
Usman Baig
5b1d3d8f0e refactor: update PricingSection styles for improved layout and accessibility; enhance OrganizationSettings to handle plan changes and display past due notices 2026-02-20 16:50:43 +01:00
Usman Baig
12975f671d fix: update invoice preview handling in OrganizationSettings to reset state and provide user feedback on calculation errors 2026-02-20 16:21:35 +01:00
Usman Baig
cc89a27972 feat: add invoice preview functionality in OrganizationSettings to enhance user experience with upcoming billing details 2026-02-20 16:18:00 +01:00
Usman Baig
99e9235f1f feat: add resume subscription functionality in OrganizationSettings for improved user control over billing 2026-02-20 16:07:17 +01:00
Usman Baig
53ed7493c6 style: update download and view invoice links in OrganizationSettings for improved UI consistency and accessibility 2026-02-20 16:04:05 +01:00
Usman Baig
a4f2bebd10 feat: enhance OrganizationSettings to display Tax IDs alongside business name for improved billing clarity 2026-02-20 15:36:50 +01:00
Usman Baig
2d37d065c0 fix: remove CheckoutSuccessToast component and its usage in SettingsPage for cleaner settings interface 2026-02-20 04:02:11 +01:00
Usman Baig
17106517d9 refactor: remove embedded checkout components and update billing API integration for streamlined checkout flow 2026-02-20 03:51:20 +01:00
Usman Baig
96b3919e52 fix: refactor CheckoutReturnPage to use Suspense for loading state and separate content into CheckoutReturnContent component 2026-02-20 03:47:10 +01:00
Usman Baig
0bbbb8a1af feat: integrate Stripe for embedded checkout; update billing API to return client_secret and adjust checkout flow in components 2026-02-20 03:41:35 +01:00
Usman Baig
6d277b126e feat: display billing information with business name in OrganizationSettings component for improved user clarity 2026-02-20 03:10:08 +01:00
Usman Baig
4410366ccf feat: add optional business_name field to SubscriptionDetails interface in billing API for enhanced billing information 2026-02-20 03:03:21 +01:00
Usman Baig
826dbdbe63 feat: implement site limits based on subscription plans across dashboard and new site creation; enhance UI feedback for plan limits 2026-02-20 02:46:23 +01:00
343 changed files with 42846 additions and 13421 deletions

38
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,38 @@
# * 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:
push:
branches: [main, staging]
pull_request:
branches: [main, staging]
concurrency:
group: test-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
packages: read
jobs:
test:
name: unit-tests
runs-on: ${{ github.event_name == 'pull_request' && 'ubuntu-latest' || 'self-hosted' }}
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci
env:
NODE_AUTH_TOKEN: ${{ secrets.PKG_READ_TOKEN }}
- name: Run tests
run: npm test

4
.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
@@ -37,5 +40,6 @@ next-env.d.ts
# PWA
public/sw.js
public/sw 2.js
public/workbox-*.js
public/swe-worker-*.js

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

@@ -6,6 +6,352 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
## [Unreleased]
### Added
- **Funnels now track actions, not just pages.** When creating or editing a funnel, you can now choose between "Page Visit" and "Custom Event" for each step. Page Visit steps work as before — matching URLs. Custom Event steps let you track specific actions like signups, purchases, or button clicks. You can also add property filters to event steps (e.g., "purchase where plan is pro") to get even more specific about what you're measuring.
- **Edit your funnels.** You can now edit existing funnels — change the name, description, steps, or conversion window without having to delete and recreate them. Click the pencil icon on any funnel's detail page.
- **Conversion window.** Funnels now have a configurable time limit. Visitors must complete all steps within your chosen window (e.g., 7 days, 24 hours) to count as converted. Set it when creating or editing a funnel — quick presets for common windows, or type your own. Default is 7 days.
- **Filter your funnels.** Apply the same filters you use on the dashboard — by device, country, browser, UTM source, and more — directly on your funnel stats. See how your funnel performs for mobile visitors vs desktop, or for traffic from a specific campaign.
- **See where visitors go after dropping off.** Each funnel step now shows the top pages visitors navigated to after leaving the funnel. A quick preview appears inline, and you can expand to see the full list. Helps you understand why visitors aren't converting.
- **Conversion trends over time.** A new chart below your funnel shows how conversion rates change day by day. See at a glance whether your funnel is improving or degrading. Toggle individual steps on or off to pinpoint which step is changing.
- **Step-level breakdowns.** Click any step in your funnel stats to open a breakdown panel showing who converts at that step — split by device, country, browser, or traffic source. Useful for spotting segments that convert better or worse than average.
- **Up to 8 steps per funnel.** The step limit has been increased from 5 to 8, so you can track longer user journeys like multi-page onboarding flows or detailed checkout processes.
- **BunnyCDN integration.** Connect your BunnyCDN account in Settings > Integrations to monitor your CDN performance right alongside your analytics. A new "CDN" tab on your dashboard shows total bandwidth served, request volume, cache hit rate, origin response time, and error counts — each with percentage changes compared to the previous period. Charts show bandwidth trends (total vs cached), daily request volume, and error breakdowns over time. A geographic breakdown shows which countries consume the most bandwidth. When connecting, Pulse automatically filters your pull zones to only show ones matching your site's domain. Pulse only stores your API key encrypted and only reads statistics — it never modifies anything in your BunnyCDN account. You can disconnect and fully remove all CDN data at any time.
- **Google Search Console integration.** Connect your Google Search Console account in Settings > Integrations to see which search queries bring visitors to your site. A new "Search" tab on your dashboard shows total clicks, impressions, average CTR, and average ranking position — with percentage changes compared to the previous period. Browse your top search queries and top pages in sortable, paginated tables. Click any query to see which pages rank for it, or click any page to see which queries drive traffic to it. A trend chart shows how clicks and impressions change over time, and a green badge highlights new queries that appeared this period. Pulse only requests read-only access to your Search Console data, encrypts your Google credentials, and lets you disconnect and fully remove all search data at any time.
- **Free plan now visible on the Pricing page.** The free tier is no longer hidden — it's displayed as the first option on the Pricing page so you can see exactly what you get before signing up: 1 site, 5,000 monthly pageviews, and 6 months of data retention, completely free.
- **Free plan limited to 1 site.** Free accounts are now limited to a single site. If you need more, you can upgrade to Solo or above from the Pricing page.
### Improved
- **Redesigned Search card on the dashboard.** The Search section of the dashboard has been completely refreshed to match the rest of Pulse. Search queries now show proportional bars so you can visually compare which queries get the most impressions. Hovering a row reveals the impression share percentage. Position badges are now color-coded — green for page 1 rankings, orange for page 2, and red for queries buried beyond page 5. You can switch between your top search queries and top pages using tabs, and expand the full list in a searchable popup without leaving the dashboard.
- **Smaller, faster tracking script.** The tracking script is now about 20% smaller. Logic like page path cleaning, referrer filtering, error page detection, and input validation has been moved from your browser to the Pulse server. This means the script loads faster on every page, and Pulse can improve these features without needing you to update anything.
- **Automatic 404 page detection.** Pulse now detects error pages (404 / "Page Not Found") automatically on the server by reading your page title — no extra setup needed. Previously this ran in the browser and couldn't be improved without updating the script. Now Pulse can recognize more error page patterns over time, including pages in other languages, without any changes on your end.
- **Smarter bot filtering.** Pulse now catches more types of automated traffic that were slipping through — like headless browsers with default screen sizes, bot farms that rotate through different locations, and bots that fire duplicate events within milliseconds. Bot detection checks have also been moved from the tracking script to the server, making the script smaller and faster for real visitors.
- **Actionable empty states.** When a dashboard section has no data yet, you now get a direct action — like "Install tracking script" or "Build a UTM URL" — instead of just passive text. Gets you set up faster.
- **Animated numbers across the dashboard.** Stats like visitors, pageviews, bounce rate, and visit duration now smoothly count up or down when you switch date ranges, apply filters, or when real-time visitor counts change — instead of just jumping to the new value.
- **Inline bar charts on dashboard lists.** Pages, referrers, locations, technology, and campaigns now show subtle proportional bars behind each row, making it easier to compare values at a glance without reading numbers.
- **Redesigned Journeys page.** The Journeys page has been rebuilt — the depth slider now matches the rest of the UI and goes up to 10 steps, controls are integrated into the chart card, and Top Paths uses a clean compact list with inline bars instead of bulky cards.
- **More reliable visit duration tracking.** Visit duration was silently dropping to 0s for visitors who only viewed one page — especially on mobile or when closing a tab quickly. The tracking script now captures time-on-page more reliably across all browsers, and sessions where duration couldn't be measured are excluded from the average instead of counting as 0s. This makes the Visit Duration metric, Journeys, and Top Paths much more accurate.
- **More accurate rage click detection.** Rage clicks no longer fire when you triple-click to select text on a page. Previously, selecting a paragraph (a normal 3-click action) was being counted as a rage click, which inflated frustration metrics. Only genuinely frustrated rapid clicking is tracked now.
- **Fresher CDN data.** BunnyCDN statistics now refresh every 3 hours instead of once a day, so your CDN tab shows much more current bandwidth, request, and cache data.
- **More accurate dead click detection.** Dead clicks were being reported on elements that actually worked — like close buttons on cart drawers, modal dismiss buttons, and page content areas. Three fixes make dead clicks much more reliable:
- Buttons that trigger changes elsewhere on the page (closing a drawer, opening a modal) are no longer flagged as dead.
- Page content areas that aren't actually clickable (like `<main>` containers) are no longer treated as interactive elements.
- Single-page app navigations are now properly detected, so links that use client-side routing aren't mistakenly reported as broken.
- **Journeys page now shows data on low-traffic sites.** The Journeys page previously required at least 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
- **User Journeys tab.** A new "Journeys" tab on your site dashboard visualizes how visitors navigate through your site. A Sankey flow diagram shows the most common paths users take — from landing page through to exit — so you can see where traffic flows and where it drops off. Filter by entry page, adjust the depth (2-10 steps), and click any page in the diagram to drill into paths through it. Below the diagram, a "Top Paths" table ranks the most common full navigation sequences with session counts and average duration.
### Removed
- **Realtime visitors detail page.** The page that showed individual active visitors and their page-by-page session journey has been removed. The live visitor count on your dashboard still works — it just no longer links to a separate page.
### Added
- **Rage click detection.** Pulse now detects when visitors rapidly click the same element 3 or more times — a strong signal of UI frustration. Rage clicks are tracked automatically (no setup required) and surfaced in the new Behavior tab with the element, page, click count, and number of affected sessions.
- **Dead click detection.** Clicks on buttons, links, and other interactive elements that produce no visible result (no navigation, no DOM change, no network request) are now detected and reported. This helps you find broken buttons, disabled links, and unresponsive UI elements your visitors are struggling with.
- **Behavior tab.** A new tab in your site dashboard — alongside Dashboard, Uptime, and Funnels — dedicated to user behavior signals. Houses rage clicks, dead clicks, a by-page frustration breakdown, and scroll depth (moved from the main dashboard for a cleaner layout).
- **Frustration summary cards.** The Behavior tab opens with three at-a-glance cards: total rage clicks, total dead clicks, and total frustration signals with the most affected page — each with a percentage change compared to the previous period.
- **Scheduled Reports.** You can now get your analytics delivered automatically — set up daily, weekly, or monthly reports sent straight to your email, Slack, Discord, or any webhook. Each report includes your key stats (visitors, pageviews, bounce rate), top pages, and traffic sources, all in a clean branded format. Set them up in your site settings under the new "Reports" tab, and hit "Test" to preview before going live. You can create up to 10 schedules per site.
- **Time-of-day report scheduling.** Choose when your reports arrive — pick the hour, day of week (for weekly), or day of month (for monthly). Schedule cards show a human-readable description like "Every Monday at 9:00 AM (UTC)."
### Changed
- **Scroll depth moved to Behavior tab.** The scroll depth radar chart has been relocated from the main dashboard to the new Behavior tab, where it fits more naturally alongside other user behavior metrics.
### Fixed
- **Region names now display correctly.** Some regions were showing as cryptic codes like "14" (Poland), "KKC" (Thailand), or "IDF" (France) instead of their actual names. The Locations panel now shows proper region names like "Masovian", "Khon Kaen", and "Île-de-France."
## [0.14.0-alpha] - 2026-03-12
### Improved
- **Smarter referrer attribution.** Traffic that arrives without a referrer on a deep page (like a blog post) is now shown as "Shared Link" instead of "Direct." Real direct traffic — visitors who land on your homepage — still shows as "Direct." This gives you a much clearer picture of where your traffic actually comes from, since most unattributed deep-page visits are people clicking links shared in messaging apps or AI chatbots that strip the referrer header.
- **More in-app browsers detected.** Pulse now recognises visits from WhatsApp, Telegram, Snapchat, Pinterest, Reddit, and Threads in-app browsers and attributes them correctly instead of lumping them into "Direct."
- **Dashboard blocks are now consistent in height.** The Goals & Events and Scroll Depth panels now match the height of every other block on the dashboard.
- **Cleaner period picker.** The date range dropdown now has visual separators between the rolling windows (Today, Last 7 days, Last 30 days), the calendar periods (This week, This month), and Custom — so it's easy to tell them apart at a glance.
- **New date range options.** The period selector now includes "This week" (Monday to today) and "This month" (1st to today) alongside the existing rolling windows. Your selection is remembered between sessions.
- **Smarter comparison labels.** The "vs …" label under each stat now matches the period you're viewing — "vs yesterday" for today, "vs last week" for this week, "vs last month" for this month, and "vs previous N days" for rolling windows.
- **Refreshed stat headers.** The Unique Visitors, Total Pageviews, Bounce Rate, and Visit Duration stats at the top of the chart have a new look — uppercase labels, the percentage change shown inline next to the number, and an orange underline on whichever metric you're currently graphing.
- **Consistent green and red colors.** The up/down percentage indicators now use the same green and red as the rest of the app, instead of slightly different shades.
- **Scroll Depth is now a radar chart.** The Scroll Depth panel has been redesigned from a bar chart into a radar chart. The four scroll milestones (25%, 50%, 75%, 100%) are plotted as axes, with the filled shape showing how far visitors are getting through your pages at a glance.
- **Polished Goals & Events panel.** The Goals & Events block on your dashboard got a visual refresh to match the style of the Pages, Referrers, and Locations panels. Counts are shown in a consistent style, and hovering any row reveals what percentage of total events that action accounts for — sliding in smoothly from the right.
- **Smarter bot protection.** The security checks on shared dashboard access and organization settings now use action-specific tokens tied to each page. A token earned on one page can't be reused on another, making it harder for automated tools to bypass the captcha.
- **More resilient under Redis outages.** If the caching layer goes down temporarily, Pulse now continues enforcing rate limits using an in-memory fallback instead of letting all traffic through unchecked. This prevents one infrastructure hiccup from snowballing into a bigger problem.
- **Better handling of traffic bursts.** The system can now absorb 5x larger spikes of incoming events before applying backpressure. When events are dropped during extreme bursts, the system now tracks and logs exactly how many — so we can detect and respond to sustained overload before it affects your data.
- **Faster map and globe loading.** The interactive 3D globe and dotted map in the Locations panel now only load when you scroll down to them, instead of rendering immediately on page load. This makes the initial dashboard load faster and saves battery on mobile devices.
- **Real-time updates work across all servers.** If Pulse runs on multiple servers behind a load balancer, real-time visitor updates now stay in sync no matter which server you're connected to. Previously, you might miss live visitor changes if your connection landed on a different server than the one fetching data.
- **Lighter memory usage in long sessions.** If you manage many sites and keep Pulse open for hours, the app now automatically clears out old cached data for sites you're no longer viewing. This keeps the tab responsive and prevents it from slowly using more and more memory over time.
- **Cleaner login storage.** Temporary data left behind by abandoned sign-in attempts is now cleaned up automatically when the app loads. This prevents clutter from building up in your browser's storage over time.
- **Tidier annotation display.** If you've added a lot of annotations to your chart, only the 20 most recent are shown as lines on the chart to keep it readable. A "+N more" label lets you know there are additional annotations.
- **Even faster dashboard loading.** Your dashboard now fetches all its data — pages, locations, devices, referrers, performance, and goals — in a single request instead of seven separate ones. This means the entire dashboard appears at once rather than sections loading one by one, and puts much less strain on the server when many people are viewing their analytics at the same time.
- **Smoother real-time updates.** The real-time visitors page now streams updates instantly from the server instead of checking for new data every few seconds. Visitors appear and disappear in real-time with no delay, and the page uses far fewer server resources — especially when many people are watching their live traffic at the same time.
- **More reliable under heavy load.** Database queries now have automatic time limits so a single slow query can never lock up the system. If your dashboard or stats take too long to load, the request is gracefully cancelled instead of hanging forever — keeping everything responsive even during traffic spikes.
- **Smarter caching for dashboard data.** Your dashboard stats are now cached for longer and shared more efficiently between requests. When the cache refreshes, only one request does the work while others wait for the result — so your dashboard loads consistently fast even when lots of people are viewing their analytics at the same time.
- **Faster filtered views.** When you filter your dashboard by country, browser, page, or any other dimension, the results are now cached so repeat views load instantly. If multiple people apply the same filter, only one lookup runs and the result is shared — making filtered views much snappier under heavy use.
- **Faster entry and exit page stats.** The queries that figure out which pages visitors land on and leave from have been rewritten to be much more efficient. Instead of sorting through every single event, they now look up just the first and last page per visit — so your Entry Pages and Exit Pages panels load noticeably faster, especially on high-traffic sites.
- **Faster goal stats.** The Goals panel on your dashboard now loads faster, especially for sites with many custom events. Goal names are now looked up in a single step instead of one at a time.
- **Fairer performance under heavy traffic.** One busy site can no longer slow down dashboards for everyone else. Each site now gets its own dedicated share of server resources, so your analytics stay fast and responsive even when other sites on the platform are experiencing traffic spikes.
- **Smoother exports.** Exporting your data to PDF, Excel, or CSV no longer freezes the page. You'll see a clear "Exporting..." indicator while your file is being prepared, and the rest of the dashboard stays fully interactive.
- **Smoother "View All" popups.** Opening the expanded view for Pages, Locations, Technology, Referrers, or Campaigns now scrolls smoothly even with hundreds of items. Only the rows you can see are rendered, so the popup opens instantly on any device.
- **Faster daily stats processing.** Behind the scenes, the system that calculates your daily visitor stats now automatically scales up when there are more sites to process — so your dashboard numbers stay accurate and up to date even as the platform grows.
- **More reliable background processing.** When multiple servers are running, long-running background tasks like daily stats calculations no longer risk being interrupted or duplicated. The system now keeps its coordination lock active for as long as the task is running.
### Added
- **Peak Hours heatmap.** A new panel on your dashboard shows a 7×24 grid of when your visitors are most active — every day of the week against every hour of the day. Cells glow brighter in brand orange the busier that hour is. Hover any cell to see the exact pageview count. No other indie analytics tool surfaces this on the main dashboard.
- **Interactive 3D Globe.** The Locations panel now has a "Globe" tab showing your visitor locations on a beautiful, interactive 3D globe. Drag to rotate, and orange markers highlight where your visitors are — sized by how much traffic each country sends. The globe slowly auto-rotates and adapts to light and dark mode.
- **Dotted world map.** The "Map" tab in Locations now uses a sleek dotted map style instead of the old filled map. Country markers glow in brand orange and show a tooltip with the country name and pageview count when you hover.
- **Hide unknown locations.** New toggle in Site Settings under Data & Privacy to hide "Unknown" entries from your Locations panel. When geographic data can't be determined for a visitor, it normally shows as "Unknown" in countries, cities, and regions. Turn this on to keep your location stats clean and only show resolved locations.
- **Chart annotations.** Mark events on your dashboard timeline — like deploys, campaigns, or incidents — so you always know why traffic changed. Click the + button on the chart to add a note on any date. Annotations appear as colored markers on the chart: blue for deploys, green for campaigns, red for incidents. Hover to see the details. Team owners and admins can add, edit, and delete annotations; everyone else (including public dashboard viewers) can see them.
### Improved
- **Beautiful funnel visualization.** Funnel reports now show a smooth, animated funnel shape instead of a plain bar chart. Each step flows into the next with curved segments, hover effects, and labels showing visitor counts and conversion percentages at a glance.
- **Tidier dashboard layout.** The tab navigation (Dashboard, Uptime, Funnels, Settings) now sits above your site name and controls, keeping the tabs front and center.
- **Instant tab switching.** Clicking between Dashboard, Uptime, Funnels, and Settings now feels instant — the tab bar stays in place while the page content loads below it, instead of the whole screen flashing with a loading skeleton.
- **Smooth tab animations.** Switching tabs now plays a sliding indicator animation on the active tab and a subtle crossfade on the page content, making navigation feel polished and responsive.
- **Cleaner focus styles.** Buttons, tabs, and links no longer show an orange outline when you click them — the focus ring now only appears when navigating with the keyboard, keeping the interface clean.
- **Faster dashboard loading.** Switching to the Dashboard and Map tabs is now instant — no more brief lag or delay when navigating between sections.
- **Expand icon for data panels.** Pages, Referrers, Locations, Technology, and Campaigns panels now show a small expand icon next to the title when there's more data to see, replacing the old "View all" button at the bottom.
- **Better expanded views.** When you expand a data panel, the popup is now wider and taller so you can see more at once. Each row shows a percentage on hover, clicking a row filters your dashboard, and there's a search bar at the top to quickly find what you're looking for.
- **Smoother theme switching.** Toggling between light and dark mode now plays a satisfying circular reveal animation that expands from the toggle button, instead of everything just flipping instantly.
- **Cleaner site navigation.** Dashboard, Uptime, Funnels, and Settings now use an underline tab bar instead of floating buttons. The active section is highlighted with an orange underline, making it easy to see where you are and switch between views.
- **Consistent icon style.** All dashboard icons now use a single, unified icon set for a cleaner look across Technology, Locations, Campaigns, and Referrers panels.
### Fixed
- **Correct Instagram attribution.** Visits from Instagram's in-app browser were showing as "Facebook" because Instagram routes shared links through Facebook's URL redirector. Pulse now checks the User-Agent to detect the real source app.
- **Android and iOS now show up in OS stats.** A bug in the User-Agent parsing order meant Android was always classified as "Linux" (because Android UAs contain "Linux") and iOS as "macOS" (because iPhone UAs contain "like Mac OS X"). Both are now detected correctly.
- **Charts no longer show tomorrow's date.** The visitor chart on 7-day and 30-day views could display the next day with zero traffic, making it look like a sudden drop. The chart now ends on today.
- **Capitalized technology labels.** Device types, browsers, and OS names in the Technology panel now display with a capital first letter (e.g. "Desktop" instead of "desktop").
- **Login no longer gets stuck after updates.** If you happened to have Pulse open when a new version was deployed, logging back in could get stuck on a loading screen. The app now automatically refreshes itself to pick up the latest version.
- **City and region data is now accurate.** Location data was incorrectly showing the CDN server's location (e.g. Paris, Villeurbanne) instead of the visitor's actual city. Fixed by reading the correct visitor IP header from Bunny CDN.
- **"Reset Data" now clears everything.** Previously, resetting a site's data in Settings only removed pageviews and daily stats. Uptime check history, uptime daily stats, and cached dashboard data were left behind. All collected data is now properly cleared when you reset, while your site configuration, goals, funnels, and uptime monitors are kept.
## [0.13.0-alpha] - 2026-03-07
### Added
- **Dashboard filtering.** Filter your entire dashboard by any dimension — browser, country, page, device, OS, referrer, or UTM parameters. A single "Filter" button lets you browse dimensions, see real values from your data with visitor counts, search or type a custom value, and apply — all in a quick dropdown. Active filters appear as removable pills above your charts. Stack multiple filters to narrow things down. Filters are saved in the URL so you can bookmark or share a filtered view.
- **Click any item to filter.** Click a referrer, browser, country, page, or any other item in your dashboard panels to instantly filter the entire dashboard to just that traffic.
- **Hover percentages.** Hover over any item in Pages, Locations, Technology, or Referrers to see what percentage of total traffic it represents.
- **Custom event properties.** Your custom events can now carry extra context — for example, `pulse.track('signup', { plan: 'pro', source: 'landing' })`. Click any event in Goals & Events to see a breakdown of its properties and values, no setup needed.
- **AI traffic source identification.** Pulse recognizes visitors from ChatGPT, Perplexity, Claude, Gemini, Copilot, DeepSeek, Grok, Meta AI, You.com, and Phind. These appear in Referrers with proper icons and names instead of raw URLs.
- **Automatic outbound link tracking.** Tracks when visitors click links to other websites. Shows up as "outbound link" events in Goals & Events — no setup needed.
- **Automatic file download tracking.** Downloads of PDFs, ZIPs, Excel, Word, MP3s, and 20+ other formats are recorded as "file download" events automatically.
- **Automatic 404 detection.** Detects when visitors land on pages that don't exist and records "404" events so you can find and fix broken links.
- **Automatic scroll depth tracking.** Tracks how far visitors scroll — at 25%, 50%, 75%, and 100% — helping you understand which content keeps people reading.
### Improved
- **Chart rebuilt from scratch.** Cleaner stat cards, wider Y-axis that no longer clips labels, whole-number ticks for visitor and pageview counts, lighter grid lines, streamlined toolbar, and a properly positioned live indicator.
- **Campaigns panel redesigned.** Clean row-based layout with UTM medium and campaign always visible below the source name. Now sits in a half-width grid next to Goals & Events.
- **Better filter design.** Solid brand-colored filter pills that are easy to spot in light and dark mode. A funnel icon on the filter button. Click any pill to remove it.
- **Underline tab switchers.** Pages, Locations, and Technology panels now use clean underline tabs instead of pill-style switchers.
- **"View all" at the bottom.** The expand action on each panel is now a subtle "View all" link at the bottom of the list instead of an icon in the header.
- **Faster dashboard loading.** Each section loads independently with smart caching. Data refreshes in the background, and switching tabs pauses updates to save resources — resuming when you return.
- **Smoother navigation.** Switching pages, changing organizations, or signing in no longer triggers unnecessary background requests.
- **Loading screen while redirecting to sign-in.** The login page now shows the Pulse logo and a message instead of a blank white screen.
- **More reliable billing.** Plan changes, cancellations, and invoice views now handle session expiry and errors gracefully.
- **Stronger browser security.** Your browser now only loads scripts, styles, and images from trusted sources, adding protection against cross-site scripting.
- **More resilient analytics processing.** The system that processes your events now recovers automatically from unexpected errors instead of stopping silently.
- **Dashboard stays responsive under heavy traffic.** Parallel queries are limited during peak usage, and in-progress queries are cancelled when you navigate away.
- **Cleaner error messages.** Invalid form submissions show a simple message instead of exposing internal details.
### Fixed
- **Tracking script now works on all tracked websites.** Page views were silently failing due to two separate issues. Both are fixed — your dashboard receives visits from all registered domains as expected.
- **Rate limiting works correctly.** A bug was treating all visitors as the same person, so one heavy user could block everyone. Each visitor is now identified individually.
- **Real-time visitor count no longer stops updating.** The live counter would hit a rate limit and stop refreshing. It now has enough headroom for normal usage.
- **Team members can view real-time data.** Previously only the site creator could see live visitors. Now any team member in the same organization has access.
- **Funnel details load correctly.** Opening a funnel previously showed an error. Funnels now display step-by-step conversion data as expected.
- **Consistent date handling.** Funnels now use the same date format as the rest of Pulse, so date pickers and bookmarked links work correctly everywhere.
- **Deleting a site cleans up all data.** Orphaned analytics events are now removed automatically before the site is deleted.
- **App switcher and site icons load correctly.** Logos and favicons were blocked by a security policy. Fixed by allowing images from Ciphera and Google's favicon service.
- **Current session highlighted in settings.** The active session marker now works correctly.
- **Notifications load on sign-in.** The notification bell no longer errors briefly after signing in.
- **Duplicate filters no longer stack.** Clicking the same item twice no longer adds the same filter again.
- **Campaigns respect active filters.** The Campaigns panel now filters along with everything else instead of always showing all campaigns.
- **No duplicate "Direct" in referrer filter.** The referrer suggestions no longer show "Direct" twice.
- **Filter dropdowns show all your data.** Previously limited to 10 items — now loads up to 100 values.
- **Chart Y-axis shows whole numbers.** Visitor and pageview counts no longer show fractional values like "0.75 visitors".
- **Duplicate goal names detected reliably.** Goal name uniqueness checks now work correctly regardless of your setup.
- **Health checks stay accurate.** The backend health check no longer falsely reports the service as unhealthy after sustained traffic.
## [0.12.0-alpha] - 2026-03-01
### Added
- **Automated testing for improved reliability.** Pulse now has a comprehensive test suite that verifies critical parts of the app work correctly before every release. This covers login and session protection, error tracking, online/offline detection, and background data refreshing. These checks run automatically so regressions are caught before they reach you.
- **App Switcher in User Menu.** Click your profile in the top right and you'll now see a "Ciphera Apps" section. Expand it to quickly jump between Pulse, Drop (file sharing), and your Ciphera Account settings. This makes it easier to discover and navigate between Ciphera products without signing in again.
- **Session synchronization across tabs.** When you sign out in one browser tab, you're now automatically signed out in all other tabs of the same app. This prevents situations where you might still appear signed in on another tab after logging out. The same applies to signing in — when you sign in on one tab, other tabs will update to reflect your authenticated state.
- **Session expiration warning.** You'll now see a heads-up banner 3 minutes before your session expires, giving you time to click "Stay signed in" to extend your session. If you ignore it or dismiss it, your session will end naturally after the 15-minute timeout for security. If you interact with the app (click, type, scroll) while the warning is showing, it automatically extends your session.
- **Faster billing page loading.** Your subscription details now load much quicker when you visit the billing page. Previously, several requests to our payment provider were made one after another, which could add 1-2 seconds to the page load. Now these happen simultaneously, cutting the wait time significantly. If any request takes too long, we gracefully continue so you always see your billing information without frustrating delays.
- **Faster funnel analysis for multi-step conversions.** We've significantly improved how conversion funnels are calculated. Instead of scanning your data multiple times for each step in a funnel, we now do it in a single efficient pass. This means complex funnels with multiple steps load almost instantly instead of taking seconds—or even timing out. We've also added a reasonable limit of 5 steps per funnel to ensure optimal performance.
- **More reliable database connections under heavy load.** We've optimized how Pulse manages its database connections to handle much higher traffic without issues. By increasing the connection pool size and improving how connections are reused, your dashboard stays responsive even when thousands of users are viewing analytics simultaneously. We also added better monitoring so we can detect and address connection issues before they affect you.
- **Better support for growing teams and traffic.** We've added infrastructure improvements that allow Pulse to run smoothly across multiple servers. When you scale up to handle more traffic, our background processes—like daily analytics calculations and data cleanup—will coordinate automatically so they don't conflict with each other. This ensures reliable performance as your team and data grow.
- **Smarter protection for heavy dashboard operations.** We've implemented a new tiered rate limiting system that treats complex dashboard queries differently from simple requests. Expensive operations—like loading your full dashboard with all its charts and data—now have their own dedicated limits to prevent anyone from accidentally overwhelming the system with too many rapid refreshes. This keeps everything running smoothly for everyone, especially during busy periods.
- **Smarter caching for faster dashboard loading.** We've added intelligent caching headers to our API responses, so your browser can remember recently loaded data and show it instantly when you navigate between pages. This works alongside our existing server-side caching to make your dashboard feel even more responsive—especially when switching between different date ranges or sections.
- **More flexible uptime monitoring.** We've made our uptime checker more adaptable to different needs. Instead of a fixed limit on how many websites we can check simultaneously, you can now configure this based on your requirements. This means faster uptime checks for busy sites with many monitors, while keeping things efficient for smaller setups.
- **Smarter data cleanup for better performance.** We've improved how old analytics data is cleaned up to keep everything running smoothly. Instead of deleting large amounts of data all at once—which could slow things down—we now remove old data in small, efficient batches. This ensures your dashboard stays fast and responsive even as we clean up months of historical data behind the scenes.
- **Faster analytics processing for all sites.** We've upgraded how your daily analytics are calculated behind the scenes. Instead of processing sites one by one, we now analyze multiple sites simultaneously using a smart parallel system. This means your daily stats—like visitor counts and page views—are updated more quickly and consistently, even as we handle data from thousands of websites.
- **Lighter dashboard data transfers.** Your dashboard now loads data in smaller, focused pieces instead of one massive bundle. This means faster loading times—especially on slower connections—and your analytics appear section by section as they become ready, rather than making you wait for everything at once.
- **Smarter data fetching.** Your dashboard now automatically prevents duplicate requests when multiple components ask for the same data at the same time. It also briefly caches recent responses, so switching between pages feels instant while still keeping everything up to date. This reduces server load and makes the app feel snappier.
- **Smarter dashboard updates.** Your dashboard now knows when you're actively viewing it versus when it's in the background. When you switch to another tab, we intelligently slow down data refreshes to save resources, then instantly catch up when you return. This keeps your analytics current without putting unnecessary load on the system.
- **Instant real-time visitor counts.** Your dashboard's "current visitors" counter now updates lightning-fast using an optimized tracking system. Instead of scanning your entire database, we maintain a live session index that shows active visitors in milliseconds—even when thousands of people are browsing your sites simultaneously.
- **Faster event tracking.** Your analytics data is now captured instantly without slowing down your website. We've switched to asynchronous processing that collects events in batches of 100, so your visitors' page views and interactions are recorded with zero impact on their browsing experience, even during traffic spikes.
- **Faster dashboard loading.** Your site analytics now load almost instantly, even during busy periods. Behind the scenes, we've added intelligent caching that remembers your dashboard data for 30 seconds and refreshes it automatically in the background. Real-time visitor counts are updated every 5 seconds so you always see current activity without waiting.
- **Better data management for long-term performance.** We've restructured how your analytics data is stored so the app stays fast even as you collect months of data. Old data is now automatically organized by month and cleaned up efficiently based on your retention settings, keeping everything running smoothly no matter how much traffic you get.
- **Smarter database indexing.** We've optimized how your analytics data is indexed, making common queries—like loading your dashboard or filtering by date—significantly faster. This also reduces storage overhead, keeping the app lean as your data grows.
- **Faster dashboard statistics.** Loading stats for any date range is now much quicker. Instead of recalculating from scratch every time, we use pre-computed daily summaries so your analytics appear instantly, even for months of data.
- **Performance insights. Track how fast your site loads with Core Web Vitals (page load speed, layout shifts, responsiveness). Turn it on in Site Settings → Data & Privacy to see a performance widget on your dashboard.
- **Goals & Events.** Define custom goals (e.g. signup, purchase) and track them with `pulse.track()` in your snippet. Counts appear on your dashboard once you add goals in Site Settings → Goals & Events.
- **2FA recovery codes backup.** When you enable 2FA, you receive recovery codes. You can now regenerate new codes (with password confirmation) from Settings and download them as a `.txt` file. Regenerating invalidates all existing codes.
### Changed
- **Request ID tracing for debugging.** All API requests now include a unique Request ID header (`X-Request-ID`) that helps trace requests across frontend and backend services. When errors occur, the Request ID is included in the response, making it easy to find the exact request in server logs for debugging.
- **App Switcher now shows consistent order.** The Ciphera Apps menu now always displays apps in the same order: Pulse, Drop, Auth — regardless of which app you're currently using. Previously, the current app was shown first, causing the order to change depending on context. This creates a more predictable navigation experience.
### Fixed
- **Shopify and embedded site tracking.** The Pulse tracking script now loads correctly when embedded on third-party sites like Shopify stores, WooCommerce, or custom storefronts. Previously, tracking failed because the script was redirected to the login page instead of loading.
- **Opening Pulse from the Ciphera hub.** Clicking Pulse on the auth apps page (auth.ciphera.net/apps) now signs you in correctly instead of showing "Invalid state". Previously, leftover OAuth data from a past login attempt could block the session flow; the callback now detects redirects from the hub (no `state` in the URL), clears stale PKCE storage, and completes token exchange.
- **Admin organizations list.** Organizations that created a site but never subscribed now appear in the admin list. Previously only orgs with a billing row were shown.
- **Sign in after inactivity.** Clicking "Sign in" after a period of inactivity no longer does nothing. Previously, stale refresh cookies caused the middleware to redirect away from the login page; now only a valid access token triggers that redirect, so you can complete OAuth sign-in when your session has expired.
- **Frequent re-login.** You no longer have to sign in multiple times a day. When the access token expires after 15 minutes of inactivity, the app now automatically refreshes it using your refresh token on the next page load, so you stay logged in for up to 30 days.
- **2FA disable now requires password confirmation.** Disabling 2FA sends the derived password to the backend for verification. This prevents an attacker with a hijacked session from stripping 2FA.
- **More accurate visitor tracking.** We fixed rare edge cases where visitor counts could be slightly off during busy traffic spikes. Previously, the timestamp-based session ID generation could occasionally create overlapping identifiers. Every visitor now gets a truly unique UUID that never overlaps with others, ensuring your analytics are always precise.
- **More reliable background processing.** When multiple Pulse servers are running, background tasks like daily analytics calculations and data cleanup now coordinate more safely. Previously, under rare timing conditions, two servers could accidentally run the same task at the same time, which could lead to slightly inaccurate stats. Each server now holds a unique token that prevents one from interfering with another's work.
- **Cross-tab sign-out cleanup.** Signing out in one tab now fully clears your session data in all other tabs. Previously, some session-related entries were left behind, which could briefly show stale state before the redirect completed.
- **Settings sidebar highlight.** The "Manage Account" section in Settings now stays highlighted when you're viewing Trusted Devices or Security Activity. Previously, navigating to a sub-page removed the highlight from the parent section, making it unclear which group you were in.
- **More accurate readiness checks.** The service health endpoint now actively verifies that the cache and real-time tracker are reachable, not just configured. Previously, the readiness check only confirmed these services were set up—not that they were actually responding—so the API could report "ready" even when Redis or the tracker was down.
## [0.11.1-alpha] - 2026-02-23
### Changed
- **Safer sign-in from the Ciphera hub.** When you open Pulse from the Ciphera Apps page, your credentials are no longer visible in the browser address bar. Sign-in now uses a secure one-time code that expires in seconds, so your session stays private even if someone sees your screen or browser history.
## [0.11.0-alpha] - 2026-02-22
### Added
- **Better page titles.** Browser tabs now show which site and page you're on (e.g. "Uptime · example.com | Pulse") instead of the same generic title everywhere.
- **Link previews for public dashboards.** Sharing a public dashboard link on social media now shows a proper preview with the site name and description.
- **Faster login redirects.** If you're not signed in and try to open a dashboard or settings page, you're redirected to login immediately instead of seeing a blank page first. Already-signed-in users who visit the login page are sent straight to the dashboard.
- **Graceful error recovery.** If a page crashes, you now see a friendly error screen with a "Try again" button instead of a blank white page. Each section of the app has its own error message so you know exactly what went wrong.
- **Security headers.** All pages now include clickjacking protection, MIME-sniffing prevention, a strict referrer policy, and HSTS. Browser APIs like camera and microphone are explicitly disabled.
- **Better form experience.** Forms now auto-focus the first field when they open, text inputs enforce character limits with a visible counter when you're close, and the settings page warns you before navigating away with unsaved changes.
- **Accessibility improvements.** The notification bell, workspace switcher, and all dashboard tabs are now fully keyboard-navigable. Screen readers announce unread counts, active organizations, and tab changes correctly. Decorative icons are hidden from assistive technology.
- **Smooth organization switching.** Switching between organizations now shows a branded loading screen instead of a blank flash while the page reloads.
- **Graceful server shutdown.** Deployments no longer kill in-flight requests or interrupt background tasks. The server finishes ongoing work before shutting down, so your active sessions aren't cut off mid-action.
- **Database connection pooling.** The backend now limits and recycles database connections, preventing exhaustion under load and keeping queries fast even with many concurrent users.
- **Date range validation.** Analytics, funnel, and uptime queries now reject invalid date ranges (end before start, or spans longer than a year) and show a clear error instead of empty or confusing results.
- **Excluded paths limit.** Sites can now have up to 50 excluded paths. Previously there was no cap, which could slow down event processing; the limit keeps things fast while still giving you flexibility.
### Changed
- **Smoother loading experience.** Pages now show a subtle preview of the layout while data loads instead of a blank screen or spinner. This applies everywhere — dashboards, settings, uptime, funnels, notifications, billing, and detail modals.
- **Clearer error messages.** When something goes wrong, the error message now tells you what failed (e.g. "Failed to load uptime monitors") instead of a generic "Failed to load data".
- **Faster favicon loading.** Site icons in the dashboard, referrers, and campaigns now use Next.js image optimization for better caching and lazy loading.
- **Tighter name limits.** Site, funnel, and monitor names are now capped at 100 characters instead of 255 — long enough for any real name, short enough to not break the UI.
- **Stricter type safety.** Eliminated all `any` types and `@ts-ignore` suppressions across the codebase, so the TypeScript compiler catches more bugs at build time and fewer edge cases slip through.
- **Smaller page downloads.** Icon imports are now tree-shaken so only the icons actually used are included in the bundle, reducing download size and speeding up page loads.
- **Removed debug logs.** Auth and organization-switching details no longer leak into the browser console in production. Error logs are now also suppressed in production and only appear during development.
### Fixed
- **Landing page dashboard preview.** The homepage now shows a realistic preview of the Pulse dashboard instead of an empty placeholder.
- **Logout redirect loop.** Signing out no longer bounces you straight to Ciphera Auth. You now land on the Pulse homepage where you can choose to sign back in.
- **No more loading flicker.** Fast-loading pages no longer flash a loading state for a split second before showing content.
- **Organization context switch.** Switching away from a deleted organization now stores the session correctly instead of using an insecure fallback.
- **Dark mode uptime chart.** The response time chart on the uptime page now correctly follows your dark mode preference instead of always showing a white tooltip background.
- **Onboarding form limits.** The welcome page now enforces the same character limits as the rest of the app.
- **Audit log reliability.** Failed audit log writes are now logged to the server instead of being silently ignored, so gaps in the audit trail are detectable.
- **Safer error messages.** Server errors no longer expose internal details (database errors, stack traces) to the browser. You see a clear message like "Failed to create site" while the full error is logged server-side for debugging.
- **Content Security Policy.** The backend CSP header was being overwritten by a duplicate, and the captcha service was incorrectly whitelisted under image sources instead of connection sources. Both are now fixed.
- **Logout redirect loop.** Signing out no longer bounces you straight to Ciphera Auth. You now land on the Pulse homepage where you can choose to sign back in.
- **Date range edge case.** The maximum date range check could be off by a day due to an internal time adjustment. It now compares calendar days accurately.
## [0.10.0-alpha] - 2026-02-21
### Changed
- **Design consistency (PULSE-59).** Pulse now feels more cohesive across all pages — headings, buttons, and layout are consistent.
- **Headings.** Marketing and integration pages use the same heading sizes for a clearer visual hierarchy.
- **Buttons.** Settings pages and the verification modal use consistent button styles. The Enterprise "Contact us" button on pricing now matches the rest.
- **Settings layout.** Profile settings, Organization Settings, and Site Settings now span the full width of the page, matching the dashboard.
- **Charts and maps.** Analytics charts, funnel views, and the uptime map now use Pulse's brand colors correctly in both light and dark mode.
- **Integration guides.** Code examples in the integration and installation guides look cleaner and work better in dark mode.
- **Dark mode.** Text and backgrounds across settings, pricing, and funnels are easier to read when you switch themes.
- **Cards and panels.** All cards use consistent padding for a more even layout.
- **Integration pages.** Integration setup guides have more comfortable spacing at the top.
- **Org slug.** The organization URL prefix correctly shows `pulse.ciphera.net/` instead of the wrong domain.
## [0.9.0-alpha] - 2026-02-21
### Added
- **Data retention settings (PULSE-58).** Site owners can choose how long raw event data is kept (1 month to 3 years depending on plan). Events older than the retention period are automatically deleted every 24 hours. Aggregated daily stats are preserved so historical charts remain intact.
- **Data Retention section in Site Settings.** Under Data & Privacy, a dropdown lets you set retention; options are capped by your plan (free: up to 6 months, solo: 1 year, team: 2 years, business: 3 years).
- **Privacy snippet includes retention.** The generated privacy policy text now mentions when raw data is automatically deleted.
## [0.8.0-alpha] - 2026-02-20
### Added
- **Renewal date and amount.** The dashboard and billing tab now show when your subscription renews and how much you'll be charged.
- **Invoice preview when changing plans.** Before you switch plans, you can see exactly what your next invoice will be (including prorations).
- **Pay now for open invoices.** Unpaid invoices show a clear "Pay now" button so you can settle them quickly.
- **Enterprise contact.** The pricing page Enterprise plan now links to email us directly instead of checkout.
- **Past due alert.** If your payment fails, a red banner appears with a link to update your payment method.
- **Pageview usage bar.** Your billing card shows a color-coded bar so you can see at a glance how close you are to your limit (green, then amber, then red).
### Changed
- **Change plan flow.** Cleaner plan selector with Solo, Team, and Business options. Shows which plan you're on and a preview of your next invoice. If the preview can't be calculated, you'll see a friendly message instead of a blank screen.
- **Billing tab layout.** Improved spacing, clearer headings, and better focus when using keyboard navigation.
- **Pricing page layout.** Updated spacing and typography. Slider and billing toggle are more accessible.
- **Billing Portal return.** After updating your payment method in Stripe's portal, you're taken back to the billing tab instead of the general settings page.
### Fixed
- **Theme toggle crash.** Fixed a crash that could occur when switching between light and dark mode on the pricing page and then opening organization settings.
## [0.7.0-alpha] - 2026-02-17
### Changed
@@ -82,7 +428,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
---
[Unreleased]: https://github.com/ciphera-net/pulse/compare/v0.7.0-alpha...HEAD
[Unreleased]: https://github.com/ciphera-net/pulse/compare/v0.13.0-alpha...HEAD
[0.13.0-alpha]: https://github.com/ciphera-net/pulse/releases/tag/v0.13.0-alpha
[0.12.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.11.1-alpha...v0.12.0-alpha
[0.11.1-alpha]: https://github.com/ciphera-net/pulse/compare/v0.11.0-alpha...v0.11.1-alpha
[0.11.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.10.0-alpha...v0.11.0-alpha
[0.10.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.9.0-alpha...v0.10.0-alpha
[0.9.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.8.0-alpha...v0.9.0-alpha
[0.8.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.7.0-alpha...v0.8.0-alpha
[0.7.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.6.0-alpha...v0.7.0-alpha
[0.6.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.5.1-alpha...v0.6.0-alpha
[0.5.1-alpha]: https://github.com/ciphera-net/pulse/compare/v0.5.0-alpha...v0.5.1-alpha

672
LICENSE
View File

@@ -1,17 +1,663 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2024-2026 Ciphera
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.

View File

@@ -0,0 +1,99 @@
import { describe, it, expect } from 'vitest'
import { NextRequest } from 'next/server'
import { middleware } from '../middleware'
function createRequest(path: string, cookies: Record<string, string> = {}): NextRequest {
const url = new URL(path, 'http://localhost:3000')
const req = new NextRequest(url)
for (const [name, value] of Object.entries(cookies)) {
req.cookies.set(name, value)
}
return req
}
describe('middleware', () => {
describe('public routes', () => {
const publicPaths = [
'/',
'/login',
'/signup',
'/auth/callback',
'/pricing',
'/features',
'/about',
'/faq',
'/changelog',
'/installation',
'/script.js',
]
publicPaths.forEach((path) => {
it(`allows unauthenticated access to ${path}`, () => {
const res = middleware(createRequest(path))
// NextResponse.next() does not set a Location header
expect(res.headers.get('Location')).toBeNull()
})
})
})
describe('public prefixes', () => {
it('allows /share/* without auth', () => {
const res = middleware(createRequest('/share/abc123'))
expect(res.headers.get('Location')).toBeNull()
})
it('allows /integrations without auth', () => {
const res = middleware(createRequest('/integrations'))
expect(res.headers.get('Location')).toBeNull()
})
it('allows /docs without auth', () => {
const res = middleware(createRequest('/docs'))
expect(res.headers.get('Location')).toBeNull()
})
})
describe('protected routes', () => {
it('redirects unauthenticated users to /login', () => {
const res = middleware(createRequest('/sites'))
expect(res.headers.get('Location')).toContain('/login')
})
it('redirects unauthenticated users from /settings to /login', () => {
const res = middleware(createRequest('/settings'))
expect(res.headers.get('Location')).toContain('/login')
})
it('allows access with access_token cookie', () => {
const res = middleware(createRequest('/sites', { access_token: 'tok' }))
expect(res.headers.get('Location')).toBeNull()
})
it('allows access with refresh_token cookie only', () => {
const res = middleware(createRequest('/sites', { refresh_token: 'tok' }))
expect(res.headers.get('Location')).toBeNull()
})
})
describe('auth-only route redirects', () => {
it('redirects authenticated user from /login to /', () => {
const res = middleware(createRequest('/login', { access_token: 'tok' }))
const location = res.headers.get('Location')
expect(location).not.toBeNull()
expect(new URL(location!).pathname).toBe('/')
})
it('redirects authenticated user from /signup to /', () => {
const res = middleware(createRequest('/signup', { access_token: 'tok' }))
const location = res.headers.get('Location')
expect(location).not.toBeNull()
expect(new URL(location!).pathname).toBe('/')
})
it('does NOT redirect from /login with only refresh_token (stale session)', () => {
const res = middleware(createRequest('/login', { refresh_token: 'tok' }))
// Should allow through to /login since only refresh_token is present
expect(res.headers.get('Location')).toBeNull()
})
})
})

19
app/about/layout.tsx Normal file
View File

@@ -0,0 +1,19 @@
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'About | Pulse',
description: 'Pulse is built by Ciphera — privacy-first web analytics made in Switzerland.',
openGraph: {
title: 'About | Pulse',
description: 'Pulse is built by Ciphera — privacy-first web analytics made in Switzerland.',
siteName: 'Pulse by Ciphera',
},
}
export default function AboutLayout({
children,
}: {
children: React.ReactNode
}) {
return children
}

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

@@ -1,19 +1,11 @@
'use server'
import { cookies } from 'next/headers'
import { logger } from '@/lib/utils/logger'
import { getCookieDomain } from '@/lib/utils/cookies'
const AUTH_API_URL = process.env.NEXT_PUBLIC_AUTH_API_URL || process.env.NEXT_PUBLIC_AUTH_URL || 'http://localhost:8081'
// * Determine cookie domain dynamically
// * In production (on ciphera.net), we want to share cookies with subdomains (e.g. pulse-api.ciphera.net)
// * In local dev (localhost), we don't set a domain
const getCookieDomain = () => {
if (process.env.NODE_ENV === 'production') {
return '.ciphera.net'
}
return undefined
}
interface AuthResponse {
access_token: string
refresh_token: string
@@ -32,19 +24,23 @@ interface UserPayload {
/** Error type returned to client for mapping to user-facing copy (no sensitive details). */
export type AuthExchangeErrorType = 'network' | 'expired' | 'invalid' | 'server'
export async function exchangeAuthCode(code: string, codeVerifier: string, redirectUri: string) {
export async function exchangeAuthCode(code: string, codeVerifier: string | null, redirectUri: string) {
try {
// * IMPORTANT: credentials: 'include' is required to receive httpOnly cookies from Auth API
// * The Auth API sets access_token, refresh_token, and csrf_token as httpOnly cookies
// * We must forward these to the browser for cross-subdomain auth to work
const res = await fetch(`${AUTH_API_URL}/oauth/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include', // * Critical: receives httpOnly cookies from Auth API
body: JSON.stringify({
grant_type: 'authorization_code',
code,
client_id: 'pulse-app',
redirect_uri: redirectUri,
code_verifier: codeVerifier,
code_verifier: codeVerifier || '',
}),
})
@@ -90,6 +86,50 @@ export async function exchangeAuthCode(code: string, codeVerifier: string, redir
maxAge: 60 * 60 * 24 * 30 // 30 days
})
// * Forward cookies from Auth API response to browser
// * The Auth API sets httpOnly cookies on auth.ciphera.net - we need to mirror them on pulse.ciphera.net
const setCookieHeaders = res.headers.getSetCookie()
if (setCookieHeaders && setCookieHeaders.length > 0) {
for (const cookieStr of setCookieHeaders) {
// * Parse Set-Cookie header (format: name=value; attributes...)
const [nameValue] = cookieStr.split(';')
const [name, value] = nameValue.trim().split('=')
if (name && value) {
// * Determine if httpOnly (default true for security)
const isHttpOnly = cookieStr.toLowerCase().includes('httponly')
// * Determine sameSite (default lax)
const sameSiteMatch = cookieStr.match(/samesite=(\w+)/i)
const sameSite = (sameSiteMatch?.[1]?.toLowerCase() as 'strict' | 'lax' | 'none') || 'lax'
// * Extract max-age if present
const maxAgeMatch = cookieStr.match(/max-age=(\d+)/i)
const maxAge = maxAgeMatch ? parseInt(maxAgeMatch[1], 10) : 60 * 60 * 24 * 30
cookieStore.set(name.trim(), decodeURIComponent(value.trim()), {
httpOnly: isHttpOnly,
secure: process.env.NODE_ENV === 'production',
sameSite: sameSite,
path: '/',
domain: cookieDomain,
maxAge: maxAge
})
}
}
}
// * Also check for CSRF token in response header (fallback)
const csrfToken = res.headers.get('X-CSRF-Token')
if (csrfToken && !cookieStore.get('csrf_token')) {
cookieStore.set('csrf_token', csrfToken, {
httpOnly: false, // * Must be readable by JS for CSRF protection
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
domain: cookieDomain,
maxAge: 60 * 60 * 24 * 30
})
}
return {
success: true,
user: {
@@ -102,7 +142,7 @@ export async function exchangeAuthCode(code: string, codeVerifier: string, redir
}
} catch (error: unknown) {
console.error('Auth Exchange Error:', error)
logger.error('Auth Exchange Error:', error)
const isNetwork =
error instanceof TypeError ||
(error instanceof Error && (error.name === 'AbortError' || /fetch|network|ECONNREFUSED|ETIMEDOUT/i.test(error.message)))
@@ -112,19 +152,14 @@ export async function exchangeAuthCode(code: string, codeVerifier: string, redir
export async function setSessionAction(accessToken: string, refreshToken?: string) {
try {
console.log('[setSessionAction] Decoding token...')
if (!accessToken) throw new Error('Access token is missing')
const payloadPart = accessToken.split('.')[1]
const payload: UserPayload = JSON.parse(Buffer.from(payloadPart, 'base64').toString())
console.log('[setSessionAction] Token Payload:', { sub: payload.sub, org_id: payload.org_id })
const cookieStore = await cookies()
const cookieDomain = getCookieDomain()
console.log('[setSessionAction] Setting cookies with domain:', cookieDomain)
cookieStore.set('access_token', accessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
@@ -146,8 +181,6 @@ export async function setSessionAction(accessToken: string, refreshToken?: strin
})
}
console.log('[setSessionAction] Cookies set successfully')
return {
success: true,
user: {
@@ -159,7 +192,7 @@ export async function setSessionAction(accessToken: string, refreshToken?: strin
}
}
} catch (e) {
console.error('[setSessionAction] Error:', e)
logger.error('[setSessionAction] Error:', e)
return { success: false as const, error: 'invalid' }
}
}

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

45
app/admin/layout.tsx Normal file
View File

@@ -0,0 +1,45 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { getAdminMe } from '@/lib/api/admin'
import { LoadingOverlay } from '@ciphera-net/ui'
export default function AdminLayout({ children }: { children: React.ReactNode }) {
const [isAdmin, setIsAdmin] = useState<boolean | null>(null)
const router = useRouter()
useEffect(() => {
getAdminMe()
.then((res) => {
if (res.is_admin) {
setIsAdmin(true)
} else {
setIsAdmin(false)
// Redirect to home if not admin
router.push('/')
}
})
.catch(() => {
setIsAdmin(false)
router.push('/')
})
}, [router])
if (isAdmin === null) {
return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Checking access..." />
}
if (!isAdmin) {
return null // Will redirect
}
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
<div className="mb-8 flex items-center justify-between">
<h1 className="text-2xl font-bold text-white">Pulse Admin</h1>
</div>
{children}
</div>
)
}

View File

@@ -0,0 +1,237 @@
'use client'
import { useEffect, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { getAdminOrg, grantPlan, type AdminOrgDetail } from '@/lib/api/admin'
import { Button, LoadingOverlay, Select, toast } from '@ciphera-net/ui'
import { formatDate, formatDateTime } from '@/lib/utils/formatDate'
function addMonths(d: Date, months: number) {
const out = new Date(d)
out.setMonth(out.getMonth() + months)
return out
}
function addYears(d: Date, years: number) {
const out = new Date(d)
out.setFullYear(out.getFullYear() + years)
return out
}
const PLAN_OPTIONS = [
{ value: 'free', label: 'Free' },
{ value: 'solo', label: 'Solo' },
{ value: 'team', label: 'Team' },
{ value: 'business', label: 'Business' },
]
const INTERVAL_OPTIONS = [
{ value: 'month', label: 'Monthly' },
{ value: 'year', label: 'Yearly' },
]
const LIMIT_OPTIONS = [
{ value: '1000', label: '1k (Free)' },
{ value: '10000', label: '10k (Solo)' },
{ value: '100000', label: '100k (Team)' },
{ value: '1000000', label: '1M (Business)' },
{ value: '5000000', label: '5M' },
{ value: '10000000', label: '10M' },
]
export default function AdminOrgDetailPage() {
const params = useParams()
const router = useRouter()
const orgId = params.id as string
const [org, setOrg] = useState<AdminOrgDetail | null>(null)
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
// Form state
const [planId, setPlanId] = useState('free')
const [interval, setInterval] = useState('month')
const [limit, setLimit] = useState('1000')
const [periodEnd, setPeriodEnd] = useState('')
useEffect(() => {
if (orgId) {
getAdminOrg(orgId)
.then((data) => {
setOrg({ ...data.billing, sites: data.sites })
setPlanId(data.billing.plan_id)
setInterval(data.billing.billing_interval || 'month')
setLimit(data.billing.pageview_limit.toString())
// Format date for input type="datetime-local" or similar
if (data.billing.current_period_end) {
setPeriodEnd(new Date(data.billing.current_period_end).toISOString().slice(0, 16))
} else {
// Default to 1 month from now
setPeriodEnd(addMonths(new Date(), 1).toISOString().slice(0, 16))
}
})
.catch(() => {
toast.error('Failed to load organization')
router.push('/admin/orgs')
})
.finally(() => setLoading(false))
}
}, [orgId, router])
const handleGrantPlan = async (e: React.FormEvent) => {
e.preventDefault()
if (!org) return
setSubmitting(true)
try {
await grantPlan(org.organization_id, {
plan_id: planId,
billing_interval: interval,
pageview_limit: parseInt(limit),
period_end: new Date(periodEnd).toISOString(),
})
toast.success('Plan granted successfully')
router.refresh()
// Reload data to show updates
const data = await getAdminOrg(orgId)
setOrg({ ...data.billing, sites: data.sites })
} catch (error) {
toast.error('Failed to grant plan')
} finally {
setSubmitting(false)
}
}
if (loading) return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Loading organization..." />
if (!org) return <div>Organization not found</div>
return (
<div className="space-y-6 max-w-4xl mx-auto">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-white">
{org.business_name || 'Unnamed Organization'}
</h2>
<span className="text-sm font-mono text-neutral-500">{org.organization_id}</span>
</div>
<div className="grid gap-6 md:grid-cols-2">
{/* Current Status */}
<div className="rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-6 shadow-sm">
<h3 className="text-lg font-semibold text-white mb-4">Current Status</h3>
<div className="grid grid-cols-2 gap-2 text-sm">
<span className="text-neutral-500">Plan:</span>
<span className="font-medium">{org.plan_id}</span>
<span className="text-neutral-500">Status:</span>
<span className="font-medium">{org.subscription_status}</span>
<span className="text-neutral-500">Limit:</span>
<span className="font-medium">{new Intl.NumberFormat().format(org.pageview_limit)}</span>
<span className="text-neutral-500">Interval:</span>
<span className="font-medium">{org.billing_interval}</span>
<span className="text-neutral-500">Period End:</span>
<span className="font-medium">
{org.current_period_end ? formatDateTime(new Date(org.current_period_end)) : '-'}
</span>
<span className="text-neutral-500">Customer ID:</span>
<span className="font-mono text-xs">{org.billing_customer_id || '-'}</span>
<span className="text-neutral-500">Subscription ID:</span>
<span className="font-mono text-xs">{org.billing_subscription_id || '-'}</span>
</div>
</div>
{/* Sites */}
<div className="rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-6 shadow-sm">
<h3 className="text-lg font-semibold text-white mb-4">Sites ({org.sites.length})</h3>
<ul className="space-y-2 max-h-60 overflow-y-auto">
{org.sites.map((site) => (
<li key={site.id} className="flex justify-between items-center text-sm p-2 bg-neutral-50 dark:bg-neutral-900 rounded">
<span className="font-medium">{site.domain}</span>
<span className="text-neutral-500 text-xs">{formatDate(new Date(site.created_at))}</span>
</li>
))}
{org.sites.length === 0 && <li className="text-neutral-500 text-sm">No sites found</li>}
</ul>
</div>
</div>
{/* Grant Plan Form */}
<div className="rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-6 shadow-sm">
<h3 className="text-lg font-semibold text-white mb-4">Grant Plan (Manual Override)</h3>
<form onSubmit={handleGrantPlan} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium">Plan Tier</label>
<Select
value={planId}
onChange={setPlanId}
options={PLAN_OPTIONS}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Billing Interval</label>
<Select
value={interval}
onChange={setInterval}
options={INTERVAL_OPTIONS}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Pageview Limit</label>
<Select
value={limit}
onChange={setLimit}
options={LIMIT_OPTIONS}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Period End Date (UTC)</label>
<input
type="datetime-local"
value={periodEnd}
onChange={(e) => setPeriodEnd(e.target.value)}
className="w-full px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-lg bg-white dark:bg-neutral-900 text-white focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-offset-2"
required
/>
<div className="flex gap-2 mt-1">
<button
type="button"
onClick={() => setPeriodEnd(addMonths(new Date(), 1).toISOString().slice(0, 16))}
className="text-xs text-blue-500 hover:underline"
>
+1 Month
</button>
<button
type="button"
onClick={() => setPeriodEnd(addYears(new Date(), 1).toISOString().slice(0, 16))}
className="text-xs text-blue-500 hover:underline"
>
+1 Year
</button>
<button
type="button"
onClick={() => setPeriodEnd(addYears(new Date(), 100).toISOString().slice(0, 16))}
className="text-xs text-blue-500 hover:underline"
>
Forever
</button>
</div>
</div>
</div>
<div className="pt-4 flex justify-end">
<Button type="submit" disabled={submitting} variant="primary">
{submitting ? 'Granting...' : 'Grant Plan'}
</Button>
</div>
</form>
</div>
</div>
)
}

105
app/admin/orgs/page.tsx Normal file
View File

@@ -0,0 +1,105 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import Link from 'next/link'
import { listAdminOrgs, type AdminOrgSummary } from '@/lib/api/admin'
import { Button, LoadingOverlay, toast } from '@ciphera-net/ui'
import { formatDate } from '@/lib/utils/formatDate'
function CopyableOrgId({ id }: { id: string }) {
const [copied, setCopied] = useState(false)
const copy = useCallback(() => {
navigator.clipboard.writeText(id)
setCopied(true)
toast.success('Org ID copied to clipboard')
setTimeout(() => setCopied(false), 2000)
}, [id])
return (
<button
type="button"
onClick={copy}
className="font-mono text-xs text-neutral-500 hover:text-brand-orange dark:hover:text-brand-orange cursor-pointer transition-colors text-left"
title="Click to copy"
>
{copied ? 'Copied!' : `${id.substring(0, 8)}...`}
</button>
)
}
export default function AdminOrgsPage() {
const [orgs, setOrgs] = useState<AdminOrgSummary[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
listAdminOrgs()
.then(setOrgs)
.finally(() => setLoading(false))
}, [])
if (loading) {
return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Loading organizations..." />
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-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-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-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-white font-medium">
{org.business_name || 'N/A'}
</td>
<td className="px-4 py-3">
<CopyableOrgId id={org.organization_id} />
</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
org.plan_id === 'business' ? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400' :
org.plan_id === 'team' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' :
org.plan_id === 'solo' ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' :
'bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-400'
}`}>
{org.plan_id}
</span>
</td>
<td className="px-4 py-3 text-neutral-600 dark:text-neutral-300">
{org.subscription_status || '-'}
</td>
<td className="px-4 py-3 text-neutral-600 dark:text-neutral-300">
{new Intl.NumberFormat().format(org.pageview_limit)}
</td>
<td className="px-4 py-3 text-neutral-500 text-xs">
{formatDate(new Date(org.updated_at))}
</td>
<td className="px-4 py-3">
<Link href={`/admin/orgs/${org.organization_id}`}>
<Button variant="ghost">Manage</Button>
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)
}

30
app/admin/page.tsx Normal file
View File

@@ -0,0 +1,30 @@
'use client'
import Link from 'next/link'
export default function AdminDashboard() {
return (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<Link
href="/admin/orgs"
className="block transition-transform hover:scale-[1.02] rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-6 shadow-sm"
>
<h3 className="text-lg font-semibold text-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

@@ -1,16 +1,9 @@
import { cookies } from 'next/headers'
import { NextResponse } from 'next/server'
import { getCookieDomain } from '@/lib/utils/cookies'
const AUTH_API_URL = process.env.NEXT_PUBLIC_AUTH_API_URL || process.env.NEXT_PUBLIC_AUTH_URL || 'http://localhost:8081'
// * Determine cookie domain dynamically
const getCookieDomain = () => {
if (process.env.NODE_ENV === 'production') {
return '.ciphera.net'
}
return undefined
}
export async function POST() {
const cookieStore = await cookies()
const refreshToken = cookieStore.get('refresh_token')?.value
@@ -19,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' },
@@ -36,8 +40,58 @@ export async function POST() {
}
const data = await res.json()
let finalAccessToken = data.access_token
cookieStore.set('access_token', data.access_token, {
// * 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 || ''
// * 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',
@@ -55,7 +109,19 @@ export async function POST() {
maxAge: 60 * 60 * 24 * 30
})
return NextResponse.json({ success: true, access_token: data.access_token })
// * Set/update CSRF token cookie (non-httpOnly, for JS access)
if (csrfToken) {
cookieStore.set('csrf_token', csrfToken, {
httpOnly: false, // * Must be readable by JS for CSRF protection
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
domain: cookieDomain,
maxAge: 60 * 60 * 24 * 30
})
}
return NextResponse.json({ success: true, access_token: finalAccessToken })
} catch (error) {
return NextResponse.json({ error: 'Internal error' }, { status: 500 })
}

View File

@@ -1,10 +1,11 @@
'use client'
import { useEffect, useState, Suspense, useRef, useCallback } from 'react'
import { logger } from '@/lib/utils/logger'
import { useRouter, useSearchParams } from 'next/navigation'
import { useAuth } from '@/lib/auth/context'
import { AUTH_URL, default as apiRequest } from '@/lib/api/client'
import { exchangeAuthCode, setSessionAction } from '@/app/actions/auth'
import { exchangeAuthCode } from '@/app/actions/auth'
import { authMessageFromErrorType, type AuthErrorType } from '@ciphera-net/ui'
import { LoadingOverlay } from '@ciphera-net/ui'
@@ -20,8 +21,15 @@ function AuthCallbackContent() {
const code = searchParams.get('code')
const codeVerifier = localStorage.getItem('oauth_code_verifier')
const redirectUri = typeof window !== 'undefined' ? window.location.origin + '/auth/callback' : ''
if (!code || !codeVerifier) return
const result = await exchangeAuthCode(code, codeVerifier, redirectUri)
if (!code) return
let result: Awaited<ReturnType<typeof exchangeAuthCode>>
try {
result = await exchangeAuthCode(code, codeVerifier, redirectUri)
} catch {
// * Stale build or network error — show error so user can retry via full navigation
setError('Something went wrong. Please try logging in again.')
return
}
if (result.success && result.user) {
// * Fetch full profile (including display_name) before navigating so header shows correct name on first paint
try {
@@ -46,59 +54,28 @@ function AuthCallbackContent() {
}, [searchParams, login, router])
useEffect(() => {
// * Prevent double execution (React Strict Mode or fast re-renders)
if (processedRef.current && !isRetrying) return
// * Check for direct token passing (from auth-frontend direct login)
// * This flow exposes tokens in URL, kept for legacy support.
// * Recommended: Use Authorization Code flow (below)
const token = searchParams.get('token')
const refreshToken = searchParams.get('refresh_token')
if (token && refreshToken) {
processedRef.current = true
const handleDirectTokens = async () => {
const result = await setSessionAction(token, refreshToken)
if (result.success && result.user) {
// * Fetch full profile (including display_name) before navigating so header shows correct name on first paint
try {
const fullProfile = await apiRequest<{ id: string; email: string; display_name?: string; totp_enabled: boolean; org_id?: string; role?: string }>('/auth/user/me')
const merged = { ...fullProfile, org_id: result.user.org_id ?? fullProfile.org_id, role: result.user.role ?? fullProfile.role }
login(merged)
} catch {
login(result.user)
}
if (typeof window !== 'undefined' && localStorage.getItem('pulse_pending_checkout')) {
router.push('/welcome')
} else {
const raw = searchParams.get('returnTo') || '/'
const safe = (typeof raw === 'string' && raw.startsWith('/') && !raw.startsWith('//')) ? raw : '/'
router.push(safe)
}
} else {
setError(authMessageFromErrorType('invalid'))
}
}
handleDirectTokens()
return
}
const code = searchParams.get('code')
if (!code) return
const state = searchParams.get('state')
if (!code || !state) return
const storedState = localStorage.getItem('oauth_state')
const codeVerifier = localStorage.getItem('oauth_code_verifier')
if (!codeVerifier) {
setError('Missing code verifier')
return
}
if (state !== storedState) {
console.error('State mismatch', { received: state, stored: storedState })
setError('Invalid state')
return
// * Session flow (from auth hub): redirect has code but no state. Clear stale PKCE
// * data from any previous app-initiated OAuth so exchange proceeds without validation.
if (!state) {
localStorage.removeItem('oauth_state')
localStorage.removeItem('oauth_code_verifier')
} else {
// * Full OAuth flow (app-initiated): validate state + use PKCE
const isFullOAuth = !!storedState && !!codeVerifier
if (isFullOAuth && state !== storedState) {
logger.error('State mismatch', { received: state, stored: storedState })
setError('Invalid state')
return
}
}
processedRef.current = true

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-3xl font-bold 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>
)
}

13
app/error.tsx Normal file
View File

@@ -0,0 +1,13 @@
'use client'
import ErrorDisplay from '@/components/ErrorDisplay'
export default function GlobalError({ reset }: { error: Error; reset: () => void }) {
return (
<ErrorDisplay
title="Something went wrong"
message="An unexpected error occurred. Please try again or go back to the dashboard."
onRetry={reset}
/>
)
}

19
app/faq/layout.tsx Normal file
View File

@@ -0,0 +1,19 @@
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'FAQ | Pulse',
description: 'Frequently asked questions about Pulse, privacy, GDPR compliance, and how it works.',
openGraph: {
title: 'FAQ | Pulse',
description: 'Frequently asked questions about Pulse, privacy, GDPR compliance, and how it works.',
siteName: 'Pulse by Ciphera',
},
}
export default function FaqLayout({
children,
}: {
children: React.ReactNode
}) {
return children
}

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:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-offset-2"
>
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white pr-4">
{faq.question}
</h3>
<ChevronDownIcon
className={`w-5 h-5 text-neutral-500 shrink-0 transition-transform duration-300 ${
isOpen ? 'rotate-180' : ''
}`}
/>
</button>
{isOpen && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3 }}
className="pb-6"
>
<p className="text-neutral-600 dark:text-neutral-400 leading-relaxed">
{faq.answer}
</p>
</motion.div>
)}
</motion.div>
)
}
export default function FAQPage() {
return (
<>
@@ -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={index} 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>

19
app/features/layout.tsx Normal file
View File

@@ -0,0 +1,19 @@
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Features | Pulse',
description: 'Dashboards, funnels, uptime monitoring, realtime visitors, and more — all without cookies.',
openGraph: {
title: 'Features | Pulse',
description: 'Dashboards, funnels, uptime monitoring, realtime visitors, and more — all without cookies.',
siteName: 'Pulse by Ciphera',
},
}
export default function FeaturesLayout({
children,
}: {
children: React.ReactNode
}) {
return children
}

View File

@@ -83,12 +83,12 @@ const capabilities = [
description: 'Automatically parse UTM parameters. Built-in link builder for campaigns, sources, and mediums.',
},
{
icon: Share2Icon,
icon: <Share2Icon className="w-5 h-5" />,
title: 'Shared Dashboards',
description: 'Generate a public link to share analytics with clients or teammates — no login required.',
},
{
icon: GlobeIcon,
icon: <GlobeIcon className="w-5 h-5" />,
title: 'Geographic Insights',
description: 'Country, region, and city-level breakdowns. IPs are never stored — derived at request time only.',
},
@@ -109,10 +109,9 @@ export default function FeaturesPage() {
<div className="relative min-h-screen flex flex-col overflow-hidden">
{/* * --- ATMOSPHERE (Background) --- */}
<div className="absolute inset-0 -z-10 pointer-events-none">
<div className="absolute top-0 left-1/4 w-[500px] h-[500px] bg-brand-orange/10 rounded-full blur-[128px] opacity-60" />
<div className="absolute bottom-0 right-1/4 w-[500px] h-[500px] bg-neutral-500/10 dark:bg-neutral-400/10 rounded-full blur-[128px] opacity-40" />
<div className="absolute bottom-0 right-1/4 w-[500px] h-[500px] bg-neutral-400/10 rounded-full blur-[128px] opacity-40" />
<div
className="absolute inset-0 bg-grid-pattern opacity-[0.02] dark:opacity-[0.05]"
className="absolute inset-0 bg-grid-pattern opacity-[0.05]"
style={{ maskImage: 'radial-gradient(ellipse at center, black 0%, transparent 70%)' }}
/>
</div>
@@ -129,11 +128,11 @@ export default function FeaturesPage() {
<span className="w-1.5 h-1.5 rounded-full bg-brand-orange animate-pulse" />
Product Tour
</span>
<h1 className="text-4xl md:text-6xl font-bold tracking-tight text-neutral-900 dark:text-white mb-6">
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-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>
@@ -147,15 +146,15 @@ export default function FeaturesPage() {
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: i * 0.1 }}
className="card-glass p-8 hover:-translate-y-1 hover:shadow-xl transition-all duration-300 group"
className="card-glass p-6 hover:-translate-y-1 hover:shadow-xl transition-all duration-300 group"
>
<div className="w-12 h-12 rounded-xl bg-brand-orange/10 flex items-center justify-center mb-6 text-brand-orange group-hover:scale-110 transition-transform duration-300">
<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-3xl md:text-4xl font-bold text-neutral-900 dark:text-white mb-4">
<h2 className="text-2xl font-bold text-white mb-4">
Powerful analytics, <span className="gradient-text">simplified</span>
</h2>
<p className="text-lg text-neutral-600 dark:text-neutral-400 max-w-2xl mx-auto">
<p className="text-lg text-neutral-400 max-w-2xl mx-auto">
Everything from real-time dashboards to conversion funnels without the bloat.
</p>
</div>
@@ -190,13 +189,13 @@ export default function FeaturesPage() {
className="flex gap-4"
>
<div className="w-10 h-10 rounded-lg bg-brand-orange/10 flex items-center justify-center shrink-0 text-brand-orange mt-0.5">
{typeof cap.icon === 'object' ? cap.icon : <cap.icon className="w-5 h-5" />}
{cap.icon}
</div>
<div>
<h3 className="font-bold text-neutral-900 dark:text-white mb-1">
<h3 className="font-bold text-white mb-1">
{cap.title}
</h3>
<p className="text-sm text-neutral-600 dark:text-neutral-400 leading-relaxed">
<p className="text-sm text-neutral-400 leading-relaxed">
{cap.description}
</p>
</div>
@@ -211,14 +210,14 @@ export default function FeaturesPage() {
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
className="mb-28 p-10 md:p-14 bg-white/50 dark:bg-neutral-900/50 backdrop-blur-sm border border-neutral-200 dark:border-neutral-800 rounded-2xl"
className="mb-28 p-10 md:p-14 bg-neutral-900/50 backdrop-blur-sm border border-neutral-800 rounded-2xl"
>
<div className="grid md:grid-cols-2 gap-10 items-center">
<div>
<h2 className="text-3xl 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-3xl md:text-4xl 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-3xl md:text-4xl 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-3xl md:text-4xl 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,33 +8,31 @@ 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-[#1e1e1e] rounded-xl overflow-hidden shadow-2xl text-left border border-neutral-800">
<div className="flex items-center px-4 py-3 bg-[#252526] border-b border-neutral-800">
<div className="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" />
<div className="w-3 h-3 rounded-full bg-yellow-500/20" />
@@ -55,16 +53,23 @@ 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-[#1e1e1e] rounded-xl overflow-hidden shadow-2xl text-left border border-neutral-800">
<div className="flex items-center px-4 py-3 bg-[#252526] border-b border-neutral-800">
<div className="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" />
<div className="w-3 h-3 rounded-full bg-yellow-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

@@ -0,0 +1,19 @@
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Integrations | Pulse',
description: 'Add Pulse analytics to Next.js, React, Vue, WordPress, and more in under a minute.',
openGraph: {
title: 'Integrations | Pulse',
description: 'Add Pulse analytics to Next.js, React, Vue, WordPress, and more in under a minute.',
siteName: 'Pulse by Ciphera',
},
}
export default function IntegrationsLayout({
children,
}: {
children: React.ReactNode
}) {
return children
}

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-12 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-3xl md:text-4xl font-bold 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-[#1e1e1e] rounded-xl overflow-hidden border border-neutral-800 my-6">
<div className="flex items-center px-4 py-2 bg-[#252526] border-b border-neutral-800">
<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-[#1e1e1e] rounded-xl overflow-hidden border border-neutral-800 my-6">
<div className="flex items-center px-4 py-2 bg-[#252526] border-b border-neutral-800">
<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-[11px] font-mono font-medium bg-neutral-200/80 dark:bg-neutral-700/80 text-neutral-500 dark:text-neutral-400 border border-neutral-300 dark:border-neutral-600">
<kbd className="hidden sm:inline-flex items-center px-1.5 py-0.5 rounded text-xs font-mono font-medium bg-neutral-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-8 bg-white/50 dark:bg-neutral-900/50 backdrop-blur-sm border border-neutral-200 dark:border-neutral-800 rounded-2xl hover:border-brand-orange/50 dark:hover:border-brand-orange/50 transition-all duration-300 hover:-translate-y-1 hover:shadow-xl block h-full focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-offset-2"
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:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-offset-2"
className="inline-flex items-center gap-2 px-5 py-2.5 bg-brand-orange-button text-white font-medium rounded-lg hover:bg-brand-orange/90 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:ring-offset-2"
>
Request Integration
</a>
@@ -351,17 +350,17 @@ export default function IntegrationsPage() {
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
className="max-w-md mx-auto mt-12 p-8 border border-dashed border-neutral-300 dark:border-neutral-700 rounded-2xl flex flex-col items-center justify-center text-center"
className="max-w-md mx-auto mt-12 p-6 border border-dashed border-neutral-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
href="mailto:support@ciphera.net"
className="text-sm font-medium text-brand-orange hover:underline focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded"
className="text-sm font-medium text-brand-orange hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:rounded"
>
Request Integration
</a>

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-12 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-3xl md:text-4xl font-bold 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-[#1e1e1e] rounded-xl overflow-hidden border border-neutral-800 my-6">
<div className="flex items-center px-4 py-2 bg-[#252526] border-b border-neutral-800">
<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-[#1e1e1e] rounded-xl overflow-hidden border border-neutral-800 my-6">
<div className="flex items-center px-4 py-2 bg-[#252526] border-b border-neutral-800">
<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-12 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-3xl md:text-4xl font-bold 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-[#1e1e1e] rounded-xl overflow-hidden border border-neutral-800 my-6">
<div className="flex items-center px-4 py-2 bg-[#252526] border-b border-neutral-800">
<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-[#1e1e1e] rounded-xl overflow-hidden border border-neutral-800 my-6">
<div className="flex items-center px-4 py-2 bg-[#252526] border-b border-neutral-800">
<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-12 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-3xl md:text-4xl font-bold 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-[#1e1e1e] rounded-xl overflow-hidden border border-neutral-800 my-6">
<div className="flex items-center px-4 py-2 bg-[#252526] border-b border-neutral-800">
<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

@@ -2,93 +2,193 @@
import { OfflineBanner } from '@/components/OfflineBanner'
import { Footer } from '@/components/Footer'
import { Header } from '@ciphera-net/ui'
import { Header, type CipheraApp } from '@ciphera-net/ui'
import { 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 { getUserOrganizations, switchContext } from '@/lib/api/organization'
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 { UnifiedSettingsProvider, useUnifiedSettings } from '@/lib/unified-settings-context'
import UnifiedSettingsModal from '@/components/settings/unified/UnifiedSettingsModal'
import DashboardShell from '@/components/dashboard/DashboardShell'
export default function LayoutContent({ children }: { children: React.ReactNode }) {
const ORG_SWITCH_KEY = 'pulse_switching_org'
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,
},
]
function LayoutInner({ children }: { children: React.ReactNode }) {
const auth = useAuth()
const router = useRouter()
const pathname = usePathname()
const isOnline = useOnlineStatus()
const [orgs, setOrgs] = useState<any[]>([])
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'
})
useEffect(() => {
if (isSwitchingOrg) {
sessionStorage.removeItem(ORG_SWITCH_KEY)
const timer = setTimeout(() => setIsSwitchingOrg(false), 600)
return () => clearTimeout(timer)
}
}, [isSwitchingOrg])
// * Fetch organizations for the header organization switcher
useEffect(() => {
if (auth.user) {
getUserOrganizations()
.then((organizations) => setOrgs(Array.isArray(organizations) ? organizations : []))
.catch(err => console.error('Failed to fetch orgs for header', err))
.catch(err => logger.error('Failed to fetch orgs for header', err))
}
}, [auth.user])
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)
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) {
console.error('Failed to switch organization', err)
setIsSwitchingOrg(false)
logger.error('Failed to switch organization', err)
}
}
const handleCreateOrganization = () => {
router.push('/onboarding')
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} />
}
const showOfflineBar = Boolean(auth.user && !isOnline);
const barHeightRem = 2.5;
const headerHeightRem = 6;
const mainTopPaddingRem = barHeightRem + headerHeightRem;
// While auth is loading on a site or checkout page, render nothing to prevent flash of public header
if (auth.loading && (isSitePage || isCheckoutPage || isDashboardPage)) {
return null
}
// Authenticated site pages: DashboardShell provided by sites layout
if (isAuthenticated && isSitePage) {
return (
<>
{showOfflineBar && <OfflineBanner isOnline={isOnline} />}
{children}
<UnifiedSettingsModal />
</>
)
}
// Authenticated dashboard pages (home, integrations, pricing): wrap in DashboardShell
if (isAuthenticated && isDashboardPage) {
return (
<>
{showOfflineBar && <OfflineBanner isOnline={isOnline} />}
<DashboardShell siteId={null}>{children}</DashboardShell>
<UnifiedSettingsModal />
</>
)
}
// Checkout page: render children only (has its own layout)
if (isAuthenticated && isCheckoutPage) {
return <>{children}</>
}
// Authenticated non-site pages (sites list, onboarding, etc.): static header
if (isAuthenticated) {
return (
<div className="flex flex-col min-h-screen">
{showOfflineBar && <OfflineBanner isOnline={isOnline} />}
<Header
auth={auth}
LinkComponent={Link}
logoSrc="/pulse_icon_no_margins.png"
appName="Pulse"
variant="static"
orgs={orgs}
activeOrgId={auth.user?.org_id}
onSwitchOrganization={handleSwitchOrganization}
onCreateOrganization={() => router.push('/onboarding')}
allowPersonalOrganization={false}
showFaq={false}
showSecurity={false}
showPricing={false}
rightSideActions={<NotificationCenter />}
apps={CIPHERA_APPS}
currentAppId="pulse"
onOpenSettings={() => openUnifiedSettings({ context: 'account', tab: 'profile' })}
/>
<main className="flex-1 pb-8">
{children}
</main>
<UnifiedSettingsModal />
</div>
)
}
// Public/marketing: sticky header + footer
return (
<>
{auth.user && <OfflineBanner isOnline={isOnline} />}
<Header
auth={auth}
LinkComponent={Link}
logoSrc="/pulse_icon_no_margins.png"
appName="Pulse"
orgs={orgs}
activeOrgId={auth.user?.org_id}
onSwitchOrganization={handleSwitchOrganization}
onCreateOrganization={handleCreateOrganization}
allowPersonalOrganization={false}
showFaq={false}
showSecurity={false}
showPricing={true}
topOffset={showOfflineBar ? `${barHeightRem}rem` : undefined}
rightSideActions={auth.user ? <NotificationCenter /> : null}
customNavItems={
<>
{!auth.user && (
<Link
href="/features"
className="px-4 py-2 text-sm font-medium text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white rounded-lg hover:bg-neutral-100/50 dark:hover:bg-neutral-800/50 transition-all duration-200"
>
Features
</Link>
)}
</>
}
/>
<main
className={`flex-1 pb-8 ${showOfflineBar ? '' : 'pt-24'}`}
style={showOfflineBar ? { paddingTop: `${mainTopPaddingRem}rem` } : undefined}
>
<div className="flex flex-col min-h-screen">
<MarketingHeader />
<main className="flex-1 pb-8">
{children}
</main>
<Footer
LinkComponent={Link}
appName="Pulse"
isAuthenticated={!!auth.user}
isAuthenticated={false}
/>
</>
</div>
)
}
export default function LayoutContent({ children }: { children: React.ReactNode }) {
return (
<UnifiedSettingsProvider>
<LayoutInner>{children}</LayoutInner>
</UnifiedSettingsProvider>
)
}

View File

@@ -1,5 +1,6 @@
import { ThemeProviders, Toaster } from '@ciphera-net/ui'
import { ThemeProvider, Toaster } from '@ciphera-net/ui'
import { AuthProvider } from '@/lib/auth/context'
import SWRProvider from '@/components/SWRProvider'
import type { Metadata, Viewport } from 'next'
import { Plus_Jakarta_Sans } from 'next/font/google'
import LayoutContent from './layout-content'
@@ -44,14 +45,16 @@ export default function RootLayout({
children: React.ReactNode
}) {
return (
<html lang="en" className={plusJakartaSans.variable} suppressHydrationWarning>
<body className="antialiased min-h-screen flex flex-col bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-50">
<ThemeProviders>
<AuthProvider>
<LayoutContent>{children}</LayoutContent>
<Toaster />
</AuthProvider>
</ThemeProviders>
<html lang="en" className={`${plusJakartaSans.variable} dark`} suppressHydrationWarning>
<body className="antialiased min-h-screen flex flex-col bg-neutral-950 text-neutral-100">
<SWRProvider>
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem={false}>
<AuthProvider>
<LayoutContent>{children}</LayoutContent>
<Toaster />
</AuthProvider>
</ThemeProvider>
</SWRProvider>
</body>
</html>
)

View File

@@ -2,6 +2,7 @@
import { useEffect } from 'react'
import { initiateOAuthFlow } from '@/lib/api/oauth'
import { LoadingOverlay } from '@ciphera-net/ui'
export default function LoginPage() {
useEffect(() => {
@@ -9,5 +10,10 @@ export default function LoginPage() {
initiateOAuthFlow()
}, [])
return null
return (
<LoadingOverlay
logoSrc="/pulse_icon_no_margins.png"
title="Redirecting to log in..."
/>
)
}

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

@@ -0,0 +1,13 @@
'use client'
import ErrorDisplay from '@/components/ErrorDisplay'
export default function NotificationsError({ reset }: { error: Error; reset: () => void }) {
return (
<ErrorDisplay
title="Notifications failed to load"
message="We couldn't load your notifications. Please try again."
onRetry={reset}
/>
)
}

View File

@@ -0,0 +1,15 @@
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Notifications | Pulse',
description: 'View your alerts and activity updates.',
robots: { index: false, follow: false },
}
export default function NotificationsLayout({
children,
}: {
children: React.ReactNode
}) {
return children
}

View File

@@ -15,13 +15,16 @@ import {
} from '@/lib/api/notifications'
import { getAuthErrorMessage } from '@ciphera-net/ui'
import { formatTimeAgo, getTypeIcon } from '@/lib/utils/notifications'
import { Button, ArrowLeftIcon, Spinner } from '@ciphera-net/ui'
import { Button, ArrowLeftIcon } from '@ciphera-net/ui'
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)
@@ -29,6 +32,8 @@ export default function NotificationsPage() {
const [offset, setOffset] = useState(0)
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)
@@ -90,7 +95,7 @@ export default function NotificationsPage() {
if (!user?.org_id) {
return (
<div className="min-h-screen pt-24 pb-12 px-4 sm:px-6">
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
<div className="max-w-2xl mx-auto text-center py-12">
<p className="text-neutral-500">Switch to an organization to view notifications.</p>
<Link href="/welcome" className="text-brand-orange hover:underline mt-4 inline-block">
@@ -102,7 +107,7 @@ export default function NotificationsPage() {
}
return (
<div className="min-h-screen pt-24 pb-12 px-4 sm:px-6">
<div className={`w-full max-w-6xl mx-auto px-4 sm:px-6 py-8 ${fadeClass}`}>
<div className="max-w-2xl mx-auto">
<div className="flex items-center justify-between mb-6">
<Link
@@ -119,30 +124,28 @@ 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>
{loading ? (
<div className="flex justify-center py-12">
<Spinner />
</div>
{showSkeleton ? (
<NotificationsListSkeleton />
) : error ? (
<div className="p-8 text-center text-red-500 bg-red-50 dark:bg-red-900/10 rounded-2xl border border-red-200 dark:border-red-800">
<div className="p-6 text-center text-red-500 bg-red-50 dark:bg-red-900/10 rounded-2xl border border-red-200 dark:border-red-800">
{error}
</div>
) : notifications.length === 0 ? (
<div className="p-8 text-center text-neutral-500 dark:text-neutral-400 rounded-2xl border border-neutral-200 dark:border-neutral-800">
<div className="p-6 text-center text-neutral-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

@@ -0,0 +1,13 @@
'use client'
import ErrorDisplay from '@/components/ErrorDisplay'
export default function OrgSettingsError({ reset }: { error: Error; reset: () => void }) {
return (
<ErrorDisplay
title="Organization settings failed to load"
message="We couldn't load your organization settings. Please try again."
onRetry={reset}
/>
)
}

View File

@@ -1,19 +1,43 @@
import { Suspense } from 'react'
import OrganizationSettings from '@/components/settings/OrganizationSettings'
'use client'
export const metadata = {
title: 'Organization Settings - Pulse',
description: 'Manage your organization settings',
}
import { Suspense, useEffect } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { useUnifiedSettings } from '@/lib/unified-settings-context'
import { Spinner } from '@ciphera-net/ui'
function OrgSettingsInner() {
const router = useRouter()
const searchParams = useSearchParams()
const { openUnifiedSettings } = useUnifiedSettings()
useEffect(() => {
const tab = searchParams.get('tab')
const tabMap: Record<string, string> = {
general: 'general',
members: 'members',
billing: 'billing',
notifications: 'notifications',
audit: 'audit',
}
const mappedTab = tab ? tabMap[tab] || 'general' : 'general'
// Go back to wherever the user came from (not always /)
router.back()
setTimeout(() => openUnifiedSettings({ context: 'workspace', tab: mappedTab }), 200)
}, [searchParams, router, openUnifiedSettings])
export default function OrgSettingsPage() {
return (
<div className="min-h-screen pt-24 pb-12 px-4 sm:px-6">
<div className="max-w-4xl mx-auto">
<Suspense fallback={<div className="p-8 text-center text-neutral-500">Loading...</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,105 +4,37 @@ 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 { 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 h-[600px] flex items-center justify-center">
{/* * Glow behind the image */}
<div className="absolute inset-0 bg-brand-orange/20 blur-[100px] -z-10 rounded-full opacity-50" />
{/* * Static Container */}
<div
className="relative w-full h-full rounded-xl border border-neutral-200/50 dark:border-neutral-800/50 bg-neutral-900/50 backdrop-blur-sm shadow-2xl overflow-hidden"
>
{/* * Header of the fake browser window */}
<div className="h-8 bg-neutral-800/50 border-b border-white/5 flex items-center px-4 gap-2">
<div className="w-3 h-3 rounded-full bg-red-500/50" />
<div className="w-3 h-3 rounded-full bg-yellow-500/50" />
<div className="w-3 h-3 rounded-full bg-green-500/50" />
</div>
{/* * Placeholder for actual dashboard screenshot - replace src with real image later */}
<div className="w-full h-[calc(100%-2rem)] bg-neutral-900 flex items-center justify-center text-neutral-700">
<div className="text-center">
<BarChartIcon className="w-16 h-16 mx-auto mb-4 opacity-20" />
<p>Dashboard Preview</p>
</div>
</div>
</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, i) => (
<tr key={i} 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() {
const { user, loading: authLoading } = useAuth()
const [sites, setSites] = useState<Site[]>([])
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) {
@@ -111,6 +43,37 @@ export default function HomePage() {
}
}, [user])
useEffect(() => {
if (sites.length === 0) {
setSiteStats({})
return
}
let cancelled = false
const today = new Date().toISOString().split('T')[0]
const emptyStats: Stats = { pageviews: 0, visitors: 0, bounce_rate: 0, avg_duration: 0 }
const load = async () => {
const results = await Promise.allSettled(
sites.map(async (site) => {
const statsRes = await getStats(site.id, today, today)
return { siteId: site.id, stats: statsRes }
})
)
if (cancelled) return
const map: SiteStatsMap = {}
results.forEach((r, i) => {
const site = sites[i]
if (r.status === 'fulfilled') {
map[site.id] = { stats: r.value.stats }
} else {
map[site.id] = { stats: emptyStats }
}
})
setSiteStats(map)
}
load()
return () => { cancelled = true }
}, [sites])
useEffect(() => {
if (typeof window === 'undefined') return
if (localStorage.getItem('pulse_welcome_completed') === 'true') setShowFinishSetupBanner(false)
@@ -132,8 +95,14 @@ export default function HomePage() {
setSitesLoading(true)
const data = await listSites()
setSites(Array.isArray(data) ? data : [])
} catch (error: any) {
toast.error(getAuthErrorMessage(error) || 'Failed to load sites: ' + ((error as Error)?.message || 'Unknown error'))
try {
const deleted = await listDeletedSites()
setDeletedSites(deleted)
} catch {
setDeletedSites([])
}
} catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || 'Failed to load your sites')
setSites([])
} finally {
setSitesLoading(false)
@@ -142,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: any) {
toast.error(getAuthErrorMessage(error) || 'Failed to delete site: ' + ((error as Error)?.message || 'Unknown error'))
} catch (error: unknown) {
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) => (
<motion.div
key={i}
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-8 hover:-translate-y-1 hover:shadow-xl transition-all duration-300 group"
>
<div className="w-12 h-12 rounded-xl bg-brand-orange/10 flex items-center justify-center mb-6 text-brand-orange group-hover:scale-110 transition-transform duration-300">
<feature.icon className="w-6 h-6" />
</div>
<h3 className="text-xl font-bold text-neutral-900 dark:text-white mb-3">{feature.title}</h3>
<p className="text-neutral-600 dark:text-neutral-400 leading-relaxed">
{feature.desc}
</p>
</motion.div>
))}
</div>
{/* * NEW: COMPARISON SECTION */}
<ComparisonSection />
{/* * NEW: CTA BOTTOM */}
{/* Live Dashboard Demo */}
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
className="text-center mb-20"
initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, delay: 0.4 }}
className="w-full max-w-7xl mx-auto px-6"
>
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-6">Ready to switch?</h2>
<Button onClick={() => initiateOAuthFlow()} variant="primary" className="px-8 py-4 text-lg shadow-lg shadow-brand-orange/20">
Start your free trial
</Button>
<p className="mt-4 text-sm text-neutral-500">No credit card required • Cancel anytime</p>
<DashboardDemo />
</motion.div>
</div>
</div>
<FeatureSections />
<ComparisonCards />
<PulseFAQ />
<CTASection />
</>
)
}
@@ -305,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">
@@ -323,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" />
@@ -332,95 +250,51 @@ export default function HomePage() {
</div>
)}
<div className="mb-8 flex items-center justify-between">
<div className="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white">Your Sites</h1>
<p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">Manage your analytics sites and view insights.</p>
<h1 className="text-lg font-semibold text-neutral-200 mb-1">Your Sites</h1>
<p className="text-sm text-neutral-400">Manage your analytics sites and view insights.</p>
</div>
{subscription?.plan_id === 'solo' && sites.length >= 1 ? (
<div className="flex items-center gap-3">
<span className="text-sm font-medium text-neutral-500 dark:text-neutral-400 bg-neutral-100 dark:bg-neutral-800 px-3 py-1.5 rounded-lg border border-neutral-200 dark:border-neutral-700">
Limit reached (1/1)
</span>
<Link href="/pricing">
<Button variant="primary" className="text-sm">
Upgrade
</Button>
</Link>
{(() => {
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-400 bg-neutral-800 px-3 py-1.5 rounded-lg border border-neutral-700">
Limit reached ({sites.length}/{siteLimit})
</span>
<Link href="/pricing">
<Button variant="primary" className="text-sm">
Upgrade
</Button>
</Link>
</div>
{deletedSites.length > 0 && (
<p className="text-sm text-neutral-400 mt-2">
You have a site pending deletion. Restore it or permanently delete it to free the slot.
</p>
)}
</div>
) : (
) : null
})() ?? (
<Link href="/sites/new">
<Button variant="primary" className="text-sm">
<Button variant="primary" className="text-sm whitespace-nowrap">
Add New Site
</Button>
</Link>
)}
</div>
{/* * Global Overview */}
<div className="mb-8 grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="rounded-2xl border border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900">
<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="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">--</p>
</div>
<div className="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 ? (
<p className="text-lg font-bold text-brand-orange">...</p>
) : 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')) && (
<p className="text-sm text-neutral-600 dark:text-neutral-400 mt-1">
{typeof subscription.sites_count === 'number' && (
<span>Sites: {subscription.plan_id === 'solo' && subscription.sites_count > 0 ? `${subscription.sites_count}/1` : 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>
)}
</p>
)}
<div className="mt-2 flex gap-2">
{subscription.has_payment_method ? (
<Link href="/org-settings?tab=billing" className="text-sm font-medium text-brand-orange hover:underline focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded">
Manage billing
</Link>
) : (
<Link href="/pricing" className="text-sm font-medium text-brand-orange hover:underline focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded">
Upgrade
</Link>
)}
</div>
</>
) : (
<p className="text-lg font-bold text-brand-orange">Free Plan</p>
)}
</div>
</div>
{!sitesLoading && sites.length === 0 && (
<div className="mb-8 rounded-2xl border-2 border-dashed border-brand-orange/30 bg-brand-orange/5 p-8 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">
@@ -432,8 +306,56 @@ export default function HomePage() {
)}
{(sitesLoading || sites.length > 0) && (
<SiteList sites={sites} loading={sitesLoading} onDelete={handleDelete} />
<SiteList sites={sites} siteStats={siteStats} loading={sitesLoading} />
)}
</div>
<DeleteSiteModal
open={!!permanentDeleteSiteModal}
onClose={() => setPermanentDeleteSiteModal(null)}
onDeleted={loadSites}
siteName={permanentDeleteSiteModal?.name || ''}
siteDomain={permanentDeleteSiteModal?.domain || ''}
siteId={permanentDeleteSiteModal?.id || ''}
permanentOnly
/>
{deletedSites.length > 0 && (
<div className="mt-8">
<h3 className="text-sm font-medium text-neutral-400 mb-4">Scheduled for Deletion</h3>
<div className="space-y-3">
{deletedSites.map((site) => {
const purgeAt = site.deleted_at ? new Date(new Date(site.deleted_at).getTime() + 7 * 24 * 60 * 60 * 1000) : null
const daysLeft = purgeAt ? Math.max(0, Math.ceil((purgeAt.getTime() - Date.now()) / (1000 * 60 * 60 * 24))) : 0
return (
<div key={site.id} className="flex items-center justify-between p-4 rounded-xl border border-neutral-800 bg-neutral-900/50 opacity-60">
<div>
<span className="font-medium text-neutral-300">{site.name}</span>
<span className="ml-2 text-sm text-neutral-400">{site.domain}</span>
<span className="ml-3 inline-flex items-center rounded-full bg-red-900/20 px-2 py-0.5 text-xs font-medium text-red-400">
Deleting in {daysLeft} day{daysLeft !== 1 ? 's' : ''}
</span>
</div>
<div className="flex gap-2">
<button
onClick={() => handleRestore(site.id)}
className="px-3 py-1.5 text-xs font-medium text-neutral-300 border border-neutral-700 rounded-lg hover:bg-neutral-800 transition-colors"
>
Restore
</button>
<button
onClick={() => handlePermanentDelete(site.id)}
className="px-3 py-1.5 text-xs font-medium text-red-400 border border-red-900 rounded-lg hover:bg-red-900/20 transition-colors"
>
Delete Now
</button>
</div>
</div>
)
})}
</div>
</div>
)}
</div>
)
}

View File

@@ -1,10 +1,30 @@
import { Suspense } from 'react'
import type { Metadata } from 'next'
import PricingSection from '@/components/PricingSection'
import { PricingCardsSkeleton } from '@/components/skeletons'
export const metadata: Metadata = {
title: 'Pricing | Pulse',
description: 'Simple, transparent pricing for privacy-first web analytics. Free tier included.',
openGraph: {
title: 'Pricing | Pulse',
description: 'Simple, transparent pricing for privacy-first web analytics. Free tier included.',
siteName: 'Pulse by Ciphera',
},
}
export default function PricingPage() {
return (
<div className="min-h-screen pt-20">
<Suspense fallback={<div className="min-h-screen pt-20 flex items-center justify-center">Loading...</div>}>
<Suspense fallback={
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-16">
<div className="text-center mb-12">
<div className="h-10 w-64 animate-pulse rounded bg-neutral-800 mx-auto mb-4" />
<div className="h-5 w-96 animate-pulse rounded bg-neutral-800 mx-auto" />
</div>
<PricingCardsSkeleton />
</div>
}>
<PricingSection />
</Suspense>
</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,19 +0,0 @@
import { Suspense } from 'react'
import ProfileSettings from '@/components/settings/ProfileSettings'
import CheckoutSuccessToast from '@/components/checkout/CheckoutSuccessToast'
export const metadata = {
title: 'Settings - Pulse',
description: 'Manage your account settings',
}
export default function SettingsPage() {
return (
<div className="min-h-screen pt-12 pb-12 px-4 sm:px-6">
<Suspense fallback={null}>
<CheckoutSuccessToast />
</Suspense>
<ProfileSettings />
</div>
)
}

13
app/share/[id]/error.tsx Normal file
View File

@@ -0,0 +1,13 @@
'use client'
import ErrorDisplay from '@/components/ErrorDisplay'
export default function ShareError({ reset }: { error: Error; reset: () => void }) {
return (
<ErrorDisplay
title="Dashboard failed to load"
message="We couldn't load this public dashboard. It may be temporarily unavailable — try again."
onRetry={reset}
/>
)
}

73
app/share/[id]/layout.tsx Normal file
View File

@@ -0,0 +1,73 @@
import type { Metadata } from 'next'
import { FAVICON_SERVICE_URL } from '@/lib/utils/favicon'
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8082'
interface SharePageParams {
params: Promise<{ id: string }>
}
export async function generateMetadata({ params }: SharePageParams): Promise<Metadata> {
const { id } = await params
const fallback: Metadata = {
title: 'Public Dashboard | Pulse',
description: 'Privacy-first web analytics — view this site\'s public stats.',
openGraph: {
title: 'Public Dashboard | Pulse',
description: 'Privacy-first web analytics — view this site\'s public stats.',
siteName: 'Pulse by Ciphera',
type: 'website',
},
twitter: {
card: 'summary',
title: 'Public Dashboard | Pulse',
description: 'Privacy-first web analytics — view this site\'s public stats.',
},
}
try {
const res = await fetch(`${API_URL}/public/sites/${id}/dashboard?limit=1`, {
next: { revalidate: 3600 },
})
if (!res.ok) return fallback
const data = await res.json()
const domain = data?.site?.domain
if (!domain) return fallback
const title = `${domain} analytics | Pulse`
const description = `Live, privacy-first analytics for ${domain} — powered by Pulse.`
return {
title,
description,
openGraph: {
title,
description,
siteName: 'Pulse by Ciphera',
type: 'website',
images: [{
url: `${FAVICON_SERVICE_URL}?domain=${domain}&sz=128`,
width: 128,
height: 128,
alt: `${domain} favicon`,
}],
},
twitter: {
card: 'summary',
title,
description,
},
}
} catch {
return fallback
}
}
export default function ShareLayout({
children,
}: {
children: React.ReactNode
}) {
return children
}

View File

@@ -1,19 +1,22 @@
'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'
import { LoadingOverlay, Button } from '@ciphera-net/ui'
import Chart from '@/components/dashboard/Chart'
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, useSkeletonFade } from '@/components/skeletons'
import ExportModal from '@/components/dashboard/ExportModal'
import { FAVICON_SERVICE_URL } from '@/lib/utils/favicon'
// Helper to get date ranges
const getDateRange = (days: number) => {
@@ -37,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('')
@@ -88,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)
})()
])
@@ -146,31 +124,20 @@ export default function PublicDashboardPage() {
setPrevStats(prevStatsData)
setPrevDailyStats(prevDailyStatsData)
setLastUpdatedAt(Date.now())
setIsPasswordProtected(false)
// Reset captcha
setCaptchaId('')
setCaptchaSolution('')
setCaptchaToken('')
} catch (error: any) {
if ((error.status === 401 || error.response?.status === 401) && (error.data?.is_protected || error.response?.data?.is_protected)) {
} 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 (error.status === 404 || error.response?.status === 404) {
} else if (apiErr?.status === 404) {
toast.error('Site not found')
} else if (!silent) {
toast.error(getAuthErrorMessage(error) || 'Failed to load dashboard: ' + ((error as Error)?.message || 'Unknown error'))
toast.error(getAuthErrorMessage(error) || 'Failed to load public dashboard')
}
} finally {
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(() => {
@@ -181,30 +148,54 @@ 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)
}
}
if (loading && !data && !isPasswordProtected) {
return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Pulse" />
const showSkeleton = useMinimumLoading(loading && !data && !isPasswordProtected)
const fadeClass = useSkeletonFade(showSkeleton)
if (showSkeleton) {
return <DashboardSkeleton />
}
if (isPasswordProtected && !data) {
return (
<div className="min-h-screen flex items-center justify-center px-4">
<div className="max-w-md w-full bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-8 shadow-lg">
<div className="max-w-md w-full bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 shadow-lg transition-shadow duration-300">
<div className="text-center mb-6">
<div className="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">
@@ -219,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>
@@ -231,6 +222,7 @@ export default function PublicDashboardPage() {
setCaptchaToken(token || '')
}}
apiUrl={process.env.NEXT_PUBLIC_CAPTCHA_API_URL}
action="share-access"
/>
</div>
<Button
@@ -248,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 || []
@@ -266,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">
@@ -278,14 +270,17 @@ 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">
<img
src={`https://www.google.com/s2/favicons?domain=${site.domain}&sz=64`}
<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}
width={32}
height={32}
className="w-8 h-8 rounded-lg"
onError={(e) => {
(e.target as HTMLImageElement).src = '/globe.svg'
}}
unoptimized
/>
{site.domain}
</h1>
@@ -383,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

@@ -0,0 +1,17 @@
'use client'
import DashboardShell from '@/components/dashboard/DashboardShell'
export default function SiteLayoutShell({
siteId,
children,
}: {
siteId: string
children: React.ReactNode
}) {
return (
<DashboardShell siteId={siteId}>
{children}
</DashboardShell>
)
}

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

@@ -0,0 +1,160 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { useParams } from 'next/navigation'
import { getDateRange, formatDate, getThisWeekRange, getThisMonthRange } from '@/lib/utils/dateRanges'
import { Select, DatePicker } from '@ciphera-net/ui'
import dynamic from 'next/dynamic'
import { getRageClicks, getDeadClicks } from '@/lib/api/stats'
import FrustrationSummaryCards from '@/components/behavior/FrustrationSummaryCards'
import FrustrationTable from '@/components/behavior/FrustrationTable'
import FrustrationByPageTable from '@/components/behavior/FrustrationByPageTable'
import FrustrationTrend from '@/components/behavior/FrustrationTrend'
import { useDashboard, useBehavior } from '@/lib/swr/dashboard'
import { BehaviorSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
const ScrollDepth = dynamic(() => import('@/components/dashboard/ScrollDepth'))
export default function BehaviorPage() {
const params = useParams()
const siteId = params.id as string
const [period, setPeriod] = useState('30')
const [dateRange, setDateRange] = useState(() => getDateRange(30))
const [isDatePickerOpen, setIsDatePickerOpen] = useState(false)
// Single request for all frustration data
const { data: behavior, isLoading: loading, error: behaviorError } = useBehavior(siteId, dateRange.start, dateRange.end)
// Fetch dashboard data for scroll depth (goal_counts + stats)
const { data: dashboard } = useDashboard(siteId, dateRange.start, dateRange.end)
const showSkeleton = useMinimumLoading(loading && !behavior)
const fadeClass = useSkeletonFade(showSkeleton)
useEffect(() => {
const domain = dashboard?.site?.domain
document.title = domain ? `Behavior · ${domain} | Pulse` : 'Behavior | Pulse'
}, [dashboard?.site?.domain])
// On-demand fetchers for modal "view all"
const fetchAllRage = useCallback(
() => getRageClicks(siteId, dateRange.start, dateRange.end, 100),
[siteId, dateRange.start, dateRange.end]
)
const fetchAllDead = useCallback(
() => getDeadClicks(siteId, dateRange.start, dateRange.end, 100),
[siteId, dateRange.start, dateRange.end]
)
const summary = behavior?.summary ?? null
const rageClicks = behavior?.rage_clicks ?? { items: [], total: 0 }
const deadClicks = behavior?.dead_clicks ?? { items: [], total: 0 }
const byPage = behavior?.by_page ?? []
if (showSkeleton) return <BehaviorSkeleton />
return (
<div className={`w-full max-w-7xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
{/* Header */}
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-lg font-semibold text-neutral-200 mb-1">
Behavior
</h1>
<p className="text-sm text-neutral-400">
Frustration signals and user engagement patterns
</p>
</div>
<Select
variant="input"
className="min-w-[140px]"
value={period}
onChange={(value) => {
if (value === 'today') {
const today = formatDate(new Date())
setDateRange({ start: today, end: today })
setPeriod('today')
} else if (value === '7') {
setDateRange(getDateRange(7))
setPeriod('7')
} else if (value === 'week') {
setDateRange(getThisWeekRange())
setPeriod('week')
} else if (value === '30') {
setDateRange(getDateRange(30))
setPeriod('30')
} else if (value === 'month') {
setDateRange(getThisMonthRange())
setPeriod('month')
} else if (value === 'custom') {
setIsDatePickerOpen(true)
}
}}
options={[
{ value: 'today', label: 'Today' },
{ value: '7', label: 'Last 7 days' },
{ value: '30', label: 'Last 30 days' },
{ value: 'divider-1', label: '', divider: true },
{ value: 'week', label: 'This week' },
{ value: 'month', label: 'This month' },
{ value: 'divider-2', label: '', divider: true },
{ value: 'custom', label: 'Custom' },
]}
/>
</div>
{/* Summary cards */}
<FrustrationSummaryCards data={summary} loading={loading} />
{/* Rage clicks + Dead clicks side by side */}
<div className="grid gap-6 lg:grid-cols-2 mb-8 [&>*]:min-w-0">
<FrustrationTable
title="Rage Clicks"
description="Elements users clicked repeatedly in frustration"
items={rageClicks.items}
total={rageClicks.total}
totalSignals={summary?.rage_clicks ?? 0}
showAvgClicks
loading={loading}
fetchAll={fetchAllRage}
/>
<FrustrationTable
title="Dead Clicks"
description="Elements users clicked that produced no response"
items={deadClicks.items}
total={deadClicks.total}
totalSignals={summary?.dead_clicks ?? 0}
loading={loading}
fetchAll={fetchAllDead}
/>
</div>
{/* By page breakdown */}
<FrustrationByPageTable pages={byPage} loading={loading} />
{/* Scroll depth + Frustration trend — hide when data failed to load */}
{!behaviorError && (
<div className="grid gap-6 lg:grid-cols-2 mb-8 [&>*]:min-w-0">
<ScrollDepth
goalCounts={dashboard?.goal_counts ?? []}
totalPageviews={dashboard?.stats?.pageviews ?? 0}
/>
<FrustrationTrend summary={summary} loading={loading} />
</div>
)}
<DatePicker
isOpen={isDatePickerOpen}
onClose={() => setIsDatePickerOpen(false)}
onApply={(range) => {
setDateRange(range)
setPeriod('custom')
setIsDatePickerOpen(false)
}}
initialRange={dateRange}
/>
</div>
)
}

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

13
app/sites/[id]/error.tsx Normal file
View File

@@ -0,0 +1,13 @@
'use client'
import ErrorDisplay from '@/components/ErrorDisplay'
export default function DashboardError({ reset }: { error: Error; reset: () => void }) {
return (
<ErrorDisplay
title="Dashboard failed to load"
message="We couldn't load your site analytics. This might be a temporary issue — try again."
onRetry={reset}
/>
)
}

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,38 +1,20 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import React, { useCallback, useEffect, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { ApiError } from '@/lib/api/client'
import { getFunnel, getFunnelStats, deleteFunnel, type Funnel, type FunnelStats } from '@/lib/api/funnels'
import { toast, LoadingOverlay, Select, DatePicker, ChevronLeftIcon, ArrowRightIcon, TrashIcon, useTheme, Button } from '@ciphera-net/ui'
import { getFunnel, getFunnelStats, getFunnelTrends, deleteFunnel, type Funnel, type FunnelStats, type FunnelTrends } from '@/lib/api/funnels'
import FilterBar from '@/components/dashboard/FilterBar'
import AddFilterDropdown from '@/components/dashboard/AddFilterDropdown'
import { type DimensionFilter, serializeFilters } from '@/lib/filters'
import { toast, Select, DatePicker, ChevronLeftIcon, ArrowRightIcon, TrashIcon, Button } from '@ciphera-net/ui'
import { PencilSimple } from '@phosphor-icons/react'
import { FunnelDetailSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
import Link from 'next/link'
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Cell
} from 'recharts'
import { FunnelChart } from '@/components/ui/funnel-chart'
import { getDateRange } from '@ciphera-net/ui'
const CHART_COLORS_LIGHT = {
border: '#E5E5E5',
axis: '#A3A3A3',
tooltipBg: '#ffffff',
tooltipBorder: '#E5E5E5',
}
const CHART_COLORS_DARK = {
border: '#404040',
axis: '#737373',
tooltipBg: '#262626',
tooltipBorder: '#404040',
}
const BRAND_ORANGE = '#FD5E0F'
import BreakdownDrawer from '@/components/funnels/BreakdownDrawer'
import { ResponsiveContainer, LineChart, Line, XAxis, YAxis, Tooltip, CartesianGrid } from 'recharts'
export default function FunnelReportPage() {
const params = useParams()
@@ -43,42 +25,44 @@ 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')
else if (status === 403) setLoadError('forbidden')
else setLoadError('error')
if (status !== 404 && status !== 403) toast.error('Failed to load funnel data')
if (status !== 404 && status !== 403) toast.error('Failed to load funnel details')
} finally {
setLoading(false)
}
}, [siteId, funnelId, dateRange])
}, [siteId, funnelId, dateRange, filters])
useEffect(() => {
loadData()
}, [loadData])
const { resolvedTheme } = useTheme()
const chartColors = useMemo(
() => (resolvedTheme === 'dark' ? CHART_COLORS_DARK : CHART_COLORS_LIGHT),
[resolvedTheme]
)
const handleDelete = async () => {
if (!confirm('Are you sure you want to delete this funnel?')) return
@@ -91,13 +75,16 @@ export default function FunnelReportPage() {
}
}
if (loading && !funnel) {
return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Pulse" />
const showSkeleton = useMinimumLoading(loading && !funnel)
const fadeClass = useSkeletonFade(showSkeleton)
if (showSkeleton) {
return <FunnelDetailSkeleton />
}
if (loadError === 'not_found' || (!funnel && !stats && !loadError)) {
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
<div className="w-full max-w-7xl mx-auto px-4 sm:px-6 pb-8">
<p className="text-neutral-600 dark:text-neutral-400">Funnel not found</p>
</div>
)
@@ -105,7 +92,7 @@ export default function FunnelReportPage() {
if (loadError === 'forbidden') {
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
<div className="w-full max-w-7xl mx-auto px-4 sm:px-6 pb-8">
<p className="text-neutral-600 dark:text-neutral-400">Access denied</p>
<Link href={`/sites/${siteId}/funnels`}>
<Button variant="primary" className="mt-4">
@@ -118,7 +105,7 @@ export default function FunnelReportPage() {
if (loadError === 'error') {
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
<div className="w-full max-w-7xl mx-auto px-4 sm:px-6 pb-8">
<p className="text-neutral-600 dark:text-neutral-400 mb-4">Unable to load funnel</p>
<Button type="button" onClick={() => loadData()} variant="primary">
Try again
@@ -129,21 +116,34 @@ export default function FunnelReportPage() {
if (!funnel || !stats) {
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
<div className="w-full max-w-7xl mx-auto px-4 sm:px-6 pb-8">
<p className="text-neutral-600 dark:text-neutral-400">Funnel not found</p>
</div>
)
}
const chartData = stats.steps.map(s => ({
name: s.step.name,
visitors: s.visitors,
dropoff: s.dropoff,
conversion: s.conversion
label: s.step.name,
value: s.visitors,
}))
const STEP_COLORS = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#06B6D4', '#84CC16']
const trendsChartData = trends ? trends.dates.map((date, idx) => {
const point: Record<string, any> = {
date: new Date(date).toLocaleDateString('en-GB', { day: 'numeric', month: 'short' }),
overall: Math.round(trends.overall[idx] * 10) / 10,
}
for (const [stepKey, values] of Object.entries(trends.steps)) {
if (visibleSteps.has(stepKey)) {
point[`step_${stepKey}`] = Math.round(values[idx] * 10) / 10
}
}
return point
}) : []
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
<div className={`w-full max-w-7xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
<div className="mb-8">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
@@ -154,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 && (
@@ -186,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"
@@ -196,121 +203,199 @@ export default function FunnelReportPage() {
</div>
</div>
{/* Filters */}
<div className="flex flex-wrap items-center gap-2 mb-6">
<AddFilterDropdown
onAdd={(f) => setFilters(prev => [...prev, f])}
/>
<FilterBar
filters={filters}
onRemove={(i) => setFilters(prev => prev.filter((_, idx) => idx !== i))}
onClear={() => setFilters([])}
/>
</div>
{/* Chart */}
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden shadow-sm p-6 mb-8">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white mb-6">
<div className="bg-neutral-900 border border-neutral-800 rounded-2xl overflow-hidden shadow-sm p-6 mb-8">
<h3 className="text-lg font-semibold text-white mb-6">
Funnel Visualization
</h3>
<div className="h-[400px] w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={chartColors.border} />
<XAxis
dataKey="name"
stroke={chartColors.axis}
fontSize={12}
tickLine={false}
axisLine={false}
/>
<YAxis
stroke={chartColors.axis}
fontSize={12}
tickLine={false}
axisLine={false}
/>
<Tooltip
cursor={{ fill: 'transparent' }}
content={({ active, payload, label }) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
return (
<div
className="p-3 rounded-xl shadow-lg border"
style={{
backgroundColor: chartColors.tooltipBg,
borderColor: chartColors.tooltipBorder,
}}
>
<p className="font-medium text-neutral-900 dark:text-white mb-1">{label}</p>
<p className="text-brand-orange font-bold text-lg">
{data.visitors.toLocaleString()} visitors
</p>
{data.dropoff > 0 && (
<p className="text-red-500 text-sm">
{Math.round(data.dropoff)}% drop-off
</p>
)}
{data.conversion > 0 && (
<p className="text-green-500 text-sm">
{Math.round(data.conversion)}% conversion (overall)
</p>
)}
</div>
);
}
return null;
}}
/>
<Bar dataKey="visitors" radius={[4, 4, 0, 0]} barSize={60}>
{chartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={BRAND_ORANGE} fillOpacity={Math.max(0.1, 1 - index * 0.15)} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
<FunnelChart
data={chartData}
orientation="horizontal"
color="var(--chart-1)"
layers={3}
labelLayout="grouped"
labelAlign="center"
labelOrientation="vertical"
style={{ aspectRatio: '4 / 1' }}
/>
</div>
{/* Conversion Trends */}
{trends && trends.dates.length > 1 && (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden shadow-sm p-6 mb-8">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-white">
Conversion Trends
</h3>
<div className="flex flex-wrap gap-2">
{stats?.steps.map((s, i) => (
<button
key={i}
type="button"
onClick={() => {
setVisibleSteps(prev => {
const next = new Set(prev)
if (next.has(String(i))) next.delete(String(i))
else next.add(String(i))
return next
})
}}
className={`px-2 py-1 text-xs rounded-md transition-colors ${
visibleSteps.has(String(i))
? 'bg-brand-orange/10 text-brand-orange border border-brand-orange/30'
: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-500 border border-transparent'
}`}
>
{s.step.name}
</button>
))}
</div>
</div>
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={trendsChartData}>
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-neutral-200 dark:text-neutral-700" />
<XAxis
dataKey="date"
tick={{ fontSize: 12 }}
className="text-neutral-500"
/>
<YAxis
domain={[0, 100]}
tickFormatter={(v) => `${v}%`}
tick={{ fontSize: 12 }}
className="text-neutral-500"
/>
<Tooltip
formatter={(value: number) => [`${value}%`]}
contentStyle={{
backgroundColor: 'var(--color-neutral-900, #171717)',
border: '1px solid var(--color-neutral-700, #404040)',
borderRadius: '8px',
color: '#fff',
fontSize: '12px',
}}
/>
<Line
type="monotone"
dataKey="overall"
name="Overall"
stroke="#F97316"
strokeWidth={2}
dot={false}
activeDot={{ r: 4 }}
/>
{Array.from(visibleSteps).map((stepKey) => (
<Line
key={stepKey}
type="monotone"
dataKey={`step_${stepKey}`}
name={stats?.steps[Number(stepKey)]?.step.name || `Step ${stepKey}`}
stroke={STEP_COLORS[Number(stepKey) % STEP_COLORS.length]}
strokeWidth={2}
dot={false}
activeDot={{ r: 4 }}
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
</div>
)}
{/* Detailed Stats Table */}
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="bg-neutral-50 dark:bg-neutral-800/50 border-b border-neutral-200 dark:border-neutral-800">
<tr>
<th className="px-6 py-4 font-medium text-neutral-500 uppercase tracking-wider">Step</th>
<th className="px-6 py-4 font-medium text-neutral-500 uppercase tracking-wider text-right">Visitors</th>
<th className="px-6 py-4 font-medium text-neutral-500 uppercase tracking-wider text-right">Drop-off</th>
<th className="px-6 py-4 font-medium text-neutral-500 uppercase tracking-wider text-right">Conversion</th>
<th className="px-6 py-4 font-medium text-neutral-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={i} className="hover:bg-neutral-50 dark:hover:bg-neutral-800/30 transition-colors">
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<span className="w-6 h-6 rounded-full bg-neutral-100 dark:bg-neutral-800 flex items-center justify-center text-xs font-medium text-neutral-600 dark:text-neutral-400">
{i + 1}
</span>
<div>
<p className="font-medium text-neutral-900 dark:text-white">{step.step.name}</p>
<p className="text-neutral-500 text-xs font-mono mt-0.5">{step.step.value}</p>
<React.Fragment key={step.step.name}>
<tr className="hover:bg-neutral-50 dark:hover:bg-neutral-800/30 transition-colors cursor-pointer" onClick={() => setBreakdownStep(i)}>
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<span className="w-6 h-6 rounded-full bg-neutral-100 dark:bg-neutral-800 flex items-center justify-center text-xs font-medium text-neutral-600 dark:text-neutral-400">
{i + 1}
</span>
<div>
<p className="font-medium text-white">{step.step.name}</p>
<p className="text-neutral-400 text-xs font-mono mt-0.5">{step.step.value}</p>
</div>
</div>
</div>
</td>
<td className="px-6 py-4 text-right">
<span className="font-medium text-neutral-900 dark:text-white">
{step.visitors.toLocaleString()}
</span>
</td>
<td className="px-6 py-4 text-right">
{i > 0 ? (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
step.dropoff > 50
? 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400'
: 'bg-neutral-100 text-neutral-800 dark:bg-neutral-800 dark:text-neutral-300'
}`}>
{Math.round(step.dropoff)}%
</td>
<td className="px-6 py-4 text-right">
<span className="font-medium text-white">
{step.visitors.toLocaleString()}
</span>
) : (
<span className="text-neutral-400">-</span>
)}
</td>
<td className="px-6 py-4 text-right">
<span className="text-green-600 dark:text-green-400 font-medium">
{Math.round(step.conversion)}%
</span>
</td>
</tr>
</td>
<td className="px-6 py-4 text-right">
{i > 0 ? (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
step.dropoff > 50
? 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400'
: 'bg-neutral-100 text-neutral-800 dark:bg-neutral-800 dark:text-neutral-300'
}`}>
{Math.round(step.dropoff)}%
</span>
) : (
<span className="text-neutral-400">-</span>
)}
</td>
<td className="px-6 py-4 text-right">
<span className="text-green-600 dark:text-green-400 font-medium">
{Math.round(step.conversion)}%
</span>
</td>
</tr>
{step.exit_pages && step.exit_pages.length > 0 && (
<tr className="bg-neutral-50/50 dark:bg-neutral-800/20">
<td colSpan={4} className="px-6 py-3">
<div className="ml-9">
<p className="text-xs font-medium text-neutral-500 mb-2">
Where visitors went after dropping off:
</p>
<div className="flex flex-wrap gap-2">
{(expandedExitStep === i ? step.exit_pages : step.exit_pages.slice(0, 3)).map(ep => (
<span key={ep.path} className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg text-xs">
<span className="font-mono text-neutral-600 dark:text-neutral-300">{ep.path}</span>
<span className="text-neutral-400">{ep.visitors}</span>
</span>
))}
</div>
{step.exit_pages.length > 3 && (
<button
type="button"
onClick={() => setExpandedExitStep(expandedExitStep === i ? null : i)}
className="mt-2 text-xs text-brand-orange hover:underline"
>
{expandedExitStep === i ? 'Show less' : `See all ${step.exit_pages.length} exit pages`}
</button>
)}
</div>
</td>
</tr>
)}
</React.Fragment>
))}
</tbody>
</table>
@@ -318,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

@@ -0,0 +1,13 @@
'use client'
import ErrorDisplay from '@/components/ErrorDisplay'
export default function FunnelsError({ reset }: { error: Error; reset: () => void }) {
return (
<ErrorDisplay
title="Funnels failed to load"
message="We couldn't load your funnels. This might be a temporary issue — try again."
onRetry={reset}
/>
)
}

View File

@@ -0,0 +1,15 @@
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Funnels | Pulse',
description: 'Track conversion funnels and user journeys.',
robots: { index: false, follow: false },
}
export default function FunnelsLayout({
children,
}: {
children: React.ReactNode
}) {
return children
}

View File

@@ -2,233 +2,38 @@
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) {
toast.error('Failed to create funnel')
} catch {
toast.error('Failed to create funnel. Please try again.')
} finally {
setSaving(false)
}
}
return (
<div className="w-full max-w-3xl mx-auto px-4 sm:px-6 py-8">
<div className="mb-8">
<Link
href={`/sites/${siteId}/funnels`}
className="inline-flex items-center gap-2 text-sm text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white mb-6 rounded-xl hover:bg-neutral-100 dark:hover:bg-neutral-800 px-2 py-1.5 -ml-2 transition-colors"
>
<ChevronLeftIcon className="w-4 h-4" />
Back to Funnels
</Link>
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-2">
Create New Funnel
</h1>
<p className="text-neutral-600 dark:text-neutral-400">
Define the steps users take to complete a goal.
</p>
</div>
<form onSubmit={handleSubmit}>
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 mb-6">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
Funnel Name
</label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Signup Flow"
required
/>
</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={index} className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-4">
<div className="flex items-start gap-4">
<div className="mt-3 text-neutral-400">
<div className="w-6 h-6 rounded-full bg-neutral-100 dark:bg-neutral-800 flex items-center justify-center text-sm font-medium text-neutral-600 dark:text-neutral-400">
{index + 1}
</div>
</div>
<div className="flex-1 grid gap-4 md:grid-cols-2">
<div>
<label className="block text-xs font-medium text-neutral-500 uppercase mb-1">
Step Name
</label>
<Input
value={step.name}
onChange={(e) => handleUpdateStep(index, 'name', e.target.value)}
placeholder="e.g. Landing Page"
/>
</div>
<div>
<label className="block text-xs font-medium text-neutral-500 uppercase mb-1">
Path / URL
</label>
<div className="flex gap-2">
<select
value={step.type}
onChange={(e) => handleUpdateStep(index, 'type', e.target.value)}
className="w-24 px-2 py-2 bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-lg text-sm focus:ring-2 focus:ring-brand-orange/20 focus:border-brand-orange outline-none"
>
<option value="exact">Exact</option>
<option value="contains">Contains</option>
<option value="regex">Regex</option>
</select>
<Input
value={step.value}
onChange={(e) => handleUpdateStep(index, 'value', e.target.value)}
placeholder={step.type === 'exact' ? '/pricing' : 'pricing'}
className="flex-1"
/>
</div>
</div>
</div>
<button
type="button"
onClick={() => handleRemoveStep(index)}
disabled={steps.length <= 1}
aria-label="Remove step"
className={`mt-3 p-2 rounded-xl transition-colors ${
steps.length <= 1
? 'text-neutral-300 cursor-not-allowed'
: 'text-neutral-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20'
}`}
>
<TrashIcon className="w-5 h-5" />
</button>
</div>
</div>
))}
<button
type="button"
onClick={handleAddStep}
className="w-full py-3 border-2 border-dashed border-neutral-200 dark:border-neutral-800 rounded-xl text-neutral-500 hover:text-neutral-900 dark:hover:text-white hover:border-neutral-300 dark:hover:border-neutral-700 transition-colors flex items-center justify-center gap-2 font-medium"
>
<PlusIcon className="w-4 h-4" />
Add Step
</button>
</div>
<div className="flex justify-end gap-4">
<Link href={`/sites/${siteId}/funnels`}>
<Button variant="secondary">
Cancel
</Button>
</Link>
<Button
type="submit"
disabled={saving}
variant="primary"
>
{saving ? 'Creating...' : 'Create Funnel'}
</Button>
</div>
</form>
</div>
<FunnelForm
siteId={siteId}
onSubmit={handleSubmit}
submitLabel={saving ? 'Creating...' : 'Create Funnel'}
cancelHref={`/sites/${siteId}/funnels`}
/>
)
}

View File

@@ -1,34 +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 { toast, LoadingOverlay, PlusIcon, ArrowRightIcon, ChevronLeftIcon, TrashIcon, Button } from '@ciphera-net/ui'
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, 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 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
@@ -37,50 +22,50 @@ export default function FunnelsPage() {
try {
await deleteFunnel(siteId, funnelId)
toast.success('Funnel deleted')
loadFunnels()
mutate()
} catch (error) {
toast.error('Failed to delete funnel')
}
}
if (loading) {
return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Pulse" />
const showSkeleton = useMinimumLoading(isLoading && !funnels.length)
const fadeClass = useSkeletonFade(showSkeleton)
if (showSkeleton) {
return <FunnelsListSkeleton />
}
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
<div className={`w-full max-w-7xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
<div className="mb-8">
<div className="flex items-center gap-4 mb-6">
<Link
href={`/sites/${siteId}`}
className="p-2 -ml-2 text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white rounded-xl hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
>
<ChevronLeftIcon className="w-5 h-5" />
</Link>
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white">
<h1 className="text-lg font-semibold text-neutral-200">
Funnels
</h1>
<p className="text-neutral-600 dark:text-neutral-400">
Track user journeys and identify drop-off points
</p>
</div>
<div className="ml-auto">
<Link href={`/sites/${siteId}/funnels/new`}>
<Button variant="primary" className="inline-flex items-center gap-2">
<PlusIcon className="w-4 h-4" />
<span>Create Funnel</span>
</Button>
</Link>
</div>
<Link href={`/sites/${siteId}/funnels/new`}>
<Button variant="primary" className="inline-flex items-center gap-2">
<PlusIcon className="w-4 h-4" />
<span>Create Funnel</span>
</Button>
</Link>
</div>
{funnels.length === 0 ? (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-12 text-center">
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4 mx-auto mb-4 w-fit">
<ArrowRightIcon className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
</div>
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white mb-2">
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-12 text-center flex flex-col items-center">
<Image
src="/illustrations/data-trends.svg"
alt="Create your first funnel"
width={260}
height={195}
className="mb-6"
unoptimized
/>
<h3 className="text-lg font-semibold text-white mb-2">
No funnels yet
</h3>
<p className="text-neutral-600 dark:text-neutral-400 mb-6 max-w-md mx-auto">
@@ -104,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 && (
@@ -114,7 +99,7 @@ export default function FunnelsPage() {
)}
<div className="flex items-center gap-2 mt-4">
{funnel.steps.map((step, i) => (
<div key={i} className="flex items-center text-sm text-neutral-500">
<div key={step.name} className="flex items-center text-sm text-neutral-500">
<span className="px-2 py-1 bg-neutral-100 dark:bg-neutral-800 rounded-lg text-neutral-700 dark:text-neutral-300">
{step.name}
</span>

View File

@@ -0,0 +1,13 @@
'use client'
import ErrorDisplay from '@/components/ErrorDisplay'
export default function JourneysError({ reset }: { error: Error; reset: () => void }) {
return (
<ErrorDisplay
title="Journeys failed to load"
message="We couldn't load the journey data. This might be a temporary issue — try again."
onRetry={reset}
/>
)
}

View File

@@ -0,0 +1,9 @@
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Journeys | Pulse',
}
export default function JourneysLayout({ children }: { children: React.ReactNode }) {
return children
}

View File

@@ -0,0 +1,245 @@
'use client'
import { useEffect, useState } from 'react'
import { useParams } from 'next/navigation'
import { motion } from 'framer-motion'
import { getDateRange, formatDate, getThisWeekRange, getThisMonthRange } from '@/lib/utils/dateRanges'
import { Select, DatePicker } from '@ciphera-net/ui'
import ColumnJourney from '@/components/journeys/ColumnJourney'
import SankeyJourney from '@/components/journeys/SankeyJourney'
import TopPathsTable from '@/components/journeys/TopPathsTable'
import { JourneysSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
import {
useDashboard,
useJourneyTransitions,
useJourneyTopPaths,
useJourneyEntryPoints,
} from '@/lib/swr/dashboard'
const DEFAULT_DEPTH = 4
export default function JourneysPage() {
const params = useParams()
const siteId = params.id as string
const [period, setPeriod] = useState('30')
const [dateRange, setDateRange] = useState(() => getDateRange(30))
const [isDatePickerOpen, setIsDatePickerOpen] = useState(false)
const [depth, setDepth] = useState(DEFAULT_DEPTH)
const [committedDepth, setCommittedDepth] = useState(DEFAULT_DEPTH)
const [entryPath, setEntryPath] = useState('')
const [viewMode, setViewMode] = useState<'columns' | 'flow'>('columns')
useEffect(() => {
const t = setTimeout(() => setCommittedDepth(depth), 300)
return () => clearTimeout(t)
}, [depth])
const isDefault = depth === DEFAULT_DEPTH && !entryPath
function resetFilters() {
setDepth(DEFAULT_DEPTH)
setCommittedDepth(DEFAULT_DEPTH)
setEntryPath('')
}
const { data: transitionsData, isLoading: transitionsLoading } = useJourneyTransitions(
siteId, dateRange.start, dateRange.end, committedDepth, 1, entryPath || undefined
)
const { data: topPaths, isLoading: topPathsLoading } = useJourneyTopPaths(
siteId, dateRange.start, dateRange.end, 20, 1, entryPath || undefined
)
const { data: entryPoints } = useJourneyEntryPoints(siteId, dateRange.start, dateRange.end)
const { data: dashboard } = useDashboard(siteId, dateRange.start, dateRange.end)
useEffect(() => {
const domain = dashboard?.site?.domain
document.title = domain ? `Journeys \u00b7 ${domain} | Pulse` : 'Journeys | Pulse'
}, [dashboard?.site?.domain])
const showSkeleton = useMinimumLoading(transitionsLoading && !transitionsData)
const fadeClass = useSkeletonFade(showSkeleton)
const entryPointOptions = [
{ value: '', label: 'All entry points' },
...(entryPoints ?? []).map((ep) => ({
value: ep.path,
label: `${ep.path} (${ep.session_count.toLocaleString()})`,
})),
]
if (showSkeleton) return <JourneysSkeleton />
const totalSessions = transitionsData?.total_sessions ?? 0
return (
<div className={`w-full max-w-7xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
{/* Header */}
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-lg font-semibold text-neutral-200 mb-1">
Journeys
</h1>
<p className="text-sm text-neutral-400">
How visitors navigate through your site
</p>
</div>
<Select
variant="input"
className="min-w-[140px]"
value={period}
onChange={(value) => {
if (value === 'today') {
const today = formatDate(new Date())
setDateRange({ start: today, end: today })
setPeriod('today')
} else if (value === '7') {
setDateRange(getDateRange(7))
setPeriod('7')
} else if (value === 'week') {
setDateRange(getThisWeekRange())
setPeriod('week')
} else if (value === '30') {
setDateRange(getDateRange(30))
setPeriod('30')
} else if (value === 'month') {
setDateRange(getThisMonthRange())
setPeriod('month')
} else if (value === 'custom') {
setIsDatePickerOpen(true)
}
}}
options={[
{ value: 'today', label: 'Today' },
{ value: '7', label: 'Last 7 days' },
{ value: '30', label: 'Last 30 days' },
{ value: 'divider-1', label: '', divider: true },
{ value: 'week', label: 'This week' },
{ value: 'month', label: 'This month' },
{ value: 'divider-2', label: '', divider: true },
{ value: 'custom', label: 'Custom' },
]}
/>
</div>
{/* Single card: toolbar + chart */}
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden">
{/* Toolbar */}
<div className="p-6 border-b border-neutral-200 dark:border-neutral-800 bg-neutral-50/50 dark:bg-neutral-900/50">
<div className="flex flex-col sm:flex-row sm:items-center gap-6">
{/* Depth slider */}
<div className="flex-1">
<div className="flex justify-between text-sm font-medium text-neutral-400 mb-3">
<span>2 steps</span>
<span className="text-brand-orange font-bold">
{depth} steps deep
</span>
<span>6 steps</span>
</div>
<input
type="range"
min={2}
max={6}
step={1}
value={depth}
onChange={(e) => setDepth(parseInt(e.target.value))}
aria-label="Journey depth"
aria-valuetext={`${depth} steps deep`}
className="w-full h-2 bg-neutral-200 rounded-lg appearance-none cursor-pointer dark:bg-neutral-700 accent-brand-orange focus:outline-none"
/>
</div>
{/* Entry point + Reset */}
<div className="flex items-center gap-3 shrink-0">
<Select
variant="input"
className="min-w-[180px]"
value={entryPath}
onChange={(value) => setEntryPath(value)}
options={entryPointOptions}
/>
<button
onClick={resetFilters}
disabled={isDefault}
className={`text-sm whitespace-nowrap transition-all duration-150 ${
isDefault
? 'opacity-0 pointer-events-none'
: 'opacity-100 text-neutral-500 hover:text-neutral-900 dark:hover:text-white'
}`}
>
Reset
</button>
</div>
</div>
{/* View toggle */}
<div className="flex gap-1 mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-800" role="tablist" aria-label="Journey view tabs">
{(['columns', 'flow'] as const).map((mode) => (
<button
key={mode}
onClick={() => setViewMode(mode)}
role="tab"
aria-selected={viewMode === mode}
className={`relative px-3 py-1 text-xs font-medium transition-colors capitalize focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange rounded cursor-pointer ${
viewMode === mode
? 'text-white'
: 'text-neutral-400 dark:text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300'
}`}
>
{mode === 'columns' ? 'Columns' : 'Flow'}
{viewMode === mode && (
<motion.div
layoutId="journeyViewTab"
className="absolute inset-x-0 -bottom-px h-0.5 bg-brand-orange"
transition={{ type: 'spring', stiffness: 500, damping: 35 }}
/>
)}
</button>
))}
</div>
</div>
{/* Journey Chart */}
<div className="p-6">
{viewMode === 'columns' ? (
<ColumnJourney
transitions={transitionsData?.transitions ?? []}
totalSessions={totalSessions}
depth={committedDepth}
/>
) : (
<SankeyJourney
transitions={transitionsData?.transitions ?? []}
totalSessions={totalSessions}
depth={committedDepth}
/>
)}
</div>
{/* Footer */}
{totalSessions > 0 && (
<div className="px-6 pb-5 text-sm text-neutral-400">
{totalSessions.toLocaleString()} sessions tracked
</div>
)}
</div>
{/* Top Paths */}
<div className="mt-6">
<TopPathsTable paths={topPaths ?? []} loading={topPathsLoading} />
</div>
{/* Date Picker Modal */}
<DatePicker
isOpen={isDatePickerOpen}
onClose={() => setIsDatePickerOpen(false)}
onApply={(range) => {
setDateRange(range)
setPeriod('custom')
setIsDatePickerOpen(false)
}}
initialRange={dateRange}
/>
</div>
)
}

19
app/sites/[id]/layout.tsx Normal file
View File

@@ -0,0 +1,19 @@
import type { Metadata } from 'next'
import SiteLayoutShell from './SiteLayoutShell'
export const metadata: Metadata = {
title: 'Dashboard | Pulse',
description: 'View your site analytics, traffic, and performance.',
robots: { index: false, follow: false },
}
export default async function SiteLayout({
children,
params,
}: {
children: React.ReactNode
params: Promise<{ id: string }>
}) {
const { id } = await params
return <SiteLayoutShell siteId={id}>{children}</SiteLayoutShell>
}

View File

@@ -1,97 +1,375 @@
'use client'
import { useAuth } from '@/lib/auth/context'
import { useCallback, useEffect, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { motion } from 'framer-motion'
import { getSite, type Site } from '@/lib/api/sites'
import { getStats, getRealtime, getDailyStats, getTopPages, getTopReferrers, getCountries, getCities, getRegions, getBrowsers, getOS, getDevices, getScreenResolutions, getEntryPages, getExitPages, getDashboard, getCampaigns, getPerformanceByPage, type Stats, type DailyStat, type PerformanceByPageStat } from '@/lib/api/stats'
import { formatNumber, formatDuration, getDateRange } from '@ciphera-net/ui'
import { logger } from '@/lib/utils/logger'
import { useCallback, useEffect, useRef, useState, useMemo } from 'react'
import { useParams, useRouter, useSearchParams } from 'next/navigation'
import {
getTopPages,
getTopReferrers,
getCountries,
getCities,
getRegions,
getBrowsers,
getOS,
getDevices,
getCampaigns,
type Stats,
type DailyStat,
} from '@/lib/api/stats'
import { getDateRange, formatDate, getThisWeekRange, getThisMonthRange } from '@/lib/utils/dateRanges'
import { toast } from '@ciphera-net/ui'
import { getAuthErrorMessage } from '@ciphera-net/ui'
import { LoadingOverlay, Button } from '@ciphera-net/ui'
import { Button } from '@ciphera-net/ui'
import { Select, DatePicker, DownloadIcon } from '@ciphera-net/ui'
import ExportModal from '@/components/dashboard/ExportModal'
import dynamic from 'next/dynamic'
import { DashboardSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
import FilterBar from '@/components/dashboard/FilterBar'
import AddFilterDropdown, { type FilterSuggestion, type FilterSuggestions } from '@/components/dashboard/AddFilterDropdown'
const Chart = dynamic(() => import('@/components/dashboard/Chart'), { ssr: false })
import ContentStats from '@/components/dashboard/ContentStats'
import TopReferrers from '@/components/dashboard/TopReferrers'
import Locations from '@/components/dashboard/Locations'
import TechSpecs from '@/components/dashboard/TechSpecs'
import Chart from '@/components/dashboard/Chart'
import PerformanceStats from '@/components/dashboard/PerformanceStats'
import GoalStats from '@/components/dashboard/GoalStats'
import Campaigns from '@/components/dashboard/Campaigns'
const GoalStats = dynamic(() => import('@/components/dashboard/GoalStats'))
const Campaigns = dynamic(() => import('@/components/dashboard/Campaigns'))
const PeakHours = dynamic(() => import('@/components/dashboard/PeakHours'))
const SearchPerformance = dynamic(() => import('@/components/dashboard/SearchPerformance'))
const EventProperties = dynamic(() => import('@/components/dashboard/EventProperties'))
const ExportModal = dynamic(() => import('@/components/dashboard/ExportModal'))
import { type DimensionFilter, serializeFilters, parseFiltersFromURL } from '@/lib/filters'
import {
useDashboard,
useRealtime,
useStats,
useDailyStats,
useCampaigns,
useAnnotations,
} from '@/lib/swr/dashboard'
import { createAnnotation, updateAnnotation, deleteAnnotation, type AnnotationCategory } from '@/lib/api/annotations'
function loadSavedSettings(): {
type?: string
dateRange?: { start: string; end: string }
todayInterval?: 'minute' | 'hour'
multiDayInterval?: 'hour' | 'day'
} | null {
if (typeof window === 'undefined') return null
try {
const saved = localStorage.getItem('pulse_dashboard_settings')
return saved ? JSON.parse(saved) : null
} catch {
return null
}
}
function getInitialDateRange(): { start: string; end: string } {
const settings = loadSavedSettings()
if (settings?.type === 'today') {
const today = formatDate(new Date())
return { start: today, end: today }
}
if (settings?.type === '7') return getDateRange(7)
if (settings?.type === 'week') return getThisWeekRange()
if (settings?.type === 'month') return getThisMonthRange()
if (settings?.type === 'custom' && settings.dateRange) return settings.dateRange
return getDateRange(30)
}
function getInitialPeriod(): string {
return loadSavedSettings()?.type || '30'
}
export default function SiteDashboardPage() {
const { user } = useAuth()
const canEdit = user?.role === 'owner' || user?.role === 'admin'
const params = useParams()
const router = useRouter()
const siteId = params.id as string
const [site, setSite] = useState<Site | null>(null)
const [loading, setLoading] = useState(true)
const [stats, setStats] = useState<Stats>({ pageviews: 0, visitors: 0, bounce_rate: 0, avg_duration: 0 })
const [prevStats, setPrevStats] = useState<Stats | undefined>(undefined)
const [realtime, setRealtime] = useState(0)
const [dailyStats, setDailyStats] = useState<DailyStat[]>([])
const [prevDailyStats, setPrevDailyStats] = useState<DailyStat[] | undefined>(undefined)
const [topPages, setTopPages] = useState<any[]>([])
const [entryPages, setEntryPages] = useState<any[]>([])
const [exitPages, setExitPages] = useState<any[]>([])
const [topReferrers, setTopReferrers] = useState<any[]>([])
const [countries, setCountries] = useState<any[]>([])
const [cities, setCities] = useState<any[]>([])
const [regions, setRegions] = useState<any[]>([])
const [browsers, setBrowsers] = useState<any[]>([])
const [os, setOS] = useState<any[]>([])
const [devices, setDevices] = useState<any[]>([])
const [screenResolutions, setScreenResolutions] = useState<any[]>([])
const [performance, setPerformance] = useState<{ lcp: number, cls: number, inp: number }>({ lcp: 0, cls: 0, inp: 0 })
const [performanceByPage, setPerformanceByPage] = useState<PerformanceByPageStat[] | null>(null)
const [goalCounts, setGoalCounts] = useState<Array<{ event_name: string; count: number }>>([])
const [campaigns, setCampaigns] = useState<any[]>([])
const [dateRange, setDateRange] = useState(getDateRange(30))
// UI state - initialized from localStorage synchronously to avoid double-fetch
const [period, setPeriod] = useState(getInitialPeriod)
const [dateRange, setDateRange] = useState(getInitialDateRange)
const [todayInterval, setTodayInterval] = useState<'minute' | 'hour'>(
() => loadSavedSettings()?.todayInterval || 'hour'
)
const [multiDayInterval, setMultiDayInterval] = useState<'hour' | 'day'>(
() => loadSavedSettings()?.multiDayInterval || 'day'
)
const [isDatePickerOpen, setIsDatePickerOpen] = useState(false)
const [isExportModalOpen, setIsExportModalOpen] = useState(false)
const [todayInterval, setTodayInterval] = useState<'minute' | 'hour'>('hour')
const [multiDayInterval, setMultiDayInterval] = useState<'hour' | 'day'>('day')
const [isSettingsLoaded, setIsSettingsLoaded] = useState(false)
const [lastUpdatedAt, setLastUpdatedAt] = useState<number | null>(null)
const [, setTick] = useState(0)
const lastUpdatedAtRef = useRef<number | null>(null)
// Load settings from localStorage
useEffect(() => {
try {
const savedSettings = localStorage.getItem('pulse_dashboard_settings')
if (savedSettings) {
const settings = JSON.parse(savedSettings)
// Dimension filters state
const searchParams = useSearchParams()
const [filters, setFilters] = useState<DimensionFilter[]>(() => {
const raw = searchParams.get('filters')
return raw ? parseFiltersFromURL(raw) : []
})
const filtersParam = useMemo(() => serializeFilters(filters), [filters])
// Restore date range
if (settings.type === 'today') {
const today = new Date().toISOString().split('T')[0]
setDateRange({ start: today, end: today })
} else if (settings.type === '7') {
setDateRange(getDateRange(7))
} else if (settings.type === '30') {
setDateRange(getDateRange(30))
} else if (settings.type === 'custom' && settings.dateRange) {
setDateRange(settings.dateRange)
}
// Selected event for property breakdown
const [selectedEvent, setSelectedEvent] = useState<string | null>(null)
// Restore intervals
if (settings.todayInterval) setTodayInterval(settings.todayInterval)
if (settings.multiDayInterval) setMultiDayInterval(settings.multiDayInterval)
}
} catch (e) {
console.error('Failed to load dashboard settings', e)
} finally {
setIsSettingsLoaded(true)
}
const handleAddFilter = useCallback((filter: DimensionFilter) => {
setFilters(prev => {
const isDuplicate = prev.some(
f => f.dimension === filter.dimension && f.operator === filter.operator && f.values.join(';') === filter.values.join(';')
)
if (isDuplicate) return prev
return [...prev, filter]
})
}, [])
const handleRemoveFilter = useCallback((index: number) => {
setFilters(prev => prev.filter((_, i) => i !== index))
}, [])
const handleClearFilters = useCallback(() => {
setFilters([])
}, [])
// Fetch full suggestion list (up to 100) when a dimension is selected in the filter dropdown
const handleFetchSuggestions = useCallback(async (dimension: string): Promise<FilterSuggestion[]> => {
const start = dateRange.start
const end = dateRange.end
const f = filtersParam || undefined
const limit = 100
try {
const regionNames = (() => { try { return new Intl.DisplayNames(['en'], { type: 'region' }) } catch { return null } })()
switch (dimension) {
case 'page': {
const data = await getTopPages(siteId, start, end, limit, f)
return data.map(p => ({ value: p.path, label: p.path, count: p.pageviews }))
}
case 'referrer': {
const data = await getTopReferrers(siteId, start, end, limit, f)
return data.filter(r => r.referrer && r.referrer !== '').map(r => ({ value: r.referrer, label: r.referrer, count: r.pageviews }))
}
case 'country': {
const data = await getCountries(siteId, start, end, limit, f)
return data.filter(c => c.country && c.country !== 'Unknown').map(c => ({ value: c.country, label: regionNames?.of(c.country) ?? c.country, count: c.pageviews }))
}
case 'city': {
const data = await getCities(siteId, start, end, limit, f)
return data.filter(c => c.city && c.city !== 'Unknown').map(c => ({ value: c.city, label: c.city, count: c.pageviews }))
}
case 'region': {
const data = await getRegions(siteId, start, end, limit, f)
return data.filter(r => r.region && r.region !== 'Unknown').map(r => ({ value: r.region, label: r.region, count: r.pageviews }))
}
case 'browser': {
const data = await getBrowsers(siteId, start, end, limit, f)
return data.filter(b => b.browser && b.browser !== 'Unknown').map(b => ({ value: b.browser, label: b.browser, count: b.pageviews }))
}
case 'os': {
const data = await getOS(siteId, start, end, limit, f)
return data.filter(o => o.os && o.os !== 'Unknown').map(o => ({ value: o.os, label: o.os, count: o.pageviews }))
}
case 'device': {
const data = await getDevices(siteId, start, end, limit, f)
return data.filter(d => d.device && d.device !== 'Unknown').map(d => ({ value: d.device, label: d.device, count: d.pageviews }))
}
case 'utm_source':
case 'utm_medium':
case 'utm_campaign': {
const data = await getCampaigns(siteId, start, end, limit, f)
const map = new Map<string, number>()
const field = dimension === 'utm_source' ? 'source' : dimension === 'utm_medium' ? 'medium' : 'campaign'
data.forEach(c => {
const val = c[field]
if (val) map.set(val, (map.get(val) ?? 0) + c.pageviews)
})
return [...map.entries()].map(([v, count]) => ({ value: v, label: v, count }))
}
default:
return []
}
} catch {
return []
}
}, [siteId, dateRange.start, dateRange.end, filtersParam])
// Sync filters to URL
useEffect(() => {
const url = new URL(window.location.href)
if (filtersParam) {
url.searchParams.set('filters', filtersParam)
} else {
url.searchParams.delete('filters')
}
window.history.replaceState({}, '', url.toString())
}, [filtersParam])
const interval = dateRange.start === dateRange.end ? todayInterval : multiDayInterval
// Previous period date range for comparison
const prevRange = useMemo(() => {
const startDate = new Date(dateRange.start)
const endDate = new Date(dateRange.end)
const duration = endDate.getTime() - startDate.getTime()
if (duration === 0) {
const prevEnd = new Date(startDate.getTime() - 24 * 60 * 60 * 1000)
return { start: prevEnd.toISOString().split('T')[0], end: prevEnd.toISOString().split('T')[0] }
}
const prevEnd = new Date(startDate.getTime() - 24 * 60 * 60 * 1000)
const prevStart = new Date(prevEnd.getTime() - duration)
return { start: prevStart.toISOString().split('T')[0], end: prevEnd.toISOString().split('T')[0] }
}, [dateRange])
// Single dashboard request replaces focused hooks (overview, pages, locations,
// devices, referrers, goals). The backend runs all queries in parallel
// and caches the result in Redis for efficient data loading.
const { data: dashboard, isLoading: dashboardLoading, isValidating: dashboardValidating, error: dashboardError } = useDashboard(siteId, dateRange.start, dateRange.end, interval, filtersParam || undefined)
const { data: realtimeData } = useRealtime(siteId)
const { data: prevStats } = useStats(siteId, prevRange.start, prevRange.end)
const { data: prevDailyStats } = useDailyStats(siteId, prevRange.start, prevRange.end, interval)
const { data: campaigns } = useCampaigns(siteId, dateRange.start, dateRange.end)
const { data: annotations, mutate: mutateAnnotations } = useAnnotations(siteId, dateRange.start, dateRange.end)
// Annotation mutation handlers
const handleCreateAnnotation = async (data: { date: string; time?: string; text: string; category: string }) => {
await createAnnotation(siteId, { ...data, category: data.category as AnnotationCategory })
mutateAnnotations()
toast.success('Annotation added')
}
const handleUpdateAnnotation = async (id: string, data: { date: string; time?: string; text: string; category: string }) => {
await updateAnnotation(siteId, id, { ...data, category: data.category as AnnotationCategory })
mutateAnnotations()
toast.success('Annotation updated')
}
const handleDeleteAnnotation = async (id: string) => {
await deleteAnnotation(siteId, id)
mutateAnnotations()
toast.success('Annotation deleted')
}
// Derive typed values from single dashboard response
const site = dashboard?.site ?? null
const stats: Stats = dashboard?.stats ?? { pageviews: 0, visitors: 0, bounce_rate: 0, avg_duration: 0 }
const realtime = realtimeData?.visitors ?? dashboard?.realtime_visitors ?? 0
const dailyStats: DailyStat[] = dashboard?.daily_stats ?? []
// Build filter suggestions from current dashboard data
const filterSuggestions = useMemo<FilterSuggestions>(() => {
const s: FilterSuggestions = {}
// Pages
const topPages = dashboard?.top_pages ?? []
if (topPages.length > 0) {
s.page = topPages.map(p => ({ value: p.path, label: p.path, count: p.pageviews }))
}
// Referrers
const refs = dashboard?.top_referrers ?? []
if (refs.length > 0) {
s.referrer = refs.filter(r => r.referrer && r.referrer !== '').map(r => ({
value: r.referrer,
label: r.referrer,
count: r.pageviews,
}))
}
// Countries
const ctrs = dashboard?.countries ?? []
if (ctrs.length > 0) {
const regionNames = (() => { try { return new Intl.DisplayNames(['en'], { type: 'region' }) } catch { return null } })()
s.country = ctrs.filter(c => c.country && c.country !== 'Unknown').map(c => ({
value: c.country,
label: regionNames?.of(c.country) ?? c.country,
count: c.pageviews,
}))
}
// Regions
const regs = dashboard?.regions ?? []
if (regs.length > 0) {
s.region = regs.filter(r => r.region && r.region !== 'Unknown').map(r => ({
value: r.region,
label: r.region,
count: r.pageviews,
}))
}
// Cities
const cts = dashboard?.cities ?? []
if (cts.length > 0) {
s.city = cts.filter(c => c.city && c.city !== 'Unknown').map(c => ({
value: c.city,
label: c.city,
count: c.pageviews,
}))
}
// Browsers
const brs = dashboard?.browsers ?? []
if (brs.length > 0) {
s.browser = brs.filter(b => b.browser && b.browser !== 'Unknown').map(b => ({
value: b.browser,
label: b.browser,
count: b.pageviews,
}))
}
// OS
const oses = dashboard?.os ?? []
if (oses.length > 0) {
s.os = oses.filter(o => o.os && o.os !== 'Unknown').map(o => ({
value: o.os,
label: o.os,
count: o.pageviews,
}))
}
// Devices
const devs = dashboard?.devices ?? []
if (devs.length > 0) {
s.device = devs.filter(d => d.device && d.device !== 'Unknown').map(d => ({
value: d.device,
label: d.device,
count: d.pageviews,
}))
}
// UTM from campaigns
const camps = campaigns ?? []
if (camps.length > 0) {
const sources = new Map<string, number>()
const mediums = new Map<string, number>()
const campNames = new Map<string, number>()
camps.forEach(c => {
if (c.source) sources.set(c.source, (sources.get(c.source) ?? 0) + c.pageviews)
if (c.medium) mediums.set(c.medium, (mediums.get(c.medium) ?? 0) + c.pageviews)
if (c.campaign) campNames.set(c.campaign, (campNames.get(c.campaign) ?? 0) + c.pageviews)
})
if (sources.size > 0) s.utm_source = [...sources.entries()].map(([v, c]) => ({ value: v, label: v, count: c }))
if (mediums.size > 0) s.utm_medium = [...mediums.entries()].map(([v, c]) => ({ value: v, label: v, count: c }))
if (campNames.size > 0) s.utm_campaign = [...campNames.entries()].map(([v, c]) => ({ value: v, label: v, count: c }))
}
return s
}, [dashboard, campaigns])
// Show error toast on fetch failure
useEffect(() => {
if (dashboardError) {
toast.error('Failed to load dashboard analytics')
}
}, [dashboardError])
// Track when data was last updated (for "Live · Xs ago" display)
useEffect(() => {
if (dashboard) lastUpdatedAtRef.current = Date.now()
}, [dashboard])
// Save settings to localStorage
const saveSettings = (type: string, newDateRange?: { start: string, end: string }) => {
const saveSettings = (type: string, newDateRange?: { start: string; end: string }) => {
try {
const settings = {
type,
@@ -102,17 +380,14 @@ export default function SiteDashboardPage() {
}
localStorage.setItem('pulse_dashboard_settings', JSON.stringify(settings))
} catch (e) {
console.error('Failed to save dashboard settings', e)
logger.error('Failed to save dashboard settings', e)
}
}
// Save intervals when they change
useEffect(() => {
if (!isSettingsLoaded) return
// Determine current type
let type = 'custom'
const today = new Date().toISOString().split('T')[0]
const today = formatDate(new Date())
if (dateRange.start === today && dateRange.end === today) type = 'today'
else if (dateRange.start === getDateRange(7).start) type = '7'
else if (dateRange.start === getDateRange(30).start) type = '30'
@@ -125,120 +400,36 @@ export default function SiteDashboardPage() {
lastUpdated: Date.now()
}
localStorage.setItem('pulse_dashboard_settings', JSON.stringify(settings))
}, [todayInterval, multiDayInterval, isSettingsLoaded]) // dateRange is handled in saveSettings/onChange
// * Tick every 1s so "Live · Xs ago" counts in real time
useEffect(() => {
const interval = setInterval(() => setTick((t) => t + 1), 1000)
return () => clearInterval(interval)
}, [])
const getPreviousDateRange = useCallback((start: string, end: string) => {
const startDate = new Date(start)
const endDate = new Date(end)
const duration = endDate.getTime() - startDate.getTime()
if (duration === 0) {
const prevEnd = new Date(startDate.getTime() - 24 * 60 * 60 * 1000)
return { start: prevEnd.toISOString().split('T')[0], end: prevEnd.toISOString().split('T')[0] }
}
const prevEnd = new Date(startDate.getTime() - 24 * 60 * 60 * 1000)
const prevStart = new Date(prevEnd.getTime() - duration)
return { start: prevStart.toISOString().split('T')[0], end: prevEnd.toISOString().split('T')[0] }
}, [])
const loadData = useCallback(async (silent = false) => {
try {
if (!silent) setLoading(true)
const interval = dateRange.start === dateRange.end ? todayInterval : multiDayInterval
const [data, prevStatsData, prevDailyStatsData, campaignsData] = await Promise.all([
getDashboard(siteId, dateRange.start, dateRange.end, 10, interval),
(async () => {
const prevRange = getPreviousDateRange(dateRange.start, dateRange.end)
return getStats(siteId, prevRange.start, prevRange.end)
})(),
(async () => {
const prevRange = getPreviousDateRange(dateRange.start, dateRange.end)
return getDailyStats(siteId, prevRange.start, prevRange.end, interval)
})(),
getCampaigns(siteId, dateRange.start, dateRange.end, 100),
])
setSite(data.site)
setStats(data.stats || { pageviews: 0, visitors: 0, bounce_rate: 0, avg_duration: 0 })
setRealtime(data.realtime_visitors || 0)
setDailyStats(Array.isArray(data.daily_stats) ? data.daily_stats : [])
setPrevStats(prevStatsData)
setPrevDailyStats(prevDailyStatsData)
setTopPages(Array.isArray(data.top_pages) ? data.top_pages : [])
setEntryPages(Array.isArray(data.entry_pages) ? data.entry_pages : [])
setExitPages(Array.isArray(data.exit_pages) ? data.exit_pages : [])
setTopReferrers(Array.isArray(data.top_referrers) ? data.top_referrers : [])
setCountries(Array.isArray(data.countries) ? data.countries : [])
setCities(Array.isArray(data.cities) ? data.cities : [])
setRegions(Array.isArray(data.regions) ? data.regions : [])
setBrowsers(Array.isArray(data.browsers) ? data.browsers : [])
setOS(Array.isArray(data.os) ? data.os : [])
setDevices(Array.isArray(data.devices) ? data.devices : [])
setScreenResolutions(Array.isArray(data.screen_resolutions) ? data.screen_resolutions : [])
setPerformance(data.performance || { lcp: 0, cls: 0, inp: 0 })
setPerformanceByPage(data.performance_by_page ?? null)
setGoalCounts(Array.isArray(data.goal_counts) ? data.goal_counts : [])
setCampaigns(Array.isArray(campaignsData) ? campaignsData : [])
setLastUpdatedAt(Date.now())
} catch (error: unknown) {
if (!silent) {
toast.error(getAuthErrorMessage(error) || 'Failed to load data: ' + ((error as Error)?.message || 'Unknown error'))
}
} finally {
if (!silent) setLoading(false)
}
}, [siteId, dateRange, todayInterval, multiDayInterval])
const loadRealtime = useCallback(async () => {
try {
const data = await getRealtime(siteId)
setRealtime(data.visitors)
} catch (error) {
// Silently fail for realtime updates
}
}, [siteId])
}, [todayInterval, multiDayInterval]) // eslint-disable-line react-hooks/exhaustive-deps -- dateRange saved via saveSettings
useEffect(() => {
if (isSettingsLoaded) loadData()
const interval = setInterval(() => {
loadData(true)
loadRealtime()
}, 30000)
return () => clearInterval(interval)
}, [siteId, dateRange, todayInterval, multiDayInterval, isSettingsLoaded, loadData, loadRealtime])
if (site?.domain) document.title = `${site.domain} | Pulse`
}, [site?.domain])
if (loading) {
return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Pulse" />
// 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 />
}
if (!site) {
return (
<div className="container mx-auto px-4 py-8">
<div className="w-full max-w-7xl mx-auto px-4 sm:px-6 pb-8">
<p className="text-neutral-600 dark:text-neutral-400">Site not found</p>
</div>
)
}
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2 }}
className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8"
>
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<div className={`w-full max-w-7xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
<div className="mb-6">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-4">
<div>
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-2">
<h1 className="text-lg font-semibold text-neutral-200 mb-1">
{site.name}
</h1>
<p className="text-neutral-600 dark:text-neutral-400">
@@ -247,9 +438,8 @@ export default function SiteDashboardPage() {
</div>
{/* Realtime Indicator */}
<button
onClick={() => router.push(`/sites/${siteId}/realtime`)}
className="flex items-center gap-2 px-3 py-1 bg-green-500/10 rounded-full border border-green-500/20 hover:bg-green-500/20 transition-colors cursor-pointer"
<div
className="flex items-center gap-2 px-3 py-1 bg-green-500/10 rounded-full border border-green-500/20"
>
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-500 opacity-75"></span>
@@ -258,7 +448,7 @@ export default function SiteDashboardPage() {
<span className="text-sm font-medium text-green-700 dark:text-green-400">
{realtime} current visitors
</span>
</button>
</div>
</div>
<div className="flex items-center gap-2">
@@ -274,33 +464,35 @@ export default function SiteDashboardPage() {
<Select
variant="input"
className="min-w-[140px]"
value={
dateRange.start === new Date().toISOString().split('T')[0] && dateRange.end === new Date().toISOString().split('T')[0]
? 'today'
: dateRange.start === getDateRange(7).start
? '7'
: dateRange.start === getDateRange(30).start
? '30'
: 'custom'
}
value={period}
onChange={(value) => {
if (value === '7') {
const range = getDateRange(7)
setDateRange(range)
saveSettings('7', range)
}
else if (value === '30') {
const range = getDateRange(30)
setDateRange(range)
saveSettings('30', range)
}
else if (value === 'today') {
const today = new Date().toISOString().split('T')[0]
if (value === 'today') {
const today = formatDate(new Date())
const range = { start: today, end: today }
setDateRange(range)
setPeriod('today')
saveSettings('today', range)
}
else if (value === 'custom') {
} else if (value === '7') {
const range = getDateRange(7)
setDateRange(range)
setPeriod('7')
saveSettings('7', range)
} else if (value === 'week') {
const range = getThisWeekRange()
setDateRange(range)
setPeriod('week')
saveSettings('week', range)
} else if (value === '30') {
const range = getDateRange(30)
setDateRange(range)
setPeriod('30')
saveSettings('30', range)
} else if (value === 'month') {
const range = getThisMonthRange()
setDateRange(range)
setPeriod('month')
saveSettings('month', range)
} else if (value === 'custom') {
setIsDatePickerOpen(true)
}
}}
@@ -308,45 +500,33 @@ export default function SiteDashboardPage() {
{ value: 'today', label: 'Today' },
{ value: '7', label: 'Last 7 days' },
{ value: '30', label: 'Last 30 days' },
{ value: 'divider-1', label: '', divider: true },
{ value: 'week', label: 'This week' },
{ value: 'month', label: 'This month' },
{ value: 'divider-2', label: '', divider: true },
{ value: 'custom', label: 'Custom' },
]}
/>
</div>
<div
className="h-6 w-px bg-neutral-200 dark:bg-neutral-700 flex-shrink-0"
aria-hidden
/>
<div className="flex items-center gap-1">
<Button
onClick={() => router.push(`/sites/${siteId}/uptime`)}
variant="ghost"
className="text-sm"
>
Uptime
</Button>
<Button
onClick={() => router.push(`/sites/${siteId}/funnels`)}
variant="ghost"
className="text-sm"
>
Funnels
</Button>
{canEdit && (
<Button
onClick={() => router.push(`/sites/${siteId}/settings`)}
variant="ghost"
className="text-sm"
>
Settings
</Button>
)}
</div>
</div>
</div>
</div>
{/* Dimension Filters */}
<div className="flex items-center gap-2 flex-wrap mb-2">
<AddFilterDropdown onAdd={handleAddFilter} suggestions={filterSuggestions} onFetchSuggestions={handleFetchSuggestions} />
<FilterBar filters={filters} onRemove={handleRemoveFilter} onClear={handleClearFilters} />
</div>
{/* 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}
@@ -354,81 +534,93 @@ export default function SiteDashboardPage() {
prevStats={prevStats}
interval={dateRange.start === dateRange.end ? todayInterval : multiDayInterval}
dateRange={dateRange}
period={period}
todayInterval={todayInterval}
setTodayInterval={setTodayInterval}
multiDayInterval={multiDayInterval}
setMultiDayInterval={setMultiDayInterval}
lastUpdatedAt={lastUpdatedAt}
lastUpdatedAt={lastUpdatedAtRef.current}
annotations={annotations}
canManageAnnotations={true}
onCreateAnnotation={handleCreateAnnotation}
onUpdateAnnotation={handleUpdateAnnotation}
onDeleteAnnotation={handleDeleteAnnotation}
/>
</div>
{/* Performance Stats - Only show if enabled */}
{site.enable_performance_insights && (
<div className="mb-8">
<PerformanceStats
stats={performance}
performanceByPage={performanceByPage}
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={topPages}
entryPages={entryPages}
exitPages={exitPages}
topPages={dashboard?.top_pages ?? []}
entryPages={dashboard?.entry_pages ?? []}
exitPages={dashboard?.exit_pages ?? []}
domain={site.domain}
collectPagePaths={site.collect_page_paths ?? true}
siteId={siteId}
dateRange={dateRange}
onFilter={handleAddFilter}
/>
<TopReferrers
referrers={topReferrers}
referrers={dashboard?.top_referrers ?? []}
collectReferrers={site.collect_referrers ?? true}
siteId={siteId}
dateRange={dateRange}
onFilter={handleAddFilter}
/>
</div>
<div className="grid gap-6 lg:grid-cols-2 mb-8">
<div className="grid gap-6 lg:grid-cols-2 mb-6 [&>*]:min-w-0">
<Locations
countries={countries}
cities={cities}
regions={regions}
countries={dashboard?.countries ?? []}
cities={dashboard?.cities ?? []}
regions={dashboard?.regions ?? []}
geoDataLevel={site.collect_geo_data || 'full'}
siteId={siteId}
dateRange={dateRange}
onFilter={handleAddFilter}
/>
<TechSpecs
browsers={browsers}
os={os}
devices={devices}
screenResolutions={screenResolutions}
browsers={dashboard?.browsers ?? []}
os={dashboard?.os ?? []}
devices={dashboard?.devices ?? []}
screenResolutions={dashboard?.screen_resolutions ?? []}
collectDeviceInfo={site.collect_device_info ?? true}
collectScreenResolution={site.collect_screen_resolution ?? true}
siteId={siteId}
dateRange={dateRange}
onFilter={handleAddFilter}
/>
</div>
{/* Campaigns Report */}
<div className="mb-8">
<Campaigns siteId={siteId} dateRange={dateRange} />
<div className="grid gap-6 lg:grid-cols-2 mb-6 [&>*]:min-w-0">
<Campaigns siteId={siteId} dateRange={dateRange} filters={filtersParam || undefined} onFilter={handleAddFilter} />
<PeakHours siteId={siteId} dateRange={dateRange} />
</div>
<div className="grid gap-6 lg:grid-cols-2 mb-6 [&>*]:min-w-0">
<SearchPerformance siteId={siteId} dateRange={dateRange} />
<GoalStats
goalCounts={(dashboard?.goal_counts ?? []).filter(g => !/^scroll_\d+$/.test(g.event_name))}
onSelectEvent={setSelectedEvent}
/>
</div>
<div className="mb-8">
<GoalStats goalCounts={goalCounts} />
</div>
{/* Event Properties Breakdown */}
{selectedEvent && (
<div className="mb-8">
<EventProperties
siteId={siteId}
eventName={selectedEvent}
dateRange={dateRange}
onClose={() => setSelectedEvent(null)}
/>
</div>
)}
<DatePicker
isOpen={isDatePickerOpen}
onClose={() => setIsDatePickerOpen(false)}
onApply={(range) => {
setDateRange(range)
setPeriod('custom')
saveSettings('custom', range)
setIsDatePickerOpen(false)
}}
@@ -440,10 +632,10 @@ export default function SiteDashboardPage() {
onClose={() => setIsExportModalOpen(false)}
data={dailyStats}
stats={stats}
topPages={topPages}
topReferrers={topReferrers}
topPages={dashboard?.top_pages}
topReferrers={dashboard?.top_referrers}
campaigns={campaigns}
/>
</motion.div>
</div>
)
}

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

@@ -1,251 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { getSite, type Site } from '@/lib/api/sites'
import { getRealtimeVisitors, getSessionDetails, type Visitor, type SessionEvent } from '@/lib/api/realtime'
import { toast } from '@ciphera-net/ui'
import { getAuthErrorMessage } from '@ciphera-net/ui'
import { LoadingOverlay, UserIcon } from '@ciphera-net/ui'
import { motion, AnimatePresence } from 'framer-motion'
function formatTimeAgo(dateString: string) {
const date = new Date(dateString)
const now = new Date()
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000)
if (diffInSeconds < 60) return 'just now'
if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)}m ago`
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)}h ago`
return `${Math.floor(diffInSeconds / 86400)}d ago`
}
export default function RealtimePage() {
const params = useParams()
const router = useRouter()
const siteId = params.id as string
const [site, setSite] = useState<Site | null>(null)
const [visitors, setVisitors] = useState<Visitor[]>([])
const [selectedVisitor, setSelectedVisitor] = useState<Visitor | null>(null)
const [sessionEvents, setSessionEvents] = useState<SessionEvent[]>([])
const [loading, setLoading] = useState(true)
const [loadingEvents, setLoadingEvents] = useState(false)
// Load site info and initial visitors
useEffect(() => {
const init = async () => {
try {
const [siteData, visitorsData] = await Promise.all([
getSite(siteId),
getRealtimeVisitors(siteId)
])
setSite(siteData)
setVisitors(visitorsData || [])
// Select first visitor if available
if (visitorsData && visitorsData.length > 0) {
handleSelectVisitor(visitorsData[0])
}
} catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || 'Failed to load data')
} finally {
setLoading(false)
}
}
init()
}, [siteId])
// Poll for updates
useEffect(() => {
const interval = setInterval(async () => {
try {
const data = await getRealtimeVisitors(siteId)
setVisitors(data || [])
// Update selected visitor reference if they are still in the list
if (selectedVisitor) {
const updatedVisitor = data?.find(v => v.session_id === selectedVisitor.session_id)
if (updatedVisitor) {
// Don't overwrite the selectedVisitor state directly to avoid flickering details
// But we could update "last seen" indicators if we wanted
}
}
} catch (e) {
// Silent fail
}
}, 5000)
return () => clearInterval(interval)
}, [siteId, selectedVisitor])
const handleSelectVisitor = async (visitor: Visitor) => {
setSelectedVisitor(visitor)
setLoadingEvents(true)
try {
const events = await getSessionDetails(siteId, visitor.session_id)
setSessionEvents(events || [])
} catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || 'Failed to load session details')
} finally {
setLoadingEvents(false)
}
}
if (loading) return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Realtime" />
if (!site) return <div className="p-8">Site not found</div>
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8 h-[calc(100vh-64px)] flex flex-col">
<div className="mb-6 flex items-center justify-between">
<div>
<div className="flex items-center gap-2 mb-1">
<button onClick={() => router.push(`/sites/${siteId}`)} className="text-sm text-neutral-500 hover:text-neutral-900 dark:hover:text-white transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded">
&larr; Back to Dashboard
</button>
</div>
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white flex items-center gap-3">
Realtime Visitors
<span className="relative flex h-3 w-3">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-3 w-3 bg-green-500"></span>
</span>
<span className="text-lg font-normal text-neutral-500" aria-live="polite" aria-atomic="true">
{visitors.length} active now
</span>
</h1>
</div>
</div>
<div className="flex flex-col md:flex-row flex-1 gap-6 min-h-0">
{/* Visitors List */}
<div className="w-full md:w-1/3 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden flex flex-col bg-white dark:bg-neutral-900">
<div className="p-4 border-b border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-800/50">
<h2 className="text-xl font-semibold text-neutral-900 dark:text-white">Active Sessions</h2>
</div>
<div className="overflow-y-auto flex-1">
{visitors.length === 0 ? (
<div className="p-8 flex flex-col items-center justify-center text-center gap-3">
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-3">
<UserIcon className="w-6 h-6 text-neutral-500 dark:text-neutral-400" />
</div>
<p className="text-sm font-medium text-neutral-900 dark:text-white">
No active visitors right now
</p>
<p className="text-xs text-neutral-500 dark:text-neutral-400">
New visitors will appear here in real-time
</p>
</div>
) : (
<div className="divide-y divide-neutral-100 dark:divide-neutral-800">
<AnimatePresence mode="popLayout">
{visitors.map((visitor) => (
<motion.button
key={visitor.session_id}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -10 }}
transition={{ duration: 0.2 }}
onClick={() => handleSelectVisitor(visitor)}
className={`w-full text-left p-4 hover:bg-neutral-50 dark:hover:bg-neutral-800/50 transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-inset ${
selectedVisitor?.session_id === visitor.session_id ? 'bg-neutral-50 dark:bg-neutral-800/50 ring-1 ring-inset ring-neutral-200 dark:ring-neutral-700' : ''
}`}
>
<div className="flex justify-between items-start mb-1">
<div className="font-medium text-neutral-900 dark:text-white truncate pr-2">
{visitor.country ? `${getFlagEmoji(visitor.country)} ${visitor.city || 'Unknown City'}` : 'Unknown Location'}
</div>
<span className="text-xs text-neutral-500 whitespace-nowrap">
{formatTimeAgo(visitor.last_seen)}
</span>
</div>
<div className="text-sm text-neutral-600 dark:text-neutral-400 truncate mb-1" title={visitor.current_path}>
{visitor.current_path}
</div>
<div className="flex items-center gap-2 text-xs text-neutral-400">
<span>{visitor.device_type}</span>
<span></span>
<span>{visitor.browser}</span>
<span></span>
<span>{visitor.os}</span>
<span className="ml-auto bg-neutral-100 dark:bg-neutral-800 px-1.5 py-0.5 rounded text-neutral-600 dark:text-neutral-400">
{visitor.pageviews} views
</span>
</div>
</motion.button>
))}
</AnimatePresence>
</div>
)}
</div>
</div>
{/* Session Details */}
<div className="flex-1 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden flex flex-col bg-white dark:bg-neutral-900">
<div className="p-4 border-b border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-800/50 flex justify-between items-center">
<h2 className="text-xl font-semibold text-neutral-900 dark:text-white">
{selectedVisitor ? 'Session Journey' : 'Select a visitor'}
</h2>
{selectedVisitor && (
<span className="text-xs font-mono text-neutral-400">
ID: {selectedVisitor.session_id.substring(0, 8)}...
</span>
)}
</div>
<div className="flex-1 overflow-y-auto p-6">
{!selectedVisitor ? (
<div className="h-full flex items-center justify-center text-neutral-500">
Select a visitor on the left to see their activity.
</div>
) : loadingEvents ? (
<div className="h-full flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-neutral-900 dark:border-white"></div>
</div>
) : (
<div className="relative pl-6 border-l-2 border-neutral-100 dark:border-neutral-800 space-y-8">
{sessionEvents.map((event, idx) => (
<div key={event.id} className="relative">
<span className={`absolute -left-[29px] top-1 h-3 w-3 rounded-full border-2 border-white dark:border-neutral-900 ${
idx === 0 ? 'bg-green-500 ring-4 ring-green-100 dark:ring-green-900/30' : 'bg-neutral-300 dark:bg-neutral-700'
}`}></span>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-neutral-900 dark:text-white">
Visited {event.path}
</span>
<span className="text-xs text-neutral-500">
{new Date(event.timestamp).toLocaleTimeString()}
</span>
</div>
{event.referrer && (
<div className="text-xs text-neutral-500">
Referrer: <span className="text-neutral-700 dark:text-neutral-300">{event.referrer}</span>
</div>
)}
</div>
</div>
))}
<div className="relative">
<span className="absolute -left-[29px] top-1 h-3 w-3 rounded-full border-2 border-white dark:border-neutral-900 bg-neutral-300 dark:bg-neutral-700"></span>
<div className="text-sm text-neutral-500">
Session started {formatTimeAgo(sessionEvents[sessionEvents.length - 1]?.timestamp || new Date().toISOString())}
</div>
</div>
</div>
)}
</div>
</div>
</div>
</div>
)
}
function getFlagEmoji(countryCode: string) {
if (!countryCode || countryCode.length !== 2) return '🌍'
const codePoints = countryCode
.toUpperCase()
.split('')
.map(char => 127397 + char.charCodeAt(0))
return String.fromCodePoint(...codePoints)
}

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

View File

@@ -0,0 +1,13 @@
'use client'
import ErrorDisplay from '@/components/ErrorDisplay'
export default function SiteSettingsError({ reset }: { error: Error; reset: () => void }) {
return (
<ErrorDisplay
title="Settings failed to load"
message="We couldn't load your site settings. Please try again."
onRetry={reset}
/>
)
}

View File

@@ -0,0 +1,15 @@
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Site Settings | Pulse',
description: 'Configure your site tracking, privacy, and goals.',
robots: { index: false, follow: false },
}
export default function SiteSettingsLayout({
children,
}: {
children: React.ReactNode
}) {
return children
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,13 @@
'use client'
import ErrorDisplay from '@/components/ErrorDisplay'
export default function UptimeError({ reset }: { error: Error; reset: () => void }) {
return (
<ErrorDisplay
title="Uptime page failed to load"
message="We couldn't load your uptime monitors. This might be a temporary issue — try again."
onRetry={reset}
/>
)
}

View File

@@ -0,0 +1,15 @@
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Uptime | Pulse',
description: 'Monitor your site uptime and response times.',
robots: { index: false, follow: false },
}
export default function UptimeLayout({
children,
}: {
children: React.ReactNode
}) {
return children
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,12 @@
'use client'
import { useState, useEffect } from 'react'
import { logger } from '@/lib/utils/logger'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { createSite, listSites, getSite, type Site } from '@/lib/api/sites'
import { getSubscription } from '@/lib/api/billing'
import { getSitesLimitForPlan } from '@/lib/plans'
import { trackSiteCreatedFromDashboard, trackSiteCreatedScriptCopied } from '@/lib/welcomeAnalytics'
import { toast } from '@ciphera-net/ui'
import { getAuthErrorMessage } from '@ciphera-net/ui'
@@ -57,13 +59,14 @@ export default function NewSitePage() {
getSubscription()
])
if (subscription?.plan_id === 'solo' && sites.length >= 1) {
const siteLimit = subscription?.plan_id ? getSitesLimitForPlan(subscription.plan_id) : null
if (siteLimit != null && sites.length >= siteLimit) {
setAtLimit(true)
toast.error('Solo plan limit reached (1 site). Please upgrade to add more sites.')
toast.error(`${subscription.plan_id} plan limit reached (${siteLimit} site${siteLimit === 1 ? '' : 's'}). Please upgrade to add more sites.`)
router.replace('/')
}
} catch (error) {
console.error('Failed to check limits', error)
logger.error('Failed to check limits', error)
} finally {
setLimitsChecked(true)
}
@@ -85,7 +88,7 @@ export default function NewSitePage() {
sessionStorage.setItem(LAST_CREATED_SITE_KEY, JSON.stringify({ id: site.id }))
}
} catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || 'Failed to create site: ' + ((error as Error)?.message || 'Unknown error'))
toast.error(getAuthErrorMessage(error) || 'Failed to create site. Please try again.')
} finally {
setLoading(false)
}
@@ -104,13 +107,13 @@ export default function NewSitePage() {
// * Step 2: Framework picker + script (same as /welcome after adding first site)
if (createdSite) {
return (
<div className="container mx-auto px-4 py-8 max-w-2xl">
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-8">
<div className="w-full max-w-2xl mx-auto px-4 sm:px-6 py-8">
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
<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>
<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">
@@ -130,11 +133,11 @@ export default function NewSitePage() {
<button
type="button"
onClick={() => setShowVerificationModal(true)}
className="flex items-center gap-2 px-4 py-2 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 text-neutral-700 dark:text-neutral-300 rounded-xl hover:bg-neutral-50 dark:hover:bg-neutral-700 transition-all text-sm font-medium focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-offset-2"
className="flex items-center gap-2 px-4 py-2 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 text-neutral-700 dark:text-neutral-300 rounded-xl hover:bg-neutral-50 dark:hover:bg-neutral-700 transition-all text-sm font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:ring-offset-2"
>
<span className="text-brand-orange">Verify installation</span>
</button>
<p className="text-xs text-neutral-500 dark:text-neutral-400">
<p className="text-xs text-neutral-400">
Check if your site is sending data correctly.
</p>
</div>
@@ -143,17 +146,17 @@ 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>
</div>
<div className="mt-8 flex flex-col sm:flex-row gap-3 justify-center">
<Button variant="primary" onClick={goToDashboard} className="min-w-[160px]">
<Button variant="primary" onClick={goToDashboard} className="min-w-40">
Back to dashboard
</Button>
<Button variant="secondary" onClick={() => router.push(`/sites/${createdSite.id}`)} className="min-w-[160px]">
<Button variant="secondary" onClick={() => router.push(`/sites/${createdSite.id}`)} className="min-w-40">
View {createdSite.name}
</Button>
</div>
@@ -170,8 +173,8 @@ export default function NewSitePage() {
// * Step 1: Name & domain form
return (
<div className="container mx-auto px-4 py-8 max-w-2xl">
<h1 className="text-2xl font-bold mb-8 text-neutral-900 dark:text-white">
<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-white">
Create New Site
</h1>
@@ -183,12 +186,14 @@ 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
id="name"
required
autoFocus
maxLength={100}
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="My Website"
@@ -196,12 +201,13 @@ 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
id="domain"
required
maxLength={253}
value={formData.domain}
onChange={(e) => setFormData({ ...formData, domain: e.target.value.toLowerCase().trim() })}
placeholder="example.com"

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)
@@ -162,7 +157,7 @@ function WelcomeContent() {
setStep(3)
}
} catch (err) {
toast.error(getAuthErrorMessage(err) || 'Failed to switch organization')
toast.error(getAuthErrorMessage(err) || 'Failed to switch workspace')
} finally {
setSwitchingOrgId(null)
}
@@ -213,28 +208,15 @@ function WelcomeContent() {
setStep(4)
return
}
setPlanLoading(true)
setPlanError('')
trackWelcomePlanContinue()
try {
trackWelcomePlanContinue()
const intent = JSON.parse(raw)
const { url } = await createCheckoutSession({
plan_id: intent.planId,
interval: intent.interval || 'month',
limit: intent.limit ?? 100000,
})
const { planId, interval, limit } = JSON.parse(raw)
localStorage.removeItem('pulse_pending_checkout')
if (url) {
setRedirectingCheckout(true)
window.location.href = url
return
}
throw new Error('No checkout URL returned')
} catch (err: unknown) {
setPlanError(getAuthErrorMessage(err) || (err as Error)?.message || 'Failed to start checkout')
router.push(`/checkout?plan=${planId}&interval=${interval || 'month'}&limit=${limit ?? 100000}`)
} catch {
setPlanError('Failed to parse checkout data')
localStorage.removeItem('pulse_pending_checkout')
} finally {
setPlanLoading(false)
}
}
@@ -322,23 +304,14 @@ 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-8 max-w-lg mx-auto'
'bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl shadow-sm p-6 max-w-lg mx-auto'
return (
<div className="flex-1 flex flex-col items-center justify-center bg-neutral-50 dark:bg-neutral-950 px-4 py-12">
<div className="w-full max-w-lg">
<div
className="flex justify-center gap-1.5 mb-8"
className="flex justify-center gap-2 mb-8"
role="progressbar"
aria-valuenow={step}
aria-valuemin={1}
@@ -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-1.5 text-sm text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300 mb-6 focus:outline-none focus:ring-2 focus:ring-brand-orange rounded"
className="flex items-center gap-2 text-sm text-neutral-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-xl 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-1.5 text-sm text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300 mb-6 focus:outline-none focus:ring-2 focus:ring-brand-orange rounded"
className="flex items-center gap-2 text-sm text-neutral-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-xl font-bold text-neutral-900 dark:text-white">
<h1 className="text-2xl font-bold text-white">
{showPendingCheckoutInStep3 ? 'Complete your plan' : "You're on the free plan"}
</h1>
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
@@ -575,7 +550,6 @@ function WelcomeContent() {
variant="primary"
className="w-full sm:w-auto"
onClick={handlePlanContinue}
disabled={planLoading}
>
Continue to checkout
</Button>
@@ -583,7 +557,6 @@ function WelcomeContent() {
variant="secondary"
className="w-full sm:w-auto"
onClick={handlePlanSkip}
disabled={planLoading}
>
Stay on free plan
</Button>
@@ -604,14 +577,14 @@ function WelcomeContent() {
<button
type="button"
onClick={() => router.push('/pricing')}
className="text-sm text-brand-orange hover:underline focus:outline-none focus:ring-2 focus:ring-brand-orange rounded"
className="text-sm text-brand-orange hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange rounded"
>
Choose a different plan
</button>
</p>
) : (
<p className="mt-4 text-center">
<Link href="/pricing" className="text-sm text-brand-orange hover:underline focus:outline-none focus:ring-2 focus:ring-brand-orange rounded">
<Link href="/pricing" className="text-sm text-brand-orange hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange rounded">
View pricing
</Link>
</p>
@@ -631,17 +604,19 @@ function WelcomeContent() {
<button
type="button"
onClick={() => setStep(3)}
className="flex items-center gap-1.5 text-sm text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300 mb-6 focus:outline-none focus:ring-2 focus:ring-brand-orange rounded"
className="flex items-center gap-2 text-sm text-neutral-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-xl 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">
@@ -659,6 +634,7 @@ function WelcomeContent() {
placeholder="My Website"
value={siteName}
onChange={(e) => setSiteName(e.target.value)}
maxLength={100}
className="w-full"
/>
</div>
@@ -672,6 +648,7 @@ function WelcomeContent() {
placeholder="example.com"
value={siteDomain}
onChange={(e) => setSiteDomain(e.target.value.toLowerCase().trim())}
maxLength={253}
className="w-full"
/>
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
@@ -721,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">
@@ -748,22 +727,22 @@ function WelcomeContent() {
<button
type="button"
onClick={() => setShowVerificationModal(true)}
className="flex items-center gap-2 px-4 py-2 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 text-neutral-700 dark:text-neutral-300 rounded-xl hover:bg-neutral-50 dark:hover:bg-neutral-700 transition-all text-sm font-medium focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-offset-2"
className="flex items-center gap-2 px-4 py-2 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 text-neutral-700 dark:text-neutral-300 rounded-xl hover:bg-neutral-50 dark:hover:bg-neutral-700 transition-all text-sm font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:ring-offset-2"
>
<span className="text-brand-orange">Verify installation</span>
</button>
<p className="text-xs text-neutral-500 dark:text-neutral-400">
<p className="text-xs text-neutral-400">
Check if your site is sending data correctly.
</p>
</div>
)}
<div className="mt-8 flex flex-col sm:flex-row gap-3 justify-center">
<Button variant="primary" onClick={goToDashboard} className="min-w-[160px]">
<Button variant="primary" onClick={goToDashboard} className="min-w-40">
Go to dashboard
</Button>
{createdSite && (
<Button variant="secondary" onClick={goToSite} className="min-w-[160px]">
<Button variant="secondary" onClick={goToSite} className="min-w-40">
View {createdSite.name}
</Button>
)}

View File

@@ -0,0 +1,63 @@
'use client'
import { Button } from '@ciphera-net/ui'
interface ErrorDisplayProps {
title?: string
message?: string
onRetry?: () => void
onGoHome?: boolean
}
/**
* Shared error UI for route-level error.tsx boundaries.
* Matches the visual style of the 404 page.
*/
export default function ErrorDisplay({
title = 'Something went wrong',
message = 'An unexpected error occurred. Please try again or go back to the dashboard.',
onRetry,
onGoHome = true,
}: ErrorDisplayProps) {
return (
<div className="relative min-h-[80vh] flex flex-col items-center justify-center overflow-hidden">
<div className="absolute inset-0 -z-10 pointer-events-none">
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[500px] h-[500px] bg-red-500/10 rounded-full blur-[128px] opacity-60" />
<div
className="absolute inset-0 bg-grid-pattern opacity-[0.02] dark:opacity-[0.05]"
style={{ maskImage: 'radial-gradient(ellipse at center, black 0%, transparent 70%)' }}
/>
</div>
<div className="text-center px-4 z-10">
<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-white mb-4">
{title}
</h2>
<p className="text-lg text-neutral-600 dark:text-neutral-400 max-w-md mx-auto mb-10 leading-relaxed">
{message}
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
{onRetry && (
<Button variant="primary" onClick={onRetry} className="px-8 py-3">
Try again
</Button>
)}
{onGoHome && (
<a href="/">
<Button variant="secondary" className="px-8 py-3">
Go to dashboard
</Button>
</a>
)}
</div>
</div>
</div>
)
}

View File

@@ -5,7 +5,7 @@ import Image from 'next/image'
import { GithubIcon, TwitterIcon, SwissFlagIcon } from '@ciphera-net/ui'
interface FooterProps {
LinkComponent?: any
LinkComponent?: React.ElementType
appName?: string
isAuthenticated?: boolean
}
@@ -48,20 +48,20 @@ export function Footer({ LinkComponent = Link, appName = 'Pulse', isAuthenticate
<footer className="w-full py-8 mt-auto border-t border-neutral-100 dark:border-neutral-800 bg-white/50 dark:bg-neutral-900/50 backdrop-blur-sm">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex flex-col md:flex-row justify-between items-center gap-4">
<div className="text-sm text-neutral-500 dark:text-neutral-400">
<div className="text-sm text-neutral-400">
© 2024-{year} Ciphera. All rights reserved.
</div>
<div className="flex gap-6 text-sm font-medium text-neutral-600 dark:text-neutral-300">
<Component href="/about" className="hover:text-brand-orange transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded">
<Component href="/about" className="hover:text-brand-orange transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus:rounded">
Why {appName}
</Component>
<Component href="/changelog" className="hover:text-brand-orange transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded">
<Component href="/changelog" className="hover:text-brand-orange transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus:rounded">
Changelog
</Component>
<Component href="/pricing" className="hover:text-brand-orange transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded">
<Component href="/pricing" className="hover:text-brand-orange transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus:rounded">
Pricing
</Component>
<Component href="/faq" className="hover:text-brand-orange transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded">
<Component href="/faq" className="hover:text-brand-orange transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus:rounded">
FAQ
</Component>
</div>
@@ -88,14 +88,14 @@ 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>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4 leading-relaxed">
Simple analytics for privacy-conscious apps.
</p>
<div className="inline-flex items-center gap-2.5 text-sm text-neutral-600 dark:text-neutral-400 mb-4">
<div className="inline-flex items-center gap-3 text-sm text-neutral-600 dark:text-neutral-400 mb-4">
<span className="flex items-center justify-center w-8 h-8 rounded-lg bg-neutral-100 dark:bg-neutral-800 shrink-0 overflow-hidden ring-1 ring-neutral-200 dark:ring-neutral-700" aria-hidden>
<SwissFlagIcon className="w-5 h-5" />
</span>
@@ -106,7 +106,7 @@ export function Footer({ LinkComponent = Link, appName = 'Pulse', isAuthenticate
href="https://github.com/ciphera-net"
target="_blank"
rel="noopener noreferrer"
className="w-9 h-9 rounded-lg bg-neutral-100 dark:bg-neutral-800 flex items-center justify-center text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange"
className="w-9 h-9 rounded-lg bg-neutral-100 dark:bg-neutral-800 flex items-center justify-center text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange"
aria-label="GitHub"
>
<GithubIcon className="w-5 h-5" />
@@ -115,7 +115,7 @@ export function Footer({ LinkComponent = Link, appName = 'Pulse', isAuthenticate
href="https://x.com/cipheranet"
target="_blank"
rel="noopener noreferrer"
className="w-9 h-9 rounded-lg bg-neutral-100 dark:bg-neutral-800 flex items-center justify-center text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange"
className="w-9 h-9 rounded-lg bg-neutral-100 dark:bg-neutral-800 flex items-center justify-center text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange"
aria-label="X (Twitter)"
>
<TwitterIcon className="w-5 h-5" />
@@ -125,7 +125,7 @@ export function Footer({ LinkComponent = Link, appName = 'Pulse', isAuthenticate
{/* * Products */}
<div>
<h4 className="font-semibold text-neutral-900 dark:text-white mb-4">Products</h4>
<h4 className="font-semibold text-white mb-4">Products</h4>
<ul className="space-y-3">
{footerLinks.products.map((link) => (
<li key={link.name}>
@@ -134,14 +134,14 @@ export function Footer({ LinkComponent = Link, appName = 'Pulse', isAuthenticate
href={link.href}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded"
className="text-sm text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus:rounded"
>
{link.name}
</a>
) : (
<Component
href={link.href}
className="text-sm text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded"
className="text-sm text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus:rounded"
>
{link.name}
</Component>
@@ -153,7 +153,7 @@ export function Footer({ LinkComponent = Link, appName = 'Pulse', isAuthenticate
{/* * Company */}
<div>
<h4 className="font-semibold text-neutral-900 dark:text-white mb-4">Company</h4>
<h4 className="font-semibold text-white mb-4">Company</h4>
<ul className="space-y-3">
{footerLinks.company.map((link) => (
<li key={link.name}>
@@ -162,14 +162,14 @@ export function Footer({ LinkComponent = Link, appName = 'Pulse', isAuthenticate
href={link.href}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded"
className="text-sm text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus:rounded"
>
{link.name}
</a>
) : (
<Component
href={link.href}
className="text-sm text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded"
className="text-sm text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus:rounded"
>
{link.name}
</Component>
@@ -181,7 +181,7 @@ export function Footer({ LinkComponent = Link, appName = 'Pulse', isAuthenticate
{/* * Resources */}
<div>
<h4 className="font-semibold text-neutral-900 dark:text-white mb-4">Resources</h4>
<h4 className="font-semibold text-white mb-4">Resources</h4>
<ul className="space-y-3">
{footerLinks.resources.map((link) => (
<li key={link.name}>
@@ -190,14 +190,14 @@ export function Footer({ LinkComponent = Link, appName = 'Pulse', isAuthenticate
href={link.href}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded"
className="text-sm text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus:rounded"
>
{link.name}
</a>
) : (
<Component
href={link.href}
className="text-sm text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded"
className="text-sm text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus:rounded"
>
{link.name}
</Component>
@@ -209,7 +209,7 @@ export function Footer({ LinkComponent = Link, appName = 'Pulse', isAuthenticate
{/* * Legal */}
<div>
<h4 className="font-semibold text-neutral-900 dark:text-white mb-4">Legal</h4>
<h4 className="font-semibold text-white mb-4">Legal</h4>
<ul className="space-y-3">
{footerLinks.legal.map((link) => (
<li key={link.name}>
@@ -232,10 +232,10 @@ export function Footer({ LinkComponent = Link, appName = 'Pulse', isAuthenticate
{/* * Bottom bar */}
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
<p className="text-sm text-neutral-500 dark:text-neutral-400">
<p className="text-sm text-neutral-400">
© 2024-{year} Ciphera. All rights reserved.
</p>
<p className="text-sm text-neutral-500 dark:text-neutral-400">
<p className="text-sm text-neutral-400">
Where Privacy Still Exists
</p>
</div>

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-12 pb-10 z-10">
<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

@@ -1,13 +1,13 @@
'use client';
import { FiWifiOff } from 'react-icons/fi';
import { WifiSlash } from '@phosphor-icons/react';
export function OfflineBanner({ isOnline }: { isOnline: boolean }) {
if (isOnline) return null;
return (
<div className="fixed top-0 left-0 right-0 z-[100] rounded-b-xl bg-yellow-500/15 dark:bg-yellow-500/25 border-b border-yellow-500/30 dark:border-yellow-500/40 text-yellow-700 dark:text-yellow-300 px-4 sm:px-8 py-2.5 text-sm flex items-center justify-center gap-2 font-medium shadow-md">
<FiWifiOff className="w-4 h-4 shrink-0" />
<div className="fixed top-0 left-0 right-0 z-[100] rounded-b-xl bg-yellow-500/15 dark:bg-yellow-500/25 border-b border-yellow-500/30 dark:border-yellow-500/40 text-yellow-700 dark:text-yellow-300 px-4 sm:px-8 py-2.5 text-sm flex items-center justify-center gap-2 font-medium shadow-md transition-shadow duration-300">
<WifiSlash className="w-4 h-4 shrink-0" />
<span>You are currently offline. Changes may not be saved.</span>
</div>
);

View File

@@ -1,109 +0,0 @@
'use client'
import { useState } from 'react'
interface PasswordInputProps {
value: string
onChange: (value: string) => void
label?: string
placeholder?: string
error?: string | null
disabled?: boolean
required?: boolean
className?: string
id?: string
autoComplete?: string
minLength?: number
onFocus?: () => void
onBlur?: () => void
}
export default function PasswordInput({
value,
onChange,
label = 'Password',
placeholder = 'Enter password',
error,
disabled = false,
required = false,
className = '',
id,
autoComplete,
minLength,
onFocus,
onBlur
}: PasswordInputProps) {
const [showPassword, setShowPassword] = useState(false)
const inputId = id || 'password-input'
const errorId = `${inputId}-error`
return (
<div className={`space-y-1.5 ${className}`}>
{label && (
<label
htmlFor={inputId}
className="block text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
{label}
{required && <span className="text-brand-orange text-xs ml-1">(Required)</span>}
</label>
)}
<div className="relative group">
<input
id={inputId}
type={showPassword ? 'text' : 'password'}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
disabled={disabled}
autoComplete={autoComplete}
minLength={minLength}
onFocus={onFocus}
onBlur={onBlur}
aria-invalid={!!error}
aria-describedby={error ? errorId : undefined}
className={`w-full pl-11 pr-12 py-3 border rounded-lg bg-neutral-50/50 dark:bg-neutral-900/50 focus:bg-white dark:focus:bg-neutral-900
transition-all duration-200 outline-none disabled:opacity-50 disabled:cursor-not-allowed dark:text-white
${error
? 'border-red-300 dark:border-red-800 focus:border-red-500 focus:ring-4 focus:ring-red-500/10'
: 'border-neutral-200 dark:border-neutral-800 hover:border-brand-orange/50 focus:border-brand-orange focus:ring-4 focus:ring-brand-orange/10'
}`}
/>
{/* Lock Icon (Left) */}
<div className={`absolute left-3.5 top-1/2 -translate-y-1/2 pointer-events-none transition-colors duration-200
${error ? 'text-red-400' : 'text-neutral-400 dark:text-neutral-500 group-focus-within:text-brand-orange'}`}>
<svg aria-hidden="true" className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
{/* Toggle Visibility Button (Right) */}
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
disabled={disabled}
aria-label={showPassword ? "Hide password" : "Show password"}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1.5 rounded-lg text-neutral-400 dark:text-neutral-500
hover:text-neutral-600 dark:hover:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-all duration-200 focus:outline-none"
>
{showPassword ? (
<svg aria-hidden="true" className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
</svg>
) : (
<svg aria-hidden="true" className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
)}
</button>
</div>
{error && (
<p id={errorId} role="alert" className="text-xs text-red-500 font-medium ml-1">
{error}
</p>
)}
</div>
)
}

View File

@@ -1,13 +1,13 @@
'use client'
import { useState, useEffect } from 'react'
import { useSearchParams } from 'next/navigation'
import { logger } from '@/lib/utils/logger'
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 = [
@@ -103,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.')
@@ -140,7 +141,7 @@ export default function PricingSection() {
// Clear intent
localStorage.removeItem('pulse_pending_checkout')
} catch (e) {
console.error('Failed to parse pending checkout', e)
logger.error('Failed to parse pending checkout', e)
localStorage.removeItem('pulse_pending_checkout')
}
}
@@ -150,8 +151,7 @@ export default function PricingSection() {
// Helper to get all price details
const getPriceDetails = (planId: string) => {
// @ts-ignore
const basePrice = currentTraffic.prices[planId]
const basePrice = currentTraffic.prices[planId as keyof typeof currentTraffic.prices]
// Handle "Custom"
if (basePrice === null || basePrice === undefined) return null
@@ -166,49 +166,25 @@ export default function PricingSection() {
}
}
const handleSubscribe = async (planId: string, options?: { interval?: string, limit?: number }) => {
try {
setLoadingPlan(planId)
// 1. If not logged in, redirect to login/signup
if (!user) {
// Store checkout intent
const intent = {
planId,
interval: isYearly ? 'year' : 'month',
limit: currentTraffic.value,
sliderIndex, // Store UI state to restore it
isYearly // Store UI state to restore it
}
localStorage.setItem('pulse_pending_checkout', JSON.stringify(intent))
initiateOAuthFlow()
return
const handleSubscribe = (planId: string, options?: { interval?: string, limit?: number }) => {
// 1. If not logged in, redirect to login/signup
if (!user) {
const intent = {
planId,
interval: isYearly ? 'year' : 'month',
limit: currentTraffic.value,
sliderIndex,
isYearly
}
// 2. Call backend to create checkout session
const interval = options?.interval || (isYearly ? 'year' : 'month')
const limit = options?.limit || currentTraffic.value
const { url } = await createCheckoutSession({
plan_id: planId,
interval,
limit,
})
// 3. Redirect to Stripe Checkout
if (url) {
window.location.href = url
} else {
throw new Error('No checkout URL returned')
}
} catch (error: any) {
console.error('Checkout error:', error)
toast.error('Failed to start checkout. Please try again.')
} finally {
setLoadingPlan(null)
localStorage.setItem('pulse_pending_checkout', JSON.stringify(intent))
initiateOAuthFlow()
return
}
// 2. Navigate to embedded checkout page
const selectedInterval = options?.interval || (isYearly ? 'year' : 'month')
const selectedLimit = options?.limit || currentTraffic.value
router.push(`/checkout?plan=${planId}&interval=${selectedInterval}&limit=${selectedLimit}`)
}
return (
@@ -219,10 +195,10 @@ export default function PricingSection() {
transition={{ duration: 0.5 }}
className="text-center mb-12"
>
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-6">
<h2 className="text-3xl font-bold text-white mb-4">
Transparent Pricing
</h2>
<p className="text-xl 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-3xl bg-white/50 dark:bg-neutral-900/50 backdrop-blur-xl shadow-sm overflow-hidden mb-20"
className="max-w-6xl mx-auto border border-neutral-800 rounded-2xl bg-neutral-900/50 backdrop-blur-xl shadow-sm overflow-hidden mb-20"
>
{/* Top Toolbar */}
<div className="p-8 border-b border-neutral-200 dark:border-neutral-800 flex flex-col md:flex-row items-center justify-between gap-8 bg-neutral-50/50 dark:bg-neutral-900/50">
<div className="p-6 border-b border-neutral-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 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
@@ -252,31 +228,37 @@ export default function PricingSection() {
step="1"
value={sliderIndex}
onChange={(e) => setSliderIndex(parseInt(e.target.value))}
className="w-full h-2 bg-neutral-200 rounded-lg appearance-none cursor-pointer dark:bg-neutral-700 accent-brand-orange"
aria-label="Monthly pageview limit"
aria-valuetext={`${currentTraffic.label} pageviews per month`}
className="w-full h-2 bg-neutral-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-[10px] 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">
<div className="bg-neutral-800 p-1 rounded-lg flex" role="radiogroup" aria-label="Billing interval">
<button
onClick={() => setIsYearly(false)}
className={`min-w-[88px] px-4 py-2 rounded-lg text-sm font-medium transition-all ${
role="radio"
aria-checked={!isYearly}
className={`min-w-[88px] px-4 py-2 rounded-lg text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange ${
!isYearly
? '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
</button>
<button
onClick={() => setIsYearly(true)}
className={`min-w-[88px] px-4 py-2 rounded-lg text-sm font-medium transition-all ${
role="radio"
aria-checked={isYearly}
className={`min-w-[88px] px-4 py-2 rounded-lg text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange ${
isYearly
? '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
@@ -286,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-8 flex flex-col relative transition-colors ${isTeam ? 'bg-brand-orange/[0.02]' : 'hover:bg-neutral-50/50 dark:hover:bg-neutral-800/50'}`}>
<div key={plan.id} className={`p-6 flex flex-col relative transition-colors ${isTeam ? 'bg-brand-orange/[0.02]' : 'hover:bg-neutral-800/50'}`}>
{isTeam && (
<>
<div className="absolute top-0 left-0 w-full h-1 bg-brand-orange" />
@@ -303,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 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 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">
@@ -326,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 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>
)}
@@ -350,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>
@@ -361,16 +380,20 @@ export default function PricingSection() {
})}
{/* Enterprise Section */}
<div className="p-8 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 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>
<Button variant="secondary" className="w-full mb-8 border-neutral-200 dark:border-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-800">
<Button
variant="secondary"
className="w-full mb-8"
onClick={() => { window.location.href = 'mailto:business@ciphera.net?subject=Enterprise%20Plan%20Inquiry' }}
>
Contact us
</Button>
@@ -383,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>
@@ -392,6 +415,7 @@ export default function PricingSection() {
</div>
</div>
</motion.div>
</section>
)
}

View File

@@ -0,0 +1,12 @@
'use client'
import { SWRConfig } from 'swr'
import { boundedCacheProvider } from '@/lib/swr/cache-provider'
export default function SWRProvider({ children }: { children: React.ReactNode }) {
return (
<SWRConfig value={{ provider: boundedCacheProvider }}>
{children}
</SWRConfig>
)
}

View File

@@ -1,109 +0,0 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { PlusIcon, PersonIcon, CubeIcon, CheckIcon } from '@radix-ui/react-icons'
import { switchContext, OrganizationMember } from '@/lib/api/organization'
import { setSessionAction } from '@/app/actions/auth'
import Link from 'next/link'
export default function OrganizationSwitcher({ orgs, activeOrgId }: { orgs: OrganizationMember[], activeOrgId: string | null }) {
const router = useRouter()
const [switching, setSwitching] = useState<string | null>(null)
const handleSwitch = async (orgId: string | null) => {
console.log('Switching to organization:', orgId)
setSwitching(orgId || 'personal')
try {
// * If orgId is null, we can't switch context via API in the same way if strict mode is on
// * Pulse doesn't support personal organization context.
// * So we should probably NOT show the "Personal" option in Pulse if strict mode is enforced.
// * However, to match Drop exactly, we might want to show it but have it fail or redirect?
// * Let's assume for now we want to match Drop's UI structure.
if (!orgId) {
// * Pulse doesn't support personal context.
// * We could redirect to onboarding or show an error.
// * For now, let's just return to avoid breaking.
return
}
const { access_token } = await switchContext(orgId)
// * Update session cookie via server action
// * Note: switchContext only returns access_token, we keep existing refresh token
await setSessionAction(access_token)
// Force reload to pick up new permissions
window.location.reload()
} catch (err) {
console.error('Failed to switch organization', err)
setSwitching(null)
}
}
return (
<div className="border-b border-neutral-100 dark:border-neutral-800 pb-2 mb-2">
<div className="px-3 py-2 text-xs font-medium text-neutral-500 uppercase tracking-wider">
Organizations
</div>
{/* Personal organization - HIDDEN IN PULSE (Strict Mode) */}
{/*
<button
onClick={() => handleSwitch(null)}
className={`w-full flex items-center justify-between px-3 py-2 text-sm rounded-lg transition-colors group ${
!activeOrgId ? 'bg-neutral-100 dark:bg-neutral-800' : 'hover:bg-neutral-50 dark:hover:bg-neutral-800/50'
}`}
>
<div className="flex items-center gap-2">
<div className="h-5 w-5 rounded bg-neutral-200 dark:bg-neutral-700 flex items-center justify-center">
<PersonIcon className="h-3 w-3 text-neutral-500 dark:text-neutral-400" />
</div>
<span className="text-neutral-700 dark:text-neutral-300">Personal</span>
</div>
<div className="flex items-center gap-2">
{switching === 'personal' && <span className="text-xs text-neutral-400">Loading...</span>}
{!activeOrgId && !switching && <CheckIcon className="h-4 w-4 text-neutral-600 dark:text-neutral-400" />}
</div>
</button>
*/}
{/* Organization list */}
{orgs.map((org) => (
<button
key={org.organization_id}
onClick={() => handleSwitch(org.organization_id)}
className={`w-full flex items-center justify-between px-3 py-2 text-sm rounded-lg transition-colors mt-1 ${
activeOrgId === org.organization_id ? 'bg-neutral-100 dark:bg-neutral-800' : 'hover:bg-neutral-50 dark:hover:bg-neutral-800/50'
}`}
>
<div className="flex items-center gap-2">
<div className="h-5 w-5 rounded bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
<CubeIcon className="h-3 w-3 text-blue-600 dark:text-blue-400" />
</div>
<span className="text-neutral-700 dark:text-neutral-300 truncate max-w-[140px]">
{org.organization_name}
</span>
</div>
<div className="flex items-center gap-2">
{switching === org.organization_id && <span className="text-xs text-neutral-400">Loading...</span>}
{activeOrgId === org.organization_id && !switching && <CheckIcon className="h-4 w-4 text-neutral-600 dark:text-neutral-400" />}
</div>
</button>
))}
{/* Create New */}
<Link
href="/onboarding"
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-neutral-500 hover:text-blue-600 dark:text-neutral-400 dark:hover:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/10 rounded-lg transition-colors mt-1"
>
<div className="h-5 w-5 rounded border border-dashed border-neutral-300 dark:border-neutral-600 flex items-center justify-center">
<PlusIcon className="h-3 w-3" />
</div>
<span>Create Organization</span>
</Link>
</div>
)
}

View File

@@ -0,0 +1,100 @@
'use client'
import { formatNumber } from '@ciphera-net/ui'
import { Files } from '@phosphor-icons/react'
import type { FrustrationByPage } from '@/lib/api/stats'
import { TableSkeleton } from '@/components/skeletons'
interface FrustrationByPageTableProps {
pages: FrustrationByPage[]
loading: boolean
}
export default function FrustrationByPageTable({ pages, loading }: FrustrationByPageTableProps) {
const hasData = pages.length > 0
const maxTotal = Math.max(...pages.map(p => p.total), 1)
return (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 mb-8">
<div className="flex items-center justify-between mb-1">
<h3 className="text-lg font-semibold text-white">
Frustration by Page
</h3>
</div>
<p className="text-sm text-neutral-400 mb-4">
Pages with the most frustration signals
</p>
{loading ? (
<TableSkeleton rows={5} cols={5} />
) : hasData ? (
<div className="overflow-x-auto -mx-6 px-6">
{/* Header */}
<div className="flex items-center justify-between px-2 -mx-2 mb-2 text-xs font-medium text-neutral-400 dark:text-neutral-500 uppercase tracking-wider">
<span>Page</span>
<div className="flex items-center gap-6">
<span className="w-12 text-right">Rage</span>
<span className="w-12 text-right">Dead</span>
<span className="w-12 text-right">Total</span>
<span className="w-16 text-right">Elements</span>
</div>
</div>
{/* Rows */}
<div className="space-y-0.5">
{pages.map((page) => {
const barWidth = (page.total / maxTotal) * 75
return (
<div
key={page.page_path}
className="relative flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors"
>
{/* Background bar */}
<div
className="absolute inset-y-0 left-0 bg-brand-orange/15 dark:bg-brand-orange/25 rounded-lg transition-all"
style={{ width: `${barWidth}%` }}
/>
<span
className="relative text-sm text-white truncate max-w-[200px] sm:max-w-[300px]"
title={page.page_path}
>
{page.page_path}
</span>
<div className="relative flex items-center gap-6">
<span className="w-12 text-right text-sm tabular-nums text-neutral-600 dark:text-neutral-400">
{formatNumber(page.rage_clicks)}
</span>
<span className="w-12 text-right text-sm tabular-nums text-neutral-600 dark:text-neutral-400">
{formatNumber(page.dead_clicks)}
</span>
<span className="w-12 text-right text-sm font-semibold tabular-nums text-white">
{formatNumber(page.total)}
</span>
<span className="w-16 text-right text-sm tabular-nums text-neutral-600 dark:text-neutral-400">
{page.unique_elements}
</span>
</div>
</div>
)
})}
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center text-center px-6 py-8 gap-4 min-h-[200px]">
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
<Files className="w-8 h-8 text-neutral-400" />
</div>
<h4 className="font-semibold text-white">
No frustration signals detected
</h4>
<p className="text-sm text-neutral-400 max-w-md">
Page-level frustration data will appear here once rage clicks or dead clicks are detected on your site.
</p>
<a href="/installation" target="_blank" rel="noopener noreferrer" className="mt-1 text-sm font-medium text-brand-orange hover:underline">
View setup guide
</a>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,113 @@
'use client'
import type { FrustrationSummary } from '@/lib/api/stats'
import { StatCardSkeleton } from '@/components/skeletons'
interface FrustrationSummaryCardsProps {
data: FrustrationSummary | null
loading: boolean
}
function pctChange(current: number, previous: number): { type: 'pct'; value: number } | { type: 'new' } | null {
if (previous === 0 && current === 0) return null
if (previous === 0) return { type: 'new' }
return { type: 'pct', value: Math.round(((current - previous) / previous) * 100) }
}
function ChangeIndicator({ change }: { change: ReturnType<typeof pctChange> }) {
if (change === null) return null
if (change.type === 'new') {
return (
<span className="text-xs font-medium bg-brand-orange/10 text-brand-orange px-1.5 py-0.5 rounded">
New
</span>
)
}
const isUp = change.value > 0
const isDown = change.value < 0
return (
<span
className={`text-xs font-medium ${
isUp
? 'text-red-600 dark:text-red-400'
: isDown
? 'text-green-600 dark:text-green-400'
: 'text-neutral-400'
}`}
>
{isUp ? '+' : ''}{change.value}%
</span>
)
}
export default function FrustrationSummaryCards({ data, loading }: FrustrationSummaryCardsProps) {
if (loading || !data) {
return (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-8">
<StatCardSkeleton />
<StatCardSkeleton />
<StatCardSkeleton />
</div>
)
}
const rageChange = pctChange(data.rage_clicks, data.prev_rage_clicks)
const deadChange = pctChange(data.dead_clicks, data.prev_dead_clicks)
const topPage = data.rage_top_page || data.dead_top_page
const totalSignals = data.rage_clicks + data.dead_clicks
return (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-8">
{/* Rage Clicks */}
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
<p className="text-sm font-medium text-neutral-400 mb-1">
Rage Clicks
</p>
<div className="flex items-baseline gap-2">
<span className="text-2xl font-bold text-white tabular-nums">
{data.rage_clicks.toLocaleString()}
</span>
<ChangeIndicator change={rageChange} />
</div>
<p className="text-xs text-neutral-400 dark:text-neutral-500 mt-1">
{data.rage_unique_elements} unique elements
</p>
</div>
{/* Dead Clicks */}
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
<p className="text-sm font-medium text-neutral-400 mb-1">
Dead Clicks
</p>
<div className="flex items-baseline gap-2">
<span className="text-2xl font-bold text-white tabular-nums">
{data.dead_clicks.toLocaleString()}
</span>
<ChangeIndicator change={deadChange} />
</div>
<p className="text-xs text-neutral-400 dark:text-neutral-500 mt-1">
{data.dead_unique_elements} unique elements
</p>
</div>
{/* Total Frustration Signals */}
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
<p className="text-sm font-medium text-neutral-400 mb-1">
Total Signals
</p>
<span className="text-2xl font-bold text-white tabular-nums">
{totalSignals.toLocaleString()}
</span>
{topPage ? (
<p className="text-xs text-neutral-400 dark:text-neutral-500 mt-1">
Top page: {topPage}
</p>
) : (
<p className="text-xs text-neutral-400 dark:text-neutral-500 mt-1">
No data in this period
</p>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,228 @@
'use client'
import { useState, useEffect } from 'react'
import { formatNumber, Modal } from '@ciphera-net/ui'
import { FrameCornersIcon, Copy, Check, CursorClick } from '@phosphor-icons/react'
import { toast } from '@ciphera-net/ui'
import type { FrustrationElement } from '@/lib/api/stats'
import { ListSkeleton } from '@/components/skeletons'
const DISPLAY_LIMIT = 7
interface FrustrationTableProps {
title: string
description: string
items: FrustrationElement[]
total: number
totalSignals: number
showAvgClicks?: boolean
loading: boolean
fetchAll?: () => Promise<{ items: FrustrationElement[]; total: number }>
}
function SkeletonRows() {
return (
<div className="space-y-2">
{Array.from({ length: DISPLAY_LIMIT }).map((_, i) => (
<div key={i} className="animate-pulse flex items-center justify-between h-9 px-2">
<div className="flex items-center gap-3 flex-1">
<div className="h-4 w-32 bg-neutral-200 dark:bg-neutral-700 rounded" />
<div className="h-3 w-20 bg-neutral-200 dark:bg-neutral-700 rounded" />
</div>
<div className="h-4 w-10 bg-neutral-200 dark:bg-neutral-700 rounded" />
</div>
))}
</div>
)
}
function SelectorCell({ selector }: { selector: string }) {
const [copied, setCopied] = useState(false)
const handleCopy = (e: React.MouseEvent) => {
e.stopPropagation()
navigator.clipboard.writeText(selector)
setCopied(true)
toast.success('Selector copied')
setTimeout(() => setCopied(false), 2000)
}
return (
<button
onClick={handleCopy}
className="flex items-center gap-1 min-w-0 group/copy cursor-pointer"
title={selector}
>
<span className="text-sm font-mono text-white truncate">
{selector}
</span>
<span className="opacity-0 group-hover/copy:opacity-100 transition-opacity shrink-0">
{copied ? (
<Check className="w-3 h-3 text-green-500" />
) : (
<Copy className="w-3 h-3 text-neutral-400" />
)}
</span>
</button>
)
}
function Row({
item,
showAvgClicks,
totalSignals,
}: {
item: FrustrationElement
showAvgClicks?: boolean
totalSignals: number
}) {
const pct = totalSignals > 0 ? `${Math.round((item.count / totalSignals) * 100)}%` : ''
return (
<div className="flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 min-w-0 overflow-hidden">
<SelectorCell selector={item.selector} />
<span
className="text-xs text-neutral-400 dark:text-neutral-500 truncate shrink-0"
title={item.page_path}
>
{item.page_path}
</span>
</div>
</div>
<div className="flex items-center gap-2 ml-4 shrink-0">
{/* Percentage badge: slides in on hover */}
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200 tabular-nums">
{pct}
</span>
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400 tabular-nums">
{formatNumber(item.count)}
</span>
</div>
</div>
)
}
export default function FrustrationTable({
title,
description,
items,
total,
totalSignals,
showAvgClicks,
loading,
fetchAll,
}: FrustrationTableProps) {
const [isModalOpen, setIsModalOpen] = useState(false)
const [fullData, setFullData] = useState<FrustrationElement[]>([])
const [isLoadingFull, setIsLoadingFull] = useState(false)
const hasData = items.length > 0
const showViewAll = hasData && total > items.length
const emptySlots = Math.max(0, DISPLAY_LIMIT - items.length)
useEffect(() => {
if (isModalOpen && fetchAll) {
const load = async () => {
setIsLoadingFull(true)
try {
const result = await fetchAll()
setFullData(result.items)
} catch {
// silent
} finally {
setIsLoadingFull(false)
}
}
load()
} else {
setFullData([])
}
}, [isModalOpen, fetchAll])
return (
<>
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold text-white">
{title}
</h3>
{showViewAll && (
<button
onClick={() => setIsModalOpen(true)}
className="p-1.5 text-neutral-400 dark:text-neutral-500 hover:text-brand-orange dark:hover:text-brand-orange hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-all cursor-pointer rounded-lg"
aria-label={`View all ${title.toLowerCase()}`}
>
<FrameCornersIcon className="w-4 h-4" weight="bold" />
</button>
)}
</div>
</div>
<p className="text-sm text-neutral-400 mb-4">
{description}
</p>
<div className="flex-1 min-h-[270px]">
{loading ? (
<SkeletonRows />
) : hasData ? (
<>
{items.map((item, i) => (
<Row key={`${item.selector}-${item.page_path}-${i}`} item={item} showAvgClicks={showAvgClicks} totalSignals={totalSignals} />
))}
{Array.from({ length: emptySlots }).map((_, i) => (
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
))}
</>
) : (
<div className="h-full flex flex-col items-center justify-center text-center px-6 py-8 gap-4">
<img
src="/illustrations/blank-canvas.svg"
alt="No frustration signals"
className="w-44 h-auto mb-1"
/>
<h4 className="font-semibold text-white">
No {title.toLowerCase()} detected
</h4>
<p className="text-sm text-neutral-400 max-w-md">
Frustration tracking requires the add-on script. Add it after your core Pulse script:
</p>
<code className="text-xs bg-neutral-100 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300 px-3 py-2 rounded-lg font-mono break-all">
{'<script defer src="https://pulse.ciphera.net/script.frustration.js"></script>'}
</code>
<a href="/installation" target="_blank" rel="noopener noreferrer" className="mt-1 text-sm font-medium text-brand-orange hover:underline">
View setup guide
</a>
</div>
)}
</div>
</div>
<Modal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
title={title}
className="max-w-2xl"
>
<div className="max-h-[80vh] overflow-y-auto">
{isLoadingFull ? (
<div className="py-4">
<ListSkeleton rows={10} />
</div>
) : fullData.length > 0 ? (
<div className="space-y-0.5">
{fullData.map((item, i) => (
<Row key={`${item.selector}-${item.page_path}-${i}`} item={item} showAvgClicks={showAvgClicks} totalSignals={totalSignals} />
))}
</div>
) : (
<p className="text-sm text-neutral-400 py-8 text-center">
No data available
</p>
)}
</div>
</Modal>
</>
)
}

View File

@@ -0,0 +1,153 @@
'use client'
import { TrendUp } from '@phosphor-icons/react'
import { Pie, PieChart, Tooltip } from 'recharts'
import {
ChartContainer,
type ChartConfig,
} from '@/components/charts'
import type { FrustrationSummary } from '@/lib/api/stats'
import { WidgetSkeleton } from '@/components/skeletons'
interface FrustrationTrendProps {
summary: FrustrationSummary | null
loading: boolean
}
const LABELS: Record<string, string> = {
rage_clicks: 'Rage Clicks',
dead_clicks: 'Dead Clicks',
prev_rage_clicks: 'Prev Rage Clicks',
prev_dead_clicks: 'Prev Dead Clicks',
}
const COLORS = {
rage_clicks: 'rgba(253, 94, 15, 0.7)',
dead_clicks: 'rgba(180, 83, 9, 0.7)',
prev_rage_clicks: 'rgba(253, 94, 15, 0.35)',
prev_dead_clicks: 'rgba(180, 83, 9, 0.35)',
} as const
const chartConfig = {
count: { label: 'Count' },
rage_clicks: { label: 'Rage Clicks', color: COLORS.rage_clicks },
dead_clicks: { label: 'Dead Clicks', color: COLORS.dead_clicks },
prev_rage_clicks: { label: 'Prev Rage Clicks', color: COLORS.prev_rage_clicks },
prev_dead_clicks: { label: 'Prev Dead Clicks', color: COLORS.prev_dead_clicks },
} satisfies ChartConfig
function CustomTooltip({ active, payload }: { active?: boolean; payload?: Array<{ payload: { type: string; count: number; fill: string } }> }) {
if (!active || !payload?.length) return null
const item = payload[0].payload
return (
<div className="flex items-center gap-2 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-2.5 py-1.5 text-xs shadow-xl">
<div
className="h-2.5 w-2.5 shrink-0 rounded-full"
style={{ backgroundColor: item.fill }}
/>
<span className="text-neutral-400">
{LABELS[item.type] ?? item.type}
</span>
<span className="font-mono font-medium tabular-nums text-neutral-900 dark:text-neutral-50">
{item.count.toLocaleString()}
</span>
</div>
)
}
export default function FrustrationTrend({ summary, loading }: FrustrationTrendProps) {
if (loading || !summary) return <WidgetSkeleton />
const hasData = summary.rage_clicks > 0 || summary.dead_clicks > 0 ||
summary.prev_rage_clicks > 0 || summary.prev_dead_clicks > 0
const totalCurrent = summary.rage_clicks + summary.dead_clicks
const totalPrevious = summary.prev_rage_clicks + summary.prev_dead_clicks
const totalChange = totalPrevious > 0
? Math.round(((totalCurrent - totalPrevious) / totalPrevious) * 100)
: null
const hasPrevious = totalPrevious > 0
const chartData = [
{ type: 'rage_clicks', count: summary.rage_clicks, fill: COLORS.rage_clicks },
{ type: 'dead_clicks', count: summary.dead_clicks, fill: COLORS.dead_clicks },
{ type: 'prev_rage_clicks', count: summary.prev_rage_clicks, fill: COLORS.prev_rage_clicks },
{ type: 'prev_dead_clicks', count: summary.prev_dead_clicks, fill: COLORS.prev_dead_clicks },
].filter(d => d.count > 0)
if (!hasData) {
return (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
<div className="flex items-center justify-between mb-1">
<h3 className="text-lg font-semibold text-white">
Frustration Trend
</h3>
</div>
<p className="text-sm text-neutral-400 mb-4">
Rage vs. dead click breakdown
</p>
<div className="flex-1 min-h-[270px] flex flex-col items-center justify-center text-center px-6 py-8 gap-4">
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
<TrendUp className="w-8 h-8 text-neutral-400" />
</div>
<h4 className="font-semibold text-white">
No trend data yet
</h4>
<p className="text-sm text-neutral-400 max-w-md">
Frustration trend data will appear here once rage clicks or dead clicks are detected on your site.
</p>
</div>
</div>
)
}
return (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
<div className="flex items-center justify-between mb-1">
<h3 className="text-lg font-semibold text-white">
Frustration Trend
</h3>
</div>
<p className="text-sm text-neutral-400 mb-4">
{hasPrevious
? 'Rage and dead clicks split across current and previous period'
: 'Rage vs. dead click breakdown'}
</p>
<div className="flex-1">
<ChartContainer
config={chartConfig}
className="mx-auto aspect-square max-h-[250px]"
>
<PieChart>
<Tooltip
cursor={false}
content={<CustomTooltip />}
/>
<Pie
data={chartData}
dataKey="count"
nameKey="type"
stroke="0"
/>
</PieChart>
</ChartContainer>
</div>
<div className="flex items-center justify-center gap-2 text-sm font-medium pt-2">
{totalChange !== null ? (
<>
{totalChange > 0 ? 'Up' : totalChange < 0 ? 'Down' : 'No change'} by {Math.abs(totalChange)}% vs previous period <TrendUp className="h-4 w-4" />
</>
) : totalCurrent > 0 ? (
<>
{totalCurrent.toLocaleString()} new signals this period <TrendUp className="h-4 w-4" />
</>
) : (
'No frustration signals detected'
)}
</div>
</div>
)
}

323
components/charts/chart.tsx Normal file
View File

@@ -0,0 +1,323 @@
'use client'
import * as React from 'react'
import { Tooltip, Legend, ResponsiveContainer } from 'recharts'
import { cn } from '@ciphera-net/ui'
// ─── ChartConfig ────────────────────────────────────────────────────
export type ChartConfig = Record<
string,
{
label?: React.ReactNode
icon?: React.ComponentType
color?: string
theme?: { light: string; dark: string }
}
>
// ─── ChartContext ───────────────────────────────────────────────────
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error('useChart must be used within a <ChartContainer />')
}
return context
}
// ─── ChartContainer ────────────────────────────────────────────────
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<'div'> & {
config: ChartConfig
children: React.ComponentProps<typeof ResponsiveContainer>['children']
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`
// Build CSS variables from config
const colorVars = React.useMemo(() => {
const vars: Record<string, string> = {}
for (const [key, value] of Object.entries(config)) {
if (value.color) {
vars[`--color-${key}`] = value.color
}
}
return vars
}, [config])
return (
<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"[&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-[var(--chart-grid)]",
"[&_.recharts-curve.recharts-tooltip-cursor]:stroke-[var(--chart-grid)]",
"[&_.recharts-rectangle.recharts-tooltip-cursor]:fill-[var(--chart-grid)]",
"[&_.recharts-reference-line_[stroke='#ccc']]:stroke-[var(--chart-grid)]",
'[&_.recharts-sector]:outline-none',
'[&_.recharts-surface]:outline-none',
className,
)}
style={colorVars as React.CSSProperties}
{...props}
>
<ResponsiveContainer width="100%" height="100%">
{children}
</ResponsiveContainer>
</div>
</ChartContext.Provider>
)
})
ChartContainer.displayName = 'ChartContainer'
// ─── ChartTooltip ──────────────────────────────────────────────────
const ChartTooltip = Tooltip
// ─── ChartTooltipContent ───────────────────────────────────────────
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof Tooltip> &
React.ComponentProps<'div'> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: 'line' | 'dot' | 'dashed'
nameKey?: string
labelKey?: string
labelFormatter?: (value: string, payload: Record<string, unknown>[]) => React.ReactNode
}
>(
(
{
active,
payload,
className,
indicator = 'dot',
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelKey,
nameKey,
},
ref,
) => {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) return null
const item = payload[0]
const key = `${labelKey || item?.dataKey || item?.name || 'value'}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === 'string'
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) {
return labelFormatter(
value as string,
payload as Record<string, unknown>[],
)
}
return value
}, [label, labelFormatter, payload, hideLabel, config, labelKey])
if (!active || !payload?.length) return null
const nestLabel = payload.length === 1 && indicator !== 'dot'
return (
<div
ref={ref}
className={cn(
'grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-2.5 py-1.5 text-xs shadow-xl',
className,
)}
>
{!nestLabel ? tooltipLabel ? (
<div className="font-medium text-neutral-900 dark:text-neutral-50">
{tooltipLabel}
</div>
) : null : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || 'value'}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = item.fill || item.color
return (
<div
key={item.dataKey || index}
className={cn(
'flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground',
indicator === 'dot' && 'items-center',
)}
>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
'shrink-0 rounded-[2px] border-[var(--color-border)] bg-[var(--color-bg)]',
indicator === 'dot' && 'h-2.5 w-2.5 rounded-full',
indicator === 'line' && 'w-1',
indicator === 'dashed' &&
'w-0 border-[1.5px] border-dashed bg-transparent',
nestLabel && indicator === 'dashed'
? 'my-0.5'
: 'my-0.5',
)}
style={
{
'--color-bg': indicatorColor,
'--color-border': indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
'flex flex-1 justify-between leading-none',
nestLabel ? 'items-end' : 'items-center',
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value != null && (
<span className="font-mono font-medium tabular-nums text-neutral-900 dark:text-neutral-50">
{typeof item.value === 'number'
? item.value.toLocaleString()
: item.value}
</span>
)}
</div>
</div>
)
})}
</div>
</div>
)
},
)
ChartTooltipContent.displayName = 'ChartTooltipContent'
// ─── ChartLegend ───────────────────────────────────────────────────
const ChartLegend = Legend
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<'div'> &
Pick<React.ComponentProps<typeof Legend>, 'payload' | 'verticalAlign'> & {
hideIcon?: boolean
nameKey?: string
}
>(
(
{ className, hideIcon = false, payload, verticalAlign = 'bottom', nameKey },
ref,
) => {
const { config } = useChart()
if (!payload?.length) return null
return (
<div
ref={ref}
className={cn(
'flex items-center justify-center gap-4',
verticalAlign === 'top' ? 'pb-3' : 'pt-3',
className,
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || 'value'}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className="flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{ backgroundColor: item.color }}
/>
)}
<span className="text-xs text-muted-foreground">
{itemConfig?.label}
</span>
</div>
)
})}
</div>
)
},
)
ChartLegendContent.displayName = 'ChartLegendContent'
// ─── Helpers ───────────────────────────────────────────────────────
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string,
) {
if (typeof payload !== 'object' || payload === null) return undefined
const payloadPayload =
'payload' in payload &&
typeof (payload as Record<string, unknown>).payload === 'object' &&
(payload as Record<string, unknown>).payload !== null
? ((payload as Record<string, unknown>).payload as Record<string, unknown>)
: undefined
let configLabelKey = key
if (
key in config
) {
configLabelKey = key
} else if (payloadPayload) {
const payloadKey = Object.keys(payloadPayload).find(
(k) => payloadPayload[k] === key && k in config,
)
if (payloadKey) configLabelKey = payloadKey
}
return configLabelKey in config ? config[configLabelKey] : config[key]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartContext,
useChart,
}

View File

@@ -0,0 +1,8 @@
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
type ChartConfig,
} from './chart'

View File

@@ -1,26 +0,0 @@
'use client'
import { useEffect } from 'react'
import { useSearchParams } from 'next/navigation'
import { toast } from '@ciphera-net/ui'
/**
* Shows a success toast when redirected from Stripe Checkout with success=true,
* then clears the query params from the URL.
*/
export default function CheckoutSuccessToast() {
const searchParams = useSearchParams()
useEffect(() => {
const success = searchParams.get('success')
if (success === 'true') {
toast.success('Thank you for subscribing! Your subscription is now active.')
const url = new URL(window.location.href)
url.searchParams.delete('success')
url.searchParams.delete('session_id')
window.history.replaceState({}, '', url.pathname + url.search)
}
}, [searchParams])
return null
}

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

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