157 Commits

Author SHA1 Message Date
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
123 changed files with 8560 additions and 2109 deletions

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

@@ -0,0 +1,33 @@
# * Runs unit tests on push/PR to main and staging.
name: Test
on:
push:
branches: [main, staging]
pull_request:
branches: [main, staging]
permissions:
contents: read
packages: read
jobs:
test:
name: unit-tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci
env:
NODE_AUTH_TOKEN: ${{ secrets.PKG_READ_TOKEN }}
- name: Run tests
run: npm test

1
.gitignore vendored
View File

@@ -37,5 +37,6 @@ next-env.d.ts
# PWA
public/sw.js
public/sw 2.js
public/workbox-*.js
public/swe-worker-*.js

View File

@@ -6,6 +6,152 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
## [Unreleased]
## [0.13.0-alpha] - 2026-03-07
### Added
- **Dashboard filtering.** Filter your entire dashboard by any dimension — browser, country, page, device, OS, referrer, or UTM parameters. A single "Filter" button lets you browse dimensions, see real values from your data with visitor counts, search or type a custom value, and apply — all in a quick dropdown. Active filters appear as removable pills above your charts. Stack multiple filters to narrow things down. Filters are saved in the URL so you can bookmark or share a filtered view.
- **Click any item to filter.** Click a referrer, browser, country, page, or any other item in your dashboard panels to instantly filter the entire dashboard to just that traffic.
- **Hover percentages.** Hover over any item in Pages, Locations, Technology, or Referrers to see what percentage of total traffic it represents.
- **Custom event properties.** Your custom events can now carry extra context — for example, `pulse.track('signup', { plan: 'pro', source: 'landing' })`. Click any event in Goals & Events to see a breakdown of its properties and values, no setup needed.
- **AI traffic source identification.** Pulse recognizes visitors from ChatGPT, Perplexity, Claude, Gemini, Copilot, DeepSeek, Grok, Meta AI, You.com, and Phind. These appear in Referrers with proper icons and names instead of raw URLs.
- **Automatic outbound link tracking.** Tracks when visitors click links to other websites. Shows up as "outbound link" events in Goals & Events — no setup needed.
- **Automatic file download tracking.** Downloads of PDFs, ZIPs, Excel, Word, MP3s, and 20+ other formats are recorded as "file download" events automatically.
- **Automatic 404 detection.** Detects when visitors land on pages that don't exist and records "404" events so you can find and fix broken links.
- **Automatic scroll depth tracking.** Tracks how far visitors scroll — at 25%, 50%, 75%, and 100% — helping you understand which content keeps people reading.
### Improved
- **Chart rebuilt from scratch.** Cleaner stat cards, wider Y-axis that no longer clips labels, whole-number ticks for visitor and pageview counts, lighter grid lines, streamlined toolbar, and a properly positioned live indicator.
- **Campaigns panel redesigned.** Clean row-based layout with UTM medium and campaign always visible below the source name. Now sits in a half-width grid next to Goals & Events.
- **Better filter design.** Solid brand-colored filter pills that are easy to spot in light and dark mode. A funnel icon on the filter button. Click any pill to remove it.
- **Underline tab switchers.** Pages, Locations, and Technology panels now use clean underline tabs instead of pill-style switchers.
- **"View all" at the bottom.** The expand action on each panel is now a subtle "View all" link at the bottom of the list instead of an icon in the header.
- **Faster dashboard loading.** Each section loads independently with smart caching. Data refreshes in the background, and switching tabs pauses updates to save resources — resuming when you return.
- **Smoother navigation.** Switching pages, changing organizations, or signing in no longer triggers unnecessary background requests.
- **Loading screen while redirecting to sign-in.** The login page now shows the Pulse logo and a message instead of a blank white screen.
- **More reliable billing.** Plan changes, cancellations, and invoice views now handle session expiry and errors gracefully.
- **Stronger browser security.** Your browser now only loads scripts, styles, and images from trusted sources, adding protection against cross-site scripting.
- **More resilient analytics processing.** The system that processes your events now recovers automatically from unexpected errors instead of stopping silently.
- **Dashboard stays responsive under heavy traffic.** Parallel queries are limited during peak usage, and in-progress queries are cancelled when you navigate away.
- **Cleaner error messages.** Invalid form submissions show a simple message instead of exposing internal details.
### Fixed
- **Tracking script now works on all tracked websites.** Page views were silently failing due to two separate issues. Both are fixed — your dashboard receives visits from all registered domains as expected.
- **Rate limiting works correctly.** A bug was treating all visitors as the same person, so one heavy user could block everyone. Each visitor is now identified individually.
- **Real-time visitor count no longer stops updating.** The live counter would hit a rate limit and stop refreshing. It now has enough headroom for normal usage.
- **Team members can view real-time data.** Previously only the site creator could see live visitors. Now any team member in the same organization has access.
- **Funnel details load correctly.** Opening a funnel previously showed an error. Funnels now display step-by-step conversion data as expected.
- **Consistent date handling.** Funnels now use the same date format as the rest of Pulse, so date pickers and bookmarked links work correctly everywhere.
- **Deleting a site cleans up all data.** Orphaned analytics events are now removed automatically before the site is deleted.
- **App switcher and site icons load correctly.** Logos and favicons were blocked by a security policy. Fixed by allowing images from Ciphera and Google's favicon service.
- **Current session highlighted in settings.** The active session marker now works correctly.
- **Notifications load on sign-in.** The notification bell no longer errors briefly after signing in.
- **Duplicate filters no longer stack.** Clicking the same item twice no longer adds the same filter again.
- **Campaigns respect active filters.** The Campaigns panel now filters along with everything else instead of always showing all campaigns.
- **No duplicate "Direct" in referrer filter.** The referrer suggestions no longer show "Direct" twice.
- **Filter dropdowns show all your data.** Previously limited to 10 items — now loads up to 100 values.
- **Chart Y-axis shows whole numbers.** Visitor and pageview counts no longer show fractional values like "0.75 visitors".
- **Duplicate goal names detected reliably.** Goal name uniqueness checks now work correctly regardless of your setup.
- **Health checks stay accurate.** The backend health check no longer falsely reports the service as unhealthy after sustained traffic.
## [0.12.0-alpha] - 2026-03-01
### Added
- **Automated testing for improved reliability.** Pulse now has a comprehensive test suite that verifies critical parts of the app work correctly before every release. This covers login and session protection, error tracking, online/offline detection, and background data refreshing. These checks run automatically so regressions are caught before they reach you.
- **App Switcher in User Menu.** Click your profile in the top right and you'll now see a "Ciphera Apps" section. Expand it to quickly jump between Pulse, Drop (file sharing), and your Ciphera Account settings. This makes it easier to discover and navigate between Ciphera products without signing in again.
- **Session synchronization across tabs.** When you sign out in one browser tab, you're now automatically signed out in all other tabs of the same app. This prevents situations where you might still appear signed in on another tab after logging out. The same applies to signing in — when you sign in on one tab, other tabs will update to reflect your authenticated state.
- **Session expiration warning.** You'll now see a heads-up banner 3 minutes before your session expires, giving you time to click "Stay signed in" to extend your session. If you ignore it or dismiss it, your session will end naturally after the 15-minute timeout for security. If you interact with the app (click, type, scroll) while the warning is showing, it automatically extends your session.
- **Faster billing page loading.** Your subscription details now load much quicker when you visit the billing page. Previously, several requests to our payment provider were made one after another, which could add 1-2 seconds to the page load. Now these happen simultaneously, cutting the wait time significantly. If any request takes too long, we gracefully continue so you always see your billing information without frustrating delays.
- **Faster funnel analysis for multi-step conversions.** We've significantly improved how conversion funnels are calculated. Instead of scanning your data multiple times for each step in a funnel, we now do it in a single efficient pass. This means complex funnels with multiple steps load almost instantly instead of taking seconds—or even timing out. We've also added a reasonable limit of 5 steps per funnel to ensure optimal performance.
- **More reliable database connections under heavy load.** We've optimized how Pulse manages its database connections to handle much higher traffic without issues. By increasing the connection pool size and improving how connections are reused, your dashboard stays responsive even when thousands of users are viewing analytics simultaneously. We also added better monitoring so we can detect and address connection issues before they affect you.
- **Better support for growing teams and traffic.** We've added infrastructure improvements that allow Pulse to run smoothly across multiple servers. When you scale up to handle more traffic, our background processes—like daily analytics calculations and data cleanup—will coordinate automatically so they don't conflict with each other. This ensures reliable performance as your team and data grow.
- **Smarter protection for heavy dashboard operations.** We've implemented a new tiered rate limiting system that treats complex dashboard queries differently from simple requests. Expensive operations—like loading your full dashboard with all its charts and data—now have their own dedicated limits to prevent anyone from accidentally overwhelming the system with too many rapid refreshes. This keeps everything running smoothly for everyone, especially during busy periods.
- **Smarter caching for faster dashboard loading.** We've added intelligent caching headers to our API responses, so your browser can remember recently loaded data and show it instantly when you navigate between pages. This works alongside our existing server-side caching to make your dashboard feel even more responsive—especially when switching between different date ranges or sections.
- **More flexible uptime monitoring.** We've made our uptime checker more adaptable to different needs. Instead of a fixed limit on how many websites we can check simultaneously, you can now configure this based on your requirements. This means faster uptime checks for busy sites with many monitors, while keeping things efficient for smaller setups.
- **Smarter data cleanup for better performance.** We've improved how old analytics data is cleaned up to keep everything running smoothly. Instead of deleting large amounts of data all at once—which could slow things down—we now remove old data in small, efficient batches. This ensures your dashboard stays fast and responsive even as we clean up months of historical data behind the scenes.
- **Faster analytics processing for all sites.** We've upgraded how your daily analytics are calculated behind the scenes. Instead of processing sites one by one, we now analyze multiple sites simultaneously using a smart parallel system. This means your daily stats—like visitor counts and page views—are updated more quickly and consistently, even as we handle data from thousands of websites.
- **Lighter dashboard data transfers.** Your dashboard now loads data in smaller, focused pieces instead of one massive bundle. This means faster loading times—especially on slower connections—and your analytics appear section by section as they become ready, rather than making you wait for everything at once.
- **Smarter data fetching.** Your dashboard now automatically prevents duplicate requests when multiple components ask for the same data at the same time. It also briefly caches recent responses, so switching between pages feels instant while still keeping everything up to date. This reduces server load and makes the app feel snappier.
- **Smarter dashboard updates.** Your dashboard now knows when you're actively viewing it versus when it's in the background. When you switch to another tab, we intelligently slow down data refreshes to save resources, then instantly catch up when you return. This keeps your analytics current without putting unnecessary load on the system.
- **Instant real-time visitor counts.** Your dashboard's "current visitors" counter now updates lightning-fast using an optimized tracking system. Instead of scanning your entire database, we maintain a live session index that shows active visitors in milliseconds—even when thousands of people are browsing your sites simultaneously.
- **Faster event tracking.** Your analytics data is now captured instantly without slowing down your website. We've switched to asynchronous processing that collects events in batches of 100, so your visitors' page views and interactions are recorded with zero impact on their browsing experience, even during traffic spikes.
- **Faster dashboard loading.** Your site analytics now load almost instantly, even during busy periods. Behind the scenes, we've added intelligent caching that remembers your dashboard data for 30 seconds and refreshes it automatically in the background. Real-time visitor counts are updated every 5 seconds so you always see current activity without waiting.
- **Better data management for long-term performance.** We've restructured how your analytics data is stored so the app stays fast even as you collect months of data. Old data is now automatically organized by month and cleaned up efficiently based on your retention settings, keeping everything running smoothly no matter how much traffic you get.
- **Smarter database indexing.** We've optimized how your analytics data is indexed, making common queries—like loading your dashboard or filtering by date—significantly faster. This also reduces storage overhead, keeping the app lean as your data grows.
- **Faster dashboard statistics.** Loading stats for any date range is now much quicker. Instead of recalculating from scratch every time, we use pre-computed daily summaries so your analytics appear instantly, even for months of data.
- **Performance insights. Track how fast your site loads with Core Web Vitals (page load speed, layout shifts, responsiveness). Turn it on in Site Settings → Data & Privacy to see a performance widget on your dashboard.
- **Goals & Events.** Define custom goals (e.g. signup, purchase) and track them with `pulse.track()` in your snippet. Counts appear on your dashboard once you add goals in Site Settings → Goals & Events.
- **2FA recovery codes backup.** When you enable 2FA, you receive recovery codes. You can now regenerate new codes (with password confirmation) from Settings and download them as a `.txt` file. Regenerating invalidates all existing codes.
### Changed
- **Request ID tracing for debugging.** All API requests now include a unique Request ID header (`X-Request-ID`) that helps trace requests across frontend and backend services. When errors occur, the Request ID is included in the response, making it easy to find the exact request in server logs for debugging.
- **App Switcher now shows consistent order.** The Ciphera Apps menu now always displays apps in the same order: Pulse, Drop, Auth — regardless of which app you're currently using. Previously, the current app was shown first, causing the order to change depending on context. This creates a more predictable navigation experience.
### Fixed
- **Shopify and embedded site tracking.** The Pulse tracking script now loads correctly when embedded on third-party sites like Shopify stores, WooCommerce, or custom storefronts. Previously, tracking failed because the script was redirected to the login page instead of loading.
- **Opening Pulse from the Ciphera hub.** Clicking Pulse on the auth apps page (auth.ciphera.net/apps) now signs you in correctly instead of showing "Invalid state". Previously, leftover OAuth data from a past login attempt could block the session flow; the callback now detects redirects from the hub (no `state` in the URL), clears stale PKCE storage, and completes token exchange.
- **Admin organizations list.** Organizations that created a site but never subscribed now appear in the admin list. Previously only orgs with a billing row were shown.
- **Sign in after inactivity.** Clicking "Sign in" after a period of inactivity no longer does nothing. Previously, stale refresh cookies caused the middleware to redirect away from the login page; now only a valid access token triggers that redirect, so you can complete OAuth sign-in when your session has expired.
- **Frequent re-login.** You no longer have to sign in multiple times a day. When the access token expires after 15 minutes of inactivity, the app now automatically refreshes it using your refresh token on the next page load, so you stay logged in for up to 30 days.
- **2FA disable now requires password confirmation.** Disabling 2FA sends the derived password to the backend for verification. This prevents an attacker with a hijacked session from stripping 2FA.
- **More accurate visitor tracking.** We fixed rare edge cases where visitor counts could be slightly off during busy traffic spikes. Previously, the timestamp-based session ID generation could occasionally create overlapping identifiers. Every visitor now gets a truly unique UUID that never overlaps with others, ensuring your analytics are always precise.
- **More reliable background processing.** When multiple Pulse servers are running, background tasks like daily analytics calculations and data cleanup now coordinate more safely. Previously, under rare timing conditions, two servers could accidentally run the same task at the same time, which could lead to slightly inaccurate stats. Each server now holds a unique token that prevents one from interfering with another's work.
- **Cross-tab sign-out cleanup.** Signing out in one tab now fully clears your session data in all other tabs. Previously, some session-related entries were left behind, which could briefly show stale state before the redirect completed.
- **Settings sidebar highlight.** The "Manage Account" section in Settings now stays highlighted when you're viewing Trusted Devices or Security Activity. Previously, navigating to a sub-page removed the highlight from the parent section, making it unclear which group you were in.
- **More accurate readiness checks.** The service health endpoint now actively verifies that the cache and real-time tracker are reachable, not just configured. Previously, the readiness check only confirmed these services were set up—not that they were actually responding—so the API could report "ready" even when Redis or the tracker was down.
## [0.11.1-alpha] - 2026-02-23
### Changed
- **Safer sign-in from the Ciphera hub.** When you open Pulse from the Ciphera Apps page, your credentials are no longer visible in the browser address bar. Sign-in now uses a secure one-time code that expires in seconds, so your session stays private even if someone sees your screen or browser history.
## [0.11.0-alpha] - 2026-02-22
### Added
- **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
@@ -127,7 +273,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
---
[Unreleased]: https://github.com/ciphera-net/pulse/compare/v0.10.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

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

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

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-neutral-900 dark:text-white">Pulse Admin</h1>
</div>
{children}
</div>
)
}

View File

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

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

@@ -0,0 +1,108 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import Link from 'next/link'
import { listAdminOrgs, type AdminOrgSummary } from '@/lib/api/admin'
import { Button, LoadingOverlay, toast } from '@ciphera-net/ui'
function formatDate(d: Date) {
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
}
function CopyableOrgId({ id }: { id: string }) {
const [copied, setCopied] = useState(false)
const copy = useCallback(() => {
navigator.clipboard.writeText(id)
setCopied(true)
toast.success('Org ID copied to clipboard')
setTimeout(() => setCopied(false), 2000)
}, [id])
return (
<button
type="button"
onClick={copy}
className="font-mono text-xs text-neutral-500 hover:text-brand-orange dark:hover:text-brand-orange cursor-pointer transition-colors text-left"
title="Click to copy"
>
{copied ? 'Copied!' : `${id.substring(0, 8)}...`}
</button>
)
}
export default function AdminOrgsPage() {
const [orgs, setOrgs] = useState<AdminOrgSummary[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
listAdminOrgs()
.then(setOrgs)
.finally(() => setLoading(false))
}, [])
if (loading) {
return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Loading organizations..." />
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-neutral-900 dark:text-white">Organizations</h2>
</div>
<div className="rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-6 shadow-sm">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white mb-4">All Organizations</h3>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="border-b border-neutral-200 dark:border-neutral-800">
<tr>
<th className="px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">Name</th>
<th className="px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">Org ID</th>
<th className="px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">Plan</th>
<th className="px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">Status</th>
<th className="px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">Limit</th>
<th className="px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">Updated</th>
<th className="px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-800">
{orgs.map((org) => (
<tr key={org.organization_id} className="hover:bg-neutral-50 dark:hover:bg-neutral-900/50">
<td className="px-4 py-3 text-neutral-900 dark:text-white font-medium">
{org.business_name || 'N/A'}
</td>
<td className="px-4 py-3">
<CopyableOrgId id={org.organization_id} />
</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
org.plan_id === 'business' ? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400' :
org.plan_id === 'team' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' :
org.plan_id === 'solo' ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' :
'bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-400'
}`}>
{org.plan_id}
</span>
</td>
<td className="px-4 py-3 text-neutral-600 dark:text-neutral-300">
{org.subscription_status || '-'}
</td>
<td className="px-4 py-3 text-neutral-600 dark:text-neutral-300">
{new Intl.NumberFormat().format(org.pageview_limit)}
</td>
<td className="px-4 py-3 text-neutral-500 text-xs">
{formatDate(new Date(org.updated_at))}
</td>
<td className="px-4 py-3">
<Link href={`/admin/orgs/${org.organization_id}`}>
<Button variant="ghost">Manage</Button>
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)
}

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

@@ -0,0 +1,20 @@
'use client'
import Link from 'next/link'
export default function AdminDashboard() {
return (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<Link
href="/admin/orgs"
className="block transition-transform hover:scale-[1.02] rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-6 shadow-sm"
>
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">Organizations</h3>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-1">Manage organization plans and limits</p>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-4">
View all organizations, check billing status, and manually grant plans.
</p>
</Link>
</div>
)
}

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
@@ -37,6 +30,9 @@ export async function POST() {
const data = await res.json()
// * Get CSRF token from Auth API response header (for cookie rotation)
const csrfToken = res.headers.get('X-CSRF-Token')
cookieStore.set('access_token', data.access_token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
@@ -55,6 +51,18 @@ export async function POST() {
maxAge: 60 * 60 * 24 * 30
})
// * Set/update CSRF token cookie (non-httpOnly, for JS access)
if (csrfToken) {
cookieStore.set('csrf_token', csrfToken, {
httpOnly: false, // * Must be readable by JS for CSRF protection
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
domain: cookieDomain,
maxAge: 60 * 60 * 24 * 30
})
}
return NextResponse.json({ success: true, access_token: data.access_token })
} catch (error) {
return NextResponse.json({ error: 'Internal error' }, { status: 500 })

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,7 +21,7 @@ function AuthCallbackContent() {
const code = searchParams.get('code')
const codeVerifier = localStorage.getItem('oauth_code_verifier')
const redirectUri = typeof window !== 'undefined' ? window.location.origin + '/auth/callback' : ''
if (!code || !codeVerifier) return
if (!code) return
const result = await exchangeAuthCode(code, codeVerifier, redirectUri)
if (result.success && result.user) {
// * Fetch full profile (including display_name) before navigating so header shows correct name on first paint
@@ -46,59 +47,28 @@ function AuthCallbackContent() {
}, [searchParams, login, router])
useEffect(() => {
// * Prevent double execution (React Strict Mode or fast re-renders)
if (processedRef.current && !isRetrying) return
// * Check for direct token passing (from auth-frontend direct login)
// * This flow exposes tokens in URL, kept for legacy support.
// * Recommended: Use Authorization Code flow (below)
const token = searchParams.get('token')
const refreshToken = searchParams.get('refresh_token')
if (token && refreshToken) {
processedRef.current = true
const handleDirectTokens = async () => {
const result = await setSessionAction(token, refreshToken)
if (result.success && result.user) {
// * Fetch full profile (including display_name) before navigating so header shows correct name on first paint
try {
const fullProfile = await apiRequest<{ id: string; email: string; display_name?: string; totp_enabled: boolean; org_id?: string; role?: string }>('/auth/user/me')
const merged = { ...fullProfile, org_id: result.user.org_id ?? fullProfile.org_id, role: result.user.role ?? fullProfile.role }
login(merged)
} catch {
login(result.user)
}
if (typeof window !== 'undefined' && localStorage.getItem('pulse_pending_checkout')) {
router.push('/welcome')
} else {
const raw = searchParams.get('returnTo') || '/'
const safe = (typeof raw === 'string' && raw.startsWith('/') && !raw.startsWith('//')) ? raw : '/'
router.push(safe)
}
} else {
setError(authMessageFromErrorType('invalid'))
}
}
handleDirectTokens()
return
}
const code = searchParams.get('code')
if (!code) return
const state = searchParams.get('state')
if (!code || !state) return
const storedState = localStorage.getItem('oauth_state')
const codeVerifier = localStorage.getItem('oauth_code_verifier')
if (!codeVerifier) {
setError('Missing code verifier')
return
}
if (state !== storedState) {
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

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

@@ -114,7 +114,7 @@ export default function FAQPage() {
<div className="max-w-3xl mx-auto">
{faqs.map((faq, index) => (
<FAQItem key={index} faq={faq} index={index} />
<FAQItem key={faq.question} faq={faq} index={index} />
))}
</div>

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

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

@@ -2,28 +2,76 @@
import { OfflineBanner } from '@/components/OfflineBanner'
import { Footer } from '@/components/Footer'
import { Header } from '@ciphera-net/ui'
import { Header, type CipheraApp } from '@ciphera-net/ui'
import NotificationCenter from '@/components/notifications/NotificationCenter'
import { useAuth } from '@/lib/auth/context'
import { useOnlineStatus } from '@/lib/hooks/useOnlineStatus'
import Link from 'next/link'
import { useEffect, useState } from 'react'
import { getUserOrganizations, switchContext } from '@/lib/api/organization'
import { logger } from '@/lib/utils/logger'
import { getUserOrganizations, switchContext, type OrganizationMember } from '@/lib/api/organization'
import { setSessionAction } from '@/app/actions/auth'
import { LoadingOverlay } from '@ciphera-net/ui'
import { useRouter } from 'next/navigation'
import { SettingsModalProvider, useSettingsModal } from '@/lib/settings-modal-context'
import SettingsModalWrapper from '@/components/settings/SettingsModalWrapper'
export default function LayoutContent({ children }: { children: React.ReactNode }) {
const ORG_SWITCH_KEY = 'pulse_switching_org'
// * Available Ciphera apps for the app switcher
const CIPHERA_APPS: CipheraApp[] = [
{
id: 'pulse',
name: 'Pulse',
description: 'Your current app — Privacy-first analytics',
icon: 'https://ciphera.net/pulse_icon_no_margins.png',
href: 'https://pulse.ciphera.net',
isAvailable: false, // * Current app
},
{
id: 'drop',
name: 'Drop',
description: 'Secure file sharing',
icon: 'https://ciphera.net/drop_icon_no_margins.png',
href: 'https://drop.ciphera.net',
isAvailable: true,
},
{
id: 'auth',
name: 'Auth',
description: 'Your Ciphera account settings',
icon: 'https://ciphera.net/auth_icon_no_margins.png',
href: 'https://auth.ciphera.net',
isAvailable: true,
},
]
function LayoutInner({ children }: { children: React.ReactNode }) {
const auth = useAuth()
const router = useRouter()
const isOnline = useOnlineStatus()
const [orgs, setOrgs] = useState<any[]>([])
const { openSettings } = useSettingsModal()
const [orgs, setOrgs] = useState<OrganizationMember[]>([])
const [isSwitchingOrg, setIsSwitchingOrg] = useState(() => {
if (typeof window === 'undefined') return false
return sessionStorage.getItem(ORG_SWITCH_KEY) === 'true'
})
// * Clear the switching flag once the page has settled after reload
useEffect(() => {
if (isSwitchingOrg) {
sessionStorage.removeItem(ORG_SWITCH_KEY)
const timer = setTimeout(() => setIsSwitchingOrg(false), 600)
return () => clearTimeout(timer)
}
}, [isSwitchingOrg])
// * Fetch organizations for the header organization switcher
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])
@@ -32,9 +80,10 @@ export default function LayoutContent({ children }: { children: React.ReactNode
try {
const { access_token } = await switchContext(orgId)
await setSessionAction(access_token)
sessionStorage.setItem(ORG_SWITCH_KEY, 'true')
window.location.reload()
} catch (err) {
console.error('Failed to switch organization', err)
logger.error('Failed to switch organization', err)
}
}
@@ -47,6 +96,10 @@ export default function LayoutContent({ children }: { children: React.ReactNode
const headerHeightRem = 6;
const mainTopPaddingRem = barHeightRem + headerHeightRem;
if (isSwitchingOrg) {
return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Pulse" portal={false} />
}
return (
<>
{auth.user && <OfflineBanner isOnline={isOnline} />}
@@ -65,6 +118,9 @@ export default function LayoutContent({ children }: { children: React.ReactNode
showPricing={true}
topOffset={showOfflineBar ? `${barHeightRem}rem` : undefined}
rightSideActions={auth.user ? <NotificationCenter /> : null}
apps={CIPHERA_APPS}
currentAppId="pulse"
onOpenSettings={openSettings}
customNavItems={
<>
{!auth.user && (
@@ -89,6 +145,15 @@ export default function LayoutContent({ children }: { children: React.ReactNode
appName="Pulse"
isAuthenticated={!!auth.user}
/>
<SettingsModalWrapper />
</>
)
}
export default function LayoutContent({ children }: { children: React.ReactNode }) {
return (
<SettingsModalProvider>
<LayoutInner>{children}</LayoutInner>
</SettingsModalProvider>
)
}

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

@@ -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,7 +15,8 @@ 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 { NotificationsListSkeleton, useMinimumLoading } from '@/components/skeletons'
import { toast } from '@ciphera-net/ui'
const PAGE_SIZE = 50
@@ -29,6 +30,7 @@ export default function NotificationsPage() {
const [offset, setOffset] = useState(0)
const [hasMore, setHasMore] = useState(true)
const [loadingMore, setLoadingMore] = useState(false)
const showSkeleton = useMinimumLoading(loading)
const fetchPage = async (pageOffset: number, append: boolean) => {
if (append) setLoadingMore(true)
@@ -127,10 +129,8 @@ export default function NotificationsPage() {
</Link>
</p>
{loading ? (
<div className="flex justify-center py-12">
<Spinner />
</div>
{showSkeleton ? (
<NotificationsListSkeleton />
) : error ? (
<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}

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,5 +1,6 @@
import { Suspense } from 'react'
import OrganizationSettings from '@/components/settings/OrganizationSettings'
import { SettingsFormSkeleton } from '@/components/skeletons'
export const metadata = {
title: 'Organization Settings - Pulse',
@@ -10,7 +11,17 @@ export default function OrgSettingsPage() {
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
<div>
<Suspense fallback={<div className="p-8 text-center text-neutral-500">Loading...</div>}>
<Suspense fallback={
<div className="space-y-8">
<div>
<div className="h-8 w-56 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800 mb-2" />
<div className="h-4 w-80 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800" />
</div>
<div className="bg-white dark:bg-neutral-900 rounded-2xl border border-neutral-200 dark:border-neutral-800 p-6 md:p-8">
<SettingsFormSkeleton />
</div>
</div>
}>
<OrganizationSettings />
</Suspense>
</div>

View File

@@ -6,10 +6,13 @@ 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 { 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 { Button } from '@ciphera-net/ui'
import Image from 'next/image'
import { BarChartIcon, LockIcon, ZapIcon, CheckCircleIcon, XIcon, GlobeIcon } from '@ciphera-net/ui'
import { toast } from '@ciphera-net/ui'
import { getAuthErrorMessage } from '@ciphera-net/ui'
@@ -17,29 +20,36 @@ 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="relative w-full max-w-7xl mx-auto mt-20 mb-32">
<div className="absolute inset-0 bg-brand-orange/20 blur-[100px] -z-10 rounded-full opacity-50" />
{/* * 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"
<motion.div
initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, delay: 0.4 }}
className="relative rounded-xl border border-neutral-200/50 dark:border-neutral-800/50 shadow-2xl overflow-hidden"
>
{/* * 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" />
{/* * Browser chrome */}
<div className="h-8 bg-neutral-100 dark:bg-neutral-800/80 border-b border-neutral-200 dark:border-white/5 flex items-center px-4 gap-2">
<div className="w-3 h-3 rounded-full bg-red-400/60" />
<div className="w-3 h-3 rounded-full bg-yellow-400/60" />
<div className="w-3 h-3 rounded-full bg-green-400/60" />
<div className="ml-4 flex-1 max-w-xs h-5 rounded bg-neutral-200 dark:bg-neutral-700/50" />
</div>
{/* * 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>
{/* * Screenshot with bottom fade */}
<div className="relative max-h-[900px] overflow-hidden">
<Image
src="/dashboard-preview-v2.png"
alt="Pulse analytics dashboard showing visitor stats, charts, top pages, referrers, locations, and technology breakdown"
width={1920}
height={3000}
className="w-full h-auto object-cover object-top"
priority
/>
<div className="absolute inset-0 pointer-events-none bg-gradient-to-b from-transparent from-60% to-white dark:to-neutral-950" />
</div>
</div>
</motion.div>
</div>
)
}
@@ -68,8 +78,8 @@ function ComparisonSection() {
{ feature: "GDPR Compliant", pulse: true, ga: "Complex" },
{ feature: "Script Size", pulse: "< 1 KB", ga: "45 KB+" },
{ feature: "Data Ownership", pulse: "Yours", ga: "Google's" },
].map((row, i) => (
<tr key={i} className="hover:bg-neutral-50/50 dark:hover:bg-neutral-800/50 transition-colors">
].map((row) => (
<tr key={row.feature} className="hover:bg-neutral-50/50 dark:hover:bg-neutral-800/50 transition-colors">
<td className="p-6 text-neutral-900 dark:text-white font-medium">{row.feature}</td>
<td className="p-6">
{row.pulse === true ? (
@@ -97,10 +107,13 @@ function ComparisonSection() {
}
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)
@@ -112,6 +125,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)
@@ -133,8 +177,8 @@ 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'))
} catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || 'Failed to load your sites')
setSites([])
} finally {
setSitesLoading(false)
@@ -162,8 +206,8 @@ export default function HomePage() {
await deleteSite(id)
toast.success('Site deleted 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 delete site')
}
}
@@ -259,7 +303,7 @@ export default function HomePage() {
{ icon: ZapIcon, title: "Lightweight", desc: "Our script is less than 1kb. It won't slow down your site or affect your SEO." }
].map((feature, i) => (
<motion.div
key={i}
key={feature.title}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
@@ -362,20 +406,29 @@ export default function HomePage() {
)}
</div>
{/* * Global Overview */}
{/* * Global Overview - min-h ensures no layout shift when Plan & usage loads */}
<div className="mb-8 grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="rounded-2xl border border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900">
<div className="flex min-h-[160px] flex-col rounded-2xl border border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900">
<p className="text-sm text-neutral-500 dark:text-neutral-400">Total Sites</p>
<p className="text-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">
<div className="flex min-h-[160px] flex-col rounded-2xl border border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900">
<p className="text-sm text-neutral-500 dark:text-neutral-400">Total Visitors (24h)</p>
<p className="text-2xl font-bold text-neutral-900 dark:text-white">--</p>
<p className="text-2xl font-bold text-neutral-900 dark:text-white">
{sites.length === 0 || Object.keys(siteStats).length < sites.length
? '--'
: Object.values(siteStats).reduce((sum, { stats }) => sum + (stats?.visitors ?? 0), 0).toLocaleString()}
</p>
</div>
<div className="rounded-2xl border border-neutral-200 bg-brand-orange/10 p-4 dark:border-neutral-800">
<div className="flex min-h-[160px] flex-col rounded-2xl border border-neutral-200 bg-brand-orange/10 p-4 dark:border-neutral-800">
<p className="text-sm text-brand-orange">Plan & usage</p>
{subscriptionLoading ? (
<p className="text-lg font-bold text-brand-orange">...</p>
<div className="animate-pulse space-y-2">
<div className="h-6 w-24 rounded bg-brand-orange/25 dark:bg-brand-orange/20" />
<div className="h-4 w-full rounded bg-brand-orange/25 dark:bg-brand-orange/20" />
<div className="h-4 w-3/4 rounded bg-brand-orange/25 dark:bg-brand-orange/20" />
<div className="h-4 w-20 rounded bg-brand-orange/25 dark:bg-brand-orange/20 pt-2" />
</div>
) : subscription ? (
<>
<p className="text-lg font-bold text-brand-orange">
@@ -456,7 +509,7 @@ export default function HomePage() {
)}
{(sitesLoading || sites.length > 0) && (
<SiteList sites={sites} loading={sitesLoading} onDelete={handleDelete} />
<SiteList sites={sites} siteStats={siteStats} loading={sitesLoading} onDelete={handleDelete} />
)}
</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-100 dark:bg-neutral-800 mx-auto mb-4" />
<div className="h-5 w-96 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800 mx-auto" />
</div>
<PricingCardsSkeleton />
</div>
}>
<PricingSection />
</Suspense>
</div>

View File

@@ -1,14 +0,0 @@
import ProfileSettings from '@/components/settings/ProfileSettings'
export const metadata = {
title: 'Settings - Pulse',
description: 'Manage your account settings',
}
export default function SettingsPage() {
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
<ProfileSettings />
</div>
)
}

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

View File

@@ -1,10 +1,12 @@
'use client'
import { useCallback, useEffect, 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 { 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'
@@ -13,7 +15,9 @@ import Locations from '@/components/dashboard/Locations'
import TechSpecs from '@/components/dashboard/TechSpecs'
import PerformanceStats from '@/components/dashboard/PerformanceStats'
import { Select, DatePicker as DatePickerModal, Captcha, DownloadIcon, ZapIcon } from '@ciphera-net/ui'
import { DashboardSkeleton, useMinimumLoading } from '@/components/skeletons'
import ExportModal from '@/components/dashboard/ExportModal'
import { FAVICON_SERVICE_URL } from '@/lib/utils/icons'
// Helper to get date ranges
const getDateRange = (days: number) => {
@@ -152,8 +156,9 @@ export default function PublicDashboardPage() {
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')
@@ -162,10 +167,10 @@ export default function PublicDashboardPage() {
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)
@@ -192,8 +197,10 @@ export default function PublicDashboardPage() {
loadDashboard()
}
if (loading && !data && !isPasswordProtected) {
return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Pulse" />
const showSkeleton = useMinimumLoading(loading && !data && !isPasswordProtected)
if (showSkeleton) {
return <DashboardSkeleton />
}
if (isPasswordProtected && !data) {
@@ -279,13 +286,16 @@ export default function PublicDashboardPage() {
<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`}
<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>

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

@@ -4,7 +4,8 @@ import { useCallback, useEffect, useMemo, 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 { toast, Select, DatePicker, ChevronLeftIcon, ArrowRightIcon, TrashIcon, useTheme, Button } from '@ciphera-net/ui'
import { FunnelDetailSkeleton, useMinimumLoading } from '@/components/skeletons'
import Link from 'next/link'
import {
BarChart,
@@ -63,7 +64,7 @@ export default function FunnelReportPage() {
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)
}
@@ -91,8 +92,10 @@ export default function FunnelReportPage() {
}
}
if (loading && !funnel) {
return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Pulse" />
const showSkeleton = useMinimumLoading(loading && !funnel)
if (showSkeleton) {
return <FunnelDetailSkeleton />
}
if (loadError === 'not_found' || (!funnel && !stats && !loadError)) {
@@ -275,7 +278,7 @@ export default function FunnelReportPage() {
</thead>
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-800">
{stats.steps.map((step, i) => (
<tr key={i} className="hover:bg-neutral-50 dark:hover:bg-neutral-800/30 transition-colors">
<tr key={step.step.name} className="hover:bg-neutral-50 dark:hover:bg-neutral-800/30 transition-colors">
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<span className="w-6 h-6 rounded-full bg-neutral-100 dark:bg-neutral-800 flex items-center justify-center text-xs font-medium text-neutral-600 dark:text-neutral-400">

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

@@ -84,7 +84,7 @@ export default function CreateFunnelPage() {
toast.success('Funnel created')
router.push(`/sites/${siteId}/funnels`)
} catch (error) {
toast.error('Failed to create funnel')
toast.error('Failed to create funnel. Please try again.')
} finally {
setSaving(false)
}
@@ -120,8 +120,13 @@ export default function CreateFunnelPage() {
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Signup Flow"
autoFocus
required
maxLength={100}
/>
{name.length > 80 && (
<span className={`text-xs tabular-nums mt-1 ${name.length > 90 ? 'text-amber-500' : 'text-neutral-400'}`}>{name.length}/100</span>
)}
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
@@ -144,7 +149,7 @@ export default function CreateFunnelPage() {
</div>
{steps.map((step, index) => (
<div key={index} className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-4">
<div key={`step-${index}`} className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-4">
<div className="flex items-start gap-4">
<div className="mt-3 text-neutral-400">
<div className="w-6 h-6 rounded-full bg-neutral-100 dark:bg-neutral-800 flex items-center justify-center text-sm font-medium text-neutral-600 dark:text-neutral-400">

View File

@@ -3,7 +3,8 @@
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 { toast, PlusIcon, ArrowRightIcon, ChevronLeftIcon, TrashIcon, Button } from '@ciphera-net/ui'
import { FunnelsListSkeleton, useMinimumLoading } from '@/components/skeletons'
import Link from 'next/link'
export default function FunnelsPage() {
@@ -20,7 +21,7 @@ export default function FunnelsPage() {
const data = await listFunnels(siteId)
setFunnels(data)
} catch (error) {
toast.error('Failed to load funnels')
toast.error('Failed to load your funnels')
} finally {
setLoading(false)
}
@@ -43,8 +44,10 @@ export default function FunnelsPage() {
}
}
if (loading) {
return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Pulse" />
const showSkeleton = useMinimumLoading(loading)
if (showSkeleton) {
return <FunnelsListSkeleton />
}
return (
@@ -114,7 +117,7 @@ export default function FunnelsPage() {
)}
<div className="flex items-center gap-2 mt-4">
{funnel.steps.map((step, i) => (
<div key={i} className="flex items-center text-sm text-neutral-500">
<div key={step.name} className="flex items-center text-sm text-neutral-500">
<span className="px-2 py-1 bg-neutral-100 dark:bg-neutral-800 rounded-lg text-neutral-700 dark:text-neutral-300">
{step.name}
</span>

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

@@ -0,0 +1,15 @@
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Dashboard | Pulse',
description: 'View your site analytics, traffic, and performance.',
robots: { index: false, follow: false },
}
export default function SiteLayout({
children,
}: {
children: React.ReactNode
}) {
return children
}

View File

@@ -1,16 +1,29 @@
'use client'
import { useAuth } from '@/lib/auth/context'
import { useCallback, useEffect, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { logger } from '@/lib/utils/logger'
import { useCallback, useEffect, useState, useMemo } from 'react'
import { useParams, useRouter, useSearchParams } from 'next/navigation'
import { motion } from 'framer-motion'
import { getSite, type Site } from '@/lib/api/sites'
import { getStats, getRealtime, getDailyStats, getTopPages, getTopReferrers, getCountries, getCities, getRegions, getBrowsers, getOS, getDevices, getScreenResolutions, getEntryPages, getExitPages, getDashboard, getCampaigns, getPerformanceByPage, type Stats, type DailyStat, type PerformanceByPageStat } from '@/lib/api/stats'
import { formatNumber, formatDuration, getDateRange } from '@ciphera-net/ui'
import {
getPerformanceByPage,
getTopPages,
getTopReferrers,
getCountries,
getCities,
getRegions,
getBrowsers,
getOS,
getDevices,
getCampaigns,
type Stats,
type DailyStat,
} from '@/lib/api/stats'
import { getDateRange } from '@ciphera-net/ui'
import { toast } from '@ciphera-net/ui'
import { getAuthErrorMessage } from '@ciphera-net/ui'
import { LoadingOverlay, Button } from '@ciphera-net/ui'
import { Button } from '@ciphera-net/ui'
import { Select, DatePicker, DownloadIcon } from '@ciphera-net/ui'
import { DashboardSkeleton, useMinimumLoading } from '@/components/skeletons'
import ExportModal from '@/components/dashboard/ExportModal'
import ContentStats from '@/components/dashboard/ContentStats'
import TopReferrers from '@/components/dashboard/TopReferrers'
@@ -19,7 +32,51 @@ import TechSpecs from '@/components/dashboard/TechSpecs'
import Chart from '@/components/dashboard/Chart'
import PerformanceStats from '@/components/dashboard/PerformanceStats'
import GoalStats from '@/components/dashboard/GoalStats'
import ScrollDepth from '@/components/dashboard/ScrollDepth'
import Campaigns from '@/components/dashboard/Campaigns'
import FilterBar from '@/components/dashboard/FilterBar'
import AddFilterDropdown, { type FilterSuggestion, type FilterSuggestions } from '@/components/dashboard/AddFilterDropdown'
import EventProperties from '@/components/dashboard/EventProperties'
import { type DimensionFilter, serializeFilters, parseFiltersFromURL } from '@/lib/filters'
import {
useDashboardOverview,
useDashboardPages,
useDashboardLocations,
useDashboardDevices,
useDashboardReferrers,
useDashboardPerformance,
useDashboardGoals,
useRealtime,
useStats,
useDailyStats,
useCampaigns,
} from '@/lib/swr/dashboard'
function loadSavedSettings(): {
type?: string
dateRange?: { start: string; end: string }
todayInterval?: 'minute' | 'hour'
multiDayInterval?: 'hour' | 'day'
} | null {
if (typeof window === 'undefined') return null
try {
const saved = localStorage.getItem('pulse_dashboard_settings')
return saved ? JSON.parse(saved) : null
} catch {
return null
}
}
function getInitialDateRange(): { start: string; end: string } {
const settings = loadSavedSettings()
if (settings?.type === 'today') {
const today = new Date().toISOString().split('T')[0]
return { start: today, end: today }
}
if (settings?.type === '7') return getDateRange(7)
if (settings?.type === 'custom' && settings.dateRange) return settings.dateRange
return getDateRange(30)
}
export default function SiteDashboardPage() {
const { user } = useAuth()
@@ -29,69 +86,279 @@ export default function SiteDashboardPage() {
const router = useRouter()
const siteId = params.id as string
const [site, setSite] = useState<Site | null>(null)
const [loading, setLoading] = useState(true)
const [stats, setStats] = useState<Stats>({ pageviews: 0, visitors: 0, bounce_rate: 0, avg_duration: 0 })
const [prevStats, setPrevStats] = useState<Stats | undefined>(undefined)
const [realtime, setRealtime] = useState(0)
const [dailyStats, setDailyStats] = useState<DailyStat[]>([])
const [prevDailyStats, setPrevDailyStats] = useState<DailyStat[] | undefined>(undefined)
const [topPages, setTopPages] = useState<any[]>([])
const [entryPages, setEntryPages] = useState<any[]>([])
const [exitPages, setExitPages] = useState<any[]>([])
const [topReferrers, setTopReferrers] = useState<any[]>([])
const [countries, setCountries] = useState<any[]>([])
const [cities, setCities] = useState<any[]>([])
const [regions, setRegions] = useState<any[]>([])
const [browsers, setBrowsers] = useState<any[]>([])
const [os, setOS] = useState<any[]>([])
const [devices, setDevices] = useState<any[]>([])
const [screenResolutions, setScreenResolutions] = useState<any[]>([])
const [performance, setPerformance] = useState<{ lcp: number, cls: number, inp: number }>({ lcp: 0, cls: 0, inp: 0 })
const [performanceByPage, setPerformanceByPage] = useState<PerformanceByPageStat[] | null>(null)
const [goalCounts, setGoalCounts] = useState<Array<{ event_name: string; count: number }>>([])
const [campaigns, setCampaigns] = useState<any[]>([])
const [dateRange, setDateRange] = useState(getDateRange(30))
// UI state - initialized from localStorage synchronously to avoid double-fetch
const [dateRange, setDateRange] = useState(getInitialDateRange)
const [todayInterval, setTodayInterval] = useState<'minute' | 'hour'>(
() => loadSavedSettings()?.todayInterval || 'hour'
)
const [multiDayInterval, setMultiDayInterval] = useState<'hour' | 'day'>(
() => loadSavedSettings()?.multiDayInterval || 'day'
)
const [isDatePickerOpen, setIsDatePickerOpen] = useState(false)
const [isExportModalOpen, setIsExportModalOpen] = useState(false)
const [todayInterval, setTodayInterval] = useState<'minute' | 'hour'>('hour')
const [multiDayInterval, setMultiDayInterval] = useState<'hour' | 'day'>('day')
const [isSettingsLoaded, setIsSettingsLoaded] = useState(false)
const [lastUpdatedAt, setLastUpdatedAt] = useState<number | null>(null)
const [, setTick] = useState(0)
// Load settings from localStorage
useEffect(() => {
// 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])
// Selected event for property breakdown
const [selectedEvent, setSelectedEvent] = useState<string | null>(null)
const handleAddFilter = useCallback((filter: DimensionFilter) => {
setFilters(prev => {
const isDuplicate = prev.some(
f => f.dimension === filter.dimension && f.operator === filter.operator && f.values.join(';') === filter.values.join(';')
)
if (isDuplicate) return prev
return [...prev, filter]
})
}, [])
const handleRemoveFilter = useCallback((index: number) => {
setFilters(prev => prev.filter((_, i) => i !== index))
}, [])
const handleClearFilters = useCallback(() => {
setFilters([])
}, [])
// Fetch full suggestion list (up to 100) when a dimension is selected in the filter dropdown
const handleFetchSuggestions = useCallback(async (dimension: string): Promise<FilterSuggestion[]> => {
const start = dateRange.start
const end = dateRange.end
const f = filtersParam || undefined
const limit = 100
try {
const savedSettings = localStorage.getItem('pulse_dashboard_settings')
if (savedSettings) {
const settings = JSON.parse(savedSettings)
const regionNames = (() => { try { return new Intl.DisplayNames(['en'], { type: 'region' }) } catch { return null } })()
// 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)
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 }))
}
// Restore intervals
if (settings.todayInterval) setTodayInterval(settings.todayInterval)
if (settings.multiDayInterval) setMultiDayInterval(settings.multiDayInterval)
case 'referrer': {
const data = await getTopReferrers(siteId, start, end, limit, f)
return data.filter(r => r.referrer && r.referrer !== '').map(r => ({ value: r.referrer, label: r.referrer, count: r.pageviews }))
}
case 'country': {
const data = await getCountries(siteId, start, end, limit, f)
return data.filter(c => c.country && c.country !== 'Unknown').map(c => ({ value: c.country, label: regionNames?.of(c.country) ?? c.country, count: c.pageviews }))
}
case 'city': {
const data = await getCities(siteId, start, end, limit, f)
return data.filter(c => c.city && c.city !== 'Unknown').map(c => ({ value: c.city, label: c.city, count: c.pageviews }))
}
case 'region': {
const data = await getRegions(siteId, start, end, limit, f)
return data.filter(r => r.region && r.region !== 'Unknown').map(r => ({ value: r.region, label: r.region, count: r.pageviews }))
}
case 'browser': {
const data = await getBrowsers(siteId, start, end, limit, f)
return data.filter(b => b.browser && b.browser !== 'Unknown').map(b => ({ value: b.browser, label: b.browser, count: b.pageviews }))
}
case 'os': {
const data = await getOS(siteId, start, end, limit, f)
return data.filter(o => o.os && o.os !== 'Unknown').map(o => ({ value: o.os, label: o.os, count: o.pageviews }))
}
case 'device': {
const data = await getDevices(siteId, start, end, limit, f)
return data.filter(d => d.device && d.device !== 'Unknown').map(d => ({ value: d.device, label: d.device, count: d.pageviews }))
}
case 'utm_source':
case 'utm_medium':
case 'utm_campaign': {
const data = await getCampaigns(siteId, start, end, limit, f)
const map = new Map<string, number>()
const field = dimension === 'utm_source' ? 'source' : dimension === 'utm_medium' ? 'medium' : 'campaign'
data.forEach(c => {
const val = c[field]
if (val) map.set(val, (map.get(val) ?? 0) + c.pageviews)
})
return [...map.entries()].map(([v, count]) => ({ value: v, label: v, count }))
}
default:
return []
}
} catch (e) {
console.error('Failed to load dashboard settings', e)
} finally {
setIsSettingsLoaded(true)
} catch {
return []
}
}, [siteId, dateRange.start, dateRange.end, filtersParam])
// Sync filters to URL
useEffect(() => {
const url = new URL(window.location.href)
if (filtersParam) {
url.searchParams.set('filters', filtersParam)
} else {
url.searchParams.delete('filters')
}
window.history.replaceState({}, '', url.toString())
}, [filtersParam])
const interval = dateRange.start === dateRange.end ? todayInterval : multiDayInterval
// Previous period date range for comparison
const prevRange = useMemo(() => {
const startDate = new Date(dateRange.start)
const endDate = new Date(dateRange.end)
const duration = endDate.getTime() - startDate.getTime()
if (duration === 0) {
const prevEnd = new Date(startDate.getTime() - 24 * 60 * 60 * 1000)
return { start: prevEnd.toISOString().split('T')[0], end: prevEnd.toISOString().split('T')[0] }
}
const prevEnd = new Date(startDate.getTime() - 24 * 60 * 60 * 1000)
const prevStart = new Date(prevEnd.getTime() - duration)
return { start: prevStart.toISOString().split('T')[0], end: prevEnd.toISOString().split('T')[0] }
}, [dateRange])
// SWR hooks - replace manual useState + useEffect + setInterval polling
// Each hook handles its own refresh interval, deduplication, and error retry
// Filters are included in cache keys so changing filters auto-refetches
const { data: overview, isLoading: overviewLoading, error: overviewError } = useDashboardOverview(siteId, dateRange.start, dateRange.end, interval, filtersParam || undefined)
const { data: pages } = useDashboardPages(siteId, dateRange.start, dateRange.end, filtersParam || undefined)
const { data: locations } = useDashboardLocations(siteId, dateRange.start, dateRange.end, filtersParam || undefined)
const { data: devicesData } = useDashboardDevices(siteId, dateRange.start, dateRange.end, filtersParam || undefined)
const { data: referrers } = useDashboardReferrers(siteId, dateRange.start, dateRange.end, filtersParam || undefined)
const { data: performanceData } = useDashboardPerformance(siteId, dateRange.start, dateRange.end, filtersParam || undefined)
const { data: goalsData } = useDashboardGoals(siteId, dateRange.start, dateRange.end, filtersParam || undefined)
const { data: realtimeData } = useRealtime(siteId)
const { data: prevStats } = useStats(siteId, prevRange.start, prevRange.end)
const { data: prevDailyStats } = useDailyStats(siteId, prevRange.start, prevRange.end, interval)
const { data: campaigns } = useCampaigns(siteId, dateRange.start, dateRange.end)
// Derive typed values from SWR data
const site = overview?.site ?? null
const stats: Stats = overview?.stats ?? { pageviews: 0, visitors: 0, bounce_rate: 0, avg_duration: 0 }
const realtime = realtimeData?.visitors ?? overview?.realtime_visitors ?? 0
const dailyStats: DailyStat[] = overview?.daily_stats ?? []
// Build filter suggestions from current dashboard data
const filterSuggestions = useMemo<FilterSuggestions>(() => {
const s: FilterSuggestions = {}
// Pages
const topPages = pages?.top_pages ?? []
if (topPages.length > 0) {
s.page = topPages.map(p => ({ value: p.path, label: p.path, count: p.pageviews }))
}
// Referrers
const refs = referrers?.top_referrers ?? []
if (refs.length > 0) {
s.referrer = refs.filter(r => r.referrer && r.referrer !== '').map(r => ({
value: r.referrer,
label: r.referrer,
count: r.pageviews,
}))
}
// Countries
const ctrs = locations?.countries ?? []
if (ctrs.length > 0) {
const regionNames = (() => { try { return new Intl.DisplayNames(['en'], { type: 'region' }) } catch { return null } })()
s.country = ctrs.filter(c => c.country && c.country !== 'Unknown').map(c => ({
value: c.country,
label: regionNames?.of(c.country) ?? c.country,
count: c.pageviews,
}))
}
// Regions
const regs = locations?.regions ?? []
if (regs.length > 0) {
s.region = regs.filter(r => r.region && r.region !== 'Unknown').map(r => ({
value: r.region,
label: r.region,
count: r.pageviews,
}))
}
// Cities
const cts = locations?.cities ?? []
if (cts.length > 0) {
s.city = cts.filter(c => c.city && c.city !== 'Unknown').map(c => ({
value: c.city,
label: c.city,
count: c.pageviews,
}))
}
// Browsers
const brs = devicesData?.browsers ?? []
if (brs.length > 0) {
s.browser = brs.filter(b => b.browser && b.browser !== 'Unknown').map(b => ({
value: b.browser,
label: b.browser,
count: b.pageviews,
}))
}
// OS
const oses = devicesData?.os ?? []
if (oses.length > 0) {
s.os = oses.filter(o => o.os && o.os !== 'Unknown').map(o => ({
value: o.os,
label: o.os,
count: o.pageviews,
}))
}
// Devices
const devs = devicesData?.devices ?? []
if (devs.length > 0) {
s.device = devs.filter(d => d.device && d.device !== 'Unknown').map(d => ({
value: d.device,
label: d.device,
count: d.pageviews,
}))
}
// UTM from campaigns
const camps = campaigns ?? []
if (camps.length > 0) {
const sources = new Map<string, number>()
const mediums = new Map<string, number>()
const campNames = new Map<string, number>()
camps.forEach(c => {
if (c.source) sources.set(c.source, (sources.get(c.source) ?? 0) + c.pageviews)
if (c.medium) mediums.set(c.medium, (mediums.get(c.medium) ?? 0) + c.pageviews)
if (c.campaign) campNames.set(c.campaign, (campNames.get(c.campaign) ?? 0) + c.pageviews)
})
if (sources.size > 0) s.utm_source = [...sources.entries()].map(([v, c]) => ({ value: v, label: v, count: c }))
if (mediums.size > 0) s.utm_medium = [...mediums.entries()].map(([v, c]) => ({ value: v, label: v, count: c }))
if (campNames.size > 0) s.utm_campaign = [...campNames.entries()].map(([v, c]) => ({ value: v, label: v, count: c }))
}
return s
}, [pages, referrers, locations, devicesData, campaigns])
// Show error toast on fetch failure
useEffect(() => {
if (overviewError) {
toast.error('Failed to load dashboard analytics')
}
}, [overviewError])
// Track when data was last updated (for "Live · Xs ago" display)
useEffect(() => {
if (overview) setLastUpdatedAt(Date.now())
}, [overview])
// Tick every 1s so "Live · Xs ago" counts in real time
useEffect(() => {
const timer = setInterval(() => setTick((t) => t + 1), 1000)
return () => clearInterval(timer)
}, [])
// Save settings to localStorage
const saveSettings = (type: string, newDateRange?: { start: string, end: string }) => {
const saveSettings = (type: string, newDateRange?: { start: string; end: string }) => {
try {
const settings = {
type,
@@ -102,15 +369,12 @@ 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]
if (dateRange.start === today && dateRange.end === today) type = 'today'
@@ -125,98 +389,16 @@ 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" />
const showSkeleton = useMinimumLoading(overviewLoading)
if (showSkeleton) {
return <DashboardSkeleton />
}
if (!site) {
@@ -345,6 +527,12 @@ export default function SiteDashboardPage() {
</div>
</div>
{/* Dimension Filters */}
<div className="flex items-center gap-2 flex-wrap mb-2">
<AddFilterDropdown onAdd={handleAddFilter} suggestions={filterSuggestions} onFetchSuggestions={handleFetchSuggestions} />
<FilterBar filters={filters} onRemove={handleRemoveFilter} onClear={handleClearFilters} />
</div>
{/* Advanced Chart with Integrated Stats */}
<div className="mb-8">
<Chart
@@ -366,8 +554,8 @@ export default function SiteDashboardPage() {
{site.enable_performance_insights && (
<div className="mb-8">
<PerformanceStats
stats={performance}
performanceByPage={performanceByPage}
stats={performanceData?.performance ?? { lcp: 0, cls: 0, inp: 0 }}
performanceByPage={performanceData?.performance_by_page ?? null}
siteId={siteId}
startDate={dateRange.start}
endDate={dateRange.end}
@@ -378,52 +566,71 @@ export default function SiteDashboardPage() {
<div className="grid gap-6 lg:grid-cols-2 mb-8">
<ContentStats
topPages={topPages}
entryPages={entryPages}
exitPages={exitPages}
topPages={pages?.top_pages ?? []}
entryPages={pages?.entry_pages ?? []}
exitPages={pages?.exit_pages ?? []}
domain={site.domain}
collectPagePaths={site.collect_page_paths ?? true}
siteId={siteId}
dateRange={dateRange}
onFilter={handleAddFilter}
/>
<TopReferrers
referrers={topReferrers}
referrers={referrers?.top_referrers ?? []}
collectReferrers={site.collect_referrers ?? true}
siteId={siteId}
dateRange={dateRange}
onFilter={handleAddFilter}
/>
</div>
<div className="grid gap-6 lg:grid-cols-2 mb-8">
<Locations
countries={countries}
cities={cities}
regions={regions}
countries={locations?.countries ?? []}
cities={locations?.cities ?? []}
regions={locations?.regions ?? []}
geoDataLevel={site.collect_geo_data || 'full'}
siteId={siteId}
dateRange={dateRange}
onFilter={handleAddFilter}
/>
<TechSpecs
browsers={browsers}
os={os}
devices={devices}
screenResolutions={screenResolutions}
browsers={devicesData?.browsers ?? []}
os={devicesData?.os ?? []}
devices={devicesData?.devices ?? []}
screenResolutions={devicesData?.screen_resolutions ?? []}
collectDeviceInfo={site.collect_device_info ?? true}
collectScreenResolution={site.collect_screen_resolution ?? true}
siteId={siteId}
dateRange={dateRange}
onFilter={handleAddFilter}
/>
</div>
{/* Campaigns Report */}
<div className="mb-8">
<Campaigns siteId={siteId} dateRange={dateRange} />
<div className="grid gap-6 lg:grid-cols-2 mb-8">
<Campaigns siteId={siteId} dateRange={dateRange} filters={filtersParam || undefined} onFilter={handleAddFilter} />
<GoalStats
goalCounts={(goalsData?.goal_counts ?? []).filter(g => !/^scroll_\d+$/.test(g.event_name))}
onSelectEvent={setSelectedEvent}
/>
</div>
<div className="mb-8">
<GoalStats goalCounts={goalCounts} />
<ScrollDepth goalCounts={goalsData?.goal_counts ?? []} totalPageviews={stats.pageviews} />
</div>
{/* Event Properties Breakdown */}
{selectedEvent && (
<div className="mb-8">
<EventProperties
siteId={siteId}
eventName={selectedEvent}
dateRange={dateRange}
onClose={() => setSelectedEvent(null)}
/>
</div>
)}
<DatePicker
isOpen={isDatePickerOpen}
onClose={() => setIsDatePickerOpen(false)}
@@ -440,8 +647,8 @@ export default function SiteDashboardPage() {
onClose={() => setIsExportModalOpen(false)}
data={dailyStats}
stats={stats}
topPages={topPages}
topReferrers={topReferrers}
topPages={pages?.top_pages}
topReferrers={referrers?.top_referrers}
campaigns={campaigns}
/>
</motion.div>

View File

@@ -0,0 +1,13 @@
'use client'
import ErrorDisplay from '@/components/ErrorDisplay'
export default function RealtimeError({ reset }: { error: Error; reset: () => void }) {
return (
<ErrorDisplay
title="Realtime view failed to load"
message="We couldn't connect to the realtime data stream. Please try again."
onRetry={reset}
/>
)
}

View File

@@ -0,0 +1,15 @@
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Realtime | Pulse',
description: 'See who is on your site right now.',
robots: { index: false, follow: false },
}
export default function RealtimeLayout({
children,
}: {
children: React.ReactNode
}) {
return children
}

View File

@@ -6,7 +6,8 @@ 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 { UserIcon } from '@ciphera-net/ui'
import { RealtimeSkeleton, SessionEventsSkeleton, useMinimumLoading } from '@/components/skeletons'
import { motion, AnimatePresence } from 'framer-motion'
function formatTimeAgo(dateString: string) {
@@ -47,7 +48,7 @@ export default function RealtimePage() {
handleSelectVisitor(visitorsData[0])
}
} catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || 'Failed to load data')
toast.error(getAuthErrorMessage(error) || 'Failed to load realtime visitors')
} finally {
setLoading(false)
}
@@ -84,13 +85,19 @@ export default function RealtimePage() {
const events = await getSessionDetails(siteId, visitor.session_id)
setSessionEvents(events || [])
} catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || 'Failed to load session details')
toast.error(getAuthErrorMessage(error) || 'Failed to load session events')
} finally {
setLoadingEvents(false)
}
}
if (loading) return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Realtime" />
useEffect(() => {
if (site?.domain) document.title = `Realtime · ${site.domain} | Pulse`
}, [site?.domain])
const showSkeleton = useMinimumLoading(loading)
if (showSkeleton) return <RealtimeSkeleton />
if (!site) return <div className="p-8">Site not found</div>
return (
@@ -197,9 +204,7 @@ export default function RealtimePage() {
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>
<SessionEventsSkeleton />
) : (
<div className="relative pl-6 border-l-2 border-neutral-100 dark:border-neutral-800 space-y-8">
{sessionEvents.map((event, idx) => (

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
}

View File

@@ -1,18 +1,19 @@
'use client'
import { useEffect, useState } from 'react'
import { useEffect, useState, useRef } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { getSite, updateSite, resetSiteData, deleteSite, type Site, type GeoDataLevel } from '@/lib/api/sites'
import { listGoals, createGoal, updateGoal, deleteGoal, type Goal } from '@/lib/api/goals'
import { toast } from '@ciphera-net/ui'
import { getAuthErrorMessage } from '@ciphera-net/ui'
import { LoadingOverlay } from '@ciphera-net/ui'
import { SettingsFormSkeleton, GoalsListSkeleton, useMinimumLoading } from '@/components/skeletons'
import VerificationModal from '@/components/sites/VerificationModal'
import ScriptSetupBlock from '@/components/sites/ScriptSetupBlock'
import { PasswordInput } from '@ciphera-net/ui'
import { Select, Modal, Button } from '@ciphera-net/ui'
import { APP_URL } from '@/lib/api/client'
import { generatePrivacySnippet } from '@/lib/utils/privacySnippet'
import { useUnsavedChanges } from '@/lib/hooks/useUnsavedChanges'
import { getSubscription, type SubscriptionDetails } from '@/lib/api/billing'
import { getRetentionOptionsForPlan, formatRetentionMonths } from '@/lib/plans'
import { motion, AnimatePresence } from 'framer-motion'
@@ -86,6 +87,7 @@ export default function SiteSettingsPage() {
const [editingGoal, setEditingGoal] = useState<Goal | null>(null)
const [goalForm, setGoalForm] = useState({ name: '', event_name: '' })
const [goalSaving, setGoalSaving] = useState(false)
const initialFormRef = useRef<string>('')
useEffect(() => {
loadSite()
@@ -146,13 +148,27 @@ export default function SiteSettingsPage() {
// Data retention (default 6 = free-tier max; avoids flash-then-clamp for existing sites)
data_retention_months: data.data_retention_months ?? 6
})
initialFormRef.current = JSON.stringify({
name: data.name,
timezone: data.timezone || 'UTC',
is_public: data.is_public || false,
excluded_paths: (data.excluded_paths || []).join('\n'),
collect_page_paths: data.collect_page_paths ?? true,
collect_referrers: data.collect_referrers ?? true,
collect_device_info: data.collect_device_info ?? true,
collect_geo_data: data.collect_geo_data || 'full',
collect_screen_resolution: data.collect_screen_resolution ?? true,
enable_performance_insights: data.enable_performance_insights ?? false,
filter_bots: data.filter_bots ?? true,
data_retention_months: data.data_retention_months ?? 6
})
if (data.has_password) {
setIsPasswordEnabled(true)
} else {
setIsPasswordEnabled(false)
}
} catch (error: any) {
toast.error(getAuthErrorMessage(error) || 'Failed to load site: ' + ((error as Error)?.message || 'Unknown error'))
} catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || 'Failed to load site settings')
} finally {
setLoading(false)
}
@@ -264,9 +280,23 @@ export default function SiteSettingsPage() {
data_retention_months: formData.data_retention_months
})
toast.success('Site updated successfully')
initialFormRef.current = JSON.stringify({
name: formData.name,
timezone: formData.timezone,
is_public: formData.is_public,
excluded_paths: formData.excluded_paths,
collect_page_paths: formData.collect_page_paths,
collect_referrers: formData.collect_referrers,
collect_device_info: formData.collect_device_info,
collect_geo_data: formData.collect_geo_data,
collect_screen_resolution: formData.collect_screen_resolution,
enable_performance_insights: formData.enable_performance_insights,
filter_bots: formData.filter_bots,
data_retention_months: formData.data_retention_months
})
loadSite()
} catch (error: any) {
toast.error(getAuthErrorMessage(error) || 'Failed to update site: ' + ((error as Error)?.message || 'Unknown error'))
} catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || 'Failed to save site settings')
} finally {
setSaving(false)
}
@@ -280,8 +310,8 @@ export default function SiteSettingsPage() {
try {
await resetSiteData(siteId)
toast.success('All site data has been reset')
} catch (error: any) {
toast.error(getAuthErrorMessage(error) || 'Failed to reset data: ' + ((error as Error)?.message || 'Unknown error'))
} catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || 'Failed to reset site data')
}
}
@@ -296,8 +326,8 @@ export default function SiteSettingsPage() {
await deleteSite(siteId)
toast.success('Site deleted successfully')
router.push('/')
} 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 delete site')
}
}
@@ -317,8 +347,50 @@ export default function SiteSettingsPage() {
setTimeout(() => setSnippetCopied(false), 2000)
}
if (loading) {
return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Pulse" />
const isFormDirty = initialFormRef.current !== '' && JSON.stringify({
name: formData.name,
timezone: formData.timezone,
is_public: formData.is_public,
excluded_paths: formData.excluded_paths,
collect_page_paths: formData.collect_page_paths,
collect_referrers: formData.collect_referrers,
collect_device_info: formData.collect_device_info,
collect_geo_data: formData.collect_geo_data,
collect_screen_resolution: formData.collect_screen_resolution,
enable_performance_insights: formData.enable_performance_insights,
filter_bots: formData.filter_bots,
data_retention_months: formData.data_retention_months
}) !== initialFormRef.current
useUnsavedChanges(isFormDirty)
useEffect(() => {
if (site?.domain) document.title = `Settings · ${site.domain} | Pulse`
}, [site?.domain])
const showSkeleton = useMinimumLoading(loading)
if (showSkeleton) {
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
<div className="space-y-8">
<div>
<div className="h-8 w-40 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800 mb-2" />
<div className="h-4 w-64 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800" />
</div>
<div className="flex flex-col md:flex-row gap-8">
<nav className="w-full md:w-64 flex-shrink-0 space-y-1">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="h-12 animate-pulse rounded-xl bg-neutral-100 dark:bg-neutral-800" />
))}
</nav>
<div className="flex-1 bg-white dark:bg-neutral-900 rounded-2xl border border-neutral-200 dark:border-neutral-800 p-6 md:p-8">
<SettingsFormSkeleton />
</div>
</div>
</div>
</div>
)
}
if (!site) {
@@ -429,11 +501,15 @@ export default function SiteSettingsPage() {
type="text"
id="name"
required
maxLength={100}
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-lg bg-neutral-50/50 dark:bg-neutral-900/50 focus:bg-white dark:focus:bg-neutral-900
focus:border-brand-orange focus:ring-4 focus:ring-brand-orange/10 outline-none transition-all duration-200 dark:text-white"
/>
{formData.name.length > 80 && (
<span className={`text-xs tabular-nums ${formData.name.length > 90 ? 'text-amber-500' : 'text-neutral-400'}`}>{formData.name.length}/100</span>
)}
</div>
<div className="space-y-1.5">
@@ -970,7 +1046,7 @@ export default function SiteSettingsPage() {
</p>
</div>
{goalsLoading ? (
<div className="py-8 text-center text-neutral-500 dark:text-neutral-400">Loading goals</div>
<GoalsListSkeleton />
) : (
<>
{canEdit && (
@@ -1037,6 +1113,7 @@ export default function SiteSettingsPage() {
value={goalForm.name}
onChange={(e) => setGoalForm({ ...goalForm, name: e.target.value })}
placeholder="e.g. Signups"
autoFocus
className="w-full px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-lg bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white"
required
/>
@@ -1048,10 +1125,14 @@ export default function SiteSettingsPage() {
value={goalForm.event_name}
onChange={(e) => setGoalForm({ ...goalForm, event_name: e.target.value })}
placeholder="e.g. signup_click (letters, numbers, underscores only)"
maxLength={64}
className="w-full px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-lg bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white"
required
/>
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">Only letters, numbers, and underscores; spaces become underscores. Invalid characters cannot be used. Max 64 characters after formatting.</p>
<div className="flex justify-between mt-1">
<p className="text-xs text-neutral-500 dark:text-neutral-400">Letters, numbers, and underscores only. Spaces become underscores.</p>
<span className={`text-xs tabular-nums ${goalForm.event_name.length > 56 ? 'text-amber-500' : 'text-neutral-400'}`}>{goalForm.event_name.length}/64</span>
</div>
{editingGoal && goalForm.event_name.trim().toLowerCase().replace(/\s+/g, '_') !== editingGoal.event_name && (
<p className="mt-2 text-xs text-amber-600 dark:text-amber-400">Changing event name does not reassign events already tracked under the previous name.</p>
)}

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
}

View File

@@ -20,7 +20,8 @@ import {
import { toast } from '@ciphera-net/ui'
import { useTheme } from '@ciphera-net/ui'
import { getAuthErrorMessage } from '@ciphera-net/ui'
import { LoadingOverlay, Button, Modal } from '@ciphera-net/ui'
import { Button, Modal } from '@ciphera-net/ui'
import { UptimeSkeleton, ChecksSkeleton, useMinimumLoading } from '@/components/skeletons'
import {
AreaChart,
Area,
@@ -283,8 +284,8 @@ function UptimeStatusBar({
// * Component: Response time chart (Recharts area chart)
function ResponseTimeChart({ checks }: { checks: UptimeCheck[] }) {
const { theme } = useTheme()
const colors = theme === 'dark' ? CHART_COLORS_DARK : CHART_COLORS_LIGHT
const { resolvedTheme } = useTheme()
const colors = resolvedTheme === 'dark' ? CHART_COLORS_DARK : CHART_COLORS_LIGHT
// * Prepare data in chronological order (oldest first)
const data = [...checks]
@@ -510,9 +511,7 @@ function MonitorCard({
{/* Response time chart */}
{loadingChecks ? (
<div className="text-center py-4 text-neutral-500 dark:text-neutral-400 text-sm">
Loading checks...
</div>
<ChecksSkeleton />
) : checks.length > 0 ? (
<>
<ResponseTimeChart checks={checks} />
@@ -616,7 +615,7 @@ export default function UptimePage() {
setSite(siteData)
setUptimeData(statusData)
} catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || 'Failed to load uptime data')
toast.error(getAuthErrorMessage(error) || 'Failed to load uptime monitors')
} finally {
setLoading(false)
}
@@ -704,7 +703,13 @@ export default function UptimePage() {
setShowEditModal(true)
}
if (loading) return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Uptime" />
useEffect(() => {
if (site?.domain) document.title = `Uptime · ${site.domain} | Pulse`
}, [site?.domain])
const showSkeleton = useMinimumLoading(loading)
if (showSkeleton) return <UptimeSkeleton />
if (!site) return <div className="p-8 text-neutral-500">Site not found</div>
const monitors = Array.isArray(uptimeData?.monitors) ? uptimeData.monitors : []
@@ -932,8 +937,13 @@ function MonitorForm({
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="e.g. API, Website, CDN"
autoFocus
maxLength={100}
className="w-full px-3 py-2 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-white placeholder-neutral-400 focus:outline-none focus:ring-2 focus:ring-brand-orange focus:border-transparent text-sm"
/>
{formData.name.length > 80 && (
<span className={`text-xs tabular-nums mt-1 ${formData.name.length > 90 ? 'text-amber-500' : 'text-neutral-400'}`}>{formData.name.length}/100</span>
)}
</div>
{/* URL with protocol dropdown + domain prefix */}

View File

@@ -1,6 +1,7 @@
'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'
@@ -65,7 +66,7 @@ export default function NewSitePage() {
router.replace('/')
}
} catch (error) {
console.error('Failed to check limits', error)
logger.error('Failed to check limits', error)
} finally {
setLimitsChecked(true)
}
@@ -87,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)
}
@@ -191,6 +192,8 @@ export default function NewSitePage() {
<Input
id="name"
required
autoFocus
maxLength={100}
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="My Website"
@@ -204,6 +207,7 @@ export default function NewSitePage() {
<Input
id="domain"
required
maxLength={253}
value={formData.domain}
onChange={(e) => setFormData({ ...formData, domain: e.target.value.toLowerCase().trim() })}
placeholder="example.com"

View File

@@ -162,7 +162,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)
}
@@ -659,6 +659,7 @@ function WelcomeContent() {
placeholder="My Website"
value={siteName}
onChange={(e) => setSiteName(e.target.value)}
maxLength={100}
className="w-full"
/>
</div>
@@ -672,6 +673,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">

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">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-red-100 dark:bg-red-900/30 mb-6">
<svg className="w-8 h-8 text-red-500" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
</svg>
</div>
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-4">
{title}
</h2>
<p className="text-lg text-neutral-600 dark:text-neutral-400 max-w-md mx-auto mb-10 leading-relaxed">
{message}
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
{onRetry && (
<Button variant="primary" onClick={onRetry} className="px-8 py-3">
Try again
</Button>
)}
{onGoHome && (
<a href="/">
<Button variant="secondary" className="px-8 py-3">
Go to dashboard
</Button>
</a>
)}
</div>
</div>
</div>
)
}

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
}

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,6 +1,7 @@
'use client'
import { useState, useEffect } from 'react'
import { logger } from '@/lib/utils/logger'
import { useSearchParams } from 'next/navigation'
import { motion } from 'framer-motion'
import { Button, CheckCircleIcon } from '@ciphera-net/ui'
@@ -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
@@ -203,9 +203,9 @@ export default function PricingSection() {
throw new Error('No checkout URL returned')
}
} catch (error: any) {
console.error('Checkout error:', error)
toast.error('Failed to start checkout. Please try again.')
} catch (error: unknown) {
logger.error('Checkout error:', error)
toast.error('Failed to start checkout — please try again')
} finally {
setLoadingPlan(null)
}

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,234 @@
'use client'
import { useState, useRef, useEffect, useCallback } from 'react'
import { DIMENSION_LABELS, OPERATORS, OPERATOR_LABELS, type DimensionFilter } from '@/lib/filters'
export interface FilterSuggestion {
value: string
label: string
count?: number
}
export interface FilterSuggestions {
[dimension: string]: FilterSuggestion[]
}
interface AddFilterDropdownProps {
onAdd: (filter: DimensionFilter) => void
suggestions?: FilterSuggestions
onFetchSuggestions?: (dimension: string) => Promise<FilterSuggestion[]>
}
const ALL_DIMS = ['page', 'referrer', 'country', 'region', 'city', 'browser', 'os', 'device', 'utm_source', 'utm_medium', 'utm_campaign']
export default function AddFilterDropdown({ onAdd, suggestions = {}, onFetchSuggestions }: AddFilterDropdownProps) {
const [isOpen, setIsOpen] = useState(false)
const [selectedDim, setSelectedDim] = useState<string | null>(null)
const [operator, setOperator] = useState<DimensionFilter['operator']>('is')
const [search, setSearch] = useState('')
const [fetchedSuggestions, setFetchedSuggestions] = useState<FilterSuggestion[]>([])
const [isFetching, setIsFetching] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
// Close on outside click or Escape
useEffect(() => {
if (!isOpen) return
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
handleClose()
}
}
function handleEsc(e: KeyboardEvent) {
if (e.key === 'Escape') handleClose()
}
document.addEventListener('mousedown', handleClick)
document.addEventListener('keydown', handleEsc)
return () => {
document.removeEventListener('mousedown', handleClick)
document.removeEventListener('keydown', handleEsc)
}
}, [isOpen])
// Focus search input when a dimension is selected
useEffect(() => {
if (selectedDim) inputRef.current?.focus()
}, [selectedDim])
// Fetch full suggestions when a dimension is selected
useEffect(() => {
if (!selectedDim || !onFetchSuggestions) {
setFetchedSuggestions([])
return
}
let cancelled = false
setIsFetching(true)
onFetchSuggestions(selectedDim).then(data => {
if (!cancelled) {
setFetchedSuggestions(data)
setIsFetching(false)
}
}).catch(() => {
if (!cancelled) setIsFetching(false)
})
return () => { cancelled = true }
}, [selectedDim, onFetchSuggestions])
const handleClose = useCallback(() => {
setIsOpen(false)
setSelectedDim(null)
setOperator('is')
setSearch('')
setFetchedSuggestions([])
}, [])
function handleSelectValue(value: string) {
onAdd({ dimension: selectedDim!, operator, values: [value] })
handleClose()
}
function handleSubmitCustom() {
if (!search.trim() || !selectedDim) return
onAdd({ dimension: selectedDim, operator, values: [search.trim()] })
handleClose()
}
// Use fetched data if available, fall back to prop suggestions
const dimSuggestions = selectedDim
? (fetchedSuggestions.length > 0 ? fetchedSuggestions : (suggestions[selectedDim] || []))
: []
const filtered = dimSuggestions.filter(s =>
s.label.toLowerCase().includes(search.toLowerCase()) ||
s.value.toLowerCase().includes(search.toLowerCase())
)
return (
<div className="relative" ref={ref}>
<button
onClick={() => {
if (isOpen) { handleClose() } else { setIsOpen(true) }
}}
className={`inline-flex items-center gap-2 px-3 py-1.5 text-xs font-medium rounded-lg transition-all cursor-pointer ${
isOpen
? 'bg-brand-orange/10 text-brand-orange border border-brand-orange/30'
: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-200 dark:hover:bg-neutral-700 hover:text-neutral-900 dark:hover:text-white border border-transparent'
}`}
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 0 1-.659 1.591l-5.432 5.432a2.25 2.25 0 0 0-.659 1.591v2.927a2.25 2.25 0 0 1-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 0 0-.659-1.591L3.659 7.409A2.25 2.25 0 0 1 3 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0 1 12 3Z" />
</svg>
Filter
</button>
{isOpen && (
<div className="absolute top-full left-0 mt-1.5 z-50 bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 rounded-xl shadow-xl overflow-hidden min-w-[280px]">
{!selectedDim ? (
/* Step 1: Dimension list */
<div className="py-1">
{ALL_DIMS.map(dim => (
<button
key={dim}
onClick={() => setSelectedDim(dim)}
className="w-full flex items-center justify-between px-4 py-2.5 text-sm text-left hover:bg-neutral-50 dark:hover:bg-neutral-800 transition-colors cursor-pointer"
>
<span className="text-neutral-900 dark:text-white font-medium">{DIMENSION_LABELS[dim]}</span>
<svg className="w-3.5 h-3.5 text-neutral-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
))}
</div>
) : (
/* Step 2: Operator + search + values */
<>
{/* Header with back button */}
<div className="flex items-center gap-2 px-3 pt-3 pb-2">
<button
onClick={() => { setSelectedDim(null); setSearch(''); setOperator('is'); setFetchedSuggestions([]) }}
className="p-1 text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors cursor-pointer rounded-md hover:bg-neutral-100 dark:hover:bg-neutral-800"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</button>
<span className="text-sm font-semibold text-neutral-900 dark:text-white">
{DIMENSION_LABELS[selectedDim]}
</span>
</div>
{/* Operator pills */}
<div className="flex gap-1 px-3 pb-2 flex-wrap">
{OPERATORS.map(op => (
<button
key={op}
onClick={() => setOperator(op)}
className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors cursor-pointer ${
operator === op
? 'bg-brand-orange text-white'
: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-500 dark:text-neutral-400 hover:bg-neutral-200 dark:hover:bg-neutral-700'
}`}
>
{OPERATOR_LABELS[op]}
</button>
))}
</div>
{/* Search input */}
<div className="px-3 pb-2">
<input
ref={inputRef}
value={search}
onChange={e => setSearch(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') {
if (filtered.length === 1) {
handleSelectValue(filtered[0].value)
} else {
handleSubmitCustom()
}
}
}}
placeholder={`Search ${DIMENSION_LABELS[selectedDim]?.toLowerCase()}...`}
className="w-full px-3 py-2 text-sm bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg text-neutral-900 dark:text-white placeholder-neutral-400 focus:outline-none focus:ring-2 focus:ring-brand-orange/40 focus:border-brand-orange transition-colors"
/>
</div>
{/* Values list */}
{isFetching ? (
<div className="px-4 py-6 text-center">
<div className="inline-block w-4 h-4 border-2 border-neutral-300 dark:border-neutral-600 border-t-brand-orange rounded-full animate-spin" />
</div>
) : filtered.length > 0 ? (
<div className="max-h-52 overflow-y-auto border-t border-neutral-100 dark:border-neutral-800">
{filtered.map(s => (
<button
key={s.value}
onClick={() => handleSelectValue(s.value)}
className="w-full flex items-center justify-between px-4 py-2 text-sm text-left hover:bg-neutral-50 dark:hover:bg-neutral-800 transition-colors cursor-pointer"
>
<span className="truncate text-neutral-900 dark:text-white">{s.label}</span>
{s.count !== undefined && (
<span className="text-xs text-neutral-400 dark:text-neutral-500 ml-2 tabular-nums flex-shrink-0">
{s.count.toLocaleString()}
</span>
)}
</button>
))}
</div>
) : search.trim() ? (
<div className="px-3 py-3 border-t border-neutral-100 dark:border-neutral-800">
<button
onClick={handleSubmitCustom}
className="w-full px-3 py-2 text-sm font-medium bg-brand-orange text-white rounded-lg hover:bg-brand-orange/90 transition-colors cursor-pointer"
>
Filter by &ldquo;{search.trim()}&rdquo;
</button>
</div>
) : null}
</>
)}
</div>
)}
</div>
)
}

View File

@@ -1,78 +1,60 @@
'use client'
import { useState, useEffect, useMemo } from 'react'
import { logger } from '@/lib/utils/logger'
import Link from 'next/link'
import Image from 'next/image'
import { formatNumber } from '@ciphera-net/ui'
import { Modal, ArrowRightIcon, Button, Spinner } from '@ciphera-net/ui'
import { ChevronDownIcon, DownloadIcon } from '@ciphera-net/ui'
import { Modal, ArrowRightIcon } from '@ciphera-net/ui'
import { ListSkeleton } from '@/components/skeletons'
import { getCampaigns, CampaignStat } from '@/lib/api/stats'
import { getReferrerFavicon, getReferrerIcon, getReferrerDisplayName } from '@/lib/utils/icons'
import { FaBullhorn } from 'react-icons/fa'
import { PlusIcon } from '@radix-ui/react-icons'
import UtmBuilder from '@/components/tools/UtmBuilder'
import { type DimensionFilter } from '@/lib/filters'
interface CampaignsProps {
siteId: string
dateRange: { start: string, end: string }
filters?: string
onFilter?: (filter: DimensionFilter) => void
}
const LIMIT = 7
const EMPTY_LABEL = '—'
type SortKey = 'source' | 'medium' | 'campaign' | 'visitors' | 'pageviews'
type SortDir = 'asc' | 'desc'
function sortCampaigns(data: CampaignStat[], key: SortKey, dir: SortDir): CampaignStat[] {
return [...data].sort((a, b) => {
const av = key === 'visitors' ? a.visitors : key === 'pageviews' ? a.pageviews : (a[key] || '').toLowerCase()
const bv = key === 'visitors' ? b.visitors : key === 'pageviews' ? b.pageviews : (b[key] || '').toLowerCase()
if (typeof av === 'number' && typeof bv === 'number') {
return dir === 'asc' ? av - bv : bv - av
}
const cmp = String(av).localeCompare(String(bv))
return dir === 'asc' ? cmp : -cmp
})
}
function campaignRowKey(item: CampaignStat): string {
return `${item.source}|${item.medium}|${item.campaign}`
}
export default function Campaigns({ siteId, dateRange }: CampaignsProps) {
export default function Campaigns({ siteId, dateRange, filters, onFilter }: CampaignsProps) {
const [data, setData] = useState<CampaignStat[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isModalOpen, setIsModalOpen] = useState(false)
const [isBuilderOpen, setIsBuilderOpen] = useState(false)
const [fullData, setFullData] = useState<CampaignStat[]>([])
const [isLoadingFull, setIsLoadingFull] = useState(false)
const [sortKey, setSortKey] = useState<SortKey>('visitors')
const [sortDir, setSortDir] = useState<SortDir>('desc')
const [faviconFailed, setFaviconFailed] = useState<Set<string>>(new Set())
useEffect(() => {
const fetchData = async () => {
setIsLoading(true)
try {
const result = await getCampaigns(siteId, dateRange.start, dateRange.end, 10)
const result = await getCampaigns(siteId, dateRange.start, dateRange.end, 10, filters)
setData(result)
} catch (e) {
console.error(e)
logger.error(e)
} finally {
setIsLoading(false)
}
}
fetchData()
}, [siteId, dateRange])
}, [siteId, dateRange, filters])
useEffect(() => {
if (isModalOpen) {
const fetchFullData = async () => {
setIsLoadingFull(true)
try {
const result = await getCampaigns(siteId, dateRange.start, dateRange.end, 100)
const result = await getCampaigns(siteId, dateRange.start, dateRange.end, 100, filters)
setFullData(result)
} catch (e) {
console.error(e)
logger.error(e)
} finally {
setIsLoadingFull(false)
}
@@ -81,40 +63,36 @@ export default function Campaigns({ siteId, dateRange }: CampaignsProps) {
} else {
setFullData([])
}
}, [isModalOpen, siteId, dateRange])
}, [isModalOpen, siteId, dateRange, filters])
const sortedData = useMemo(
() => sortCampaigns(data, sortKey, sortDir),
[data, sortKey, sortDir]
() => [...data].sort((a, b) => b.visitors - a.visitors),
[data]
)
const sortedFullData = useMemo(
() => sortCampaigns(fullData.length > 0 ? fullData : data, sortKey, sortDir),
[fullData, data, sortKey, sortDir]
() => [...(fullData.length > 0 ? fullData : data)].sort((a, b) => b.visitors - a.visitors),
[fullData, data]
)
const totalVisitors = sortedData.reduce((sum, c) => sum + c.visitors, 0)
const hasData = data.length > 0
const displayedData = hasData ? sortedData.slice(0, LIMIT) : []
const emptySlots = Math.max(0, LIMIT - displayedData.length)
const showViewAll = hasData && data.length > LIMIT
const handleSort = (key: SortKey) => {
if (sortKey === key) {
setSortDir(d => d === 'asc' ? 'desc' : 'asc')
} else {
setSortKey(key)
setSortDir(key === 'visitors' || key === 'pageviews' ? 'desc' : 'asc')
}
}
const emptySlots = Math.max(0, LIMIT - displayedData.length)
function renderSourceIcon(source: string) {
const faviconUrl = getReferrerFavicon(source)
const useFavicon = faviconUrl && !faviconFailed.has(source)
if (useFavicon) {
return (
<img
<Image
src={faviconUrl}
alt=""
width={20}
height={20}
className="w-5 h-5 flex-shrink-0 rounded object-contain"
onError={() => setFaviconFailed((prev) => new Set(prev).add(source))}
unoptimized
/>
)
}
@@ -122,13 +100,13 @@ export default function Campaigns({ siteId, dateRange }: CampaignsProps) {
}
const handleExportCampaigns = () => {
const rows = sortedData.length > 0 ? sortedData : data
const rows = sortedFullData.length > 0 ? sortedFullData : sortedData
if (rows.length === 0) return
const header = ['Source', 'Medium', 'Campaign', 'Visitors', 'Pageviews']
const csvRows = [
header.join(','),
...rows.map(r =>
[r.source, r.medium || EMPTY_LABEL, r.campaign || EMPTY_LABEL, r.visitors, r.pageviews].join(',')
[r.source, r.medium || '', r.campaign || '', r.visitors, r.pageviews].join(',')
),
]
const blob = new Blob([csvRows.join('\n')], { type: 'text/csv;charset=utf-8;' })
@@ -142,22 +120,6 @@ export default function Campaigns({ siteId, dateRange }: CampaignsProps) {
URL.revokeObjectURL(url)
}
const SortHeader = ({ label, colKey, className = '' }: { label: string; colKey: SortKey; className?: string }) => (
<button
type="button"
onClick={() => handleSort(colKey)}
className={`inline-flex items-center gap-1 text-xs font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-inset rounded ${className}`}
aria-label={`Sort by ${label}`}
>
{label}
{sortKey === colKey ? (
<ChevronDownIcon className={`w-3 h-3 text-brand-orange ${sortDir === 'asc' ? 'rotate-180' : ''}`} />
) : (
<span className="w-3 h-3 inline-block text-neutral-400" aria-hidden />
)}
</button>
)
return (
<>
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
@@ -165,124 +127,87 @@ export default function Campaigns({ siteId, dateRange }: CampaignsProps) {
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
Campaigns
</h3>
<div className="flex items-center gap-2">
{hasData && (
<Button
variant="ghost"
onClick={handleExportCampaigns}
className="h-8 px-3 text-xs gap-2"
>
<DownloadIcon className="w-3.5 h-3.5" />
Export
</Button>
)}
<Button
variant="ghost"
onClick={() => setIsBuilderOpen(true)}
className="h-8 px-3 text-xs gap-2"
>
<PlusIcon className="w-3.5 h-3.5" />
Build URL
</Button>
{showViewAll && (
<Button
variant="ghost"
onClick={() => setIsModalOpen(true)}
className="h-8 px-3 text-xs"
>
View All
</Button>
)}
</div>
<button
onClick={() => setIsBuilderOpen(true)}
className="text-xs font-medium text-neutral-400 dark:text-neutral-500 hover:text-brand-orange dark:hover:text-brand-orange transition-colors cursor-pointer"
>
Build URL
</button>
</div>
{isLoading ? (
<div className="space-y-2 flex-1 min-h-[270px]">
<div className="grid grid-cols-12 gap-2 mb-2 px-2">
<div className="col-span-4 h-4 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800" />
<div className="col-span-2 h-4 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800" />
<div className="col-span-2 h-4 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800" />
<div className="col-span-2 h-4 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800" />
<div className="col-span-2 h-4 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800" />
</div>
{Array.from({ length: 7 }).map((_, i) => (
<div key={`skeleton-${i}`} className="grid grid-cols-12 gap-2 h-9 px-2 -mx-2">
<div className="col-span-4 h-4 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800" />
<div className="col-span-2 h-4 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800" />
<div className="col-span-2 h-4 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800" />
<div className="col-span-2 h-4 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800" />
<div className="col-span-2 h-4 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800" />
<div className="space-y-2 flex-1 min-h-[270px]">
{isLoading ? (
<ListSkeleton rows={LIMIT} />
) : hasData ? (
<>
{displayedData.map((item) => {
return (
<div
key={`${item.source}|${item.medium}|${item.campaign}`}
onClick={() => onFilter?.({ dimension: 'utm_source', operator: 'is', values: [item.source] })}
className={`flex items-center justify-between py-1.5 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors${onFilter ? ' cursor-pointer' : ''}`}
>
<div className="flex-1 text-neutral-900 dark:text-white flex items-center gap-3 min-w-0">
{renderSourceIcon(item.source)}
<div className="min-w-0">
<div className="truncate font-medium text-sm" title={item.source}>
{getReferrerDisplayName(item.source)}
</div>
<div className="flex items-center gap-1.5 text-[11px] text-neutral-400 dark:text-neutral-500">
<span>{item.medium || '—'}</span>
<span>·</span>
<span className="truncate">{item.campaign || '—'}</span>
</div>
</div>
</div>
<div className="flex items-center gap-2 ml-4">
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
{totalVisitors > 0 ? `${Math.round((item.visitors / totalVisitors) * 100)}%` : ''}
</span>
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
{formatNumber(item.visitors)}
</span>
</div>
</div>
)
})}
{showViewAll ? (
<button
onClick={() => setIsModalOpen(true)}
className="flex items-center justify-center gap-1.5 h-9 w-full text-xs font-medium text-neutral-400 dark:text-neutral-500 hover:text-brand-orange dark:hover:text-brand-orange transition-colors cursor-pointer rounded-lg px-2 -mx-2"
>
View all
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</button>
) : (
Array.from({ length: emptySlots }).map((_, i) => (
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
))
)}
</>
) : (
<div className="h-full flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
<FaBullhorn className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
</div>
))}
</div>
) : hasData ? (
<div className="space-y-2 flex-1 min-h-[270px]">
<div className="grid grid-cols-12 gap-2 text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-2 px-2">
<div className="col-span-4">
<SortHeader label="Source" colKey="source" className="text-left" />
</div>
<div className="col-span-2">
<SortHeader label="Medium" colKey="medium" className="text-left" />
</div>
<div className="col-span-2">
<SortHeader label="Campaign" colKey="campaign" className="text-left" />
</div>
<div className="col-span-2 text-right">
<SortHeader label="Visitors" colKey="visitors" className="text-right justify-end" />
</div>
<div className="col-span-2 text-right">
<SortHeader label="Pageviews" colKey="pageviews" className="text-right justify-end" />
</div>
</div>
{displayedData.map((item) => (
<div
key={campaignRowKey(item)}
className="grid grid-cols-12 gap-2 items-center h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors text-sm"
<h4 className="font-semibold text-neutral-900 dark:text-white">
Track your marketing campaigns
</h4>
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs">
Add UTM parameters to your links to see campaign performance here.
</p>
<Link
href="/installation"
className="inline-flex items-center gap-2 text-sm font-medium text-brand-orange hover:text-brand-orange/90 hover:underline focus:outline-none focus:ring-2 focus:ring-brand-orange/20 rounded"
>
<div className="col-span-4 flex items-center gap-3 truncate">
{renderSourceIcon(item.source)}
<span className="truncate text-neutral-900 dark:text-white font-medium" title={item.source}>
{getReferrerDisplayName(item.source)}
</span>
</div>
<div className="col-span-2 truncate text-neutral-500 dark:text-neutral-400" title={item.medium}>
{item.medium || EMPTY_LABEL}
</div>
<div className="col-span-2 truncate text-neutral-500 dark:text-neutral-400" title={item.campaign}>
{item.campaign || EMPTY_LABEL}
</div>
<div className="col-span-2 text-right font-semibold text-neutral-900 dark:text-white">
{formatNumber(item.visitors)}
</div>
<div className="col-span-2 text-right text-neutral-600 dark:text-neutral-400">
{formatNumber(item.pageviews)}
</div>
</div>
))}
{Array.from({ length: emptySlots }).map((_, i) => (
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
))}
</div>
) : (
<div className="flex-1 min-h-[270px] flex flex-col items-center justify-center text-center px-6 py-8 gap-4">
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
<FaBullhorn className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
Learn more
<ArrowRightIcon className="w-4 h-4" />
</Link>
</div>
<h4 className="font-semibold text-neutral-900 dark:text-white">
Track your marketing campaigns
</h4>
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-md">
Add <code className="px-1.5 py-0.5 rounded bg-neutral-200 dark:bg-neutral-700 text-xs font-mono">utm_source</code>, <code className="px-1.5 py-0.5 rounded bg-neutral-200 dark:bg-neutral-700 text-xs font-mono">utm_medium</code>, and <code className="px-1.5 py-0.5 rounded bg-neutral-200 dark:bg-neutral-700 text-xs font-mono">utm_campaign</code> parameters to your links to see them here.
</p>
<Link
href="/installation"
className="inline-flex items-center gap-2 text-sm font-medium text-brand-orange hover:text-brand-orange/90 hover:underline focus:outline-none focus:ring-2 focus:ring-brand-orange/20 rounded"
>
Read documentation
<ArrowRightIcon className="w-4 h-4" />
</Link>
</div>
)}
)}
</div>
</div>
<Modal
@@ -290,46 +215,51 @@ export default function Campaigns({ siteId, dateRange }: CampaignsProps) {
onClose={() => setIsModalOpen(false)}
title="All Campaigns"
>
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-2">
<div className="space-y-1 max-h-[60vh] overflow-y-auto pr-2">
{isLoadingFull ? (
<div className="py-8 flex flex-col items-center gap-2">
<Spinner />
<p className="text-sm text-neutral-500 dark:text-neutral-400">Loading...</p>
<div className="py-4">
<ListSkeleton rows={10} />
</div>
) : (
<>
<div className="grid grid-cols-12 gap-2 text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-2 px-2 sticky top-0 bg-white dark:bg-neutral-900 py-2 z-10">
<div className="col-span-4">Source</div>
<div className="col-span-2">Medium</div>
<div className="col-span-2">Campaign</div>
<div className="col-span-2 text-right">Visitors</div>
<div className="col-span-2 text-right">Pageviews</div>
</div>
{sortedFullData.map((item) => (
<div
key={campaignRowKey(item)}
className="grid grid-cols-12 gap-2 items-center py-2 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors text-sm border-b border-neutral-100 dark:border-neutral-800 last:border-0"
<div className="flex items-center justify-end mb-2">
<button
onClick={handleExportCampaigns}
className="text-xs font-medium text-neutral-400 hover:text-brand-orange transition-colors cursor-pointer"
>
<div className="col-span-4 flex items-center gap-3 truncate">
{renderSourceIcon(item.source)}
<span className="truncate text-neutral-900 dark:text-white font-medium" title={item.source}>
{getReferrerDisplayName(item.source)}
</span>
Export CSV
</button>
</div>
{sortedFullData.map((item) => {
return (
<div
key={`${item.source}|${item.medium}|${item.campaign}`}
className="flex items-center justify-between py-2 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors"
>
<div className="flex-1 flex items-center gap-3 min-w-0">
{renderSourceIcon(item.source)}
<div className="min-w-0">
<div className="text-neutral-900 dark:text-white font-medium truncate text-sm" title={item.source}>
{getReferrerDisplayName(item.source)}
</div>
<div className="flex items-center gap-1.5 text-[11px] text-neutral-400 dark:text-neutral-500">
<span>{item.medium || '—'}</span>
<span>·</span>
<span className="truncate">{item.campaign || '—'}</span>
</div>
</div>
</div>
<div className="flex items-center gap-4 ml-4 text-sm">
<span className="font-semibold text-neutral-900 dark:text-white">
{formatNumber(item.visitors)}
</span>
<span className="text-neutral-400 dark:text-neutral-500 w-16 text-right">
{formatNumber(item.pageviews)} pv
</span>
</div>
</div>
<div className="col-span-2 truncate text-neutral-500 dark:text-neutral-400" title={item.medium}>
{item.medium || EMPTY_LABEL}
</div>
<div className="col-span-2 truncate text-neutral-500 dark:text-neutral-400" title={item.campaign}>
{item.campaign || EMPTY_LABEL}
</div>
<div className="col-span-2 text-right font-semibold text-neutral-900 dark:text-white">
{formatNumber(item.visitors)}
</div>
<div className="col-span-2 text-right text-neutral-600 dark:text-neutral-400">
{formatNumber(item.pageviews)}
</div>
</div>
))}
)
})}
</>
)}
</div>

View File

@@ -10,11 +10,10 @@ import {
CartesianGrid,
Tooltip,
ResponsiveContainer,
ReferenceLine,
} from 'recharts'
import type { TooltipProps } from 'recharts'
import { formatNumber, formatDuration, formatUpdatedAgo } from '@ciphera-net/ui'
import { ArrowUpRightIcon, ArrowDownRightIcon, BarChartIcon, Select, Button, DownloadIcon } from '@ciphera-net/ui'
import { ArrowUpRightIcon, ArrowDownRightIcon, BarChartIcon, Select, DownloadIcon } from '@ciphera-net/ui'
import { Checkbox } from '@ciphera-net/ui'
const COLORS = {
@@ -25,6 +24,7 @@ const COLORS = {
const CHART_COLORS_LIGHT = {
border: 'var(--color-neutral-200)',
grid: 'var(--color-neutral-100)',
text: 'var(--color-neutral-900)',
textMuted: 'var(--color-neutral-500)',
axis: 'var(--color-neutral-400)',
@@ -34,6 +34,7 @@ const CHART_COLORS_LIGHT = {
const CHART_COLORS_DARK = {
border: 'var(--color-neutral-700)',
grid: 'var(--color-neutral-800)',
text: 'var(--color-neutral-50)',
textMuted: 'var(--color-neutral-400)',
axis: 'var(--color-neutral-500)',
@@ -67,119 +68,29 @@ interface ChartProps {
setTodayInterval: (interval: 'minute' | 'hour') => void
multiDayInterval: 'hour' | 'day'
setMultiDayInterval: (interval: 'hour' | 'day') => void
/** Optional: callback when user requests chart export (parent can open ExportModal or handle export) */
onExportChart?: () => void
/** Optional: timestamp of last data fetch for "Live · Xs ago" indicator */
lastUpdatedAt?: number | null
}
type MetricType = 'pageviews' | 'visitors' | 'bounce_rate' | 'avg_duration'
// * Custom tooltip with comparison and theme-aware styling
function ChartTooltip({
active,
payload,
label,
metric,
metricLabel,
formatNumberFn,
showComparison,
prevPeriodLabel,
colors,
}: {
active?: boolean
payload?: Array<{ payload: { prevPageviews?: number; prevVisitors?: number; prevBounceRate?: number; prevAvgDuration?: number }; value: number }>
label?: string
metric: MetricType
metricLabel: string
formatNumberFn: (n: number) => string
showComparison: boolean
prevPeriodLabel?: string
colors: typeof CHART_COLORS_LIGHT
}) {
if (!active || !payload?.length || !label) return null
// * Recharts sends one payload entry per Area; order can be [prevSeries, currentSeries].
// * Use the entry for the current metric so the tooltip shows today's value, not yesterday's.
type PayloadItem = { dataKey?: string; value?: number; payload: { prevPageviews?: number; prevVisitors?: number; prevBounceRate?: number; prevAvgDuration?: number; visitors?: number; pageviews?: number; bounce_rate?: number; avg_duration?: number } }
const items = payload as PayloadItem[]
const current = items.find((p) => p.dataKey === metric) ?? items[items.length - 1]
const value = Number(current?.value ?? (current?.payload as Record<string, number>)?.[metric] ?? 0)
// ─── Helpers ─────────────────────────────────────────────────────────
let prev: number | undefined
switch (metric) {
case 'visitors': prev = current?.payload?.prevVisitors; break;
case 'pageviews': prev = current?.payload?.prevPageviews; break;
case 'bounce_rate': prev = current?.payload?.prevBounceRate; break;
case 'avg_duration': prev = current?.payload?.prevAvgDuration; break;
}
const hasPrev = showComparison && prev != null
const delta =
hasPrev && (prev as number) > 0
? Math.round(((value - (prev as number)) / (prev as number)) * 100)
: null
const formatValue = (v: number) => {
if (metric === 'bounce_rate') return `${Math.round(v)}%`
if (metric === 'avg_duration') return formatDuration(v)
return formatNumberFn(v)
}
return (
<div
className="rounded-lg border px-4 py-3 shadow-lg transition-shadow duration-300"
style={{
backgroundColor: colors.tooltipBg,
borderColor: colors.tooltipBorder,
}}
>
<div className="text-xs font-medium" style={{ color: colors.textMuted, marginBottom: 6 }}>
{label}
</div>
<div className="flex items-baseline gap-2">
<span className="text-base font-bold" style={{ color: colors.text }}>
{formatValue(value)}
</span>
<span className="text-xs" style={{ color: colors.textMuted }}>
{metricLabel}
</span>
</div>
{hasPrev && (
<div className="mt-1.5 flex items-center gap-2 text-xs" style={{ color: colors.textMuted }}>
<span>vs {formatValue(prev as number)} {prevPeriodLabel ? `(${prevPeriodLabel})` : 'prev'}</span>
{delta !== null && (
<span
className="font-medium"
style={{
color: delta > 0 ? (metric === 'bounce_rate' ? COLORS.danger : COLORS.success) : delta < 0 ? (metric === 'bounce_rate' ? COLORS.success : COLORS.danger) : colors.textMuted,
}}
>
{delta > 0 ? '+' : ''}{delta}%
</span>
)}
</div>
)}
</div>
)
}
// * Compact Y-axis formatter: 1.5M, 12k, 99
function formatAxisValue(value: number): string {
if (value >= 1e6) return `${value / 1e6}M`
if (value >= 1000) return `${value / 1000}k`
if (value >= 1e6) return `${+(value / 1e6).toFixed(1)}M`
if (value >= 1000) return `${+(value / 1000).toFixed(1)}k`
if (!Number.isInteger(value)) return value.toFixed(1)
return String(value)
}
// * Compact duration for Y-axis ticks (avoids truncation: "5m" not "5m 0s")
function formatAxisDuration(seconds: number): string {
if (!seconds) return '0s'
const m = Math.floor(seconds / 60)
const s = Math.floor(seconds % 60)
if (m > 0) return s > 0 ? `${m}m ${s}s` : `${m}m`
if (m > 0) return s > 0 ? `${m}m${s}s` : `${m}m`
return `${s}s`
}
// * Returns human-readable label for the previous comparison period (e.g. "Feb 10" or "Jan 5 Feb 4")
function getPrevDateRangeLabel(dateRange: { start: string; end: string }): string {
const startDate = new Date(dateRange.start)
const endDate = new Date(dateRange.end)
@@ -196,7 +107,6 @@ function getPrevDateRangeLabel(dateRange: { start: string; end: string }): strin
return `${fmt(prevStart)} ${fmt(prevEnd)}`
}
// * Returns short trend context (e.g. "vs yesterday", "vs previous 7 days")
function getTrendContext(dateRange: { start: string; end: string }): string {
const startDate = new Date(dateRange.start)
const endDate = new Date(dateRange.end)
@@ -208,51 +118,83 @@ function getTrendContext(dateRange: { start: string; end: string }): string {
return `vs previous ${days} days`
}
// * Mini sparkline SVG for KPI cards
function Sparkline({
data,
dataKey,
color,
width = 56,
height = 20,
// ─── Tooltip ─────────────────────────────────────────────────────────
function ChartTooltip({
active,
payload,
label,
metric,
metricLabel,
formatNumberFn,
showComparison,
prevPeriodLabel,
colors,
}: {
data: Array<Record<string, unknown>>
dataKey: string
color: string
width?: number
height?: number
active?: boolean
payload?: Array<{ payload: Record<string, number>; value: number; dataKey?: string }>
label?: string
metric: MetricType
metricLabel: string
formatNumberFn: (n: number) => string
showComparison: boolean
prevPeriodLabel?: string
colors: typeof CHART_COLORS_LIGHT
}) {
if (!data.length) return null
const values = data.map((d) => Number(d[dataKey] ?? 0))
const max = Math.max(...values, 1)
const min = Math.min(...values, 0)
const range = max - min || 1
const padding = 2
const w = width - padding * 2
const h = height - padding * 2
if (!active || !payload?.length || !label) return null
const points = values.map((v, i) => {
const x = padding + (i / Math.max(values.length - 1, 1)) * w
const y = padding + h - ((v - min) / range) * h
return `${x},${y}`
})
const current = payload.find((p) => p.dataKey === metric) ?? payload[payload.length - 1]
const value = Number(current?.value ?? current?.payload?.[metric] ?? 0)
const pathD = points.length > 1 ? `M ${points.join(' L ')}` : `M ${points[0]} L ${points[0]}`
const prevKey = metric === 'visitors' ? 'prevVisitors' : metric === 'pageviews' ? 'prevPageviews' : metric === 'bounce_rate' ? 'prevBounceRate' : 'prevAvgDuration'
const prev = current?.payload?.[prevKey]
const hasPrev = showComparison && prev != null
const delta = hasPrev && prev > 0 ? Math.round(((value - prev) / prev) * 100) : null
const formatValue = (v: number) => {
if (metric === 'bounce_rate') return `${Math.round(v)}%`
if (metric === 'avg_duration') return formatDuration(v)
return formatNumberFn(v)
}
return (
<svg width={width} height={height} className="flex-shrink-0" aria-hidden>
<path
d={pathD}
fill="none"
stroke={color}
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<div
className="rounded-lg border px-3.5 py-2.5 shadow-lg"
style={{ backgroundColor: colors.tooltipBg, borderColor: colors.tooltipBorder }}
>
<div className="text-[11px] font-medium mb-1" style={{ color: colors.textMuted }}>
{label}
</div>
<div className="flex items-baseline gap-1.5">
<span className="text-sm font-bold" style={{ color: colors.text }}>
{formatValue(value)}
</span>
<span className="text-[11px]" style={{ color: colors.textMuted }}>
{metricLabel}
</span>
</div>
{hasPrev && (
<div className="mt-1 flex items-center gap-1.5 text-[11px]" style={{ color: colors.textMuted }}>
<span>vs {formatValue(prev)} {prevPeriodLabel ? `(${prevPeriodLabel})` : ''}</span>
{delta !== null && (
<span
className="font-medium"
style={{
color: delta > 0 ? (metric === 'bounce_rate' ? COLORS.danger : COLORS.success) : delta < 0 ? (metric === 'bounce_rate' ? COLORS.success : COLORS.danger) : colors.textMuted,
}}
>
{delta > 0 ? '+' : ''}{delta}%
</span>
)}
</div>
)}
</div>
)
}
// ─── Chart Component ─────────────────────────────────────────────────
export default function Chart({
data,
prevData,
@@ -273,24 +215,21 @@ export default function Chart({
const { resolvedTheme } = useTheme()
const handleExportChart = useCallback(async () => {
if (onExportChart) {
onExportChart()
return
}
if (onExportChart) { onExportChart(); return }
if (!chartContainerRef.current) return
try {
const { toPng } = await import('html-to-image')
// Resolve the actual background color from the DOM (CSS vars don't work in html-to-image)
const bg = getComputedStyle(chartContainerRef.current).backgroundColor || (resolvedTheme === 'dark' ? '#171717' : '#ffffff')
const dataUrl = await toPng(chartContainerRef.current, {
cacheBust: true,
backgroundColor: resolvedTheme === 'dark' ? 'var(--color-neutral-900)' : '#ffffff',
backgroundColor: bg,
})
const link = document.createElement('a')
link.download = `chart-${dateRange.start}-${dateRange.end}.png`
link.href = dataUrl
link.click()
} catch {
// Fallback: do nothing if export fails
}
} catch { /* noop */ }
}, [onExportChart, dateRange, resolvedTheme])
const colors = useMemo(
@@ -298,20 +237,17 @@ export default function Chart({
[resolvedTheme]
)
// * Align current and previous data
// ─── Data ──────────────────────────────────────────────────────────
const chartData = data.map((item, i) => {
// * Try to find matching previous item (assuming same length/order)
// * For more robustness, we could match by relative index
const prevItem = prevData?.[i]
// * Format date based on interval
let formattedDate: string
if (interval === 'minute') {
formattedDate = new Date(item.date).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
} else if (interval === 'hour') {
const d = new Date(item.date)
const isMidnight = d.getHours() === 0 && d.getMinutes() === 0
// * At 12:00 AM: date only (used for X-axis ticks). Non-midnight: date + time for tooltip only.
formattedDate = isMidnight
? d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + ' 12:00 AM'
: d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + ', ' + d.toLocaleTimeString('en-US', { hour: 'numeric', minute: 'numeric' })
@@ -333,7 +269,8 @@ export default function Chart({
}
})
// * Calculate trends
// ─── Metrics ───────────────────────────────────────────────────────
const calculateTrend = (current: number, previous?: number) => {
if (!previous) return null
if (previous === 0) return current > 0 ? 100 : 0
@@ -341,282 +278,201 @@ export default function Chart({
}
const metrics = [
{
id: 'visitors',
label: 'Unique Visitors',
value: formatNumber(stats.visitors),
trend: calculateTrend(stats.visitors, prevStats?.visitors),
color: COLORS.brand,
invertTrend: false,
},
{
id: 'pageviews',
label: 'Total Pageviews',
value: formatNumber(stats.pageviews),
trend: calculateTrend(stats.pageviews, prevStats?.pageviews),
color: COLORS.brand,
invertTrend: false,
},
{
id: 'bounce_rate',
label: 'Bounce Rate',
value: `${Math.round(stats.bounce_rate)}%`,
trend: calculateTrend(stats.bounce_rate, prevStats?.bounce_rate),
color: COLORS.brand,
invertTrend: true, // Lower bounce rate is better
},
{
id: 'avg_duration',
label: 'Visit Duration',
value: formatDuration(stats.avg_duration),
trend: calculateTrend(stats.avg_duration, prevStats?.avg_duration),
color: COLORS.brand,
invertTrend: false,
},
] as const
{ id: 'visitors' as const, label: 'Unique Visitors', value: formatNumber(stats.visitors), trend: calculateTrend(stats.visitors, prevStats?.visitors), invertTrend: false },
{ id: 'pageviews' as const, label: 'Total Pageviews', value: formatNumber(stats.pageviews), trend: calculateTrend(stats.pageviews, prevStats?.pageviews), invertTrend: false },
{ id: 'bounce_rate' as const, label: 'Bounce Rate', value: `${Math.round(stats.bounce_rate)}%`, trend: calculateTrend(stats.bounce_rate, prevStats?.bounce_rate), invertTrend: true },
{ id: 'avg_duration' as const, label: 'Visit Duration', value: formatDuration(stats.avg_duration), trend: calculateTrend(stats.avg_duration, prevStats?.avg_duration), invertTrend: false },
]
const activeMetric = metrics.find((m) => m.id === metric) || metrics[0]
const chartMetric = metric
const metricLabel = metrics.find(m => m.id === metric)?.label || 'visitors'
const metricLabel = activeMetric.label
const prevPeriodLabel = prevData?.length ? getPrevDateRangeLabel(dateRange) : ''
const trendContext = getTrendContext(dateRange)
const avg = chartData.length
? chartData.reduce((s, d) => s + (d[chartMetric] as number), 0) / chartData.length
: 0
const hasPrev = !!(prevData?.length && showComparison)
const hasData = data.length > 0
const hasAnyNonZero = hasData && chartData.some((d) => (d[chartMetric] as number) > 0)
const hasAnyNonZero = hasData && chartData.some((d) => (d[metric] as number) > 0)
// * In hourly view, only show X-axis labels at 12:00 AM (date + 12:00 AM).
const midnightTicks =
interval === 'hour'
? (() => {
const t = chartData
.filter((_, i) => {
const d = new Date(data[i].date)
return d.getHours() === 0 && d.getMinutes() === 0
})
.map((c) => c.date)
return t.length > 0 ? t : undefined
})()
: undefined
// Count metrics should never show decimal Y-axis ticks
const isCountMetric = metric === 'visitors' || metric === 'pageviews'
// ─── X-Axis Ticks ─────────────────────────────────────────────────
const midnightTicks = interval === 'hour'
? (() => {
const t = chartData
.filter((_, i) => { const d = new Date(data[i].date); return d.getHours() === 0 && d.getMinutes() === 0 })
.map((c) => c.date)
return t.length > 0 ? t : undefined
})()
: undefined
// * In daily view, only show the date at each day (12:00 AM / start-of-day mark), no time.
const dayTicks = interval === 'day' && chartData.length > 0 ? chartData.map((c) => c.date) : undefined
// ─── Trend Badge ──────────────────────────────────────────────────
function TrendBadge({ trend, invert }: { trend: number | null; invert: boolean }) {
if (trend === null) return <span className="text-neutral-400 dark:text-neutral-500"></span>
const effective = invert ? -trend : trend
const isPositive = effective > 0
const isNegative = effective < 0
return (
<span className={`inline-flex items-center text-xs font-medium ${isPositive ? 'text-emerald-600 dark:text-emerald-400' : isNegative ? 'text-red-500 dark:text-red-400' : 'text-neutral-400'}`}>
{isPositive ? <ArrowUpRightIcon className="w-3 h-3 mr-0.5" /> : isNegative ? <ArrowDownRightIcon className="w-3 h-3 mr-0.5" /> : null}
{Math.abs(trend)}%
</span>
)
}
// ─── Render ────────────────────────────────────────────────────────
return (
<div
ref={chartContainerRef}
className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden shadow-sm relative"
className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden relative"
role="region"
aria-label={`Analytics chart showing ${metricLabel} over time`}
>
{/* * Subtle live/updated indicator in bottom-right corner */}
{lastUpdatedAt != null && (
<div
className="absolute bottom-3 right-6 flex items-center gap-2 text-xs text-neutral-500 dark:text-neutral-400 pointer-events-none"
title="Data refreshes every 30 seconds"
>
<span className="relative flex h-1.5 w-1.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-500 opacity-75" />
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-green-500" />
</span>
Live · {formatUpdatedAgo(lastUpdatedAt)}
</div>
)}
{/* Stats Header (Interactive Tabs) */}
{/* Stat Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 divide-x divide-neutral-200 dark:divide-neutral-800 border-b border-neutral-200 dark:border-neutral-800">
{metrics.map((item) => (
<button
key={item.id}
type="button"
onClick={() => setMetric(item.id as MetricType)}
aria-pressed={metric === item.id}
aria-label={`Show ${item.label} chart`}
className={`
p-4 sm:p-6 text-left transition-colors relative group
hover:bg-neutral-50 dark:hover:bg-neutral-800/50
${metric === item.id ? 'bg-neutral-50 dark:bg-neutral-800/50' : ''}
cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-orange-500 focus-visible:ring-offset-2
`}
>
<div className={`text-xs font-semibold uppercase tracking-wider mb-1 flex items-center gap-2 ${metric === item.id ? 'text-neutral-900 dark:text-white' : 'text-neutral-500'}`}>
{item.label}
</div>
<div className="flex items-baseline gap-2 flex-wrap">
<span className="text-xl sm:text-2xl font-bold text-neutral-900 dark:text-white">
{item.value}
</span>
<span className="flex items-center text-sm font-medium">
{item.trend !== null ? (
<>
<span className={
(item.invertTrend ? -item.trend : item.trend) > 0
? 'text-emerald-600 dark:text-emerald-500'
: (item.invertTrend ? -item.trend : item.trend) < 0
? 'text-red-600 dark:text-red-500'
: 'text-neutral-500'
}>
{(item.invertTrend ? -item.trend : item.trend) > 0 ? (
<ArrowUpRightIcon className="w-3 h-3 mr-0.5 inline" />
) : (item.invertTrend ? -item.trend : item.trend) < 0 ? (
<ArrowDownRightIcon className="w-3 h-3 mr-0.5 inline" />
) : null}
{Math.abs(item.trend)}%
</span>
</>
) : (
<span className="text-neutral-500 dark:text-neutral-400"></span>
)}
</span>
</div>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-0.5">{trendContext}</p>
{hasData && (
<div className="mt-2">
<Sparkline data={chartData} dataKey={item.id} color={item.color} />
{metrics.map((item) => {
const isActive = metric === item.id
return (
<button
key={item.id}
type="button"
onClick={() => setMetric(item.id)}
aria-pressed={isActive}
aria-label={`Show ${item.label} chart`}
className={`p-4 sm:px-6 sm:py-5 text-left transition-colors relative cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange/50 ${isActive ? 'bg-neutral-50 dark:bg-neutral-800/40' : 'hover:bg-neutral-50/50 dark:hover:bg-neutral-800/20'}`}
>
<div className={`text-[11px] font-semibold uppercase tracking-wider mb-1.5 ${isActive ? 'text-neutral-900 dark:text-white' : 'text-neutral-400 dark:text-neutral-500'}`}>
{item.label}
</div>
)}
{metric === item.id && (
<div className="absolute bottom-0 left-0 right-0 h-1" style={{ backgroundColor: item.color }} />
)}
</button>
))}
<div className="flex items-baseline gap-2">
<span className="text-xl sm:text-2xl font-bold text-neutral-900 dark:text-white">
{item.value}
</span>
<TrendBadge trend={item.trend} invert={item.invertTrend} />
</div>
<p className="text-[11px] text-neutral-400 dark:text-neutral-500 mt-0.5">{trendContext}</p>
{isActive && (
<div className="absolute bottom-0 left-0 right-0 h-[3px] bg-brand-orange" />
)}
</button>
)
})}
</div>
{/* Chart Area */}
<div className="p-6">
{/* Toolbar Row */}
<div className="mb-6 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
{/* Left side: Legend */}
<div className="flex items-center">
<div className="px-4 sm:px-6 pt-4 pb-2">
{/* Toolbar */}
<div className="flex items-center justify-between gap-3 mb-4">
{/* Left: metric label + avg badge */}
<div className="flex items-center gap-3">
<span className="text-xs font-medium text-neutral-500 dark:text-neutral-400">
{metricLabel}
</span>
{hasPrev && (
<div className="flex items-center gap-4 text-xs font-medium" style={{ color: colors.textMuted }}>
<span className="flex items-center gap-2">
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: activeMetric.color }}
/>
<div className="hidden sm:flex items-center gap-3 text-[11px] font-medium text-neutral-400 dark:text-neutral-500 ml-2">
<span className="flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-brand-orange" />
Current
</span>
<span className="flex items-center gap-2">
<span
className="h-2 w-2 rounded-full border border-dashed"
style={{ borderColor: colors.axis }}
/>
<span className="flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full border border-dashed" style={{ borderColor: colors.axis }} />
Previous{prevPeriodLabel ? ` (${prevPeriodLabel})` : ''}
</span>
</div>
)}
</div>
{/* Right side: Controls */}
<div className="flex flex-wrap items-center gap-3 self-end sm:self-auto">
<div className="flex items-center gap-2">
<span className="text-xs text-neutral-500 dark:text-neutral-400">Group by:</span>
{dateRange.start === dateRange.end && (
<Select
value={todayInterval}
onChange={(value) => setTodayInterval(value as 'minute' | 'hour')}
options={[
{ value: 'minute', label: '1 min' },
{ value: 'hour', label: '1 hour' },
]}
className="min-w-[100px]"
/>
)}
{dateRange.start !== dateRange.end && (
<Select
value={multiDayInterval}
onChange={(value) => setMultiDayInterval(value as 'hour' | 'day')}
options={[
{ value: 'hour', label: '1 hour' },
{ value: 'day', label: '1 day' },
]}
className="min-w-[100px]"
/>
)}
</div>
{/* Right: controls */}
<div className="flex items-center gap-2">
{dateRange.start === dateRange.end ? (
<Select
value={todayInterval}
onChange={(value) => setTodayInterval(value as 'minute' | 'hour')}
options={[
{ value: 'minute', label: '1 min' },
{ value: 'hour', label: '1 hour' },
]}
className="min-w-[90px]"
/>
) : (
<Select
value={multiDayInterval}
onChange={(value) => setMultiDayInterval(value as 'hour' | 'day')}
options={[
{ value: 'hour', label: '1 hour' },
{ value: 'day', label: '1 day' },
]}
className="min-w-[90px]"
/>
)}
{prevData?.length ? (
<div className="flex flex-col gap-1">
<Checkbox
checked={showComparison}
onCheckedChange={setShowComparison}
label="Compare"
/>
{showComparison && prevPeriodLabel && (
<span className="text-xs text-neutral-500 dark:text-neutral-400">
({prevPeriodLabel})
</span>
)}
</div>
<Checkbox
checked={showComparison}
onCheckedChange={setShowComparison}
label="Compare"
/>
) : null}
<Button
variant="ghost"
<button
onClick={handleExportChart}
disabled={!hasData}
className="gap-2 py-1.5 px-3 text-sm text-neutral-600 dark:text-neutral-400"
className="p-1.5 text-neutral-400 dark:text-neutral-500 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors disabled:opacity-30 cursor-pointer"
title="Export chart as PNG"
>
<DownloadIcon className="w-4 h-4" />
Export chart
</Button>
{/* Vertical Separator */}
<div className="h-4 w-px bg-neutral-200 dark:bg-neutral-800" />
</button>
</div>
</div>
{!hasData ? (
<div className="flex h-80 flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-neutral-200 dark:border-neutral-700 bg-neutral-50/50 dark:bg-neutral-800/30">
<BarChartIcon className="h-12 w-12 text-neutral-300 dark:text-neutral-600" aria-hidden />
<p className="text-sm font-medium text-neutral-500 dark:text-neutral-400">
No data for this period
</p>
<p className="text-xs text-neutral-400 dark:text-neutral-500">Try a different date range</p>
<div className="flex h-72 flex-col items-center justify-center gap-2">
<BarChartIcon className="h-10 w-10 text-neutral-200 dark:text-neutral-700" aria-hidden />
<p className="text-sm text-neutral-400 dark:text-neutral-500">No data for this period</p>
</div>
) : !hasAnyNonZero ? (
<div className="flex h-80 flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-neutral-200 dark:border-neutral-700 bg-neutral-50/50 dark:bg-neutral-800/30">
<BarChartIcon className="h-12 w-12 text-neutral-300 dark:text-neutral-600" aria-hidden />
<p className="text-sm font-medium text-neutral-500 dark:text-neutral-400">
No {metricLabel.toLowerCase()} data for this period
</p>
<p className="text-xs text-neutral-400 dark:text-neutral-500">Try selecting another metric or date range</p>
<div className="flex h-72 flex-col items-center justify-center gap-2">
<BarChartIcon className="h-10 w-10 text-neutral-200 dark:text-neutral-700" aria-hidden />
<p className="text-sm text-neutral-400 dark:text-neutral-500">No {metricLabel.toLowerCase()} recorded</p>
</div>
) : (
<div className="h-[360px] w-full flex flex-col">
<div className="text-xs font-medium mb-1 flex-shrink-0" style={{ color: colors.axis }}>
{metricLabel}
</div>
<div className="flex-1 min-h-0 w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: 24, bottom: 24 }}>
<div className="h-[320px] w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData} margin={{ top: 8, right: 8, left: 0, bottom: 8 }}>
<defs>
<linearGradient id={`gradient-${metric}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={activeMetric.color} stopOpacity={0.35} />
<stop offset="50%" stopColor={activeMetric.color} stopOpacity={0.12} />
<stop offset="100%" stopColor={activeMetric.color} stopOpacity={0} />
<stop offset="0%" stopColor={COLORS.brand} stopOpacity={0.25} />
<stop offset="100%" stopColor={COLORS.brand} stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={colors.border} />
<CartesianGrid
strokeDasharray="3 3"
vertical={false}
stroke={colors.grid}
/>
<XAxis
dataKey="date"
stroke={colors.axis}
fontSize={12}
fontSize={11}
tickLine={false}
axisLine={false}
minTickGap={28}
minTickGap={40}
ticks={midnightTicks ?? dayTicks}
dy={8}
/>
<YAxis
stroke={colors.axis}
fontSize={12}
fontSize={11}
tickLine={false}
axisLine={false}
domain={[0, 'auto']}
width={24}
width={40}
allowDecimals={!isCountMetric}
tickFormatter={(val) => {
if (metric === 'bounce_rate') return `${val}%`
if (metric === 'avg_duration') return formatAxisDuration(val)
@@ -627,12 +483,9 @@ export default function Chart({
content={(p: TooltipProps<number, string>) => (
<ChartTooltip
active={p.active}
payload={p.payload as Array<{
payload: { prevPageviews?: number; prevVisitors?: number }
value: number
}>}
payload={p.payload as Array<{ payload: Record<string, number>; value: number; dataKey?: string }>}
label={p.label as string}
metric={chartMetric}
metric={metric}
metricLabel={metricLabel}
formatNumberFn={formatNumber}
showComparison={hasPrev}
@@ -640,42 +493,23 @@ export default function Chart({
colors={colors}
/>
)}
cursor={{ stroke: activeMetric.color, strokeDasharray: '4 4', strokeWidth: 1 }}
cursor={{ stroke: colors.axis, strokeOpacity: 0.3, strokeWidth: 1 }}
/>
{avg > 0 && (
<ReferenceLine
y={avg}
stroke={colors.axis}
strokeDasharray="4 4"
strokeOpacity={0.7}
label={{
value: `Avg: ${metric === 'bounce_rate' ? `${Math.round(avg)}%` : metric === 'avg_duration' ? formatAxisDuration(avg) : formatAxisValue(avg)}`,
position: 'insideTopRight',
fill: colors.axis,
fontSize: 11,
}}
/>
)}
{hasPrev && (
<Area
type="monotone"
dataKey={
chartMetric === 'visitors' ? 'prevVisitors' :
chartMetric === 'pageviews' ? 'prevPageviews' :
chartMetric === 'bounce_rate' ? 'prevBounceRate' :
'prevAvgDuration'
}
dataKey={metric === 'visitors' ? 'prevVisitors' : metric === 'pageviews' ? 'prevPageviews' : metric === 'bounce_rate' ? 'prevBounceRate' : 'prevAvgDuration'}
stroke={colors.axis}
strokeWidth={2}
strokeDasharray="5 5"
strokeWidth={1.5}
strokeDasharray="4 4"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
dot={false}
isAnimationActive
animationDuration={500}
animationDuration={400}
animationEasing="ease-out"
/>
)}
@@ -683,30 +517,42 @@ export default function Chart({
<Area
type="monotone"
baseValue={0}
dataKey={chartMetric}
stroke={activeMetric.color}
strokeWidth={2.5}
dataKey={metric}
stroke={COLORS.brand}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
fillOpacity={1}
fill={`url(#gradient-${metric})`}
dot={false}
activeDot={{
r: 5,
r: 4,
strokeWidth: 2,
fill: resolvedTheme === 'dark' ? 'var(--color-neutral-800)' : '#ffffff',
stroke: activeMetric.color,
fill: resolvedTheme === 'dark' ? 'var(--color-neutral-900)' : '#ffffff',
stroke: COLORS.brand,
}}
isAnimationActive
animationDuration={500}
animationDuration={400}
animationEasing="ease-out"
/>
</AreaChart>
</ResponsiveContainer>
</div>
</AreaChart>
</ResponsiveContainer>
</div>
)}
</div>
{/* Live indicator */}
{lastUpdatedAt != null && (
<div className="px-4 sm:px-6 pb-3 flex justify-end">
<div className="flex items-center gap-1.5 text-[11px] text-neutral-400 dark:text-neutral-500">
<span className="relative flex h-1.5 w-1.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-500 opacity-75" />
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-green-500" />
</span>
Live · {formatUpdatedAgo(lastUpdatedAt)}
</div>
</div>
)}
</div>
)
}

View File

@@ -1,9 +1,13 @@
'use client'
import { useState, useEffect } from 'react'
import { logger } from '@/lib/utils/logger'
import { formatNumber } from '@ciphera-net/ui'
import { useTabListKeyboard } from '@/lib/hooks/useTabListKeyboard'
import { TopPage, getTopPages, getEntryPages, getExitPages } from '@/lib/api/stats'
import { Modal, ArrowUpRightIcon, LayoutDashboardIcon, Spinner } from '@ciphera-net/ui'
import { Modal, ArrowUpRightIcon, LayoutDashboardIcon } from '@ciphera-net/ui'
import { ListSkeleton } from '@/components/skeletons'
import { type DimensionFilter } from '@/lib/filters'
interface ContentStatsProps {
topPages: TopPage[]
@@ -13,14 +17,16 @@ interface ContentStatsProps {
collectPagePaths?: boolean
siteId: string
dateRange: { start: string, end: string }
onFilter?: (filter: DimensionFilter) => void
}
type Tab = 'top_pages' | 'entry_pages' | 'exit_pages'
const LIMIT = 7
export default function ContentStats({ topPages, entryPages, exitPages, domain, collectPagePaths = true, siteId, dateRange }: ContentStatsProps) {
export default function ContentStats({ topPages, entryPages, exitPages, domain, collectPagePaths = true, siteId, dateRange, onFilter }: ContentStatsProps) {
const [activeTab, setActiveTab] = useState<Tab>('top_pages')
const handleTabKeyDown = useTabListKeyboard()
const [isModalOpen, setIsModalOpen] = useState(false)
const [fullData, setFullData] = useState<TopPage[]>([])
const [isLoadingFull, setIsLoadingFull] = useState(false)
@@ -47,7 +53,7 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain,
}
setFullData(filterGenericPaths(data))
} catch (e) {
console.error(e)
logger.error(e)
} finally {
setIsLoadingFull(false)
}
@@ -72,6 +78,7 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain,
}
const data = getData()
const totalPageviews = data.reduce((sum, p) => sum + p.pageviews, 0)
const hasData = data && data.length > 0
const displayedData = hasData ? data.slice(0, LIMIT) : []
const emptySlots = Math.max(0, LIMIT - displayedData.length)
@@ -89,30 +96,20 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain,
<>
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
Content
</h3>
{showViewAll && (
<button
onClick={() => setIsModalOpen(true)}
className="text-xs font-medium text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded"
>
View All
</button>
)}
</div>
<div className="flex p-1 bg-neutral-100 dark:bg-neutral-800 rounded-lg" role="tablist" aria-label="Content view tabs">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
Pages
</h3>
<div className="flex gap-1" role="tablist" aria-label="Pages view tabs" onKeyDown={handleTabKeyDown}>
{(['top_pages', 'entry_pages', 'exit_pages'] as Tab[]).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
role="tab"
aria-selected={activeTab === tab}
className={`px-3 py-1 text-xs font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange ${
className={`px-2.5 py-1 text-xs font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange rounded cursor-pointer border-b-2 ${
activeTab === tab
? 'bg-white dark:bg-neutral-700 text-neutral-900 dark:text-white shadow-sm'
: 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white'
? 'border-brand-orange text-neutral-900 dark:text-white'
: 'border-transparent text-neutral-400 dark:text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300'
}`}
>
{getTabLabel(tab)}
@@ -128,27 +125,49 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain,
</div>
) : hasData ? (
<>
{displayedData.map((page, index) => (
<div key={index} className="flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors">
{displayedData.map((page) => (
<div
key={page.path}
onClick={() => onFilter?.({ dimension: 'page', operator: 'is', values: [page.path] })}
className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors${onFilter ? ' cursor-pointer' : ''}`}
>
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center">
<span className="truncate">{page.path}</span>
<a
href={`https://${domain.replace(/^https?:\/\//, '')}${page.path}`}
target="_blank"
rel="noopener noreferrer"
className="hover:underline flex items-center"
onClick={e => e.stopPropagation()}
className="ml-2 flex-shrink-0"
>
{page.path}
<ArrowUpRightIcon className="w-3 h-3 ml-2 text-neutral-400 opacity-0 group-hover:opacity-100 transition-opacity" />
<ArrowUpRightIcon className="w-3 h-3 text-neutral-400 opacity-0 group-hover:opacity-100 transition-opacity hover:text-brand-orange" />
</a>
</div>
<div className="text-sm font-semibold text-neutral-600 dark:text-neutral-400 ml-4">
{formatNumber(page.pageviews)}
<div className="flex items-center gap-2 ml-4">
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
{totalPageviews > 0 ? `${Math.round((page.pageviews / totalPageviews) * 100)}%` : ''}
</span>
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
{formatNumber(page.pageviews)}
</span>
</div>
</div>
))}
{Array.from({ length: emptySlots }).map((_, i) => (
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
))}
{showViewAll ? (
<button
onClick={() => setIsModalOpen(true)}
className="flex items-center justify-center gap-1.5 h-9 w-full text-xs font-medium text-neutral-400 dark:text-neutral-500 hover:text-brand-orange dark:hover:text-brand-orange transition-colors cursor-pointer rounded-lg px-2 -mx-2"
>
View all
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</button>
) : (
Array.from({ length: emptySlots }).map((_, i) => (
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
))
)}
</>
) : (
<div className="h-full flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
@@ -169,17 +188,16 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain,
<Modal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
title={`Content - ${getTabLabel(activeTab)}`}
title={`Pages - ${getTabLabel(activeTab)}`}
>
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-2">
{isLoadingFull ? (
<div className="py-8 flex flex-col items-center gap-2">
<Spinner />
<p className="text-sm text-neutral-500 dark:text-neutral-400">Loading...</p>
<div className="py-4">
<ListSkeleton rows={10} />
</div>
) : (
(fullData.length > 0 ? fullData : data).map((page, index) => (
<div key={index} className="flex items-center justify-between py-2 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors">
(fullData.length > 0 ? fullData : data).map((page) => (
<div key={page.path} className="flex items-center justify-between py-2 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors">
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center">
<a
href={`https://${domain.replace(/^https?:\/\//, '')}${page.path}`}

View File

@@ -1,140 +0,0 @@
'use client'
import { useState } from 'react'
import { formatNumber } from '@ciphera-net/ui'
import * as Flags from 'country-flag-icons/react/3x2'
import WorldMap from './WorldMap'
import { GlobeIcon } from '@ciphera-net/ui'
interface LocationProps {
countries: Array<{ country: string; pageviews: number }>
cities: Array<{ city: string; country: string; pageviews: number }>
}
type Tab = 'countries' | 'cities'
export default function Locations({ countries, cities }: LocationProps) {
const [activeTab, setActiveTab] = useState<Tab>('countries')
const getFlagComponent = (countryCode: string) => {
if (!countryCode || countryCode === 'Unknown') return null
// * The API returns 2-letter country codes (e.g. US, DE)
// * We cast it to the flag component name
const FlagComponent = (Flags as any)[countryCode]
return FlagComponent ? <FlagComponent className="w-5 h-5 rounded-sm shadow-sm" /> : null
}
const getCountryName = (code: string) => {
if (!code || code === 'Unknown') return 'Unknown'
try {
const regionNames = new Intl.DisplayNames(['en'], { type: 'region' })
return regionNames.of(code) || code
} catch (e) {
return code
}
}
const renderContent = () => {
if (activeTab === 'countries') {
if (!countries || countries.length === 0) {
return (
<div className="h-full flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
<GlobeIcon className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
</div>
<h4 className="font-semibold text-neutral-900 dark:text-white">
No location data yet
</h4>
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs">
Visitor locations will appear here based on anonymous geographic data.
</p>
</div>
)
}
return (
<div className="space-y-4">
<WorldMap data={countries} />
<div className="space-y-3">
{countries.map((country, index) => (
<div key={index} className="flex items-center justify-between">
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
<span className="shrink-0">{getFlagComponent(country.country)}</span>
<span className="truncate">{getCountryName(country.country)}</span>
</div>
<div className="text-sm font-semibold text-neutral-600 dark:text-neutral-400 ml-4">
{formatNumber(country.pageviews)}
</div>
</div>
))}
</div>
</div>
)
}
if (activeTab === 'cities') {
if (!cities || cities.length === 0) {
return (
<div className="h-full flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
<GlobeIcon className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
</div>
<h4 className="font-semibold text-neutral-900 dark:text-white">
No city data yet
</h4>
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs">
City-level visitor data will appear as traffic grows.
</p>
</div>
)
}
return (
<div className="space-y-3">
{cities.map((city, index) => (
<div key={index} className="flex items-center justify-between">
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
<span className="shrink-0">{getFlagComponent(city.country)}</span>
<span className="truncate">{city.city === 'Unknown' ? 'Unknown' : city.city}</span>
</div>
<div className="text-sm font-semibold text-neutral-600 dark:text-neutral-400 ml-4">
{formatNumber(city.pageviews)}
</div>
</div>
))}
</div>
)
}
}
return (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
Locations
</h3>
<div className="flex p-1 bg-neutral-100 dark:bg-neutral-800 rounded-lg">
<button
onClick={() => setActiveTab('countries')}
className={`px-3 py-1 text-xs font-medium rounded-lg transition-colors ${
activeTab === 'countries'
? 'bg-white dark:bg-neutral-700 text-neutral-900 dark:text-white shadow-sm'
: 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white'
}`}
>
Countries
</button>
<button
onClick={() => setActiveTab('cities')}
className={`px-3 py-1 text-xs font-medium rounded-lg transition-colors ${
activeTab === 'cities'
? 'bg-white dark:bg-neutral-700 text-neutral-900 dark:text-white shadow-sm'
: 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white'
}`}
>
Cities
</button>
</div>
</div>
{renderContent()}
</div>
)
}

View File

@@ -0,0 +1,108 @@
'use client'
import { useState, useEffect } from 'react'
import { formatNumber } from '@ciphera-net/ui'
import { getEventPropertyKeys, getEventPropertyValues, type EventPropertyKey, type EventPropertyValue } from '@/lib/api/stats'
interface EventPropertiesProps {
siteId: string
eventName: string
dateRange: { start: string; end: string }
onClose: () => void
}
export default function EventProperties({ siteId, eventName, dateRange, onClose }: EventPropertiesProps) {
const [keys, setKeys] = useState<EventPropertyKey[]>([])
const [selectedKey, setSelectedKey] = useState<string | null>(null)
const [values, setValues] = useState<EventPropertyValue[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
setLoading(true)
getEventPropertyKeys(siteId, eventName, dateRange.start, dateRange.end)
.then(k => {
setKeys(k)
if (k.length > 0) setSelectedKey(k[0].key)
})
.finally(() => setLoading(false))
}, [siteId, eventName, dateRange.start, dateRange.end])
useEffect(() => {
if (!selectedKey) return
getEventPropertyValues(siteId, eventName, selectedKey, dateRange.start, dateRange.end)
.then(setValues)
}, [siteId, eventName, selectedKey, dateRange.start, dateRange.end])
const maxCount = values.length > 0 ? values[0].count : 1
return (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
Properties: <span className="text-brand-orange">{eventName.replace(/_/g, ' ')}</span>
</h3>
<button
onClick={onClose}
className="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors cursor-pointer"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{loading ? (
<div className="animate-pulse space-y-3">
{[1, 2, 3].map(i => (
<div key={i} className="h-8 bg-neutral-100 dark:bg-neutral-800 rounded-lg" />
))}
</div>
) : keys.length === 0 ? (
<p className="text-sm text-neutral-500 dark:text-neutral-400 py-4 text-center">
No properties recorded for this event yet.
</p>
) : (
<>
<div className="flex gap-2 mb-4 flex-wrap">
{keys.map(k => (
<button
key={k.key}
onClick={() => setSelectedKey(k.key)}
className={`px-3 py-1 text-xs font-medium rounded-full transition-colors cursor-pointer ${
selectedKey === k.key
? 'bg-brand-orange text-white'
: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-200 dark:hover:bg-neutral-700'
}`}
>
{k.key}
</button>
))}
</div>
<div className="space-y-2">
{values.map(v => (
<div key={v.value} className="flex items-center gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium text-neutral-900 dark:text-white truncate">
{v.value}
</span>
<span className="text-xs font-semibold text-brand-orange tabular-nums ml-2">
{formatNumber(v.count)}
</span>
</div>
<div className="w-full h-1.5 bg-neutral-100 dark:bg-neutral-800 rounded-full overflow-hidden">
<div
className="h-full bg-brand-orange/60 rounded-full transition-all"
style={{ width: `${(v.count / maxCount) * 100}%` }}
/>
</div>
</div>
</div>
))}
</div>
</>
)}
</div>
)
}

View File

@@ -67,9 +67,9 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to
// Prepare data
const exportData = data.map((item) => {
const filteredItem: Partial<DailyStat> = {}
const filteredItem: Record<string, string | number> = {}
fields.forEach((field) => {
(filteredItem as any)[field] = item[field]
filteredItem[field] = item[field]
})
return filteredItem
})
@@ -212,7 +212,7 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to
autoTable(doc, {
startY: startY,
head: [fields.map(f => f.charAt(0).toUpperCase() + f.slice(1).replace('_', ' '))],
body: tableData as any[][],
body: tableData as (string | number)[][],
styles: {
font: 'helvetica',
fontSize: 9,
@@ -249,7 +249,7 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to
}
})
let finalY = (doc as any).lastAutoTable.finalY + 10
let finalY = doc.lastAutoTable.finalY + 10
// Top Pages Table
if (topPages && topPages.length > 0) {
@@ -276,7 +276,7 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to
alternateRowStyles: { fillColor: [255, 250, 245] },
})
finalY = (doc as any).lastAutoTable.finalY + 10
finalY = doc.lastAutoTable.finalY + 10
}
// Top Referrers Table
@@ -305,7 +305,7 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to
alternateRowStyles: { fillColor: [255, 250, 245] },
})
finalY = (doc as any).lastAutoTable.finalY + 10
finalY = doc.lastAutoTable.finalY + 10
}
// Campaigns Table

View File

@@ -0,0 +1,39 @@
'use client'
import { type DimensionFilter, filterLabel } from '@/lib/filters'
interface FilterBarProps {
filters: DimensionFilter[]
onRemove: (index: number) => void
onClear: () => void
}
export default function FilterBar({ filters, onRemove, onClear }: FilterBarProps) {
if (filters.length === 0) return null
return (
<>
{filters.map((f, i) => (
<button
key={`${f.dimension}-${f.operator}-${f.values.join(',')}`}
onClick={() => onRemove(i)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg bg-brand-orange text-white hover:bg-brand-orange/80 transition-colors cursor-pointer group"
title={`Remove filter: ${filterLabel(f)}`}
>
<span>{filterLabel(f)}</span>
<svg className="w-3 h-3 opacity-70 group-hover:opacity-100" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
))}
{filters.length > 1 && (
<button
onClick={onClear}
className="px-2 py-1.5 text-xs font-medium text-neutral-500 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white transition-colors cursor-pointer"
>
Clear all
</button>
)}
</>
)
}

View File

@@ -7,11 +7,12 @@ import type { GoalCountStat } from '@/lib/api/stats'
interface GoalStatsProps {
goalCounts: GoalCountStat[]
onSelectEvent?: (eventName: string) => void
}
const LIMIT = 10
export default function GoalStats({ goalCounts }: GoalStatsProps) {
export default function GoalStats({ goalCounts, onSelectEvent }: GoalStatsProps) {
const list = (goalCounts || []).slice(0, LIMIT)
const hasData = list.length > 0
@@ -28,7 +29,8 @@ export default function GoalStats({ goalCounts }: GoalStatsProps) {
{list.map((row) => (
<div
key={row.event_name}
className="flex items-center justify-between py-2 px-3 rounded-lg bg-neutral-50 dark:bg-neutral-800/50 hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
onClick={() => onSelectEvent?.(row.event_name)}
className={`flex items-center justify-between py-2 px-3 rounded-lg bg-neutral-50 dark:bg-neutral-800/50 hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors${onSelectEvent ? ' cursor-pointer' : ''}`}
>
<span className="text-sm font-medium text-neutral-900 dark:text-white truncate">
{row.display_name ?? row.event_name.replace(/_/g, ' ')}

View File

@@ -1,15 +1,18 @@
'use client'
import { useState, useEffect } from 'react'
import { logger } from '@/lib/utils/logger'
import { formatNumber } from '@ciphera-net/ui'
import { useTabListKeyboard } from '@/lib/hooks/useTabListKeyboard'
import * as Flags from 'country-flag-icons/react/3x2'
// @ts-ignore
import iso3166 from 'iso-3166-2'
import WorldMap from './WorldMap'
import { Modal, GlobeIcon, Spinner } from '@ciphera-net/ui'
import { Modal, GlobeIcon } from '@ciphera-net/ui'
import { ListSkeleton } from '@/components/skeletons'
import { SiTorproject } from 'react-icons/si'
import { FaUserSecret, FaSatellite } from 'react-icons/fa'
import { getCountries, getCities, getRegions } from '@/lib/api/stats'
import { type DimensionFilter } from '@/lib/filters'
interface LocationProps {
countries: Array<{ country: string; pageviews: number }>
@@ -18,16 +21,21 @@ interface LocationProps {
geoDataLevel?: 'full' | 'country' | 'none'
siteId: string
dateRange: { start: string, end: string }
onFilter?: (filter: DimensionFilter) => void
}
type Tab = 'map' | 'countries' | 'regions' | 'cities'
const LIMIT = 7
export default function Locations({ countries, cities, regions, geoDataLevel = 'full', siteId, dateRange }: LocationProps) {
const TAB_TO_DIMENSION: Record<string, string> = { countries: 'country', regions: 'region', cities: 'city' }
export default function Locations({ countries, cities, regions, geoDataLevel = 'full', siteId, dateRange, onFilter }: LocationProps) {
const [activeTab, setActiveTab] = useState<Tab>('map')
const handleTabKeyDown = useTabListKeyboard()
const [isModalOpen, setIsModalOpen] = useState(false)
const [fullData, setFullData] = useState<any[]>([])
type LocationItem = { country?: string; city?: string; region?: string; pageviews: number }
const [fullData, setFullData] = useState<LocationItem[]>([])
const [isLoadingFull, setIsLoadingFull] = useState(false)
useEffect(() => {
@@ -35,7 +43,7 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
const fetchData = async () => {
setIsLoadingFull(true)
try {
let data: any[] = []
let data: LocationItem[] = []
if (activeTab === 'countries') {
data = await getCountries(siteId, dateRange.start, dateRange.end, 250)
} else if (activeTab === 'regions') {
@@ -45,7 +53,7 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
}
setFullData(data)
} catch (e) {
console.error(e)
logger.error(e)
} finally {
setIsLoadingFull(false)
}
@@ -72,7 +80,7 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
return <GlobeIcon className="w-5 h-5 text-neutral-500 dark:text-neutral-400" />
}
const FlagComponent = (Flags as any)[countryCode]
const FlagComponent = (Flags as Record<string, React.ComponentType<{ className?: string }>>)[countryCode]
return FlagComponent ? <FlagComponent className="w-5 h-5 rounded-sm shadow-sm" /> : null
}
@@ -157,7 +165,7 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
}
// Filter out "Unknown" entries that result from disabled collection
const filterUnknown = (data: any[]) => {
const filterUnknown = (data: LocationItem[]) => {
return data.filter(item => {
if (activeTab === 'countries') return item.country && item.country !== 'Unknown' && item.country !== ''
if (activeTab === 'regions') return item.region && item.region !== 'Unknown' && item.region !== ''
@@ -168,10 +176,11 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
const rawData = activeTab === 'map' ? [] : getData()
const data = filterUnknown(rawData)
const totalPageviews = data.reduce((sum, item) => sum + item.pageviews, 0)
const hasData = activeTab === 'map'
? (countries && filterUnknown(countries).length > 0)
: (data && data.length > 0)
const displayedData = (activeTab !== 'map' && hasData) ? (data as any[]).slice(0, LIMIT) : []
const displayedData = (activeTab !== 'map' && hasData) ? data.slice(0, LIMIT) : []
const emptySlots = Math.max(0, LIMIT - displayedData.length)
const showViewAll = activeTab !== 'map' && hasData && data.length > LIMIT
@@ -189,30 +198,20 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
<>
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
Locations
</h3>
{showViewAll && (
<button
onClick={() => setIsModalOpen(true)}
className="text-xs font-medium text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded"
>
View All
</button>
)}
</div>
<div className="flex p-1 bg-neutral-100 dark:bg-neutral-800 rounded-lg" role="tablist" aria-label="Location view tabs">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
Locations
</h3>
<div className="flex gap-1" role="tablist" aria-label="Location view tabs" onKeyDown={handleTabKeyDown}>
{(['map', 'countries', 'regions', 'cities'] as Tab[]).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
role="tab"
aria-selected={activeTab === tab}
className={`px-3 py-1 text-xs font-medium rounded-lg transition-colors capitalize focus:outline-none focus:ring-2 focus:ring-brand-orange ${
className={`px-2.5 py-1 text-xs font-medium transition-colors capitalize focus:outline-none focus:ring-2 focus:ring-brand-orange rounded cursor-pointer border-b-2 ${
activeTab === tab
? 'bg-white dark:bg-neutral-700 text-neutral-900 dark:text-white shadow-sm'
: 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white'
? 'border-brand-orange text-neutral-900 dark:text-white'
: 'border-transparent text-neutral-400 dark:text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300'
}`}
>
{tab}
@@ -227,7 +226,7 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
<p className="text-neutral-500 dark:text-neutral-400 text-sm">{getDisabledMessage()}</p>
</div>
) : activeTab === 'map' ? (
hasData ? <WorldMap data={filterUnknown(countries)} /> : (
hasData ? <WorldMap data={filterUnknown(countries) as { country: string; pageviews: number }[]} /> : (
<div className="h-full flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
<GlobeIcon className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
@@ -243,26 +242,50 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
) : (
hasData ? (
<>
{displayedData.map((item, index) => (
<div key={index} className="flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors">
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
{activeTab === 'countries' && <span className="shrink-0">{getFlagComponent(item.country)}</span>}
{activeTab !== 'countries' && <span className="shrink-0">{getFlagComponent(item.country)}</span>}
<span className="truncate">
{activeTab === 'countries' ? getCountryName(item.country) :
activeTab === 'regions' ? getRegionName(item.region, item.country) :
getCityName(item.city)}
</span>
{displayedData.map((item) => {
const dim = TAB_TO_DIMENSION[activeTab]
const filterValue = activeTab === 'countries' ? item.country : activeTab === 'regions' ? item.region : item.city
const canFilter = onFilter && dim && filterValue
return (
<div
key={`${item.country ?? ''}-${item.region ?? ''}-${item.city ?? ''}`}
onClick={() => canFilter && onFilter({ dimension: dim, operator: 'is', values: [filterValue!] })}
className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors${canFilter ? ' cursor-pointer' : ''}`}
>
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
<span className="shrink-0">{getFlagComponent(item.country ?? '')}</span>
<span className="truncate">
{activeTab === 'countries' ? getCountryName(item.country ?? '') :
activeTab === 'regions' ? getRegionName(item.region ?? '', item.country ?? '') :
getCityName(item.city ?? '')}
</span>
</div>
<div className="flex items-center gap-2 ml-4">
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
{totalPageviews > 0 ? `${Math.round((item.pageviews / totalPageviews) * 100)}%` : ''}
</span>
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
{formatNumber(item.pageviews)}
</span>
</div>
</div>
<div className="text-sm font-semibold text-neutral-600 dark:text-neutral-400 ml-4">
{formatNumber(item.pageviews)}
</div>
</div>
))}
{Array.from({ length: emptySlots }).map((_, i) => (
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
))}
)
})}
{showViewAll ? (
<button
onClick={() => setIsModalOpen(true)}
className="flex items-center justify-center gap-1.5 h-9 w-full text-xs font-medium text-neutral-400 dark:text-neutral-500 hover:text-brand-orange dark:hover:text-brand-orange transition-colors cursor-pointer rounded-lg px-2 -mx-2"
>
View all
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</button>
) : (
Array.from({ length: emptySlots }).map((_, i) => (
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
))
)}
</>
) : (
<div className="h-full flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
@@ -288,19 +311,18 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
>
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-2">
{isLoadingFull ? (
<div className="py-8 flex flex-col items-center gap-2">
<Spinner />
<p className="text-sm text-neutral-500 dark:text-neutral-400">Loading...</p>
<div className="py-4">
<ListSkeleton rows={10} />
</div>
) : (
(fullData.length > 0 ? fullData : data as any[]).map((item, index) => (
<div key={index} className="flex items-center justify-between py-2 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors">
(fullData.length > 0 ? fullData : data).map((item) => (
<div key={`${item.country ?? ''}-${item.region ?? ''}-${item.city ?? ''}`} className="flex items-center justify-between py-2 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors">
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
<span className="shrink-0">{getFlagComponent(item.country)}</span>
<span className="shrink-0">{getFlagComponent(item.country ?? '')}</span>
<span className="truncate">
{activeTab === 'countries' ? getCountryName(item.country) :
activeTab === 'regions' ? getRegionName(item.region, item.country) :
getCityName(item.city)}
{activeTab === 'countries' ? getCountryName(item.country ?? '') :
activeTab === 'regions' ? getRegionName(item.region ?? '', item.country ?? '') :
getCityName(item.city ?? '')}
</span>
</div>
<div className="text-sm font-semibold text-neutral-600 dark:text-neutral-400 ml-4">

View File

@@ -5,6 +5,7 @@ import { motion } from 'framer-motion'
import { ChevronDownIcon } from '@ciphera-net/ui'
import { PerformanceStats as Stats, PerformanceByPageStat, getPerformanceByPage } from '@/lib/api/stats'
import { Select } from '@ciphera-net/ui'
import { TableSkeleton } from '@/components/skeletons'
interface Props {
stats: Stats
@@ -205,7 +206,7 @@ export default function PerformanceStats({ stats, performanceByPage, siteId, sta
style={{ overflow: 'hidden' }}
>
{loadingTable ? (
<div className="py-8 text-center text-neutral-500 text-sm">Loading</div>
<div className="py-4"><TableSkeleton rows={5} cols={5} /></div>
) : rows.length === 0 ? (
<div className="py-6 text-center text-neutral-500 text-sm">
No per-page metrics yet. Data appears as visitors are tracked with performance insights enabled.

View File

@@ -0,0 +1,80 @@
'use client'
import { formatNumber } from '@ciphera-net/ui'
import { BarChartIcon } from '@ciphera-net/ui'
import type { GoalCountStat } from '@/lib/api/stats'
interface ScrollDepthProps {
goalCounts: GoalCountStat[]
totalPageviews: number
}
const THRESHOLDS = [25, 50, 75, 100] as const
export default function ScrollDepth({ goalCounts, totalPageviews }: ScrollDepthProps) {
const scrollCounts = new Map<number, number>()
for (const row of goalCounts) {
const match = row.event_name.match(/^scroll_(\d+)$/)
if (match) {
scrollCounts.set(Number(match[1]), row.count)
}
}
const hasData = scrollCounts.size > 0 && totalPageviews > 0
return (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
Scroll Depth
</h3>
</div>
{hasData ? (
<div className="space-y-3 flex-1 min-h-[200px]">
{THRESHOLDS.map((threshold) => {
const count = scrollCounts.get(threshold) ?? 0
const pct = totalPageviews > 0 ? Math.round((count / totalPageviews) * 100) : 0
const barWidth = Math.max(pct, 2)
return (
<div key={threshold} className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span className="font-medium text-neutral-900 dark:text-white">
{threshold}%
</span>
<div className="flex items-center gap-2">
<span className="text-neutral-500 dark:text-neutral-400 tabular-nums">
{formatNumber(count)}
</span>
<span className="font-semibold text-brand-orange tabular-nums w-12 text-right">
{pct}%
</span>
</div>
</div>
<div className="h-2 rounded-full bg-neutral-100 dark:bg-neutral-800 overflow-hidden">
<div
className="h-full rounded-full bg-brand-orange transition-all duration-500"
style={{ width: `${barWidth}%` }}
/>
</div>
</div>
)
})}
</div>
) : (
<div className="flex-1 min-h-[200px] flex flex-col items-center justify-center text-center px-6 py-8 gap-4">
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
<BarChartIcon className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
</div>
<h4 className="font-semibold text-neutral-900 dark:text-white">
No scroll data yet
</h4>
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-md">
Scroll depth tracking is automatic data will appear here once visitors start scrolling on your pages.
</p>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,50 @@
'use client'
/**
* Mini sparkline SVG for KPI cards.
* Renders a line chart from an array of data points.
*/
export default function Sparkline({
data,
dataKey,
color,
width = 56,
height = 20,
}: {
/** Array of objects with numeric values (e.g. DailyStat with visitors, pageviews) */
data: ReadonlyArray<object>
dataKey: string
color: string
width?: number
height?: number
}) {
if (!data.length) return null
const values = data.map((d) => Number((d as Record<string, unknown>)[dataKey] ?? 0))
const max = Math.max(...values, 1)
const min = Math.min(...values, 0)
const range = max - min || 1
const padding = 2
const w = width - padding * 2
const h = height - padding * 2
const points = values.map((v, i) => {
const x = padding + (i / Math.max(values.length - 1, 1)) * w
const y = padding + h - ((v - min) / range) * h
return `${x},${y}`
})
const pathD = points.length > 1 ? `M ${points.join(' L ')}` : `M ${points[0]} L ${points[0]}`
return (
<svg width={width} height={height} className="flex-shrink-0" aria-hidden>
<path
d={pathD}
fill="none"
stroke={color}
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}

View File

@@ -1,11 +1,15 @@
'use client'
import { useState, useEffect } from 'react'
import { logger } from '@/lib/utils/logger'
import { formatNumber } from '@ciphera-net/ui'
import { useTabListKeyboard } from '@/lib/hooks/useTabListKeyboard'
import { getBrowserIcon, getOSIcon, getDeviceIcon } from '@/lib/utils/icons'
import { MdMonitor } from 'react-icons/md'
import { Modal, GridIcon, Spinner } from '@ciphera-net/ui'
import { Modal, GridIcon } from '@ciphera-net/ui'
import { ListSkeleton } from '@/components/skeletons'
import { getBrowsers, getOS, getDevices, getScreenResolutions } from '@/lib/api/stats'
import { type DimensionFilter } from '@/lib/filters'
interface TechSpecsProps {
browsers: Array<{ browser: string; pageviews: number }>
@@ -16,16 +20,21 @@ interface TechSpecsProps {
collectScreenResolution?: boolean
siteId: string
dateRange: { start: string, end: string }
onFilter?: (filter: DimensionFilter) => void
}
type Tab = 'browsers' | 'os' | 'devices' | 'screens'
const LIMIT = 7
export default function TechSpecs({ browsers, os, devices, screenResolutions, collectDeviceInfo = true, collectScreenResolution = true, siteId, dateRange }: TechSpecsProps) {
const TAB_TO_DIMENSION: Record<string, string> = { browsers: 'browser', os: 'os', devices: 'device' }
export default function TechSpecs({ browsers, os, devices, screenResolutions, collectDeviceInfo = true, collectScreenResolution = true, siteId, dateRange, onFilter }: TechSpecsProps) {
const [activeTab, setActiveTab] = useState<Tab>('browsers')
const handleTabKeyDown = useTabListKeyboard()
const [isModalOpen, setIsModalOpen] = useState(false)
const [fullData, setFullData] = useState<any[]>([])
type TechItem = { name: string; pageviews: number; icon: React.ReactNode }
const [fullData, setFullData] = useState<TechItem[]>([])
const [isLoadingFull, setIsLoadingFull] = useState(false)
// Filter out "Unknown" entries that result from disabled collection
@@ -38,7 +47,7 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
const fetchData = async () => {
setIsLoadingFull(true)
try {
let data: any[] = []
let data: TechItem[] = []
if (activeTab === 'browsers') {
const res = await getBrowsers(siteId, dateRange.start, dateRange.end, 100)
data = res.map(b => ({ name: b.browser, pageviews: b.pageviews, icon: getBrowserIcon(b.browser) }))
@@ -54,7 +63,7 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
}
setFullData(filterUnknown(data))
} catch (e) {
console.error(e)
logger.error(e)
} finally {
setIsLoadingFull(false)
}
@@ -103,6 +112,7 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
const rawData = getRawData()
const data = filterUnknown(rawData)
const totalPageviews = data.reduce((sum, item) => sum + item.pageviews, 0)
const hasData = data && data.length > 0
const displayedData = hasData ? data.slice(0, LIMIT) : []
const emptySlots = Math.max(0, LIMIT - displayedData.length)
@@ -112,30 +122,20 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
<>
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
Technology
</h3>
{showViewAll && (
<button
onClick={() => setIsModalOpen(true)}
className="text-xs font-medium text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded"
>
View All
</button>
)}
</div>
<div className="flex p-1 bg-neutral-100 dark:bg-neutral-800 rounded-lg" role="tablist" aria-label="Technology view tabs">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
Technology
</h3>
<div className="flex gap-1" role="tablist" aria-label="Technology view tabs" onKeyDown={handleTabKeyDown}>
{(['browsers', 'os', 'devices', 'screens'] as Tab[]).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
role="tab"
aria-selected={activeTab === tab}
className={`px-3 py-1 text-xs font-medium rounded-lg transition-colors capitalize focus:outline-none focus:ring-2 focus:ring-brand-orange ${
className={`px-2.5 py-1 text-xs font-medium transition-colors capitalize focus:outline-none focus:ring-2 focus:ring-brand-orange rounded cursor-pointer border-b-2 ${
activeTab === tab
? 'bg-white dark:bg-neutral-700 text-neutral-900 dark:text-white shadow-sm'
: 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white'
? 'border-brand-orange text-neutral-900 dark:text-white'
: 'border-transparent text-neutral-400 dark:text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300'
}`}
>
{tab}
@@ -151,20 +151,45 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
</div>
) : hasData ? (
<>
{displayedData.map((item, index) => (
<div key={index} className="flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors">
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
{item.icon && <span className="text-lg">{item.icon}</span>}
<span className="truncate">{item.name}</span>
{displayedData.map((item) => {
const dim = TAB_TO_DIMENSION[activeTab]
const canFilter = onFilter && dim
return (
<div
key={item.name}
onClick={() => canFilter && onFilter({ dimension: dim, operator: 'is', values: [item.name] })}
className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors${canFilter ? ' cursor-pointer' : ''}`}
>
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
{item.icon && <span className="text-lg">{item.icon}</span>}
<span className="truncate">{item.name}</span>
</div>
<div className="flex items-center gap-2 ml-4">
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
{totalPageviews > 0 ? `${Math.round((item.pageviews / totalPageviews) * 100)}%` : ''}
</span>
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
{formatNumber(item.pageviews)}
</span>
</div>
</div>
<div className="text-sm font-semibold text-neutral-600 dark:text-neutral-400 ml-4">
{formatNumber(item.pageviews)}
</div>
</div>
))}
{Array.from({ length: emptySlots }).map((_, i) => (
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
))}
)
})}
{showViewAll ? (
<button
onClick={() => setIsModalOpen(true)}
className="flex items-center justify-center gap-1.5 h-9 w-full text-xs font-medium text-neutral-400 dark:text-neutral-500 hover:text-brand-orange dark:hover:text-brand-orange transition-colors cursor-pointer rounded-lg px-2 -mx-2"
>
View all
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</button>
) : (
Array.from({ length: emptySlots }).map((_, i) => (
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
))
)}
</>
) : (
<div className="h-full flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
@@ -189,13 +214,12 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
>
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-2">
{isLoadingFull ? (
<div className="py-8 flex flex-col items-center gap-2">
<Spinner />
<p className="text-sm text-neutral-500 dark:text-neutral-400">Loading...</p>
<div className="py-4">
<ListSkeleton rows={10} />
</div>
) : (
(fullData.length > 0 ? fullData : data).map((item, index) => (
<div key={index} className="flex items-center justify-between py-2 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors">
(fullData.length > 0 ? fullData : data).map((item) => (
<div key={item.name} className="flex items-center justify-between py-2 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors">
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
{item.icon && <span className="text-lg">{item.icon}</span>}
<span className="truncate">{item.name === 'Unknown' ? 'Unknown' : item.name}</span>

View File

@@ -1,51 +0,0 @@
'use client'
import { formatNumber } from '@ciphera-net/ui'
import { LayoutDashboardIcon } from '@ciphera-net/ui'
interface TopPagesProps {
pages: Array<{ path: string; pageviews: number }>
}
export default function TopPages({ pages }: TopPagesProps) {
if (!pages || pages.length === 0) {
return (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 flex flex-col">
<h3 className="text-lg font-semibold mb-4 text-neutral-900 dark:text-white">
Top Pages
</h3>
<div className="flex-1 flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
<LayoutDashboardIcon className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
</div>
<h4 className="font-semibold text-neutral-900 dark:text-white">
No page data yet
</h4>
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs">
Your most visited pages will appear here as traffic arrives.
</p>
</div>
</div>
)
}
return (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
<h3 className="text-lg font-semibold mb-4 text-neutral-900 dark:text-white">
Top Pages
</h3>
<div className="space-y-3">
{pages.map((page, index) => (
<div key={index} className="flex items-center justify-between">
<div className="flex-1 truncate text-neutral-900 dark:text-white">
{page.path}
</div>
<div className="text-sm font-semibold text-neutral-600 dark:text-neutral-400 ml-4">
{formatNumber(page.pageviews)}
</div>
</div>
))}
</div>
</div>
)
}

View File

@@ -1,21 +1,26 @@
'use client'
import { useState, useEffect } from 'react'
import { logger } from '@/lib/utils/logger'
import Image from 'next/image'
import { formatNumber } from '@ciphera-net/ui'
import { getReferrerDisplayName, getReferrerFavicon, getReferrerIcon, mergeReferrersByDisplayName } from '@/lib/utils/icons'
import { Modal, GlobeIcon, Spinner } from '@ciphera-net/ui'
import { Modal, GlobeIcon } from '@ciphera-net/ui'
import { ListSkeleton } from '@/components/skeletons'
import { getTopReferrers, TopReferrer } from '@/lib/api/stats'
import { type DimensionFilter } from '@/lib/filters'
interface TopReferrersProps {
referrers: Array<{ referrer: string; pageviews: number }>
collectReferrers?: boolean
siteId: string
dateRange: { start: string, end: string }
onFilter?: (filter: DimensionFilter) => void
}
const LIMIT = 7
export default function TopReferrers({ referrers, collectReferrers = true, siteId, dateRange }: TopReferrersProps) {
export default function TopReferrers({ referrers, collectReferrers = true, siteId, dateRange, onFilter }: TopReferrersProps) {
const [isModalOpen, setIsModalOpen] = useState(false)
const [fullData, setFullData] = useState<TopReferrer[]>([])
const [isLoadingFull, setIsLoadingFull] = useState(false)
@@ -28,6 +33,7 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
const mergedReferrers = mergeReferrersByDisplayName(filteredReferrers)
const totalPageviews = mergedReferrers.reduce((sum, r) => sum + r.pageviews, 0)
const hasData = mergedReferrers.length > 0
const displayedReferrers = hasData ? mergedReferrers.slice(0, LIMIT) : []
const emptySlots = Math.max(0, LIMIT - displayedReferrers.length)
@@ -38,11 +44,14 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
const useFavicon = faviconUrl && !faviconFailed.has(referrer)
if (useFavicon) {
return (
<img
<Image
src={faviconUrl}
alt=""
width={20}
height={20}
className="w-5 h-5 flex-shrink-0 rounded object-contain"
onError={() => setFaviconFailed((prev) => new Set(prev).add(referrer))}
unoptimized
/>
)
}
@@ -61,7 +70,7 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
)
setFullData(filtered)
} catch (e) {
console.error(e)
logger.error(e)
} finally {
setIsLoadingFull(false)
}
@@ -77,16 +86,8 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
Top Referrers
Referrers
</h3>
{showViewAll && (
<button
onClick={() => setIsModalOpen(true)}
className="text-xs font-medium text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded"
>
View All
</button>
)}
</div>
<div className="space-y-2 flex-1 min-h-[270px]">
@@ -96,20 +97,41 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
</div>
) : hasData ? (
<>
{displayedReferrers.map((ref, index) => (
<div key={index} className="flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors">
{displayedReferrers.map((ref) => (
<div
key={ref.referrer}
onClick={() => onFilter?.({ dimension: 'referrer', operator: 'is', values: [ref.referrer] })}
className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors${onFilter ? ' cursor-pointer' : ''}`}
>
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
{renderReferrerIcon(ref.referrer)}
<span className="truncate" title={ref.referrer}>{getReferrerDisplayName(ref.referrer)}</span>
</div>
<div className="text-sm font-semibold text-neutral-600 dark:text-neutral-400 ml-4">
{formatNumber(ref.pageviews)}
<div className="flex items-center gap-2 ml-4">
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
{totalPageviews > 0 ? `${Math.round((ref.pageviews / totalPageviews) * 100)}%` : ''}
</span>
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
{formatNumber(ref.pageviews)}
</span>
</div>
</div>
))}
{Array.from({ length: emptySlots }).map((_, i) => (
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
))}
{showViewAll ? (
<button
onClick={() => setIsModalOpen(true)}
className="flex items-center justify-center gap-1.5 h-9 w-full text-xs font-medium text-neutral-400 dark:text-neutral-500 hover:text-brand-orange dark:hover:text-brand-orange transition-colors cursor-pointer rounded-lg px-2 -mx-2"
>
View all
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</button>
) : (
Array.from({ length: emptySlots }).map((_, i) => (
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
))
)}
</>
) : (
<div className="h-full flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
@@ -130,17 +152,16 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
<Modal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
title="Top Referrers"
title="Referrers"
>
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-2">
{isLoadingFull ? (
<div className="py-8 flex flex-col items-center gap-2">
<Spinner />
<p className="text-sm text-neutral-500 dark:text-neutral-400">Loading...</p>
<div className="py-4">
<ListSkeleton rows={10} />
</div>
) : (
mergeReferrersByDisplayName(fullData.length > 0 ? fullData : filteredReferrers).map((ref, index) => (
<div key={index} className="flex items-center justify-between py-2 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors">
mergeReferrersByDisplayName(fullData.length > 0 ? fullData : filteredReferrers).map((ref) => (
<div key={ref.referrer} className="flex items-center justify-between py-2 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors">
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
{renderReferrerIcon(ref.referrer)}
<span className="truncate" title={ref.referrer}>{getReferrerDisplayName(ref.referrer)}</span>

View File

@@ -10,6 +10,7 @@ import { listNotifications, markNotificationRead, markAllNotificationsRead, type
import { getAuthErrorMessage } from '@ciphera-net/ui'
import { formatTimeAgo, getTypeIcon } from '@/lib/utils/notifications'
import { SettingsIcon } from '@ciphera-net/ui'
import { SkeletonLine, SkeletonCircle } from '@/components/skeletons'
// * Bell icon (simple SVG, no extra deps)
function BellIcon({ className }: { className?: string }) {
@@ -25,6 +26,7 @@ function BellIcon({ className }: { className?: string }) {
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
@@ -82,16 +84,22 @@ export default function NotificationCenter() {
return () => clearInterval(id)
}, [])
// * Close dropdown when clicking outside
// * Close dropdown when clicking outside or pressing Escape
useEffect(() => {
if (!open) return
function handleClickOutside(e: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setOpen(false)
}
}
if (open) {
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') setOpen(false)
}
document.addEventListener('mousedown', handleClickOutside)
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
document.removeEventListener('keydown', handleKeyDown)
}
}, [open])
@@ -127,23 +135,32 @@ export default function NotificationCenter() {
<button
type="button"
onClick={() => setOpen(!open)}
aria-expanded={open}
aria-haspopup="true"
aria-controls={open ? 'notification-dropdown' : undefined}
className="relative p-2 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-colors"
aria-label={unreadCount > 0 ? `${unreadCount} unread notifications` : 'Notifications'}
aria-label={unreadCount > 0 ? `Notifications, ${unreadCount} unread` : 'Notifications'}
>
<BellIcon />
{unreadCount > 0 && (
<span className="absolute top-1 right-1 w-2 h-2 bg-brand-orange rounded-full" />
<span className="absolute top-1 right-1 w-2 h-2 bg-brand-orange rounded-full" aria-hidden="true" />
)}
</button>
{open && (
<div className="absolute right-0 top-full mt-2 w-80 sm:w-96 bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 rounded-xl shadow-xl overflow-hidden z-[100]">
<div
id="notification-dropdown"
role="dialog"
aria-label="Notifications"
className="absolute right-0 top-full mt-2 w-80 sm:w-96 bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 rounded-xl shadow-xl overflow-hidden z-[100]"
>
<div className="flex items-center justify-between px-4 py-3 border-b border-neutral-200 dark:border-neutral-700">
<h3 className="font-semibold text-neutral-900 dark:text-white">Notifications</h3>
{unreadCount > 0 && (
<button
type="button"
onClick={handleMarkAllRead}
aria-label="Mark all notifications as read"
className="text-sm text-brand-orange hover:underline"
>
Mark all read
@@ -153,8 +170,16 @@ export default function NotificationCenter() {
<div className="max-h-80 overflow-y-auto">
{loading && (
<div className="p-6 text-center text-neutral-500 dark:text-neutral-400 text-sm">
Loading
<div className="p-3 space-y-1">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex gap-3 px-4 py-3">
<SkeletonCircle className="h-8 w-8 shrink-0" />
<div className="flex-1 space-y-1.5">
<SkeletonLine className="h-3.5 w-3/4" />
<SkeletonLine className="h-3 w-1/2" />
</div>
</div>
))}
</div>
)}
{error && (
@@ -193,12 +218,10 @@ export default function NotificationCenter() {
</div>
</Link>
) : (
<div
role="button"
tabIndex={0}
<button
type="button"
onClick={() => handleNotificationClick(n)}
onKeyDown={(e) => e.key === 'Enter' && handleNotificationClick(n)}
className={`block px-4 py-3 hover:bg-neutral-50 dark:hover:bg-neutral-800/50 cursor-pointer ${!n.read ? 'bg-brand-orange/5 dark:bg-brand-orange/10' : ''}`}
className={`w-full text-left block px-4 py-3 hover:bg-neutral-50 dark:hover:bg-neutral-800/50 cursor-pointer ${!n.read ? 'bg-brand-orange/5 dark:bg-brand-orange/10' : ''}`}
>
<div className="flex gap-3">
{getTypeIcon(n.type)}
@@ -216,7 +239,7 @@ export default function NotificationCenter() {
</p>
</div>
</div>
</div>
</button>
)}
</li>
))}
@@ -237,7 +260,7 @@ export default function NotificationCenter() {
onClick={() => setOpen(false)}
className="flex items-center gap-2 text-sm text-neutral-500 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors"
>
<SettingsIcon className="w-4 h-4" />
<SettingsIcon className="w-4 h-4" aria-hidden="true" />
Manage settings
</Link>
</div>

View File

@@ -2,6 +2,8 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { setSessionAction } from '@/app/actions/auth'
import { logger } from '@/lib/utils/logger'
import { useAuth } from '@/lib/auth/context'
import {
deleteOrganization,
@@ -37,6 +39,7 @@ import {
LayoutDashboardIcon,
Spinner,
} from '@ciphera-net/ui'
import { MembersListSkeleton, InvoicesListSkeleton, AuditLogSkeleton, SettingsFormSkeleton, SkeletonCard } from '@/components/skeletons'
// * Bell icon for notifications tab
function BellIcon({ className }: { className?: string }) {
@@ -47,7 +50,6 @@ function BellIcon({ className }: { className?: string }) {
</svg>
)
}
// @ts-ignore
import { Button, Input } from '@ciphera-net/ui'
export default function OrganizationSettings() {
@@ -169,7 +171,7 @@ export default function OrganizationSettings() {
setOrgName(orgData.name)
setOrgSlug(orgData.slug)
} catch (error) {
console.error('Failed to load data:', error)
logger.error('Failed to load data:', error)
// toast.error('Failed to load members')
} finally {
setIsLoadingMembers(false)
@@ -183,7 +185,7 @@ export default function OrganizationSettings() {
const sub = await getSubscription()
setSubscription(sub)
} catch (error) {
console.error('Failed to load subscription:', error)
logger.error('Failed to load subscription:', error)
// toast.error('Failed to load subscription details')
} finally {
setIsLoadingSubscription(false)
@@ -197,7 +199,7 @@ export default function OrganizationSettings() {
const invs = await getInvoices()
setInvoices(invs)
} catch (error) {
console.error('Failed to load invoices:', error)
logger.error('Failed to load invoices:', error)
} finally {
setIsLoadingInvoices(false)
}
@@ -246,8 +248,8 @@ export default function OrganizationSettings() {
setAuditEntries(Array.isArray(entries) ? entries : [])
setAuditTotal(typeof total === 'number' ? total : 0)
} catch (error) {
console.error('Failed to load audit log', error)
toast.error(getAuthErrorMessage(error as Error) || 'Failed to load audit log')
logger.error('Failed to load audit log', error)
toast.error(getAuthErrorMessage(error as Error) || 'Failed to load audit log entries')
} finally {
setIsLoadingAudit(false)
}
@@ -278,7 +280,7 @@ export default function OrganizationSettings() {
setNotificationSettings(res.settings || {})
setNotificationCategories(res.categories || [])
} catch (error) {
console.error('Failed to load notification settings', error)
logger.error('Failed to load notification settings', error)
toast.error(getAuthErrorMessage(error as Error) || 'Failed to load notification settings')
} finally {
setIsLoadingNotificationSettings(false)
@@ -331,8 +333,8 @@ export default function OrganizationSettings() {
try {
const { url } = await createPortalSession()
window.location.href = url
} catch (error: any) {
toast.error(getAuthErrorMessage(error) || error.message || 'Failed to redirect to billing portal')
} catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || (error instanceof Error ? error.message : '') || 'Failed to open billing portal')
setIsRedirectingToPortal(false)
}
}
@@ -344,8 +346,8 @@ export default function OrganizationSettings() {
toast.success(atPeriodEnd ? 'Subscription will cancel at the end of the billing period.' : 'Subscription canceled.')
setShowCancelPrompt(false)
loadSubscription()
} catch (error: any) {
toast.error(getAuthErrorMessage(error) || error.message || 'Failed to cancel subscription')
} catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || (error instanceof Error ? error.message : '') || 'Failed to cancel subscription')
} finally {
setCancelLoadingAction(null)
}
@@ -357,8 +359,8 @@ export default function OrganizationSettings() {
await resumeSubscription()
toast.success('Subscription will continue. Cancellation has been undone.')
loadSubscription()
} catch (error: any) {
toast.error(getAuthErrorMessage(error) || error.message || 'Failed to resume subscription')
} catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || (error instanceof Error ? error.message : '') || 'Failed to resume subscription')
} finally {
setIsResuming(false)
}
@@ -396,8 +398,8 @@ export default function OrganizationSettings() {
if (url) window.location.href = url
else throw new Error('No checkout URL')
}
} catch (error: any) {
toast.error(getAuthErrorMessage(error) || error.message || 'Something went wrong.')
} catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || (error instanceof Error ? error.message : '') || 'Failed to update plan')
} finally {
setIsChangingPlan(false)
}
@@ -417,17 +419,18 @@ export default function OrganizationSettings() {
// * Switch to personal context explicitly
try {
const { access_token } = await switchContext(null)
localStorage.setItem('token', access_token)
await setSessionAction(access_token)
sessionStorage.setItem('pulse_switching_org', 'true')
window.location.href = '/'
} catch (switchErr) {
console.error('Failed to switch to personal context after delete:', switchErr)
// Fallback: reload and let backend handle invalid token if any
logger.error('Failed to switch to personal context after delete:', switchErr)
sessionStorage.setItem('pulse_switching_org', 'true')
window.location.href = '/'
}
} catch (err: any) {
console.error(err)
toast.error(getAuthErrorMessage(err) || err.message || 'Failed to delete organization')
} catch (err: unknown) {
logger.error(err)
toast.error(getAuthErrorMessage(err) || (err instanceof Error ? err.message : '') || 'Failed to delete organization')
setIsDeleting(false)
}
}
@@ -455,8 +458,8 @@ export default function OrganizationSettings() {
setCaptchaSolution('')
setCaptchaToken('')
loadMembers() // Refresh list
} catch (error: any) {
toast.error(getAuthErrorMessage(error) || error.message || 'Failed to send invitation')
} catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || (error instanceof Error ? error.message : '') || 'Failed to send invitation')
} finally {
setIsInviting(false)
}
@@ -467,8 +470,8 @@ export default function OrganizationSettings() {
await revokeInvitation(currentOrgId, inviteId)
toast.success('Invitation revoked')
loadMembers() // Refresh list
} catch (error: any) {
toast.error(getAuthErrorMessage(error) || error.message || 'Failed to revoke invitation')
} catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || (error instanceof Error ? error.message : '') || 'Failed to revoke invitation')
}
}
@@ -482,8 +485,8 @@ export default function OrganizationSettings() {
toast.success('Organization updated successfully')
setIsEditing(false)
loadMembers()
} catch (error: any) {
toast.error(getAuthErrorMessage(error) || error.message || 'Failed to update organization')
} catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || (error instanceof Error ? error.message : '') || 'Failed to save organization settings')
} finally {
setIsSaving(false)
}
@@ -601,7 +604,7 @@ export default function OrganizationSettings() {
<Input
type="text"
value={orgName}
onChange={(e: any) => setOrgName(e.target.value)}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setOrgName(e.target.value)}
required
minLength={2}
maxLength={50}
@@ -621,7 +624,7 @@ export default function OrganizationSettings() {
<Input
type="text"
value={orgSlug}
onChange={(e: any) => setOrgSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setOrgSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))}
required
minLength={3}
maxLength={30}
@@ -701,7 +704,7 @@ export default function OrganizationSettings() {
type="email"
placeholder="colleague@company.com"
value={inviteEmail}
onChange={(e: any) => setInviteEmail(e.target.value)}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setInviteEmail(e.target.value)}
required
className="bg-white dark:bg-neutral-900"
/>
@@ -740,9 +743,7 @@ export default function OrganizationSettings() {
<h3 className="text-sm font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Active Members</h3>
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden divide-y divide-neutral-200 dark:divide-neutral-800">
{isLoadingMembers ? (
<div className="flex items-center justify-center py-8">
<Spinner />
</div>
<MembersListSkeleton />
) : members.length === 0 ? (
<div className="p-8 text-center text-neutral-500 dark:text-neutral-400">No members found.</div>
) : (
@@ -821,8 +822,9 @@ export default function OrganizationSettings() {
</div>
{isLoadingSubscription ? (
<div className="flex items-center justify-center py-12">
<Spinner />
<div className="space-y-4">
<SkeletonCard className="h-32" />
<SkeletonCard className="h-20" />
</div>
) : !subscription ? (
<div className="p-6 text-center bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-200 dark:border-neutral-800">
@@ -1046,9 +1048,7 @@ export default function OrganizationSettings() {
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white mb-3">Recent invoices</h3>
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden divide-y divide-neutral-200 dark:divide-neutral-800">
{isLoadingInvoices ? (
<div className="flex items-center justify-center py-8">
<Spinner />
</div>
<InvoicesListSkeleton />
) : invoices.length === 0 ? (
<div className="p-8 text-center text-neutral-500 dark:text-neutral-400">No invoices found.</div>
) : (
@@ -1117,9 +1117,7 @@ export default function OrganizationSettings() {
</div>
{isLoadingNotificationSettings ? (
<div className="flex items-center justify-center py-12">
<Spinner />
</div>
<SettingsFormSkeleton />
) : (
<div className="space-y-4">
<h3 className="text-sm font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Notification categories</h3>
@@ -1149,7 +1147,7 @@ export default function OrganizationSettings() {
toast.success('Notification settings updated')
})
.catch((err) => {
toast.error(getAuthErrorMessage(err) || 'Failed to update settings')
toast.error(getAuthErrorMessage(err) || 'Failed to save notification preferences')
setNotificationSettings(prev)
})
.finally(() => setIsSavingNotificationSettings(false))
@@ -1244,9 +1242,7 @@ export default function OrganizationSettings() {
{/* Table */}
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden">
{isLoadingAudit ? (
<div className="flex items-center justify-center py-12">
<Spinner />
</div>
<AuditLogSkeleton />
) : (auditEntries ?? []).length === 0 ? (
<div className="p-8 text-center text-neutral-500">No audit events found.</div>
) : (

View File

@@ -6,8 +6,15 @@ import api from '@/lib/api/client'
import { deriveAuthKey } from '@/lib/crypto/password'
import { deleteAccount, getUserSessions, revokeSession, updateUserPreferences, updateDisplayName } from '@/lib/api/user'
import { setup2FA, verify2FA, disable2FA, regenerateRecoveryCodes } from '@/lib/api/2fa'
import { registerPasskey, listPasskeys, deletePasskey } from '@/lib/api/webauthn'
export default function ProfileSettings() {
interface Props {
activeTab?: 'profile' | 'security' | 'preferences' | 'danger-zone'
borderless?: boolean
hideDangerZone?: boolean
}
export default function ProfileSettings({ activeTab, borderless, hideDangerZone }: Props = {}) {
const { user, refresh, logout } = useAuth()
if (!user) return null
@@ -46,10 +53,18 @@ export default function ProfileSettings() {
onRegenerateRecoveryCodes={regenerateRecoveryCodes}
onGetSessions={getUserSessions}
onRevokeSession={revokeSession}
onRegisterPasskey={registerPasskey}
onListPasskeys={listPasskeys}
onDeletePasskey={deletePasskey}
onUpdatePreferences={updateUserPreferences}
deriveAuthKey={deriveAuthKey}
refreshUser={refresh}
logout={logout}
activeTab={activeTab}
hideNav={activeTab !== undefined}
hideNotifications
borderless={borderless}
hideDangerZone={hideDangerZone}
/>
)
}

View File

@@ -0,0 +1,216 @@
'use client'
import { useEffect, useState, useCallback } from 'react'
import { useAuth } from '@/lib/auth/context'
import { getUserActivity, type AuditLogEntry } from '@/lib/api/activity'
import { Spinner } from '@ciphera-net/ui'
import { formatRelativeTime, formatFullDate } from '@/lib/utils/formatDate'
const PAGE_SIZE = 20
const EVENT_LABELS: Record<string, string> = {
login_success: 'Sign in',
login_failure: 'Failed sign in',
oauth_login_success: 'OAuth sign in',
oauth_login_failure: 'Failed OAuth sign in',
password_change: 'Password changed',
'2fa_enabled': '2FA enabled',
'2fa_disabled': '2FA disabled',
recovery_codes_regenerated: 'Recovery codes regenerated',
account_deleted: 'Account deleted',
}
const EVENT_ICONS: Record<string, string> = {
login_success: 'M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9',
login_failure: 'M12 9v3.75m0-10.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.75c0 5.592 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.249-8.25-3.286zm0 13.036h.008v.008H12v-.008z',
oauth_login_success: 'M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9',
oauth_login_failure: 'M12 9v3.75m0-10.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.75c0 5.592 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.249-8.25-3.286zm0 13.036h.008v.008H12v-.008z',
password_change: 'M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z',
'2fa_enabled': 'M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z',
'2fa_disabled': 'M12 9v3.75m0-10.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.75c0 5.592 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.249-8.25-3.286zm0 13.036h.008v.008H12v-.008z',
recovery_codes_regenerated: 'M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z',
account_deleted: 'M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0',
}
function getEventColor(eventType: string, outcome: string): string {
if (outcome === 'failure') return 'text-red-500 dark:text-red-400 bg-red-50 dark:bg-red-950/30'
if (eventType === '2fa_enabled') return 'text-green-500 dark:text-green-400 bg-green-50 dark:bg-green-950/30'
if (eventType === '2fa_disabled') return 'text-amber-500 dark:text-amber-400 bg-amber-50 dark:bg-amber-950/30'
if (eventType === 'account_deleted') return 'text-red-500 dark:text-red-400 bg-red-50 dark:bg-red-950/30'
if (eventType === 'recovery_codes_regenerated') return 'text-amber-500 dark:text-amber-400 bg-amber-50 dark:bg-amber-950/30'
return 'text-neutral-500 dark:text-neutral-400 bg-neutral-100 dark:bg-neutral-800'
}
function getMethodLabel(entry: AuditLogEntry): string | null {
const method = entry.metadata?.method
if (!method) return null
if (method === 'magic_link') return 'Magic link'
if (method === 'passkey') return 'Passkey'
return method as string
}
function getFailureReason(entry: AuditLogEntry): string | null {
if (entry.outcome !== 'failure') return null
const reason = entry.metadata?.reason
if (!reason) return null
const labels: Record<string, string> = {
invalid_credentials: 'Invalid credentials',
invalid_password: 'Wrong password',
account_locked: 'Account locked',
email_not_verified: 'Email not verified',
invalid_2fa: 'Invalid 2FA code',
}
return labels[reason as string] || (reason as string).replace(/_/g, ' ')
}
function parseBrowserName(ua: string): string {
if (!ua) return 'Unknown'
if (ua.includes('Firefox')) return 'Firefox'
if (ua.includes('Edg/')) return 'Edge'
if (ua.includes('Chrome')) return 'Chrome'
if (ua.includes('Safari')) return 'Safari'
if (ua.includes('Opera') || ua.includes('OPR')) return 'Opera'
return 'Browser'
}
function parseOS(ua: string): string {
if (!ua) return ''
if (ua.includes('Mac OS X')) return 'macOS'
if (ua.includes('Windows')) return 'Windows'
if (ua.includes('Linux')) return 'Linux'
if (ua.includes('Android')) return 'Android'
if (ua.includes('iPhone') || ua.includes('iPad')) return 'iOS'
return ''
}
export default function SecurityActivityCard() {
const { user } = useAuth()
const [entries, setEntries] = useState<AuditLogEntry[]>([])
const [totalCount, setTotalCount] = useState(0)
const [hasMore, setHasMore] = useState(false)
const [loading, setLoading] = useState(true)
const [loadingMore, setLoadingMore] = useState(false)
const [error, setError] = useState('')
const [offset, setOffset] = useState(0)
const fetchActivity = useCallback(async (currentOffset: number, append: boolean) => {
try {
const data = await getUserActivity(PAGE_SIZE, currentOffset)
const newEntries = data.entries ?? []
setEntries(prev => append ? [...prev, ...newEntries] : newEntries)
setTotalCount(data.total_count)
setHasMore(data.has_more)
setOffset(currentOffset + newEntries.length)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load activity')
}
}, [])
useEffect(() => {
if (!user) return
setLoading(true)
fetchActivity(0, false).finally(() => setLoading(false))
}, [user, fetchActivity])
const handleLoadMore = async () => {
setLoadingMore(true)
await fetchActivity(offset, true)
setLoadingMore(false)
}
return (
<div>
<h2 className="text-xl font-semibold text-neutral-900 dark:text-white mb-1">Security Activity</h2>
<p className="text-neutral-500 dark:text-neutral-400 text-sm mb-6">
Recent security events on your account{totalCount > 0 ? ` (${totalCount})` : ''}
</p>
{loading ? (
<div className="flex items-center justify-center py-12">
<Spinner />
</div>
) : error ? (
<div className="rounded-2xl border border-red-200 dark:border-red-900/50 bg-red-50 dark:bg-red-950/20 p-6 text-center">
<p className="text-red-600 dark:text-red-400">{error}</p>
</div>
) : entries.length === 0 ? (
<div className="rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-8 text-center">
<svg className="w-12 h-12 mx-auto mb-3 text-neutral-300 dark:text-neutral-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
</svg>
<p className="text-neutral-500 dark:text-neutral-400">No activity recorded yet.</p>
</div>
) : (
<div className="space-y-2">
{entries.map((entry) => {
const label = EVENT_LABELS[entry.event_type] || entry.event_type.replace(/_/g, ' ')
const color = getEventColor(entry.event_type, entry.outcome)
const iconPath = EVENT_ICONS[entry.event_type] || EVENT_ICONS['login_success']
const method = getMethodLabel(entry)
const reason = getFailureReason(entry)
const browser = entry.user_agent ? parseBrowserName(entry.user_agent) : null
const os = entry.user_agent ? parseOS(entry.user_agent) : null
const deviceStr = [browser, os].filter(Boolean).join(' on ')
return (
<div
key={entry.id}
className="flex items-start gap-3 rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-4 py-3"
>
<div className={`flex-shrink-0 w-9 h-9 rounded-lg flex items-center justify-center mt-0.5 ${color}`}>
<svg className="w-4.5 h-4.5" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d={iconPath} />
</svg>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-neutral-900 dark:text-white text-sm">
{label}
</span>
{method && (
<span className="text-xs px-1.5 py-0.5 rounded bg-neutral-100 dark:bg-neutral-800 text-neutral-500 dark:text-neutral-400">
{method}
</span>
)}
{entry.outcome === 'failure' && (
<span className="text-xs px-1.5 py-0.5 rounded bg-red-100 dark:bg-red-950/40 text-red-600 dark:text-red-400">
Failed
</span>
)}
</div>
<div className="flex items-center gap-2 mt-0.5 text-xs text-neutral-500 dark:text-neutral-400 flex-wrap">
{reason && <span>{reason}</span>}
{reason && (deviceStr || entry.ip_address) && <span>&middot;</span>}
{deviceStr && <span>{deviceStr}</span>}
{deviceStr && entry.ip_address && <span>&middot;</span>}
{entry.ip_address && <span>{entry.ip_address}</span>}
</div>
</div>
<div className="flex-shrink-0 text-right">
<span className="text-xs text-neutral-500 dark:text-neutral-400" title={formatFullDate(entry.created_at)}>
{formatRelativeTime(entry.created_at)}
</span>
</div>
</div>
)
})}
{hasMore && (
<div className="pt-2 text-center">
<button
type="button"
onClick={handleLoadMore}
disabled={loadingMore}
className="text-sm font-medium text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white transition-colors disabled:opacity-50"
>
{loadingMore ? 'Loading...' : 'Load more'}
</button>
</div>
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,124 @@
'use client'
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { SettingsModal, type SettingsSection } from '@ciphera-net/ui'
import { UserIcon, LockIcon, BellIcon, ChevronRightIcon } from '@ciphera-net/ui'
import { NotificationToggleList, type NotificationOption } from '@ciphera-net/ui'
import ProfileSettings from '@/components/settings/ProfileSettings'
import TrustedDevicesCard from '@/components/settings/TrustedDevicesCard'
import SecurityActivityCard from '@/components/settings/SecurityActivityCard'
import { useSettingsModal } from '@/lib/settings-modal-context'
import { useAuth } from '@/lib/auth/context'
import { updateUserPreferences } from '@/lib/api/user'
// --- Security Alerts ---
const SECURITY_ALERT_OPTIONS: NotificationOption[] = [
{ key: 'login_alerts', label: 'Login Activity', description: 'New device sign-ins and suspicious login attempts.' },
{ key: 'password_alerts', label: 'Password Changes', description: 'Password changes and session revocations.' },
{ key: 'two_factor_alerts', label: 'Two-Factor Authentication', description: '2FA enabled/disabled and recovery code changes.' },
]
function SecurityAlertsCard() {
const { user } = useAuth()
const [emailNotifications, setEmailNotifications] = useState<Record<string, boolean>>({})
useEffect(() => {
if (user?.preferences?.email_notifications) {
setEmailNotifications(user.preferences.email_notifications)
} else {
const defaults = SECURITY_ALERT_OPTIONS.reduce((acc, option) => ({
...acc,
[option.key]: true
}), {} as Record<string, boolean>)
setEmailNotifications(defaults)
}
}, [user])
const handleToggle = async (key: string) => {
const newState = {
...emailNotifications,
[key]: !emailNotifications[key]
}
setEmailNotifications(newState)
try {
await updateUserPreferences({
email_notifications: newState as { new_file_received: boolean; file_downloaded: boolean; login_alerts: boolean; password_alerts: boolean; two_factor_alerts: boolean }
})
} catch {
setEmailNotifications(prev => ({
...prev,
[key]: !prev[key]
}))
}
}
return (
<NotificationToggleList
title="Security Alerts"
description="Choose which security events trigger email alerts"
icon={<BellIcon className="w-5 h-5 text-brand-orange" />}
options={SECURITY_ALERT_OPTIONS}
values={emailNotifications}
onToggle={handleToggle}
/>
)
}
// --- Notification Center Placeholder ---
function NotificationCenterPlaceholder() {
return (
<div className="text-center max-w-md mx-auto py-8">
<BellIcon className="w-12 h-12 text-neutral-300 mx-auto mb-4" />
<h3 className="text-lg font-medium text-neutral-900 dark:text-white mb-2">Notification Center</h3>
<p className="text-sm text-neutral-500 mb-4">View and manage all your notifications in one place.</p>
<Link href="/notifications" className="inline-flex items-center gap-2 px-4 py-2 bg-brand-orange text-white rounded-lg hover:bg-brand-orange/90 transition-colors">
Open Notification Center
<ChevronRightIcon className="w-4 h-4" />
</Link>
</div>
)
}
// --- Main Wrapper ---
export default function SettingsModalWrapper() {
const { isOpen, closeSettings } = useSettingsModal()
const sections: SettingsSection[] = [
{
id: 'pulse',
label: 'Account',
icon: UserIcon,
defaultExpanded: true,
items: [
{ id: 'profile', label: 'Profile', content: <ProfileSettings activeTab="profile" borderless hideDangerZone /> },
{ id: 'security', label: 'Security', content: <ProfileSettings activeTab="security" borderless /> },
{ id: 'preferences', label: 'Preferences', content: <ProfileSettings activeTab="preferences" borderless /> },
{ id: 'danger-zone', label: 'Danger Zone', content: <ProfileSettings activeTab="danger-zone" borderless /> },
],
},
{
id: 'security-section',
label: 'Security',
icon: LockIcon,
items: [
{ id: 'devices', label: 'Trusted Devices', content: <TrustedDevicesCard /> },
{ id: 'activity', label: 'Security Activity', content: <SecurityActivityCard /> },
],
},
{
id: 'notifications',
label: 'Notifications',
icon: BellIcon,
items: [
{ id: 'security-alerts', label: 'Security Alerts', content: <SecurityAlertsCard /> },
{ id: 'center', label: 'Notification Center', content: <NotificationCenterPlaceholder /> },
],
},
]
return <SettingsModal open={isOpen} onClose={closeSettings} sections={sections} />
}

View File

@@ -0,0 +1,130 @@
'use client'
import { useEffect, useState, useCallback } from 'react'
import { useAuth } from '@/lib/auth/context'
import { getUserDevices, removeDevice, type TrustedDevice } from '@/lib/api/devices'
import { Spinner, toast } from '@ciphera-net/ui'
import { formatRelativeTime, formatFullDate } from '@/lib/utils/formatDate'
function getDeviceIcon(hint: string): string {
const h = hint.toLowerCase()
if (h.includes('iphone') || h.includes('android') || h.includes('ios')) {
return 'M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3'
}
return 'M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25A2.25 2.25 0 015.25 3h13.5A2.25 2.25 0 0121 5.25z'
}
export default function TrustedDevicesCard() {
const { user } = useAuth()
const [devices, setDevices] = useState<TrustedDevice[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [removingId, setRemovingId] = useState<string | null>(null)
const fetchDevices = useCallback(async () => {
try {
const data = await getUserDevices()
setDevices(data.devices ?? [])
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load devices')
}
}, [])
useEffect(() => {
if (!user) return
setLoading(true)
fetchDevices().finally(() => setLoading(false))
}, [user, fetchDevices])
const handleRemove = async (device: TrustedDevice) => {
if (device.is_current) {
toast.error('You cannot remove the device you are currently using.')
return
}
setRemovingId(device.id)
try {
await removeDevice(device.id)
setDevices(prev => prev.filter(d => d.id !== device.id))
toast.success('Device removed. A new sign-in from it will trigger an alert.')
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to remove device')
} finally {
setRemovingId(null)
}
}
return (
<div>
<h2 className="text-xl font-semibold text-neutral-900 dark:text-white mb-1">Trusted Devices</h2>
<p className="text-neutral-500 dark:text-neutral-400 text-sm mb-6">
Devices that have signed in to your account. Removing a device means the next sign-in from it will trigger a new device alert.
</p>
{loading ? (
<div className="flex items-center justify-center py-12">
<Spinner />
</div>
) : error ? (
<div className="rounded-2xl border border-red-200 dark:border-red-900/50 bg-red-50 dark:bg-red-950/20 p-6 text-center">
<p className="text-red-600 dark:text-red-400">{error}</p>
</div>
) : devices.length === 0 ? (
<div className="rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-8 text-center">
<svg className="w-12 h-12 mx-auto mb-3 text-neutral-300 dark:text-neutral-600" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25A2.25 2.25 0 015.25 3h13.5A2.25 2.25 0 0121 5.25z" />
</svg>
<p className="text-neutral-500 dark:text-neutral-400">No trusted devices yet. They appear after you sign in.</p>
</div>
) : (
<div className="space-y-2">
{devices.map((device) => (
<div
key={device.id}
className="flex items-center gap-3 rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-4 py-3"
>
<div className="flex-shrink-0 w-9 h-9 rounded-lg flex items-center justify-center bg-neutral-100 dark:bg-neutral-800 text-neutral-500 dark:text-neutral-400">
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d={getDeviceIcon(device.display_hint)} />
</svg>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-neutral-900 dark:text-white text-sm truncate">
{device.display_hint || 'Unknown device'}
</span>
{device.is_current && (
<span className="text-xs px-1.5 py-0.5 rounded bg-green-100 dark:bg-green-950/40 text-green-600 dark:text-green-400 flex-shrink-0">
This device
</span>
)}
</div>
<div className="flex items-center gap-2 mt-0.5 text-xs text-neutral-500 dark:text-neutral-400">
<span title={formatFullDate(device.first_seen_at)}>
First seen {formatRelativeTime(device.first_seen_at)}
</span>
<span>&middot;</span>
<span title={formatFullDate(device.last_seen_at)}>
Last seen {formatRelativeTime(device.last_seen_at)}
</span>
</div>
</div>
{!device.is_current && (
<button
type="button"
onClick={() => handleRemove(device)}
disabled={removingId === device.id}
className="flex-shrink-0 text-xs font-medium text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 transition-colors disabled:opacity-50"
>
{removingId === device.id ? 'Removing...' : 'Remove'}
</button>
)}
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -1,18 +1,118 @@
'use client'
import Link from 'next/link'
import Image from 'next/image'
import { Site } from '@/lib/api/sites'
import type { Stats } from '@/lib/api/stats'
import { formatNumber } from '@ciphera-net/ui'
import { BarChartIcon, SettingsIcon, BookOpenIcon, ExternalLinkIcon, Button } from '@ciphera-net/ui'
import { useAuth } from '@/lib/auth/context'
import { FAVICON_SERVICE_URL } from '@/lib/utils/icons'
export type SiteStatsMap = Record<string, { stats: Stats }>
interface SiteListProps {
sites: Site[]
siteStats: SiteStatsMap
loading: boolean
onDelete: (id: string) => void
}
export default function SiteList({ sites, loading, onDelete }: SiteListProps) {
interface SiteCardProps {
site: Site
stats: Stats | null
statsLoading: boolean
onDelete: (id: string) => void
canDelete: boolean
}
function SiteCard({ site, stats, statsLoading, onDelete, canDelete }: SiteCardProps) {
const visitors24h = stats?.visitors ?? 0
const pageviews = stats?.pageviews ?? 0
return (
<div className="group relative flex flex-col rounded-2xl border border-neutral-200 bg-white p-6 shadow-sm transition-all hover:shadow-md dark:border-neutral-800 dark:bg-neutral-900">
{/* Header: Icon + Name + Live Status */}
<div className="flex items-start justify-between mb-6">
<div className="flex items-center gap-4">
<div className="h-12 w-12 overflow-hidden rounded-lg border border-neutral-100 bg-neutral-50 p-1 dark:border-neutral-800 dark:bg-neutral-800">
<Image
src={`${FAVICON_SERVICE_URL}?domain=${site.domain}&sz=64`}
alt={site.name}
width={40}
height={40}
className="h-full w-full object-contain"
unoptimized
/>
</div>
<div>
<h3 className="font-semibold text-neutral-900 dark:text-white">{site.name}</h3>
<div className="flex items-center gap-1 text-sm text-neutral-500 dark:text-neutral-400">
{site.domain}
<a
href={`https://${site.domain}`}
target="_blank"
rel="noopener noreferrer"
className="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300"
onClick={(e) => e.stopPropagation()}
>
<ExternalLinkIcon className="h-3 w-3" />
</a>
</div>
</div>
</div>
<div className="flex items-center gap-2 rounded-full bg-green-50 px-2 py-1 text-xs font-medium text-green-700 dark:bg-green-900/20 dark:text-green-400">
<span className="relative flex h-2 w-2">
<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-2 w-2 bg-green-500"></span>
</span>
Active
</div>
</div>
{/* Mini Stats Grid */}
<div className="mb-6 grid grid-cols-2 gap-4 rounded-lg bg-neutral-50 p-3 dark:bg-neutral-800/50">
<div>
<p className="text-xs text-neutral-500">Visitors (24h)</p>
<p className="font-mono text-lg font-medium text-neutral-900 dark:text-white">
{statsLoading ? '--' : formatNumber(visitors24h)}
</p>
</div>
<div>
<p className="text-xs text-neutral-500">Pageviews</p>
<p className="font-mono text-lg font-medium text-neutral-900 dark:text-white">
{statsLoading ? '--' : formatNumber(pageviews)}
</p>
</div>
</div>
{/* Actions */}
<div className="mt-auto flex gap-2">
<Link href={`/sites/${site.id}`} className="flex-1">
<Button variant="primary" className="w-full justify-center text-sm">
<BarChartIcon className="w-4 h-4" />
View Dashboard
</Button>
</Link>
{canDelete && (
<button
type="button"
onClick={() => onDelete(site.id)}
className="flex items-center justify-center rounded-lg border border-neutral-200 px-3 hover:bg-neutral-50 dark:border-neutral-700 dark:hover:bg-neutral-800 text-neutral-500 hover:text-red-600 dark:hover:text-red-400 transition-colors focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
title="Delete Site"
>
<SettingsIcon className="h-4 w-4" />
</button>
)}
</div>
</div>
)
}
export default function SiteList({ sites, siteStats, loading, onDelete }: SiteListProps) {
const { user } = useAuth()
const canDelete = user?.role === 'owner' || user?.role === 'admin'
if (loading) {
return (
@@ -40,85 +140,19 @@ export default function SiteList({ sites, loading, onDelete }: SiteListProps) {
return (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{sites.map((site) => (
<div
key={site.id}
className="group relative flex flex-col rounded-2xl border border-neutral-200 bg-white p-6 shadow-sm transition-all hover:shadow-md dark:border-neutral-800 dark:bg-neutral-900"
>
{/* Header: Icon + Name + Live Status */}
<div className="flex items-start justify-between mb-6">
<div className="flex items-center gap-4">
{/* Auto-fetch favicon */}
<div className="h-12 w-12 overflow-hidden rounded-lg border border-neutral-100 bg-neutral-50 p-1 dark:border-neutral-800 dark:bg-neutral-800">
<img
src={`https://www.google.com/s2/favicons?domain=${site.domain}&sz=64`}
alt={site.name}
className="h-full w-full object-contain"
/>
</div>
<div>
<h3 className="font-semibold text-neutral-900 dark:text-white">{site.name}</h3>
<div className="flex items-center gap-1 text-sm text-neutral-500 dark:text-neutral-400">
{site.domain}
<a
href={`https://${site.domain}`}
target="_blank"
rel="noopener noreferrer"
className="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300"
onClick={(e) => e.stopPropagation()}
>
<ExternalLinkIcon className="h-3 w-3" />
</a>
</div>
</div>
</div>
{/* "Live" Indicator */}
<div className="flex items-center gap-2 rounded-full bg-green-50 px-2 py-1 text-xs font-medium text-green-700 dark:bg-green-900/20 dark:text-green-400">
<span className="relative flex h-2 w-2">
<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-2 w-2 bg-green-500"></span>
</span>
Active
</div>
</div>
{/* Mini Stats Grid */}
<div className="mb-6 grid grid-cols-2 gap-4 rounded-lg bg-neutral-50 p-3 dark:bg-neutral-800/50">
<div>
<p className="text-xs text-neutral-500">Visitors (24h)</p>
<p className="font-mono text-lg font-medium text-neutral-900 dark:text-white">--</p>
</div>
<div>
<p className="text-xs text-neutral-500">Pageviews</p>
<p className="font-mono text-lg font-medium text-neutral-900 dark:text-white">--</p>
</div>
</div>
{/* Actions */}
<div className="mt-auto flex gap-2">
<Link
href={`/sites/${site.id}`}
className="flex-1"
>
<Button variant="primary" className="w-full justify-center text-sm">
<BarChartIcon className="w-4 h-4" />
View Dashboard
</Button>
</Link>
{(user?.role === 'owner' || user?.role === 'admin') && (
<button
type="button"
onClick={() => onDelete(site.id)}
className="flex items-center justify-center rounded-lg border border-neutral-200 px-3 hover:bg-neutral-50 dark:border-neutral-700 dark:hover:bg-neutral-800 text-neutral-500 hover:text-red-600 dark:hover:text-red-400 transition-colors focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
title="Delete Site"
>
<SettingsIcon className="h-4 w-4" />
</button>
)}
</div>
</div>
))}
{sites.map((site) => {
const data = siteStats[site.id]
return (
<SiteCard
key={site.id}
site={site}
stats={data?.stats ?? null}
statsLoading={!data}
onDelete={onDelete}
canDelete={canDelete}
/>
)
})}
{/* Resources Card */}
<div className="flex flex-col items-center justify-center rounded-2xl border border-dashed border-neutral-300 bg-neutral-50 p-6 text-center dark:border-neutral-700 dark:bg-neutral-900/50">

463
components/skeletons.tsx Normal file
View File

@@ -0,0 +1,463 @@
/**
* Reusable skeleton loading primitives and composites for Pulse.
* All skeletons follow the design-system pattern:
* animate-pulse + bg-neutral-100 dark:bg-neutral-800 + rounded
*/
const SK = 'animate-pulse bg-neutral-100 dark:bg-neutral-800'
export { useMinimumLoading } from './useMinimumLoading'
// ─── Primitives ──────────────────────────────────────────────
export function SkeletonLine({ className = '' }: { className?: string }) {
return <div className={`${SK} rounded ${className}`} />
}
export function SkeletonCircle({ className = '' }: { className?: string }) {
return <div className={`${SK} rounded-full ${className}`} />
}
export function SkeletonCard({ className = '' }: { className?: string }) {
return <div className={`${SK} rounded-2xl ${className}`} />
}
// ─── List skeleton (icon + two text lines per row) ───────────
export function ListRowSkeleton() {
return (
<div className="flex items-center justify-between h-9 px-2 -mx-2">
<div className="flex items-center gap-3 flex-1">
<SkeletonLine className="h-5 w-5 rounded shrink-0" />
<SkeletonLine className="h-4 w-3/5" />
</div>
<SkeletonLine className="h-4 w-12" />
</div>
)
}
export function ListSkeleton({ rows = 7 }: { rows?: number }) {
return (
<div className="space-y-2">
{Array.from({ length: rows }).map((_, i) => (
<ListRowSkeleton key={i} />
))}
</div>
)
}
// ─── Table skeleton (header row + data rows) ─────────────────
export function TableSkeleton({ rows = 7, cols = 5 }: { rows?: number; cols?: number }) {
return (
<div className="space-y-2">
<div className={`grid gap-2 mb-2 px-2`} style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` }}>
{Array.from({ length: cols }).map((_, i) => (
<SkeletonLine key={`th-${i}`} className="h-4" />
))}
</div>
{Array.from({ length: rows }).map((_, i) => (
<div key={`tr-${i}`} className="grid gap-2 h-9 px-2 -mx-2" style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` }}>
{Array.from({ length: cols }).map((_, j) => (
<SkeletonLine key={`td-${i}-${j}`} className="h-4" />
))}
</div>
))}
</div>
)
}
// ─── Widget panel skeleton (used inside dashboard grid) ──────
export function WidgetSkeleton() {
return (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
<div className="flex items-center justify-between mb-4">
<SkeletonLine className="h-6 w-32" />
<div className="flex gap-1">
<SkeletonLine className="h-7 w-16 rounded-lg" />
<SkeletonLine className="h-7 w-16 rounded-lg" />
</div>
</div>
<div className="space-y-2 flex-1 min-h-[270px]">
<ListSkeleton rows={7} />
</div>
</div>
)
}
// ─── Stat card skeleton ──────────────────────────────────────
export function StatCardSkeleton() {
return (
<div className="p-4 rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900">
<SkeletonLine className="h-4 w-20 mb-2" />
<SkeletonLine className="h-8 w-28" />
</div>
)
}
// ─── Chart area skeleton ─────────────────────────────────────
export function ChartSkeleton() {
return (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="space-y-1">
<SkeletonLine className="h-3 w-16" />
<SkeletonLine className="h-7 w-24" />
</div>
))}
</div>
<SkeletonLine className="h-8 w-32 rounded-lg" />
</div>
<SkeletonLine className="h-64 w-full rounded-xl" />
</div>
)
}
// ─── Full dashboard skeleton ─────────────────────────────────
export function DashboardSkeleton() {
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
{/* Header */}
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-4">
<div>
<SkeletonLine className="h-8 w-48 mb-2" />
<SkeletonLine className="h-4 w-32" />
</div>
<SkeletonLine className="h-8 w-40 rounded-full" />
</div>
<div className="flex items-center gap-2">
<SkeletonLine className="h-10 w-24 rounded-lg" />
<SkeletonLine className="h-10 w-36 rounded-lg" />
</div>
</div>
</div>
{/* Chart */}
<div className="mb-8">
<ChartSkeleton />
</div>
{/* Widget grid (2 cols) */}
<div className="grid gap-6 lg:grid-cols-2 mb-8">
<WidgetSkeleton />
<WidgetSkeleton />
</div>
<div className="grid gap-6 lg:grid-cols-2 mb-8">
<WidgetSkeleton />
<WidgetSkeleton />
</div>
{/* Campaigns table */}
<div className="mb-8">
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
<SkeletonLine className="h-6 w-32 mb-4" />
<TableSkeleton rows={7} cols={5} />
</div>
</div>
</div>
)
}
// ─── Realtime page skeleton ──────────────────────────────────
export function RealtimeSkeleton() {
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">
<SkeletonLine className="h-4 w-32 mb-2" />
<SkeletonLine className="h-8 w-64" />
</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">
<SkeletonLine className="h-6 w-32" />
</div>
<div className="p-2 space-y-1">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="p-4 space-y-2">
<div className="flex justify-between">
<SkeletonLine className="h-4 w-32" />
<SkeletonLine className="h-4 w-16" />
</div>
<SkeletonLine className="h-3 w-48" />
<div className="flex gap-2">
<SkeletonLine className="h-3 w-16" />
<SkeletonLine className="h-3 w-16" />
<SkeletonLine className="h-3 w-16" />
</div>
</div>
))}
</div>
</div>
{/* Session details */}
<div className="flex-1 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden bg-white dark:bg-neutral-900">
<div className="p-4 border-b border-neutral-200 dark:border-neutral-800">
<SkeletonLine className="h-6 w-40" />
</div>
<div className="p-6 space-y-6">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex gap-4 pl-6">
<SkeletonCircle className="h-3 w-3 shrink-0 mt-1" />
<div className="space-y-1 flex-1">
<SkeletonLine className="h-4 w-48" />
<SkeletonLine className="h-3 w-32" />
</div>
</div>
))}
</div>
</div>
</div>
</div>
)
}
// ─── Session events skeleton (for loading events panel) ──────
export function SessionEventsSkeleton() {
return (
<div className="relative pl-6 border-l-2 border-neutral-100 dark:border-neutral-800 space-y-8">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="relative">
<span className={`absolute -left-[29px] top-1 h-3 w-3 rounded-full ${SK}`} />
<div className="space-y-1">
<SkeletonLine className="h-4 w-48" />
<SkeletonLine className="h-3 w-32" />
</div>
</div>
))}
</div>
)
}
// ─── Uptime page skeleton ────────────────────────────────────
export function UptimeSkeleton() {
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
<div className="mb-8">
<SkeletonLine className="h-4 w-32 mb-2" />
<SkeletonLine className="h-8 w-24 mb-1" />
<SkeletonLine className="h-4 w-64" />
</div>
{/* Overall status */}
<SkeletonCard className="h-20 mb-6" />
{/* Monitor cards */}
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-5 space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<SkeletonCircle className="w-3 h-3" />
<SkeletonLine className="h-5 w-32" />
<SkeletonLine className="h-4 w-48 hidden sm:block" />
</div>
<SkeletonLine className="h-4 w-28" />
</div>
<SkeletonLine className="h-8 w-full rounded-sm" />
</div>
))}
</div>
</div>
)
}
// ─── Checks / Response time skeleton ─────────────────────────
export function ChecksSkeleton() {
return (
<div className="space-y-4">
<SkeletonLine className="h-40 w-full rounded-xl" />
<div className="space-y-1.5">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center justify-between py-1.5 px-2">
<div className="flex items-center gap-2">
<SkeletonCircle className="w-2 h-2" />
<SkeletonLine className="h-3 w-32" />
</div>
<SkeletonLine className="h-3 w-16" />
</div>
))}
</div>
</div>
)
}
// ─── Funnels list skeleton ───────────────────────────────────
export function FunnelsListSkeleton() {
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
<div className="mb-8">
<div className="flex items-center gap-4 mb-6">
<SkeletonLine className="h-10 w-10 rounded-xl" />
<div>
<SkeletonLine className="h-8 w-24 mb-1" />
<SkeletonLine className="h-4 w-64" />
</div>
</div>
<div className="grid gap-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
<SkeletonLine className="h-6 w-40 mb-2" />
<SkeletonLine className="h-4 w-64 mb-4" />
<div className="flex items-center gap-2">
{Array.from({ length: 3 }).map((_, j) => (
<div key={j} className="flex items-center">
<SkeletonLine className="h-7 w-20 rounded-lg" />
{j < 2 && <SkeletonLine className="h-4 w-4 mx-2 rounded" />}
</div>
))}
</div>
</div>
))}
</div>
</div>
</div>
)
}
// ─── Funnel detail skeleton ──────────────────────────────────
export function FunnelDetailSkeleton() {
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
<div className="mb-8">
<SkeletonLine className="h-4 w-32 mb-2" />
<SkeletonLine className="h-8 w-48 mb-1" />
<SkeletonLine className="h-4 w-64" />
</div>
<SkeletonCard className="h-80 mb-8" />
<div className="grid gap-4 md:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => (
<SkeletonCard key={i} className="h-28" />
))}
</div>
</div>
)
}
// ─── Notifications list skeleton ─────────────────────────────
export function NotificationsListSkeleton() {
return (
<div className="space-y-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="flex items-start gap-3 p-4 rounded-xl border border-neutral-200 dark:border-neutral-800">
<SkeletonCircle className="h-10 w-10 shrink-0" />
<div className="flex-1 space-y-2">
<SkeletonLine className="h-4 w-3/4" />
<SkeletonLine className="h-3 w-1/2" />
</div>
<SkeletonLine className="h-3 w-16 shrink-0" />
</div>
))}
</div>
)
}
// ─── Settings form skeleton ──────────────────────────────────
export function SettingsFormSkeleton() {
return (
<div className="space-y-6">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="space-y-2">
<SkeletonLine className="h-4 w-24" />
<SkeletonLine className="h-10 w-full rounded-lg" />
</div>
))}
<SkeletonLine className="h-10 w-28 rounded-lg" />
</div>
)
}
// ─── Goals list skeleton ─────────────────────────────────────
export function GoalsListSkeleton() {
return (
<div className="space-y-2">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex items-center justify-between py-3 px-4 rounded-2xl border border-neutral-200 dark:border-neutral-800">
<div className="flex items-center gap-2">
<SkeletonLine className="h-4 w-24" />
<SkeletonLine className="h-3 w-20" />
</div>
<div className="flex items-center gap-2">
<SkeletonLine className="h-4 w-10" />
<SkeletonLine className="h-4 w-12" />
</div>
</div>
))}
</div>
)
}
// ─── Pricing cards skeleton ──────────────────────────────────
export function PricingCardsSkeleton() {
return (
<div className="grid gap-6 md:grid-cols-3 max-w-5xl mx-auto">
{Array.from({ length: 3 }).map((_, i) => (
<SkeletonCard key={i} className="h-96" />
))}
</div>
)
}
// ─── Organization settings skeleton (members, billing, etc) ─
export function MembersListSkeleton() {
return (
<div className="space-y-3">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 p-3 rounded-xl">
<SkeletonCircle className="h-10 w-10 shrink-0" />
<div className="flex-1 space-y-1">
<SkeletonLine className="h-4 w-32" />
<SkeletonLine className="h-3 w-48" />
</div>
<SkeletonLine className="h-6 w-16 rounded-full" />
</div>
))}
</div>
)
}
export function InvoicesListSkeleton() {
return (
<div className="space-y-2">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex items-center justify-between py-3 px-4 rounded-lg">
<div className="flex items-center gap-3">
<SkeletonLine className="h-4 w-24" />
<SkeletonLine className="h-4 w-16" />
</div>
<SkeletonLine className="h-4 w-20" />
</div>
))}
</div>
)
}
export function AuditLogSkeleton() {
return (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 py-2 px-4">
<SkeletonLine className="h-3 w-28" />
<SkeletonLine className="h-3 w-16" />
<SkeletonLine className="h-3 w-48 flex-1" />
</div>
))}
</div>
)
}

View File

@@ -1,6 +1,7 @@
'use client'
import { useState, useEffect } from 'react'
import { logger } from '@/lib/utils/logger'
import { CopyIcon, CheckIcon } from '@radix-ui/react-icons'
import { listSites, Site } from '@/lib/api/sites'
import { Select, Input, Button } from '@ciphera-net/ui'
@@ -30,7 +31,7 @@ export default function UtmBuilder({ initialSiteId }: UtmBuilderProps) {
const data = await listSites()
setSites(data)
} catch (e) {
console.error('Failed to load sites for UTM builder', e)
logger.error('Failed to load sites for UTM builder', e)
}
}
fetchSites()

View File

@@ -0,0 +1,34 @@
'use client'
import { useState, useEffect, useRef } from 'react'
/**
* Prevents skeleton flicker on fast loads by keeping it visible
* for at least `minMs` once it appears.
*
* @param loading - The raw loading state from data fetching
* @param minMs - Minimum milliseconds the skeleton stays visible (default 300)
* @returns Whether the skeleton should be shown
*/
export function useMinimumLoading(loading: boolean, minMs = 300): boolean {
const [show, setShow] = useState(loading)
const startRef = useRef<number>(loading ? Date.now() : 0)
useEffect(() => {
if (loading) {
startRef.current = Date.now()
setShow(true)
} else {
const elapsed = Date.now() - startRef.current
const remaining = minMs - elapsed
if (remaining > 0) {
const timer = setTimeout(() => setShow(false), remaining)
return () => clearTimeout(timer)
} else {
setShow(false)
}
}
}, [loading, minMs])
return show
}

View File

@@ -405,8 +405,8 @@ toast.success('Site created successfully')
**Error Toast:**
```tsx
toast.error('Failed to load data')
// Red toast with X icon
toast.error('Failed to load uptime monitors')
// Red toast with X icon — always mention what failed
```
**Error Display:**

View File

@@ -27,14 +27,16 @@ export async function verify2FA(code: string): Promise<Verify2FAResponse> {
})
}
export async function disable2FA(): Promise<void> {
export async function disable2FA(passwordDerived: string): Promise<void> {
return apiRequest<void>('/auth/2fa/disable', {
method: 'POST',
body: JSON.stringify({ password: passwordDerived }),
})
}
export async function regenerateRecoveryCodes(): Promise<RegenerateCodesResponse> {
export async function regenerateRecoveryCodes(passwordDerived: string): Promise<RegenerateCodesResponse> {
return apiRequest<RegenerateCodesResponse>('/auth/2fa/recovery', {
method: 'POST',
body: JSON.stringify({ password: passwordDerived }),
})
}

28
lib/api/activity.ts Normal file
View File

@@ -0,0 +1,28 @@
import apiRequest from './client'
export interface AuditLogEntry {
id: string
created_at: string
event_type: string
outcome: string
ip_address?: string
user_agent?: string
metadata?: Record<string, string>
}
export interface ActivityResponse {
entries: AuditLogEntry[] | null
total_count: number
has_more: boolean
limit: number
offset: number
}
export async function getUserActivity(
limit = 20,
offset = 0
): Promise<ActivityResponse> {
return apiRequest<ActivityResponse>(
`/auth/user/activity?limit=${limit}&offset=${offset}`
)
}

62
lib/api/admin.ts Normal file
View File

@@ -0,0 +1,62 @@
import { authFetch } from './client'
export interface AdminOrgSummary {
organization_id: string
stripe_customer_id: string
stripe_subscription_id: string
plan_id: string
billing_interval: string
pageview_limit: number
subscription_status: string
current_period_end: string
business_name: string
last_payment_at?: string
created_at: string
updated_at: string
}
export interface Site {
id: string
domain: string
name: string
created_at: string
}
export interface AdminOrgDetail extends AdminOrgSummary {
sites: Site[]
}
export interface GrantPlanParams {
plan_id: string
billing_interval: string
pageview_limit: number
period_end: string // ISO date string
}
// Check if current user is admin
export async function getAdminMe(): Promise<{ is_admin: boolean }> {
try {
return await authFetch<{ is_admin: boolean }>('/api/admin/me')
} catch (e) {
return { is_admin: false }
}
}
// List all organizations (admin view)
export async function listAdminOrgs(): Promise<AdminOrgSummary[]> {
const data = await authFetch<{ organizations: AdminOrgSummary[] }>('/api/admin/orgs')
return data.organizations || []
}
// Get details for a specific organization
export async function getAdminOrg(orgId: string): Promise<{ billing: AdminOrgSummary; sites: Site[] }> {
return await authFetch<{ billing: AdminOrgSummary; sites: Site[] }>(`/api/admin/orgs/${orgId}`)
}
// Grant a plan to an organization manually
export async function grantPlan(orgId: string, params: GrantPlanParams): Promise<void> {
await authFetch(`/api/admin/orgs/${orgId}/grant-plan`, {
method: 'POST',
body: JSON.stringify(params),
})
}

View File

@@ -1,4 +1,4 @@
import { API_URL } from './client'
import apiRequest from './client'
export interface TaxID {
type: string
@@ -31,39 +31,12 @@ export interface SubscriptionDetails {
next_invoice_period_end?: number
}
async function billingFetch<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const url = `${API_URL}${endpoint}`
const headers: HeadersInit = {
'Content-Type': 'application/json',
...options.headers,
}
const response = await fetch(url, {
...options,
headers,
credentials: 'include', // Send cookies
})
if (!response.ok) {
const errorBody = await response.json().catch(() => ({
error: 'Unknown error',
message: `HTTP ${response.status}: ${response.statusText}`,
}))
throw new Error(errorBody.message || errorBody.error || 'Request failed')
}
return response.json()
}
export async function getSubscription(): Promise<SubscriptionDetails> {
return await billingFetch<SubscriptionDetails>('/api/billing/subscription', {
method: 'GET',
})
return apiRequest<SubscriptionDetails>('/api/billing/subscription')
}
export async function createPortalSession(): Promise<{ url: string }> {
return await billingFetch<{ url: string }>('/api/billing/portal', {
return apiRequest<{ url: string }>('/api/billing/portal', {
method: 'POST',
})
}
@@ -74,7 +47,7 @@ export interface CancelSubscriptionParams {
}
export async function cancelSubscription(params?: CancelSubscriptionParams): Promise<{ ok: boolean; at_period_end: boolean }> {
return await billingFetch<{ ok: boolean; at_period_end: boolean }>('/api/billing/cancel', {
return apiRequest<{ ok: boolean; at_period_end: boolean }>('/api/billing/cancel', {
method: 'POST',
body: JSON.stringify({ at_period_end: params?.at_period_end ?? true }),
})
@@ -82,7 +55,7 @@ export async function cancelSubscription(params?: CancelSubscriptionParams): Pro
/** Clears cancel_at_period_end so the subscription continues past the current period. */
export async function resumeSubscription(): Promise<{ ok: boolean }> {
return await billingFetch<{ ok: boolean }>('/api/billing/resume', {
return apiRequest<{ ok: boolean }>('/api/billing/resume', {
method: 'POST',
})
}
@@ -100,7 +73,7 @@ export interface PreviewInvoiceResult {
}
export async function previewInvoice(params: ChangePlanParams): Promise<PreviewInvoiceResult | null> {
const res = await billingFetch<PreviewInvoiceResult | Record<string, never>>('/api/billing/preview-invoice', {
const res = await apiRequest<PreviewInvoiceResult | Record<string, never>>('/api/billing/preview-invoice', {
method: 'POST',
body: JSON.stringify(params),
})
@@ -111,7 +84,7 @@ export async function previewInvoice(params: ChangePlanParams): Promise<PreviewI
}
export async function changePlan(params: ChangePlanParams): Promise<{ ok: boolean }> {
return await billingFetch<{ ok: boolean }>('/api/billing/change-plan', {
return apiRequest<{ ok: boolean }>('/api/billing/change-plan', {
method: 'POST',
body: JSON.stringify(params),
})
@@ -124,7 +97,7 @@ export interface CreateCheckoutParams {
}
export async function createCheckoutSession(params: CreateCheckoutParams): Promise<{ url: string }> {
return await billingFetch<{ url: string }>('/api/billing/checkout', {
return apiRequest<{ url: string }>('/api/billing/checkout', {
method: 'POST',
body: JSON.stringify(params),
})
@@ -142,7 +115,5 @@ export interface Invoice {
}
export async function getInvoices(): Promise<Invoice[]> {
return await billingFetch<Invoice[]>('/api/billing/invoices', {
method: 'GET',
})
return apiRequest<Invoice[]>('/api/billing/invoices')
}

View File

@@ -1,8 +1,10 @@
/**
* HTTP client wrapper for API calls
* Includes Request ID propagation for debugging across services
*/
import { authMessageFromStatus, AUTH_ERROR_MESSAGES } from '@ciphera-net/ui'
import { generateRequestId, getRequestIdHeader, setLastRequestId } from '@/lib/utils/requestId'
/** Request timeout in ms; network errors surface as user-facing "Network error, please try again." */
const FETCH_TIMEOUT_MS = 30_000
@@ -22,11 +24,41 @@ export function getSignupUrl(redirectPath = '/auth/callback') {
return `${AUTH_URL}/signup?client_id=pulse-app&redirect_uri=${redirectUri}&response_type=code`
}
// * ============================================================================
// * CSRF Token Handling
// * ============================================================================
/**
* Get CSRF token from the csrf_token cookie (non-httpOnly)
* This is needed for state-changing requests to the Auth API
*/
function getCSRFToken(): string | null {
if (typeof document === 'undefined') return null
const cookies = document.cookie.split(';')
for (const cookie of cookies) {
const [name, value] = cookie.trim().split('=')
if (name === 'csrf_token') {
return decodeURIComponent(value)
}
}
return null
}
/**
* Check if a request method requires CSRF protection
* State-changing methods (POST, PUT, DELETE, PATCH) need CSRF tokens
*/
function isStateChangingMethod(method: string): boolean {
const stateChangingMethods = ['POST', 'PUT', 'DELETE', 'PATCH']
return stateChangingMethods.includes(method.toUpperCase())
}
export class ApiError extends Error {
status: number
data?: any
data?: Record<string, unknown>
constructor(message: string, status: number, data?: any) {
constructor(message: string, status: number, data?: Record<string, unknown>) {
super(message)
this.status = status
this.data = data
@@ -58,13 +90,89 @@ function onRefreshFailed(err: unknown) {
refreshSubscribers = []
}
// * ============================================================================
// * Request Deduplication & Caching
// * ============================================================================
/** Cache TTL in milliseconds (2 seconds) */
const CACHE_TTL_MS = 2_000
/** Stores in-flight requests for deduplication */
interface PendingRequest {
promise: Promise<unknown>
timestamp: number
}
const pendingRequests = new Map<string, PendingRequest>()
/** Stores cached responses */
interface CachedResponse {
data: unknown
timestamp: number
}
const responseCache = new Map<string, CachedResponse>()
/**
* Base API client with error handling
* Generate a unique key for a request based on endpoint and options
*/
function getRequestKey(endpoint: string, options: RequestInit): string {
const method = options.method || 'GET'
const body = options.body || ''
return `${method}:${endpoint}:${body}`
}
/**
* Clean up expired entries from pending requests and response cache
*/
function cleanupExpiredEntries(): void {
const now = Date.now()
// * Clean up stale pending requests (older than 30 seconds)
for (const [key, pending] of pendingRequests.entries()) {
if (now - pending.timestamp > 30_000) {
pendingRequests.delete(key)
}
}
// * Clean up stale cached responses (older than CACHE_TTL_MS)
for (const [key, cached] of responseCache.entries()) {
if (now - cached.timestamp > CACHE_TTL_MS) {
responseCache.delete(key)
}
}
}
/**
* Base API client with error handling, request deduplication, and short-term caching
*/
async function apiRequest<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
// * Skip deduplication for non-GET requests (mutations should always execute)
const method = options.method || 'GET'
const shouldDedupe = method === 'GET'
if (shouldDedupe) {
// * Clean up expired entries periodically
if (pendingRequests.size > 100 || responseCache.size > 100) {
cleanupExpiredEntries()
}
const requestKey = getRequestKey(endpoint, options)
// * Check if we have a recent cached response (within 2 seconds)
const cached = responseCache.get(requestKey)
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
return cached.data as T
}
// * Check if there's an identical request in flight
const pending = pendingRequests.get(requestKey)
if (pending && Date.now() - pending.timestamp < 30000) {
return pending.promise as Promise<T>
}
}
// * Determine base URL
const isAuthRequest = endpoint.startsWith('/auth')
const baseUrl = isAuthRequest ? AUTH_API_URL : API_URL
@@ -74,34 +182,57 @@ async function apiRequest<T>(
? `${baseUrl}${endpoint}`
: `${baseUrl}/api/v1${endpoint}`
const headers: HeadersInit = {
// * Generate and store request ID for tracing
const requestId = generateRequestId()
setLastRequestId(requestId)
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...options.headers,
[getRequestIdHeader()]: requestId,
}
// * Merge any additional headers from options
if (options.headers) {
const additionalHeaders = options.headers as Record<string, string>
Object.entries(additionalHeaders).forEach(([key, value]) => {
headers[key] = value
})
}
// * We rely on HttpOnly cookies, so no manual Authorization header injection.
// * We MUST set credentials: 'include' for the browser to send cookies cross-origin (or same-site).
// * Add CSRF token for state-changing requests to Auth API
// * Auth API uses Double Submit Cookie pattern for CSRF protection
if (isAuthRequest && isStateChangingMethod(method)) {
const csrfToken = getCSRFToken()
if (csrfToken) {
headers['X-CSRF-Token'] = csrfToken
}
}
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
const signal = options.signal ?? controller.signal
let response: Response
try {
response = await fetch(url, {
...options,
headers,
credentials: 'include', // * IMPORTANT: Send cookies
signal,
})
clearTimeout(timeoutId)
} catch (e) {
clearTimeout(timeoutId)
if (e instanceof Error && (e.name === 'AbortError' || e.name === 'TypeError')) {
throw new ApiError(AUTH_ERROR_MESSAGES.NETWORK, 0)
// * Create the request promise
const requestPromise = (async (): Promise<T> => {
let response: Response
try {
response = await fetch(url, {
...options,
headers,
credentials: 'include', // * IMPORTANT: Send cookies
signal,
})
clearTimeout(timeoutId)
} catch (e) {
clearTimeout(timeoutId)
if (e instanceof Error && (e.name === 'AbortError' || e.name === 'TypeError')) {
throw new ApiError(AUTH_ERROR_MESSAGES.NETWORK, 0)
}
throw e
}
throw e
}
if (!response.ok) {
if (response.status === 401) {
@@ -182,47 +313,38 @@ async function apiRequest<T>(
}
return response.json()
}
})()
// * Legacy axios-style client for compatibility
export function getClient() {
return {
post: async (endpoint: string, body: any) => {
// Handle the case where endpoint might start with /api (remove it if our base client adds it, OR adjust usage)
// Our apiRequest adds /api/v1 prefix.
// If we pass /api/billing/checkout, apiRequest makes it /api/v1/api/billing/checkout -> Wrong.
// We should probably just expose apiRequest directly or wrap it properly.
// * For GET requests, track the promise for deduplication and cache the result
if (shouldDedupe) {
const requestKey = getRequestKey(endpoint, options)
// Let's adapt the endpoint:
// If endpoint starts with /api/, strip it because apiRequest adds /api/v1
// BUT WAIT: The backend billing endpoint is likely at /api/billing/checkout (not /api/v1/billing/checkout) if I registered it at root group?
// Let's check backend routing.
// In main.go: billingGroup := router.Group("/api/billing") -> so it is at /api/billing/... NOT /api/v1/billing...
// * Store in pending requests
pendingRequests.set(requestKey, {
promise: requestPromise as Promise<unknown>,
timestamp: Date.now(),
})
// So we need a raw fetch for this, or modify apiRequest to support non-v1 routes.
// For now, let's just implement a simple fetch wrapper that mimics axios
const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null
const headers: any = { 'Content-Type': 'application/json' }
// Although we use cookies, sometimes we might fallback to token if cookies fail?
// Pulse uses cookies primarily now.
const url = `${API_URL}${endpoint}`
const res = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(body),
credentials: 'include'
// * Clean up pending request and cache the result when done
requestPromise
.then((data) => {
// * Cache successful response
responseCache.set(requestKey, {
data,
timestamp: Date.now(),
})
// * Remove from pending
pendingRequests.delete(requestKey)
return data
})
.catch((error) => {
// * Remove from pending on error too
pendingRequests.delete(requestKey)
throw error
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(err.error || 'Request failed')
}
return { data: await res.json() }
}
}
return requestPromise
}
export const authFetch = apiRequest

19
lib/api/devices.ts Normal file
View File

@@ -0,0 +1,19 @@
import apiRequest from './client'
export interface TrustedDevice {
id: string
display_hint: string
first_seen_at: string
last_seen_at: string
is_current: boolean
}
export async function getUserDevices(): Promise<{ devices: TrustedDevice[] }> {
return apiRequest<{ devices: TrustedDevice[] }>('/auth/user/devices')
}
export async function removeDevice(deviceId: string): Promise<void> {
return apiRequest<void>(`/auth/user/devices/${deviceId}`, {
method: 'DELETE',
})
}

View File

@@ -64,27 +64,10 @@ export async function deleteFunnel(siteId: string, funnelId: string): Promise<vo
})
}
const DATE_ONLY_REGEX = /^\d{4}-\d{2}-\d{2}$/
/** Normalize date-only (YYYY-MM-DD) to RFC3339 for backend funnel stats API. Uses UTC for boundaries (API/server timestamps are UTC). */
function toRFC3339Range(from: string, to: string): { from: string; to: string } {
return {
from: DATE_ONLY_REGEX.test(from) ? `${from}T00:00:00.000Z` : from,
to: DATE_ONLY_REGEX.test(to) ? `${to}T23:59:59.999Z` : to,
}
}
export async function getFunnelStats(siteId: string, funnelId: string, from?: string, to?: string): Promise<FunnelStats> {
export async function getFunnelStats(siteId: string, funnelId: string, startDate?: string, endDate?: string): Promise<FunnelStats> {
const params = new URLSearchParams()
if (from && to) {
const { from: fromRfc, to: toRfc } = toRFC3339Range(from, to)
params.append('from', fromRfc)
params.append('to', toRfc)
} else if (from) {
params.append('from', DATE_ONLY_REGEX.test(from) ? `${from}T00:00:00.000Z` : from)
} else if (to) {
params.append('to', DATE_ONLY_REGEX.test(to) ? `${to}T23:59:59.999Z` : to)
}
if (startDate) params.append('start_date', startDate)
if (endDate) params.append('end_date', endDate)
const queryString = params.toString() ? `?${params.toString()}` : ''
return apiRequest<FunnelStats>(`/sites/${siteId}/funnels/${funnelId}/stats${queryString}`)
}

View File

@@ -47,7 +47,6 @@ export async function getUserOrganizations(): Promise<OrganizationMember[]> {
// Switch Context (Get token for specific org)
export async function switchContext(organizationId: string | null): Promise<{ access_token: string; expires_in: number }> {
const payload = { organization_id: organizationId || '' }
console.log('Sending switch context request:', payload)
return await authFetch<{ access_token: string; expires_in: number }>('/auth/switch-context', {
method: 'POST',
body: JSON.stringify(payload),
@@ -87,10 +86,7 @@ export async function sendInvitation(
role: string = 'member',
captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string }
): Promise<OrganizationInvitation> {
const body: any = {
email,
role
}
const body: Record<string, string> = { email, role }
if (captcha?.captcha_id) body.captcha_id = captcha.captcha_id
if (captcha?.captcha_solution) body.captcha_solution = captcha.captcha_solution

View File

@@ -1,6 +1,8 @@
import apiRequest from './client'
import { Site } from './sites'
// ─── Types ──────────────────────────────────────────────────────────
export interface Stats {
pageviews: number
visitors: number
@@ -11,7 +13,7 @@ export interface Stats {
export interface TopPage {
path: string
pageviews: number
visits?: number // For entry/exit pages
visits?: number
}
export interface ScreenResolutionStat {
@@ -101,6 +103,8 @@ export interface AuthParams {
captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string }
}
// ─── Helpers ────────────────────────────────────────────────────────
function appendAuthParams(params: URLSearchParams, auth?: AuthParams) {
if (auth?.password) params.append('password', auth.password)
if (auth?.captcha?.captcha_id) params.append('captcha_id', auth.captcha.captcha_id)
@@ -108,198 +112,117 @@ function appendAuthParams(params: URLSearchParams, auth?: AuthParams) {
if (auth?.captcha?.captcha_token) params.append('captcha_token', auth.captcha.captcha_token)
}
export async function getStats(siteId: string, startDate?: string, endDate?: string): Promise<Stats> {
function buildQuery(
opts: {
startDate?: string
endDate?: string
limit?: number
interval?: string
countryLimit?: number
sort?: string
filters?: string
},
auth?: AuthParams
): string {
const params = new URLSearchParams()
if (startDate) params.append('start_date', startDate)
if (endDate) params.append('end_date', endDate)
if (opts.startDate) params.append('start_date', opts.startDate)
if (opts.endDate) params.append('end_date', opts.endDate)
if (opts.limit != null) params.append('limit', opts.limit.toString())
if (opts.interval) params.append('interval', opts.interval)
if (opts.countryLimit != null) params.append('country_limit', opts.countryLimit.toString())
if (opts.sort) params.append('sort', opts.sort)
if (opts.filters) params.append('filters', opts.filters)
if (auth) appendAuthParams(params, auth)
const query = params.toString()
return apiRequest<Stats>(`/sites/${siteId}/stats${query ? `?${query}` : ''}`)
return query ? `?${query}` : ''
}
export async function getPublicStats(siteId: string, startDate?: string, endDate?: string, auth?: AuthParams): Promise<Stats> {
const params = new URLSearchParams()
if (startDate) params.append('start_date', startDate)
if (endDate) params.append('end_date', endDate)
appendAuthParams(params, auth)
const query = params.toString()
return apiRequest<Stats>(`/public/sites/${siteId}/stats${query ? `?${query}` : ''}`)
/** Factory for endpoints that return an array nested under a response key. */
function createListFetcher<T>(path: string, field: string, defaultLimit = 10) {
return (siteId: string, startDate?: string, endDate?: string, limit = defaultLimit, filters?: string): Promise<T[]> =>
apiRequest<Record<string, T[]>>(`/sites/${siteId}/${path}${buildQuery({ startDate, endDate, limit, filters })}`)
.then(r => r?.[field] || [])
}
export async function getRealtime(siteId: string): Promise<RealtimeStats> {
// ─── List Endpoints ─────────────────────────────────────────────────
export const getTopPages = createListFetcher<TopPage>('pages', 'pages')
export const getTopReferrers = createListFetcher<TopReferrer>('referrers', 'referrers')
export const getCountries = createListFetcher<CountryStat>('countries', 'countries')
export const getCities = createListFetcher<CityStat>('cities', 'cities')
export const getRegions = createListFetcher<RegionStat>('regions', 'regions')
export const getBrowsers = createListFetcher<BrowserStat>('browsers', 'browsers')
export const getOS = createListFetcher<OSStat>('os', 'os')
export const getDevices = createListFetcher<DeviceStat>('devices', 'devices')
export const getEntryPages = createListFetcher<TopPage>('entry-pages', 'pages')
export const getExitPages = createListFetcher<TopPage>('exit-pages', 'pages')
export const getScreenResolutions = createListFetcher<ScreenResolutionStat>('screen-resolutions', 'screen_resolutions')
export const getGoalStats = createListFetcher<GoalCountStat>('goals/stats', 'goal_counts', 20)
export const getCampaigns = createListFetcher<CampaignStat>('campaigns', 'campaigns')
// ─── Stats & Realtime ───────────────────────────────────────────────
export function getStats(siteId: string, startDate?: string, endDate?: string, filters?: string): Promise<Stats> {
return apiRequest<Stats>(`/sites/${siteId}/stats${buildQuery({ startDate, endDate, filters })}`)
}
export function getPublicStats(siteId: string, startDate?: string, endDate?: string, auth?: AuthParams): Promise<Stats> {
return apiRequest<Stats>(`/public/sites/${siteId}/stats${buildQuery({ startDate, endDate }, auth)}`)
}
export function getRealtime(siteId: string): Promise<RealtimeStats> {
return apiRequest<RealtimeStats>(`/sites/${siteId}/realtime`)
}
export async function getPublicRealtime(siteId: string, auth?: AuthParams): Promise<RealtimeStats> {
const params = new URLSearchParams()
appendAuthParams(params, auth)
return apiRequest<RealtimeStats>(`/public/sites/${siteId}/realtime?${params.toString()}`)
export function getPublicRealtime(siteId: string, auth?: AuthParams): Promise<RealtimeStats> {
return apiRequest<RealtimeStats>(`/public/sites/${siteId}/realtime${buildQuery({}, auth)}`)
}
export async function getTopPages(siteId: string, startDate?: string, endDate?: string, limit = 10): Promise<TopPage[]> {
const params = new URLSearchParams()
if (startDate) params.append('start_date', startDate)
if (endDate) params.append('end_date', endDate)
params.append('limit', limit.toString())
return apiRequest<{ pages: TopPage[] }>(`/sites/${siteId}/pages?${params.toString()}`).then(r => r?.pages || [])
// ─── Daily Stats ────────────────────────────────────────────────────
export function getDailyStats(siteId: string, startDate?: string, endDate?: string, interval?: string, filters?: string): Promise<DailyStat[]> {
return apiRequest<{ stats: DailyStat[] }>(`/sites/${siteId}/daily${buildQuery({ startDate, endDate, interval, filters })}`)
.then(r => r?.stats || [])
}
export async function getTopReferrers(siteId: string, startDate?: string, endDate?: string, limit = 10): Promise<TopReferrer[]> {
const params = new URLSearchParams()
if (startDate) params.append('start_date', startDate)
if (endDate) params.append('end_date', endDate)
params.append('limit', limit.toString())
return apiRequest<{ referrers: TopReferrer[] }>(`/sites/${siteId}/referrers?${params.toString()}`).then(r => r?.referrers || [])
export function getPublicDailyStats(siteId: string, startDate?: string, endDate?: string, interval?: string, auth?: AuthParams): Promise<DailyStat[]> {
return apiRequest<{ stats: DailyStat[] }>(`/public/sites/${siteId}/daily${buildQuery({ startDate, endDate, interval }, auth)}`)
.then(r => r?.stats || [])
}
export async function getCountries(siteId: string, startDate?: string, endDate?: string, limit = 10): Promise<CountryStat[]> {
const params = new URLSearchParams()
if (startDate) params.append('start_date', startDate)
if (endDate) params.append('end_date', endDate)
params.append('limit', limit.toString())
return apiRequest<{ countries: CountryStat[] }>(`/sites/${siteId}/countries?${params.toString()}`).then(r => r?.countries || [])
// ─── Public Campaigns ───────────────────────────────────────────────
export function getPublicCampaigns(siteId: string, startDate?: string, endDate?: string, limit = 10, auth?: AuthParams): Promise<CampaignStat[]> {
return apiRequest<{ campaigns: CampaignStat[] }>(`/public/sites/${siteId}/campaigns${buildQuery({ startDate, endDate, limit }, auth)}`)
.then(r => r?.campaigns || [])
}
export async function getCities(siteId: string, startDate?: string, endDate?: string, limit = 10): Promise<CityStat[]> {
const params = new URLSearchParams()
if (startDate) params.append('start_date', startDate)
if (endDate) params.append('end_date', endDate)
params.append('limit', limit.toString())
return apiRequest<{ cities: CityStat[] }>(`/sites/${siteId}/cities?${params.toString()}`).then(r => r?.cities || [])
}
// ─── Performance By Page ────────────────────────────────────────────
export async function getRegions(siteId: string, startDate?: string, endDate?: string, limit = 10): Promise<RegionStat[]> {
const params = new URLSearchParams()
if (startDate) params.append('start_date', startDate)
if (endDate) params.append('end_date', endDate)
params.append('limit', limit.toString())
return apiRequest<{ regions: RegionStat[] }>(`/sites/${siteId}/regions?${params.toString()}`).then(r => r?.regions || [])
}
export async function getBrowsers(siteId: string, startDate?: string, endDate?: string, limit = 10): Promise<BrowserStat[]> {
const params = new URLSearchParams()
if (startDate) params.append('start_date', startDate)
if (endDate) params.append('end_date', endDate)
params.append('limit', limit.toString())
return apiRequest<{ browsers: BrowserStat[] }>(`/sites/${siteId}/browsers?${params.toString()}`).then(r => r?.browsers || [])
}
export async function getOS(siteId: string, startDate?: string, endDate?: string, limit = 10): Promise<OSStat[]> {
const params = new URLSearchParams()
if (startDate) params.append('start_date', startDate)
if (endDate) params.append('end_date', endDate)
params.append('limit', limit.toString())
return apiRequest<{ os: OSStat[] }>(`/sites/${siteId}/os?${params.toString()}`).then(r => r?.os || [])
}
export async function getDevices(siteId: string, startDate?: string, endDate?: string, limit = 10): Promise<DeviceStat[]> {
const params = new URLSearchParams()
if (startDate) params.append('start_date', startDate)
if (endDate) params.append('end_date', endDate)
params.append('limit', limit.toString())
return apiRequest<{ devices: DeviceStat[] }>(`/sites/${siteId}/devices?${params.toString()}`).then(r => r?.devices || [])
}
export async function getDailyStats(siteId: string, startDate?: string, endDate?: string, interval?: string): Promise<DailyStat[]> {
const params = new URLSearchParams()
if (startDate) params.append('start_date', startDate)
if (endDate) params.append('end_date', endDate)
if (interval) params.append('interval', interval)
return apiRequest<{ stats: DailyStat[] }>(`/sites/${siteId}/daily?${params.toString()}`).then(r => r?.stats || [])
}
export async function getPublicDailyStats(siteId: string, startDate?: string, endDate?: string, interval?: string, auth?: AuthParams): Promise<DailyStat[]> {
const params = new URLSearchParams()
if (startDate) params.append('start_date', startDate)
if (endDate) params.append('end_date', endDate)
if (interval) params.append('interval', interval)
appendAuthParams(params, auth)
return apiRequest<{ stats: DailyStat[] }>(`/public/sites/${siteId}/daily?${params.toString()}`).then(r => r?.stats || [])
}
export async function getEntryPages(siteId: string, startDate?: string, endDate?: string, limit = 10): Promise<TopPage[]> {
const params = new URLSearchParams()
if (startDate) params.append('start_date', startDate)
if (endDate) params.append('end_date', endDate)
params.append('limit', limit.toString())
return apiRequest<{ pages: TopPage[] }>(`/sites/${siteId}/entry-pages?${params.toString()}`).then(r => r?.pages || [])
}
export async function getExitPages(siteId: string, startDate?: string, endDate?: string, limit = 10): Promise<TopPage[]> {
const params = new URLSearchParams()
if (startDate) params.append('start_date', startDate)
if (endDate) params.append('end_date', endDate)
params.append('limit', limit.toString())
return apiRequest<{ pages: TopPage[] }>(`/sites/${siteId}/exit-pages?${params.toString()}`).then(r => r?.pages || [])
}
export async function getScreenResolutions(siteId: string, startDate?: string, endDate?: string, limit = 10): Promise<ScreenResolutionStat[]> {
const params = new URLSearchParams()
if (startDate) params.append('start_date', startDate)
if (endDate) params.append('end_date', endDate)
params.append('limit', limit.toString())
return apiRequest<{ screen_resolutions: ScreenResolutionStat[] }>(`/sites/${siteId}/screen-resolutions?${params.toString()}`).then(r => r?.screen_resolutions || [])
}
export async function getPerformanceByPage(
export function getPerformanceByPage(
siteId: string,
startDate?: string,
endDate?: string,
opts?: { limit?: number; sort?: 'lcp' | 'cls' | 'inp' }
): Promise<PerformanceByPageStat[]> {
const params = new URLSearchParams()
if (startDate) params.append('start_date', startDate)
if (endDate) params.append('end_date', endDate)
if (opts?.limit != null) params.append('limit', String(opts.limit))
if (opts?.sort) params.append('sort', opts.sort)
const res = await apiRequest<{ performance_by_page: PerformanceByPageStat[] }>(
`/sites/${siteId}/performance-by-page?${params.toString()}`
)
return res?.performance_by_page ?? []
return apiRequest<{ performance_by_page: PerformanceByPageStat[] }>(
`/sites/${siteId}/performance-by-page${buildQuery({ startDate, endDate, limit: opts?.limit, sort: opts?.sort })}`
).then(r => r?.performance_by_page ?? [])
}
export async function getPublicPerformanceByPage(
export function getPublicPerformanceByPage(
siteId: string,
startDate?: string,
endDate?: string,
opts?: { limit?: number; sort?: 'lcp' | 'cls' | 'inp' },
auth?: AuthParams
): Promise<PerformanceByPageStat[]> {
const params = new URLSearchParams()
if (startDate) params.append('start_date', startDate)
if (endDate) params.append('end_date', endDate)
if (opts?.limit != null) params.append('limit', String(opts.limit))
if (opts?.sort) params.append('sort', opts.sort)
appendAuthParams(params, auth)
const res = await apiRequest<{ performance_by_page: PerformanceByPageStat[] }>(
`/public/sites/${siteId}/performance-by-page?${params.toString()}`
)
return res?.performance_by_page ?? []
return apiRequest<{ performance_by_page: PerformanceByPageStat[] }>(
`/public/sites/${siteId}/performance-by-page${buildQuery({ startDate, endDate, limit: opts?.limit, sort: opts?.sort }, auth)}`
).then(r => r?.performance_by_page ?? [])
}
export async function getGoalStats(siteId: string, startDate?: string, endDate?: string, limit = 20): Promise<GoalCountStat[]> {
const params = new URLSearchParams()
if (startDate) params.append('start_date', startDate)
if (endDate) params.append('end_date', endDate)
params.append('limit', limit.toString())
return apiRequest<{ goal_counts: GoalCountStat[] }>(`/sites/${siteId}/goals/stats?${params.toString()}`).then(r => r?.goal_counts || [])
}
export async function getCampaigns(siteId: string, startDate?: string, endDate?: string, limit = 10): Promise<CampaignStat[]> {
const params = new URLSearchParams()
if (startDate) params.append('start_date', startDate)
if (endDate) params.append('end_date', endDate)
params.append('limit', limit.toString())
return apiRequest<{ campaigns: CampaignStat[] }>(`/sites/${siteId}/campaigns?${params.toString()}`).then(r => r?.campaigns || [])
}
export async function getPublicCampaigns(siteId: string, startDate?: string, endDate?: string, limit = 10, auth?: AuthParams): Promise<CampaignStat[]> {
const params = new URLSearchParams()
if (startDate) params.append('start_date', startDate)
if (endDate) params.append('end_date', endDate)
params.append('limit', limit.toString())
appendAuthParams(params, auth)
return apiRequest<{ campaigns: CampaignStat[] }>(`/public/sites/${siteId}/campaigns?${params.toString()}`).then(r => r?.campaigns || [])
}
// ─── Full Dashboard ─────────────────────────────────────────────────
export interface DashboardData {
site: Site
@@ -322,16 +245,11 @@ export interface DashboardData {
goal_counts?: GoalCountStat[]
}
export async function getDashboard(siteId: string, startDate?: string, endDate?: string, limit = 10, interval?: string): Promise<DashboardData> {
const params = new URLSearchParams()
if (startDate) params.append('start_date', startDate)
if (endDate) params.append('end_date', endDate)
if (interval) params.append('interval', interval)
params.append('limit', limit.toString())
return apiRequest<DashboardData>(`/sites/${siteId}/dashboard?${params.toString()}`)
export function getDashboard(siteId: string, startDate?: string, endDate?: string, limit = 10, interval?: string): Promise<DashboardData> {
return apiRequest<DashboardData>(`/sites/${siteId}/dashboard${buildQuery({ startDate, endDate, limit, interval })}`)
}
export async function getPublicDashboard(
export function getPublicDashboard(
siteId: string,
startDate?: string,
endDate?: string,
@@ -340,13 +258,147 @@ export async function getPublicDashboard(
password?: string,
captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string }
): Promise<DashboardData> {
const params = new URLSearchParams()
if (startDate) params.append('start_date', startDate)
if (endDate) params.append('end_date', endDate)
if (interval) params.append('interval', interval)
appendAuthParams(params, { password, captcha })
params.append('limit', limit.toString())
return apiRequest<DashboardData>(`/public/sites/${siteId}/dashboard?${params.toString()}`)
return apiRequest<DashboardData>(
`/public/sites/${siteId}/dashboard${buildQuery({ startDate, endDate, limit, interval }, { password, captcha })}`
)
}
// ─── Focused Dashboard Endpoints ────────────────────────────────────
export interface DashboardOverviewData {
site: Site
stats: Stats
realtime_visitors: number
daily_stats: DailyStat[]
}
export interface DashboardPagesData {
top_pages: TopPage[]
entry_pages: TopPage[]
exit_pages: TopPage[]
}
export interface DashboardLocationsData {
countries: CountryStat[]
cities: CityStat[]
regions: RegionStat[]
}
export interface DashboardDevicesData {
browsers: BrowserStat[]
os: OSStat[]
devices: DeviceStat[]
screen_resolutions: ScreenResolutionStat[]
}
export interface DashboardReferrersData {
top_referrers: TopReferrer[]
}
export interface DashboardPerformanceData {
performance?: PerformanceStats
performance_by_page?: PerformanceByPageStat[]
}
export interface DashboardGoalsData {
goal_counts: GoalCountStat[]
}
export function getDashboardOverview(siteId: string, startDate?: string, endDate?: string, interval?: string, filters?: string): Promise<DashboardOverviewData> {
return apiRequest<DashboardOverviewData>(`/sites/${siteId}/dashboard/overview${buildQuery({ startDate, endDate, interval, filters })}`)
}
export function getPublicDashboardOverview(
siteId: string, startDate?: string, endDate?: string, interval?: string,
password?: string, captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string }
): Promise<DashboardOverviewData> {
return apiRequest<DashboardOverviewData>(`/public/sites/${siteId}/dashboard/overview${buildQuery({ startDate, endDate, interval }, { password, captcha })}`)
}
export function getDashboardPages(siteId: string, startDate?: string, endDate?: string, limit = 10, filters?: string): Promise<DashboardPagesData> {
return apiRequest<DashboardPagesData>(`/sites/${siteId}/dashboard/pages${buildQuery({ startDate, endDate, limit, filters })}`)
}
export function getPublicDashboardPages(
siteId: string, startDate?: string, endDate?: string, limit = 10,
password?: string, captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string }
): Promise<DashboardPagesData> {
return apiRequest<DashboardPagesData>(`/public/sites/${siteId}/dashboard/pages${buildQuery({ startDate, endDate, limit }, { password, captcha })}`)
}
export function getDashboardLocations(siteId: string, startDate?: string, endDate?: string, limit = 10, countryLimit = 250, filters?: string): Promise<DashboardLocationsData> {
return apiRequest<DashboardLocationsData>(`/sites/${siteId}/dashboard/locations${buildQuery({ startDate, endDate, limit, countryLimit, filters })}`)
}
export function getPublicDashboardLocations(
siteId: string, startDate?: string, endDate?: string, limit = 10, countryLimit = 250,
password?: string, captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string }
): Promise<DashboardLocationsData> {
return apiRequest<DashboardLocationsData>(`/public/sites/${siteId}/dashboard/locations${buildQuery({ startDate, endDate, limit, countryLimit }, { password, captcha })}`)
}
export function getDashboardDevices(siteId: string, startDate?: string, endDate?: string, limit = 10, filters?: string): Promise<DashboardDevicesData> {
return apiRequest<DashboardDevicesData>(`/sites/${siteId}/dashboard/devices${buildQuery({ startDate, endDate, limit, filters })}`)
}
export function getPublicDashboardDevices(
siteId: string, startDate?: string, endDate?: string, limit = 10,
password?: string, captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string }
): Promise<DashboardDevicesData> {
return apiRequest<DashboardDevicesData>(`/public/sites/${siteId}/dashboard/devices${buildQuery({ startDate, endDate, limit }, { password, captcha })}`)
}
export function getDashboardReferrers(siteId: string, startDate?: string, endDate?: string, limit = 10, filters?: string): Promise<DashboardReferrersData> {
return apiRequest<DashboardReferrersData>(`/sites/${siteId}/dashboard/referrers${buildQuery({ startDate, endDate, limit, filters })}`)
}
export function getPublicDashboardReferrers(
siteId: string, startDate?: string, endDate?: string, limit = 10,
password?: string, captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string }
): Promise<DashboardReferrersData> {
return apiRequest<DashboardReferrersData>(`/public/sites/${siteId}/dashboard/referrers${buildQuery({ startDate, endDate, limit }, { password, captcha })}`)
}
export function getDashboardPerformance(siteId: string, startDate?: string, endDate?: string, filters?: string): Promise<DashboardPerformanceData> {
return apiRequest<DashboardPerformanceData>(`/sites/${siteId}/dashboard/performance${buildQuery({ startDate, endDate, filters })}`)
}
export function getPublicDashboardPerformance(
siteId: string, startDate?: string, endDate?: string,
password?: string, captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string }
): Promise<DashboardPerformanceData> {
return apiRequest<DashboardPerformanceData>(`/public/sites/${siteId}/dashboard/performance${buildQuery({ startDate, endDate }, { password, captcha })}`)
}
export function getDashboardGoals(siteId: string, startDate?: string, endDate?: string, limit = 10, filters?: string): Promise<DashboardGoalsData> {
return apiRequest<DashboardGoalsData>(`/sites/${siteId}/dashboard/goals${buildQuery({ startDate, endDate, limit, filters })}`)
}
export function getPublicDashboardGoals(
siteId: string, startDate?: string, endDate?: string, limit = 10,
password?: string, captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string }
): Promise<DashboardGoalsData> {
return apiRequest<DashboardGoalsData>(`/public/sites/${siteId}/dashboard/goals${buildQuery({ startDate, endDate, limit }, { password, captcha })}`)
}
// ─── Event Properties ────────────────────────────────────────────────
export interface EventPropertyKey {
key: string
count: number
}
export interface EventPropertyValue {
value: string
count: number
}
export function getEventPropertyKeys(siteId: string, eventName: string, startDate?: string, endDate?: string): Promise<EventPropertyKey[]> {
return apiRequest<{ keys: EventPropertyKey[] }>(`/sites/${siteId}/goals/${encodeURIComponent(eventName)}/properties${buildQuery({ startDate, endDate })}`)
.then(r => r?.keys || [])
}
export function getEventPropertyValues(siteId: string, eventName: string, propName: string, startDate?: string, endDate?: string, limit = 20): Promise<EventPropertyValue[]> {
return apiRequest<{ values: EventPropertyValue[] }>(`/sites/${siteId}/goals/${encodeURIComponent(eventName)}/properties/${encodeURIComponent(propName)}${buildQuery({ startDate, endDate, limit })}`)
.then(r => r?.values || [])
}

View File

@@ -18,24 +18,8 @@ export interface Session {
}
export async function getUserSessions(): Promise<{ sessions: Session[] }> {
// Hash the current refresh token to identify current session
const refreshToken = typeof window !== 'undefined' ? localStorage.getItem('refreshToken') : null
let currentTokenHash = ''
if (refreshToken) {
// Hash the refresh token using SHA-256
const encoder = new TextEncoder()
const data = encoder.encode(refreshToken)
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
const hashArray = Array.from(new Uint8Array(hashBuffer))
currentTokenHash = hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
}
return apiRequest<{ sessions: Session[] }>('/auth/user/sessions', {
headers: currentTokenHash ? {
'X-Current-Session-Hash': currentTokenHash,
} : undefined,
})
// Current session is identified server-side via the httpOnly refresh token cookie
return apiRequest<{ sessions: Session[] }>('/auth/user/sessions')
}
export async function revokeSession(sessionId: string): Promise<void> {
@@ -48,7 +32,9 @@ export interface UserPreferences {
email_notifications: {
new_file_received: boolean
file_downloaded: boolean
security_alerts: boolean
login_alerts: boolean
password_alerts: boolean
two_factor_alerts: boolean
}
}

54
lib/api/webauthn.ts Normal file
View File

@@ -0,0 +1,54 @@
/**
* WebAuthn / Passkey API client for settings (list, register, delete).
*/
import { startRegistration, type PublicKeyCredentialCreationOptionsJSON } from '@simplewebauthn/browser'
import apiRequest from './client'
export interface BeginRegistrationResponse {
sessionId: string
creationOptions: {
publicKey: Record<string, unknown>
mediation?: string
}
}
export interface PasskeyCredential {
id: string
createdAt: string
}
export interface ListPasskeysResponse {
credentials: PasskeyCredential[]
}
export async function registerPasskey(): Promise<void> {
const { sessionId, creationOptions } = await apiRequest<BeginRegistrationResponse>(
'/auth/webauthn/register/begin',
{ method: 'POST' }
)
const optionsJSON = creationOptions?.publicKey
if (!optionsJSON) {
throw new Error('Invalid registration options')
}
const response = await startRegistration({
optionsJSON: optionsJSON as unknown as PublicKeyCredentialCreationOptionsJSON,
})
await apiRequest<{ message: string }>('/auth/webauthn/register/finish', {
method: 'POST',
body: JSON.stringify({ sessionId, response }),
})
}
export async function listPasskeys(): Promise<ListPasskeysResponse> {
return apiRequest<ListPasskeysResponse>('/auth/webauthn/credentials', {
method: 'GET',
})
}
export async function deletePasskey(credentialId: string): Promise<void> {
return apiRequest<void>(
`/auth/webauthn/credentials/${encodeURIComponent(credentialId)}`,
{ method: 'DELETE' }
)
}

View File

@@ -3,9 +3,10 @@
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react'
import { useRouter, usePathname } from 'next/navigation'
import apiRequest from '@/lib/api/client'
import { LoadingOverlay } from '@ciphera-net/ui'
import { LoadingOverlay, useSessionSync, SessionExpiryWarning } from '@ciphera-net/ui'
import { logoutAction, getSessionAction, setSessionAction } from '@/app/actions/auth'
import { getUserOrganizations, switchContext } from '@/lib/api/organization'
import { logger } from '@/lib/utils/logger'
interface User {
id: string
@@ -18,7 +19,9 @@ interface User {
email_notifications?: {
new_file_received: boolean
file_downloaded: boolean
security_alerts: boolean
login_alerts: boolean
password_alerts: boolean
two_factor_alerts: boolean
}
}
}
@@ -48,9 +51,25 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const router = useRouter()
const pathname = usePathname()
const refreshToken = useCallback(async (): Promise<boolean> => {
try {
const res = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include',
})
if (res.ok) {
localStorage.setItem('ciphera_token_refreshed_at', Date.now().toString())
}
return res.ok
} catch {
return false
}
}, [])
const login = (userData: User) => {
// * We still store user profile in localStorage for optimistic UI, but NOT the token
localStorage.setItem('user', JSON.stringify(userData))
localStorage.setItem('ciphera_token_refreshed_at', Date.now().toString())
setUser(userData)
router.refresh()
// * Fetch full profile (including display_name) so header shows correct name without page refresh
@@ -66,17 +85,21 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
return merged
})
})
.catch((e) => console.error('Failed to fetch full profile after login', e))
.catch((e) => logger.error('Failed to fetch full profile after login', e))
}
const logout = useCallback(async () => {
setIsLoggingOut(true)
await logoutAction()
localStorage.removeItem('user')
// * Clear legacy tokens if they exist
localStorage.removeItem('token')
localStorage.removeItem('refreshToken')
localStorage.removeItem('ciphera_token_refreshed_at')
localStorage.removeItem('ciphera_last_activity')
// * Broadcast logout to other tabs (BroadcastChannel will handle if available)
if (typeof window !== 'undefined' && 'BroadcastChannel' in window) {
const channel = new BroadcastChannel('ciphera_session')
channel.postMessage({ type: 'LOGOUT' })
channel.close()
}
setTimeout(() => {
window.location.href = '/'
}, 500)
@@ -96,7 +119,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
return merged
})
} catch (e) {
console.error('Failed to refresh user data', e)
logger.error('Failed to refresh user data', e)
}
router.refresh()
}, [router])
@@ -109,11 +132,24 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
const init = async () => {
// * 1. Check server-side session (cookies)
const session = await getSessionAction()
let session = await getSessionAction()
// * 2. If no access_token but refresh_token may exist, try refresh (fixes 15-min inactivity logout)
if (!session && typeof window !== 'undefined') {
const refreshRes = await fetch('/api/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
})
if (refreshRes.ok) {
session = await getSessionAction()
}
}
if (session) {
setUser(session)
localStorage.setItem('user', JSON.stringify(session))
localStorage.setItem('ciphera_token_refreshed_at', Date.now().toString())
// * Fetch full profile (including display_name) from API; preserve org_id/role from session
try {
const userData = await apiRequest<User>('/auth/user/me')
@@ -121,7 +157,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setUser(merged)
localStorage.setItem('user', JSON.stringify(merged))
} catch (e) {
console.error('Failed to fetch full profile', e)
logger.error('Failed to fetch full profile', e)
}
} else {
// * Session invalid/expired
@@ -129,21 +165,37 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setUser(null)
}
// * Clear legacy tokens if they exist (migration)
if (localStorage.getItem('token')) {
localStorage.removeItem('token')
localStorage.removeItem('refreshToken')
}
setLoading(false)
}
init()
}, [])
// * Sync session across browser tabs using BroadcastChannel
useSessionSync({
onLogout: () => {
localStorage.removeItem('user')
localStorage.removeItem('ciphera_token_refreshed_at')
localStorage.removeItem('ciphera_last_activity')
window.location.href = '/'
},
onLogin: (userData) => {
setUser(userData as User)
router.refresh()
},
onRefresh: () => {
refresh()
},
})
// * Stable primitives for the effect dependency array — avoids re-running
// * on every render when the `user` object reference changes.
const isAuthenticated = !!user
const userOrgId = user?.org_id
// * Organization Wall & Auto-Switch
useEffect(() => {
const checkOrg = async () => {
if (!loading && user) {
if (!loading && isAuthenticated) {
if (pathname?.startsWith('/onboarding')) return
if (pathname?.startsWith('/auth/callback')) return
@@ -157,9 +209,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}
// * If user has organizations but no context (org_id), switch to the first one
if (!user.org_id && organizations.length > 0) {
if (!userOrgId && organizations.length > 0) {
const firstOrg = organizations[0]
console.log('Auto-switching to organization:', firstOrg.organization_name)
try {
const { access_token } = await switchContext(firstOrg.organization_id)
@@ -179,21 +230,26 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
router.refresh()
}
} catch (e) {
console.error('Failed to auto-switch context', e)
logger.error('Failed to auto-switch context', e)
}
}
} catch (e) {
console.error("Failed to fetch organizations", e)
logger.error("Failed to fetch organizations", e)
}
}
}
checkOrg()
}, [loading, user, pathname, router])
}, [loading, isAuthenticated, userOrgId, pathname, router])
return (
<AuthContext.Provider value={{ user, loading, login, logout, refresh, refreshSession }}>
{isLoggingOut && <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Pulse" />}
<SessionExpiryWarning
isAuthenticated={!!user}
onRefreshToken={refreshToken}
onExpired={logout}
/>
{children}
</AuthContext.Provider>
)

60
lib/filters.ts Normal file
View File

@@ -0,0 +1,60 @@
// * Dimension filter types and utilities for dashboard filtering
export interface DimensionFilter {
dimension: string
operator: 'is' | 'is_not' | 'contains' | 'not_contains'
values: string[]
}
export const DIMENSION_LABELS: Record<string, string> = {
page: 'Page',
referrer: 'Referrer',
country: 'Country',
city: 'City',
region: 'Region',
browser: 'Browser',
os: 'OS',
device: 'Device',
utm_source: 'UTM Source',
utm_medium: 'UTM Medium',
utm_campaign: 'UTM Campaign',
}
export const OPERATOR_LABELS: Record<string, string> = {
is: 'is',
is_not: 'is not',
contains: 'contains',
not_contains: 'does not contain',
}
export const DIMENSIONS = Object.keys(DIMENSION_LABELS)
export const OPERATORS = Object.keys(OPERATOR_LABELS) as DimensionFilter['operator'][]
/** Serialize filters to query param format: "browser|is|Chrome,country|is|US" */
export function serializeFilters(filters: DimensionFilter[]): string {
if (!filters.length) return ''
return filters
.map(f => `${f.dimension}|${f.operator}|${f.values.join(';')}`)
.join(',')
}
/** Parse filters from URL search param string */
export function parseFiltersFromURL(raw: string): DimensionFilter[] {
if (!raw) return []
return raw.split(',').map(part => {
const [dimension, operator, valuesRaw] = part.split('|')
return {
dimension,
operator: operator as DimensionFilter['operator'],
values: valuesRaw?.split(';') ?? [],
}
}).filter(f => f.dimension && f.operator && f.values.length > 0)
}
/** Build display label for a filter pill */
export function filterLabel(f: DimensionFilter): string {
const dim = DIMENSION_LABELS[f.dimension] || f.dimension
const op = OPERATOR_LABELS[f.operator] || f.operator
const val = f.values.length > 1 ? `${f.values[0]} +${f.values.length - 1}` : f.values[0]
return `${dim} ${op} ${val}`
}

View File

@@ -0,0 +1,34 @@
import { describe, it, expect } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useOnlineStatus } from '../useOnlineStatus'
describe('useOnlineStatus', () => {
it('returns true initially', () => {
const { result } = renderHook(() => useOnlineStatus())
expect(result.current).toBe(true)
})
it('returns false when offline event fires', () => {
const { result } = renderHook(() => useOnlineStatus())
act(() => {
window.dispatchEvent(new Event('offline'))
})
expect(result.current).toBe(false)
})
it('returns true when online event fires after offline', () => {
const { result } = renderHook(() => useOnlineStatus())
act(() => {
window.dispatchEvent(new Event('offline'))
})
expect(result.current).toBe(false)
act(() => {
window.dispatchEvent(new Event('online'))
})
expect(result.current).toBe(true)
})
})

View File

@@ -0,0 +1,99 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useVisibilityPolling } from '../useVisibilityPolling'
describe('useVisibilityPolling', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('starts polling and calls callback at the visible interval', () => {
const callback = vi.fn()
renderHook(() =>
useVisibilityPolling(callback, {
visibleInterval: 1000,
hiddenInterval: null,
})
)
// Initial call might not happen immediately; advance to trigger interval
act(() => {
vi.advanceTimersByTime(1000)
})
expect(callback).toHaveBeenCalled()
})
it('reports isPolling as true when active', () => {
const callback = vi.fn()
const { result } = renderHook(() =>
useVisibilityPolling(callback, {
visibleInterval: 1000,
hiddenInterval: null,
})
)
expect(result.current.isPolling).toBe(true)
})
it('calls callback multiple times over multiple intervals', () => {
const callback = vi.fn()
renderHook(() =>
useVisibilityPolling(callback, {
visibleInterval: 500,
hiddenInterval: null,
})
)
act(() => {
vi.advanceTimersByTime(1500)
})
expect(callback.mock.calls.length).toBeGreaterThanOrEqual(2)
})
it('triggerPoll calls callback immediately', () => {
const callback = vi.fn()
const { result } = renderHook(() =>
useVisibilityPolling(callback, {
visibleInterval: 10000,
hiddenInterval: null,
})
)
act(() => {
result.current.triggerPoll()
})
expect(callback).toHaveBeenCalled()
expect(result.current.lastPollTime).not.toBeNull()
})
it('cleans up intervals on unmount', () => {
const callback = vi.fn()
const { unmount } = renderHook(() =>
useVisibilityPolling(callback, {
visibleInterval: 1000,
hiddenInterval: null,
})
)
unmount()
callback.mockClear()
act(() => {
vi.advanceTimersByTime(5000)
})
expect(callback).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,28 @@
'use client'
import { useCallback } from 'react'
/**
* Provides an onKeyDown handler for WAI-ARIA tab lists.
* Moves focus between sibling `[role="tab"]` buttons with Left/Right arrow keys.
*/
export function useTabListKeyboard() {
return useCallback((e: React.KeyboardEvent<HTMLElement>) => {
const target = e.currentTarget
const tabs = Array.from(target.querySelectorAll<HTMLElement>('[role="tab"]'))
const index = tabs.indexOf(e.target as HTMLElement)
if (index < 0) return
let next: number | null = null
if (e.key === 'ArrowRight') next = (index + 1) % tabs.length
else if (e.key === 'ArrowLeft') next = (index - 1 + tabs.length) % tabs.length
else if (e.key === 'Home') next = 0
else if (e.key === 'End') next = tabs.length - 1
if (next !== null) {
e.preventDefault()
tabs[next].focus()
tabs[next].click()
}
}, [])
}

View File

@@ -0,0 +1,24 @@
'use client'
import { useEffect, useCallback } from 'react'
/**
* Warns users with a browser prompt when they try to navigate away
* or close the tab while there are unsaved form changes.
*
* @param isDirty - Whether the form has unsaved changes
*/
export function useUnsavedChanges(isDirty: boolean) {
const handleBeforeUnload = useCallback(
(e: BeforeUnloadEvent) => {
if (!isDirty) return
e.preventDefault()
},
[isDirty]
)
useEffect(() => {
window.addEventListener('beforeunload', handleBeforeUnload)
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
}, [handleBeforeUnload])
}

View File

@@ -0,0 +1,128 @@
// * Custom hook for visibility-aware polling
// * Pauses polling when tab is not visible, resumes when visible
// * Reduces server load when users aren't actively viewing the dashboard
import { useEffect, useRef, useState, useCallback } from 'react'
interface UseVisibilityPollingOptions {
// * Polling interval when tab is visible (in milliseconds)
visibleInterval: number
// * Polling interval when tab is hidden (in milliseconds, or null to pause)
hiddenInterval: number | null
}
interface UseVisibilityPollingReturn {
// * Whether polling is currently active
isPolling: boolean
// * Time since last poll
lastPollTime: number | null
// * Force a poll immediately
triggerPoll: () => void
}
export function useVisibilityPolling(
callback: () => void | Promise<void>,
options: UseVisibilityPollingOptions
): UseVisibilityPollingReturn {
const { visibleInterval, hiddenInterval } = options
const [isPolling, setIsPolling] = useState(false)
const [lastPollTime, setLastPollTime] = useState<number | null>(null)
const intervalRef = useRef<NodeJS.Timeout | null>(null)
const callbackRef = useRef(callback)
// * Keep callback reference up to date
useEffect(() => {
callbackRef.current = callback
}, [callback])
// * Get current polling interval based on visibility
const getInterval = useCallback((): number | null => {
if (typeof document === 'undefined') return null
const isVisible = document.visibilityState === 'visible'
if (isVisible) {
return visibleInterval
}
return hiddenInterval
}, [visibleInterval, hiddenInterval])
// * Start polling with current interval
const startPolling = useCallback(() => {
const interval = getInterval()
if (interval === null) {
setIsPolling(false)
return
}
setIsPolling(true)
// * Clear any existing interval
if (intervalRef.current) {
clearInterval(intervalRef.current)
}
// * Set up new interval
intervalRef.current = setInterval(() => {
callbackRef.current()
setLastPollTime(Date.now())
}, interval)
}, [getInterval])
// * Stop polling
const stopPolling = useCallback(() => {
if (intervalRef.current) {
clearInterval(intervalRef.current)
intervalRef.current = null
}
setIsPolling(false)
}, [])
// * Trigger immediate poll
const triggerPoll = useCallback(() => {
callbackRef.current()
setLastPollTime(Date.now())
// * Restart polling timer
startPolling()
}, [startPolling])
// * Handle visibility changes
useEffect(() => {
if (typeof document === 'undefined') return
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
// * Tab became visible - resume polling with visible interval
startPolling()
// * Trigger immediate poll to get fresh data
triggerPoll()
} else {
// * Tab hidden - switch to hidden interval or pause
const interval = getInterval()
if (interval === null) {
stopPolling()
} else {
// * Restart with hidden interval
startPolling()
}
}
}
// * Listen for visibility changes
document.addEventListener('visibilitychange', handleVisibilityChange)
// * Start polling initially
startPolling()
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange)
stopPolling()
}
}, [startPolling, stopPolling, triggerPoll, getInterval])
return {
isPolling,
lastPollTime,
triggerPoll,
}
}

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