197 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
Usman
20959683e5 Merge pull request #34 from ciphera-net/staging
[PULSE-59] Design consistency audit fixes
2026-02-22 00:09:41 +01:00
Usman Baig
1a970279b5 chore: release version 0.10.0-alpha with design consistency improvements across various components 2026-02-22 00:06:26 +01:00
Usman Baig
ee25d87097 chore: update package versions and dependencies for improved functionality 2026-02-21 23:58:39 +01:00
Usman Baig
4dead4b399 style: standardize gap sizes across multiple components for improved visual consistency 2026-02-21 23:48:03 +01:00
Usman Baig
aada06c207 style: update domain name in OrganizationSettings component for consistency with new branding 2026-02-21 23:45:51 +01:00
Usman Baig
947e37168d style: update background colors and border styles in integration and installation pages for improved visual consistency 2026-02-21 23:45:05 +01:00
Usman Baig
d08c8f00a0 style: add transition effects to shadow properties across multiple components for improved visual feedback 2026-02-21 23:42:12 +01:00
Usman Baig
0b68db58be style: standardize min-width values across multiple components for improved layout consistency 2026-02-21 23:39:29 +01:00
Usman Baig
fb47cb0c86 style: update padding in integration pages and IntegrationGuide component for improved layout consistency 2026-02-21 23:36:54 +01:00
Usman Baig
8f8761ed3d style: standardize padding across multiple components for improved layout consistency 2026-02-21 23:29:50 +01:00
Usman Baig
fb3490feb9 refactor: replace anchor tag with Button component in PricingSection for improved styling and consistency 2026-02-21 23:25:00 +01:00
Usman Baig
65ba7ccba2 style: enhance dark mode support by updating text colors across multiple components for improved readability 2026-02-21 23:13:52 +01:00
Usman Baig
f1e6d5a48e style: refactor chart color variables across multiple components to use CSS variables for improved theming consistency 2026-02-21 23:09:34 +01:00
Usman Baig
72c06816fe style: update layout of multiple pages to use consistent max-width and padding for improved responsiveness 2026-02-21 22:53:26 +01:00
Usman Baig
23ba5f77a9 refactor: replace button elements with a unified Button component in SiteSettingsPage and VerificationModal for consistency and improved styling 2026-02-21 22:41:43 +01:00
Usman Baig
e8e304e238 style: update heading sizes across various pages for improved typography consistency 2026-02-21 22:29:26 +01:00
Usman
4ffd61963c Merge pull request #33 from ciphera-net/staging
[PULSE-58] Data retention settings in Site Settings
2026-02-21 20:03:25 +01:00
Usman Baig
d1d82f5b3c feat: refine data retention adjustment logic in SiteSettingsPage to snap to nearest valid option upon subscription load 2026-02-21 19:58:48 +01:00
Usman Baig
98eef9c366 feat: adjust default data retention to 6 months in SiteSettingsPage and add error handling for subscription loading failures 2026-02-21 19:50:27 +01:00
Usman Baig
5c0babe273 feat: implement data retention clamping in SiteSettingsPage to ensure user settings align with subscription plan limits 2026-02-21 19:45:35 +01:00
Usman Baig
22b2c036ac chore: update CHANGELOG and package version to 0.9.0-alpha, adding data retention features and settings for site owners 2026-02-21 19:40:33 +01:00
Usman Baig
1e41bedc86 fix: update maximum data retention for business plan from 60 to 36 months and adjust retention options accordingly 2026-02-21 18:28:56 +01:00
Usman Baig
1ae20dba4c feat: add data retention settings to SiteSettingsPage, including subscription-based options and UI updates for user interaction 2026-02-21 18:21:43 +01:00
Usman
42ed7d91dd Merge pull request #32 from ciphera-net/staging
[PULSE-57] Billing UX: renewal display, design fixes, React crash fix
2026-02-20 18:32:33 +01:00
Usman Baig
b8cb7e177e chore: update CHANGELOG for version 0.8.0-alpha, adding new features, changes, and fixes related to billing and subscription management 2026-02-20 18:32:12 +01:00
Usman Baig
fa3982001d feat: enhance HomePage and OrganizationSettings to display detailed subscription information and improve user interaction with invoice links 2026-02-20 18:05:59 +01:00
Usman Baig
6817f0c9fa fix: streamline invoice preview logic in OrganizationSettings to improve performance and user feedback during plan changes 2026-02-20 17:50:46 +01:00
Usman Baig
5b1d3d8f0e refactor: update PricingSection styles for improved layout and accessibility; enhance OrganizationSettings to handle plan changes and display past due notices 2026-02-20 16:50:43 +01:00
Usman Baig
12975f671d fix: update invoice preview handling in OrganizationSettings to reset state and provide user feedback on calculation errors 2026-02-20 16:21:35 +01:00
Usman Baig
cc89a27972 feat: add invoice preview functionality in OrganizationSettings to enhance user experience with upcoming billing details 2026-02-20 16:18:00 +01:00
Usman Baig
99e9235f1f feat: add resume subscription functionality in OrganizationSettings for improved user control over billing 2026-02-20 16:07:17 +01:00
Usman Baig
53ed7493c6 style: update download and view invoice links in OrganizationSettings for improved UI consistency and accessibility 2026-02-20 16:04:05 +01:00
Usman Baig
a4f2bebd10 feat: enhance OrganizationSettings to display Tax IDs alongside business name for improved billing clarity 2026-02-20 15:36:50 +01:00
Usman Baig
2d37d065c0 fix: remove CheckoutSuccessToast component and its usage in SettingsPage for cleaner settings interface 2026-02-20 04:02:11 +01:00
Usman Baig
17106517d9 refactor: remove embedded checkout components and update billing API integration for streamlined checkout flow 2026-02-20 03:51:20 +01:00
Usman Baig
96b3919e52 fix: refactor CheckoutReturnPage to use Suspense for loading state and separate content into CheckoutReturnContent component 2026-02-20 03:47:10 +01:00
Usman Baig
0bbbb8a1af feat: integrate Stripe for embedded checkout; update billing API to return client_secret and adjust checkout flow in components 2026-02-20 03:41:35 +01:00
Usman Baig
6d277b126e feat: display billing information with business name in OrganizationSettings component for improved user clarity 2026-02-20 03:10:08 +01:00
Usman Baig
4410366ccf feat: add optional business_name field to SubscriptionDetails interface in billing API for enhanced billing information 2026-02-20 03:03:21 +01:00
Usman Baig
826dbdbe63 feat: implement site limits based on subscription plans across dashboard and new site creation; enhance UI feedback for plan limits 2026-02-20 02:46:23 +01:00
139 changed files with 9281 additions and 2423 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,197 @@ 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
- **Design consistency (PULSE-59).** Pulse now feels more cohesive across all pages — headings, buttons, and layout are consistent.
- **Headings.** Marketing and integration pages use the same heading sizes for a clearer visual hierarchy.
- **Buttons.** Settings pages and the verification modal use consistent button styles. The Enterprise "Contact us" button on pricing now matches the rest.
- **Settings layout.** Profile settings, Organization Settings, and Site Settings now span the full width of the page, matching the dashboard.
- **Charts and maps.** Analytics charts, funnel views, and the uptime map now use Pulse's brand colors correctly in both light and dark mode.
- **Integration guides.** Code examples in the integration and installation guides look cleaner and work better in dark mode.
- **Dark mode.** Text and backgrounds across settings, pricing, and funnels are easier to read when you switch themes.
- **Cards and panels.** All cards use consistent padding for a more even layout.
- **Integration pages.** Integration setup guides have more comfortable spacing at the top.
- **Org slug.** The organization URL prefix correctly shows `pulse.ciphera.net/` instead of the wrong domain.
## [0.9.0-alpha] - 2026-02-21
### Added
- **Data retention settings (PULSE-58).** Site owners can choose how long raw event data is kept (1 month to 3 years depending on plan). Events older than the retention period are automatically deleted every 24 hours. Aggregated daily stats are preserved so historical charts remain intact.
- **Data Retention section in Site Settings.** Under Data & Privacy, a dropdown lets you set retention; options are capped by your plan (free: up to 6 months, solo: 1 year, team: 2 years, business: 3 years).
- **Privacy snippet includes retention.** The generated privacy policy text now mentions when raw data is automatically deleted.
## [0.8.0-alpha] - 2026-02-20
### Added
- **Renewal date and amount.** The dashboard and billing tab now show when your subscription renews and how much you'll be charged.
- **Invoice preview when changing plans.** Before you switch plans, you can see exactly what your next invoice will be (including prorations).
- **Pay now for open invoices.** Unpaid invoices show a clear "Pay now" button so you can settle them quickly.
- **Enterprise contact.** The pricing page Enterprise plan now links to email us directly instead of checkout.
- **Past due alert.** If your payment fails, a red banner appears with a link to update your payment method.
- **Pageview usage bar.** Your billing card shows a color-coded bar so you can see at a glance how close you are to your limit (green, then amber, then red).
### Changed
- **Change plan flow.** Cleaner plan selector with Solo, Team, and Business options. Shows which plan you're on and a preview of your next invoice. If the preview can't be calculated, you'll see a friendly message instead of a blank screen.
- **Billing tab layout.** Improved spacing, clearer headings, and better focus when using keyboard navigation.
- **Pricing page layout.** Updated spacing and typography. Slider and billing toggle are more accessible.
- **Billing Portal return.** After updating your payment method in Stripe's portal, you're taken back to the billing tab instead of the general settings page.
### Fixed
- **Theme toggle crash.** Fixed a crash that could occur when switching between light and dark mode on the pricing page and then opening organization settings.
## [0.7.0-alpha] - 2026-02-17
### Changed
@@ -82,7 +273,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
---
[Unreleased]: https://github.com/ciphera-net/pulse/compare/v0.7.0-alpha...HEAD
[Unreleased]: https://github.com/ciphera-net/pulse/compare/v0.13.0-alpha...HEAD
[0.13.0-alpha]: https://github.com/ciphera-net/pulse/releases/tag/v0.13.0-alpha
[0.12.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.11.1-alpha...v0.12.0-alpha
[0.11.1-alpha]: https://github.com/ciphera-net/pulse/compare/v0.11.0-alpha...v0.11.1-alpha
[0.11.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.10.0-alpha...v0.11.0-alpha
[0.10.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.9.0-alpha...v0.10.0-alpha
[0.9.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.8.0-alpha...v0.9.0-alpha
[0.8.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.7.0-alpha...v0.8.0-alpha
[0.7.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.6.0-alpha...v0.7.0-alpha
[0.6.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.5.1-alpha...v0.6.0-alpha
[0.5.1-alpha]: https://github.com/ciphera-net/pulse/compare/v0.5.0-alpha...v0.5.1-alpha

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,18 +152,13 @@ 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,
@@ -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

View File

@@ -18,7 +18,7 @@ export default function ChangelogPage() {
return (
<div className="mx-auto max-w-3xl px-4 sm:px-6 py-8">
<h1 className="text-3xl font-bold text-neutral-900 dark:text-white mb-2">
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-neutral-900 dark:text-white mb-2">
Changelog
</h1>
<p className="text-neutral-600 dark:text-neutral-400 mb-8 text-sm">

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

@@ -129,7 +129,7 @@ export default function FeaturesPage() {
<span className="w-1.5 h-1.5 rounded-full bg-brand-orange animate-pulse" />
Product Tour
</span>
<h1 className="text-4xl md:text-6xl font-bold tracking-tight text-neutral-900 dark:text-white mb-6">
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-neutral-900 dark:text-white mb-6">
Everything you need. <br />
<span className="gradient-text">Nothing you don&apos;t.</span>
</h1>
@@ -147,7 +147,7 @@ export default function FeaturesPage() {
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: i * 0.1 }}
className="card-glass p-8 hover:-translate-y-1 hover:shadow-xl transition-all duration-300 group"
className="card-glass p-6 hover:-translate-y-1 hover:shadow-xl transition-all duration-300 group"
>
<div className="w-12 h-12 rounded-xl bg-brand-orange/10 flex items-center justify-center mb-6 text-brand-orange group-hover:scale-110 transition-transform duration-300">
<feature.icon className="w-6 h-6" />
@@ -171,7 +171,7 @@ export default function FeaturesPage() {
className="mb-28"
>
<div className="text-center mb-14">
<h2 className="text-3xl md:text-4xl font-bold text-neutral-900 dark:text-white mb-4">
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-4">
Powerful analytics, <span className="gradient-text">simplified</span>
</h2>
<p className="text-lg text-neutral-600 dark:text-neutral-400 max-w-2xl mx-auto">
@@ -215,7 +215,7 @@ export default function FeaturesPage() {
>
<div className="grid md:grid-cols-2 gap-10 items-center">
<div>
<h2 className="text-3xl font-bold text-neutral-900 dark:text-white mb-4">
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-4">
Content that <span className="gradient-text">performs</span>
</h2>
<p className="text-neutral-600 dark:text-neutral-400 leading-relaxed mb-6">
@@ -285,7 +285,7 @@ export default function FeaturesPage() {
className="mb-28"
>
<div className="text-center mb-12">
<h2 className="text-3xl md:text-4xl font-bold text-neutral-900 dark:text-white mb-4">
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-4">
Built for trust
</h2>
<p className="text-lg text-neutral-600 dark:text-neutral-400 max-w-2xl mx-auto">
@@ -341,7 +341,7 @@ export default function FeaturesPage() {
className="mb-28"
>
<div className="text-center mb-14">
<h2 className="text-3xl md:text-4xl font-bold text-neutral-900 dark:text-white mb-4">
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-4">
Up and running in <span className="gradient-text">3 minutes</span>
</h2>
<p className="text-lg text-neutral-600 dark:text-neutral-400 max-w-2xl mx-auto">
@@ -390,7 +390,7 @@ export default function FeaturesPage() {
transition={{ duration: 0.5 }}
className="text-center mb-20"
>
<h2 className="text-3xl md:text-4xl font-bold text-neutral-900 dark:text-white mb-4">
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-4">
Ready to see it in action?
</h2>
<p className="text-neutral-600 dark:text-neutral-400 mb-8 max-w-lg mx-auto">

View File

@@ -33,8 +33,8 @@ export default function InstallationPage() {
<h2 className="text-2xl font-bold mb-8 text-neutral-900 dark:text-white">Add the snippet</h2>
<p className="text-neutral-500 mb-8">Just add this snippet to your &lt;head&gt; tag in your layout or index file.</p>
<div className="max-w-2xl mx-auto bg-[#1e1e1e] rounded-xl overflow-hidden shadow-2xl text-left border border-neutral-800">
<div className="flex items-center px-4 py-3 bg-[#252526] border-b border-neutral-800">
<div className="max-w-2xl mx-auto bg-neutral-900 rounded-xl overflow-hidden shadow-2xl text-left border border-neutral-800">
<div className="flex items-center px-4 py-3 bg-neutral-800 border-b border-neutral-800">
<div className="flex gap-2">
<div className="w-3 h-3 rounded-full bg-red-500/20" />
<div className="w-3 h-3 rounded-full bg-yellow-500/20" />
@@ -63,8 +63,8 @@ export default function InstallationPage() {
<p className="text-neutral-500 mb-6 max-w-xl mx-auto">
Track custom events (e.g. signup, purchase) with <code className="px-1.5 py-0.5 rounded bg-neutral-200 dark:bg-neutral-700 text-sm font-mono">pulse.track(&apos;event_name&apos;)</code>. Use letters, numbers, and underscores only. Define goals in your site Settings Goals & Events to see counts in the dashboard.
</p>
<div className="max-w-2xl mx-auto bg-[#1e1e1e] rounded-xl overflow-hidden shadow-2xl text-left border border-neutral-800">
<div className="flex items-center px-4 py-3 bg-[#252526] border-b border-neutral-800">
<div className="max-w-2xl mx-auto bg-neutral-900 rounded-xl overflow-hidden shadow-2xl text-left border border-neutral-800">
<div className="flex items-center px-4 py-3 bg-neutral-800 border-b border-neutral-800">
<div className="flex gap-2">
<div className="w-3 h-3 rounded-full bg-red-500/20" />
<div className="w-3 h-3 rounded-full bg-yellow-500/20" />

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

@@ -16,7 +16,7 @@ export default function NextJsIntegrationPage() {
/>
</div>
<div className="flex-grow w-full max-w-4xl mx-auto px-4 pt-12 pb-10 z-10">
<div className="flex-grow w-full max-w-4xl mx-auto px-4 pt-20 pb-10 z-10">
<Link
href="/integrations"
className="inline-flex items-center text-sm text-neutral-500 hover:text-brand-orange mb-8 transition-colors"
@@ -31,7 +31,7 @@ export default function NextJsIntegrationPage() {
<path d="M64 0C28.7 0 0 28.7 0 64s28.7 64 64 64 64-28.7 64-64S99.3 0 64 0zm27.6 93.9c-.8.9-2.2 1-3.1.2L42.8 52.8V88c0 1.3-1.1 2.3-2.3 2.3h-7.4c-1.3 0-2.3-1.1-2.3-2.3V40c0-1.3 1.1-2.3 2.3-2.3h7.4c1 0 1.9.6 2.2 1.5l48.6 44.8V40c0-1.3 1.1-2.3 2.3-2.3h7.4c1.3 0 2.3 1.1 2.3 2.3v48c0 1.3-1.1 2.3-2.3 2.3h-6.8c-.9 0-1.7-.5-2.1-1.3z" />
</svg>
</div>
<h1 className="text-3xl md:text-4xl font-bold text-neutral-900 dark:text-white">
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-neutral-900 dark:text-white">
Next.js Integration
</h1>
</div>
@@ -48,8 +48,8 @@ export default function NextJsIntegrationPage() {
Add the script to your root layout file (usually <code>app/layout.tsx</code> or <code>app/layout.js</code>).
</p>
<div className="bg-[#1e1e1e] rounded-xl overflow-hidden border border-neutral-800 my-6">
<div className="flex items-center px-4 py-2 bg-[#252526] border-b border-neutral-800">
<div className="bg-neutral-900 rounded-xl overflow-hidden border border-neutral-800 my-6">
<div className="flex items-center px-4 py-2 bg-neutral-800 border-b border-neutral-800">
<span className="text-xs text-neutral-400 font-mono">app/layout.tsx</span>
</div>
<div className="p-4 overflow-x-auto">
@@ -84,8 +84,8 @@ export default function RootLayout({
If you are using the older Pages Router, add the script to your custom <code>_app.tsx</code> or <code>_document.tsx</code>.
</p>
<div className="bg-[#1e1e1e] rounded-xl overflow-hidden border border-neutral-800 my-6">
<div className="flex items-center px-4 py-2 bg-[#252526] border-b border-neutral-800">
<div className="bg-neutral-900 rounded-xl overflow-hidden border border-neutral-800 my-6">
<div className="flex items-center px-4 py-2 bg-neutral-800 border-b border-neutral-800">
<span className="text-xs text-neutral-400 font-mono">pages/_app.tsx</span>
</div>
<div className="p-4 overflow-x-auto">

View File

@@ -158,7 +158,7 @@ export default function IntegrationsPage() {
</button>
) : (
<div className="absolute inset-y-0 right-0 flex items-center pr-4 pointer-events-none">
<kbd className="hidden sm:inline-flex items-center px-1.5 py-0.5 rounded text-[11px] font-mono font-medium bg-neutral-200/80 dark:bg-neutral-700/80 text-neutral-500 dark:text-neutral-400 border border-neutral-300 dark:border-neutral-600">
<kbd className="hidden sm:inline-flex items-center px-1.5 py-0.5 rounded text-xs font-mono font-medium bg-neutral-200/80 dark:bg-neutral-700/80 text-neutral-500 dark:text-neutral-400 border border-neutral-300 dark:border-neutral-600">
/
</kbd>
</div>
@@ -285,7 +285,7 @@ export default function IntegrationsPage() {
>
<Link
href={`/integrations/${integration.id}`}
className="group relative p-8 bg-white/50 dark:bg-neutral-900/50 backdrop-blur-sm border border-neutral-200 dark:border-neutral-800 rounded-2xl hover:border-brand-orange/50 dark:hover:border-brand-orange/50 transition-all duration-300 hover:-translate-y-1 hover:shadow-xl block h-full focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-offset-2"
className="group relative p-6 bg-white/50 dark:bg-neutral-900/50 backdrop-blur-sm border border-neutral-200 dark:border-neutral-800 rounded-2xl hover:border-brand-orange/50 dark:hover:border-brand-orange/50 transition-all duration-300 hover:-translate-y-1 hover:shadow-xl block h-full focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-offset-2"
>
<div className="flex items-start justify-between mb-6">
<div className="p-3 bg-neutral-100 dark:bg-neutral-800 rounded-xl group-hover:scale-110 transition-transform duration-300">
@@ -351,7 +351,7 @@ export default function IntegrationsPage() {
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
className="max-w-md mx-auto mt-12 p-8 border border-dashed border-neutral-300 dark:border-neutral-700 rounded-2xl flex flex-col items-center justify-center text-center"
className="max-w-md mx-auto mt-12 p-6 border border-dashed border-neutral-300 dark:border-neutral-700 rounded-2xl flex flex-col items-center justify-center text-center"
>
<h3 className="text-xl font-bold text-neutral-900 dark:text-white mb-2">
Missing something?

View File

@@ -16,7 +16,7 @@ export default function ReactIntegrationPage() {
/>
</div>
<div className="flex-grow w-full max-w-4xl mx-auto px-4 pt-12 pb-10 z-10">
<div className="flex-grow w-full max-w-4xl mx-auto px-4 pt-20 pb-10 z-10">
<Link
href="/integrations"
className="inline-flex items-center text-sm text-neutral-500 hover:text-brand-orange mb-8 transition-colors"
@@ -32,7 +32,7 @@ export default function ReactIntegrationPage() {
<circle cx="64" cy="64" r="10.6" />
</svg>
</div>
<h1 className="text-3xl md:text-4xl font-bold text-neutral-900 dark:text-white">
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-neutral-900 dark:text-white">
React Integration
</h1>
</div>
@@ -49,8 +49,8 @@ export default function ReactIntegrationPage() {
The simplest way is to add the script tag directly to the <code>&lt;head&gt;</code> of your <code>index.html</code> file.
</p>
<div className="bg-[#1e1e1e] rounded-xl overflow-hidden border border-neutral-800 my-6">
<div className="flex items-center px-4 py-2 bg-[#252526] border-b border-neutral-800">
<div className="bg-neutral-900 rounded-xl overflow-hidden border border-neutral-800 my-6">
<div className="flex items-center px-4 py-2 bg-neutral-800 border-b border-neutral-800">
<span className="text-xs text-neutral-400 font-mono">public/index.html</span>
</div>
<div className="p-4 overflow-x-auto">
@@ -83,8 +83,8 @@ export default function ReactIntegrationPage() {
If you need to load the script dynamically (e.g., only in production), you can use a <code>useEffect</code> hook in your main App component.
</p>
<div className="bg-[#1e1e1e] rounded-xl overflow-hidden border border-neutral-800 my-6">
<div className="flex items-center px-4 py-2 bg-[#252526] border-b border-neutral-800">
<div className="bg-neutral-900 rounded-xl overflow-hidden border border-neutral-800 my-6">
<div className="flex items-center px-4 py-2 bg-neutral-800 border-b border-neutral-800">
<span className="text-xs text-neutral-400 font-mono">src/App.tsx</span>
</div>
<div className="p-4 overflow-x-auto">

View File

@@ -16,7 +16,7 @@ export default function VueIntegrationPage() {
/>
</div>
<div className="flex-grow w-full max-w-4xl mx-auto px-4 pt-12 pb-10 z-10">
<div className="flex-grow w-full max-w-4xl mx-auto px-4 pt-20 pb-10 z-10">
<Link
href="/integrations"
className="inline-flex items-center text-sm text-neutral-500 hover:text-brand-orange mb-8 transition-colors"
@@ -32,7 +32,7 @@ export default function VueIntegrationPage() {
<path d="M64 24.6H39L64 67.4l25-42.8H64z" fill="#35495E" />
</svg>
</div>
<h1 className="text-3xl md:text-4xl font-bold text-neutral-900 dark:text-white">
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-neutral-900 dark:text-white">
Vue.js Integration
</h1>
</div>
@@ -49,8 +49,8 @@ export default function VueIntegrationPage() {
Add the script tag to the <code>&lt;head&gt;</code> section of your <code>index.html</code> file. This works for both Vue 2 and Vue 3 projects created with Vue CLI or Vite.
</p>
<div className="bg-[#1e1e1e] rounded-xl overflow-hidden border border-neutral-800 my-6">
<div className="flex items-center px-4 py-2 bg-[#252526] border-b border-neutral-800">
<div className="bg-neutral-900 rounded-xl overflow-hidden border border-neutral-800 my-6">
<div className="flex items-center px-4 py-2 bg-neutral-800 border-b border-neutral-800">
<span className="text-xs text-neutral-400 font-mono">index.html</span>
</div>
<div className="p-4 overflow-x-auto">
@@ -84,8 +84,8 @@ export default function VueIntegrationPage() {
For Nuxt.js applications, you should add the script to your <code>nuxt.config.js</code> or <code>nuxt.config.ts</code> file.
</p>
<div className="bg-[#1e1e1e] rounded-xl overflow-hidden border border-neutral-800 my-6">
<div className="flex items-center px-4 py-2 bg-[#252526] border-b border-neutral-800">
<div className="bg-neutral-900 rounded-xl overflow-hidden border border-neutral-800 my-6">
<div className="flex items-center px-4 py-2 bg-neutral-800 border-b border-neutral-800">
<span className="text-xs text-neutral-400 font-mono">nuxt.config.ts</span>
</div>
<div className="p-4 overflow-x-auto">

View File

@@ -16,7 +16,7 @@ export default function WordPressIntegrationPage() {
/>
</div>
<div className="flex-grow w-full max-w-4xl mx-auto px-4 pt-12 pb-10 z-10">
<div className="flex-grow w-full max-w-4xl mx-auto px-4 pt-20 pb-10 z-10">
<Link
href="/integrations"
className="inline-flex items-center text-sm text-neutral-500 hover:text-brand-orange mb-8 transition-colors"
@@ -31,7 +31,7 @@ export default function WordPressIntegrationPage() {
<path d="M116.6 64c0-19.2-10.4-36-26-45.2l28.6 78.4c-1 3.2-2.2 6.2-3.6 9.2-11.4 12.4-27.8 20.2-46 20.2-6.2 0-12.2-.8-17.8-2.4l26.2-76.4c1.2.2 2.4.4 3.6.4 5.4 0 13.8-.8 13.8-.8 2.8-.2 3.2 4 .4 4.2 0 0-2.8.2-6 .4l19 56.6 5.4-18c2.4-7.4 4.2-12.8 4.2-17.4 0-6-2.2-10.2-7.6-12.6-2.8-1.2-2.2-5.4 1.4-5.4h4.4zM64 121.2c-15.8 0-30.2-6.4-40.8-16.8L46.6 36.8c-2.8-.2-5.8-.4-5.8-.4-2.8-.2-2.4-4.4.4-4.2 0 0 8.4.8 13.6.8 5.4 0 13.6-.8 13.6-.8 2.8-.2 3.2 4 .4 4.2 0 0-2.8.2-5.8.4l18.2 54.4 10.6-31.8L64 121.2zM11.4 64c0 17 8.2 32.2 20.8 41.8L18.8 66.8c-.8-3.4-1.2-6.6-1.2-9.2 0-6.8 2.6-13 6.2-17.8C15.6 47.4 11.4 55.2 11.4 64zM64 6.8c16.2 0 30.8 6.8 41.4 17.6-1.4-.2-2.8-.2-4.2-.2-7.8 0-14.2 1.4-14.2 1.4-2.8.6-2.2 4.8.6 4.2 0 0 5-1 10.6-1 2.2 0 4.6.2 6.6.4L88.2 53 71.4 6.8h-7.4z" />
</svg>
</div>
<h1 className="text-3xl md:text-4xl font-bold text-neutral-900 dark:text-white">
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-neutral-900 dark:text-white">
WordPress Integration
</h1>
</div>
@@ -50,8 +50,8 @@ export default function WordPressIntegrationPage() {
<li>Paste the following code snippet:</li>
</ol>
<div className="bg-[#1e1e1e] rounded-xl overflow-hidden border border-neutral-800 my-6">
<div className="flex items-center px-4 py-2 bg-[#252526] border-b border-neutral-800">
<div className="bg-neutral-900 rounded-xl overflow-hidden border border-neutral-800 my-6">
<div className="flex items-center px-4 py-2 bg-neutral-800 border-b border-neutral-800">
<span className="text-xs text-neutral-400 font-mono">Header Script</span>
</div>
<div className="p-4 overflow-x-auto">

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,27 +80,32 @@ 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)
}
}
const handleCreateOrganization = () => {
router.push('/onboarding')
}
const showOfflineBar = Boolean(auth.user && !isOnline);
const barHeightRem = 2.5;
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} />}
<Header
auth={auth}
LinkComponent={Link}
<Header
auth={auth}
LinkComponent={Link}
logoSrc="/pulse_icon_no_margins.png"
appName="Pulse"
orgs={orgs}
@@ -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 && (
@@ -84,11 +140,20 @@ export default function LayoutContent({ children }: { children: React.ReactNode
>
{children}
</main>
<Footer
<Footer
LinkComponent={Link}
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)
@@ -90,7 +92,7 @@ export default function NotificationsPage() {
if (!user?.org_id) {
return (
<div className="min-h-screen pt-24 pb-12 px-4 sm:px-6">
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
<div className="max-w-2xl mx-auto text-center py-12">
<p className="text-neutral-500">Switch to an organization to view notifications.</p>
<Link href="/welcome" className="text-brand-orange hover:underline mt-4 inline-block">
@@ -102,7 +104,7 @@ export default function NotificationsPage() {
}
return (
<div className="min-h-screen pt-24 pb-12 px-4 sm:px-6">
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
<div className="max-w-2xl mx-auto">
<div className="flex items-center justify-between mb-6">
<Link
@@ -127,16 +129,14 @@ export default function NotificationsPage() {
</Link>
</p>
{loading ? (
<div className="flex justify-center py-12">
<Spinner />
</div>
{showSkeleton ? (
<NotificationsListSkeleton />
) : error ? (
<div className="p-8 text-center text-red-500 bg-red-50 dark:bg-red-900/10 rounded-2xl border border-red-200 dark:border-red-800">
<div className="p-6 text-center text-red-500 bg-red-50 dark:bg-red-900/10 rounded-2xl border border-red-200 dark:border-red-800">
{error}
</div>
) : notifications.length === 0 ? (
<div className="p-8 text-center text-neutral-500 dark:text-neutral-400 rounded-2xl border border-neutral-200 dark:border-neutral-800">
<div className="p-6 text-center text-neutral-500 dark:text-neutral-400 rounded-2xl border border-neutral-200 dark:border-neutral-800">
<p>No notifications yet</p>
<p className="text-sm mt-2">
Manage which notifications you receive in{' '}

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',
@@ -8,9 +9,19 @@ export const metadata = {
export default function OrgSettingsPage() {
return (
<div className="min-h-screen pt-24 pb-12 px-4 sm:px-6">
<div className="max-w-4xl mx-auto">
<Suspense fallback={<div className="p-8 text-center text-neutral-500">Loading...</div>}>
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
<div>
<Suspense fallback={
<div className="space-y-8">
<div>
<div className="h-8 w-56 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800 mb-2" />
<div className="h-4 w-80 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800" />
</div>
<div className="bg-white dark:bg-neutral-900 rounded-2xl border border-neutral-200 dark:border-neutral-800 p-6 md:p-8">
<SettingsFormSkeleton />
</div>
</div>
}>
<OrganizationSettings />
</Suspense>
</div>

View File

@@ -6,39 +6,50 @@ 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'
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>
)
}
@@ -67,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 ? (
@@ -96,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)
@@ -111,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)
@@ -132,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)
@@ -161,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')
}
}
@@ -258,12 +303,12 @@ export default function HomePage() {
{ icon: ZapIcon, title: "Lightweight", desc: "Our script is less than 1kb. It won't slow down your site or affect your SEO." }
].map((feature, i) => (
<motion.div
key={i}
key={feature.title}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: i * 0.1 }}
className="card-glass p-8 hover:-translate-y-1 hover:shadow-xl transition-all duration-300 group"
className="card-glass p-6 hover:-translate-y-1 hover:shadow-xl transition-all duration-300 group"
>
<div className="w-12 h-12 rounded-xl bg-brand-orange/10 flex items-center justify-center mb-6 text-brand-orange group-hover:scale-110 transition-transform duration-300">
<feature.icon className="w-6 h-6" />
@@ -337,10 +382,13 @@ export default function HomePage() {
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white">Your Sites</h1>
<p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">Manage your analytics sites and view insights.</p>
</div>
{subscription?.plan_id === 'solo' && sites.length >= 1 ? (
{(() => {
const siteLimit = getSitesLimitForPlan(subscription?.plan_id)
const atLimit = siteLimit != null && sites.length >= siteLimit
return atLimit ? (
<div className="flex items-center gap-3">
<span className="text-sm font-medium text-neutral-500 dark:text-neutral-400 bg-neutral-100 dark:bg-neutral-800 px-3 py-1.5 rounded-lg border border-neutral-200 dark:border-neutral-700">
Limit reached (1/1)
Limit reached ({sites.length}/{siteLimit})
</span>
<Link href="/pricing">
<Button variant="primary" className="text-sm">
@@ -348,7 +396,8 @@ export default function HomePage() {
</Button>
</Link>
</div>
) : (
) : null
})() ?? (
<Link href="/sites/new">
<Button variant="primary" className="text-sm">
Add New Site
@@ -357,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">
@@ -385,15 +443,34 @@ export default function HomePage() {
return `${label} Plan`
})()}
</p>
{(typeof subscription.sites_count === 'number' || (subscription.pageview_limit > 0 && typeof subscription.pageview_usage === 'number')) && (
{(typeof subscription.sites_count === 'number' || (subscription.pageview_limit > 0 && typeof subscription.pageview_usage === 'number') || (subscription.next_invoice_amount_due != null && subscription.next_invoice_currency && !subscription.cancel_at_period_end && (subscription.subscription_status === 'active' || subscription.subscription_status === 'trialing'))) && (
<p className="text-sm text-neutral-600 dark:text-neutral-400 mt-1">
{typeof subscription.sites_count === 'number' && (
<span>Sites: {subscription.plan_id === 'solo' && subscription.sites_count > 0 ? `${subscription.sites_count}/1` : subscription.sites_count}</span>
<span>Sites: {(() => {
const limit = getSitesLimitForPlan(subscription.plan_id)
return limit != null && typeof subscription.sites_count === 'number' ? `${subscription.sites_count}/${limit}` : subscription.sites_count
})()}</span>
)}
{typeof subscription.sites_count === 'number' && subscription.pageview_limit > 0 && typeof subscription.pageview_usage === 'number' && ' · '}
{typeof subscription.sites_count === 'number' && (subscription.pageview_limit > 0 && typeof subscription.pageview_usage === 'number') && ' · '}
{subscription.pageview_limit > 0 && typeof subscription.pageview_usage === 'number' && (
<span>Pageviews: {subscription.pageview_usage.toLocaleString()}/{subscription.pageview_limit.toLocaleString()}</span>
)}
{subscription.next_invoice_amount_due != null && subscription.next_invoice_currency && !subscription.cancel_at_period_end && (subscription.subscription_status === 'active' || subscription.subscription_status === 'trialing') && (
<span className="block mt-1">
Renews {(() => {
const ts = subscription.next_invoice_period_end ?? subscription.current_period_end
const d = ts ? new Date(typeof ts === 'number' ? ts * 1000 : ts) : null
const dateStr = d && !Number.isNaN(d.getTime()) && d.getTime() !== 0
? d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
: null
const amount = (subscription.next_invoice_amount_due / 100).toLocaleString('en-US', {
style: 'currency',
currency: subscription.next_invoice_currency.toUpperCase(),
})
return dateStr ? `${dateStr} for ${amount}` : amount
})()}
</span>
)}
</p>
)}
<div className="mt-2 flex gap-2">
@@ -415,7 +492,7 @@ export default function HomePage() {
</div>
{!sitesLoading && sites.length === 0 && (
<div className="mb-8 rounded-2xl border-2 border-dashed border-brand-orange/30 bg-brand-orange/5 p-8 text-center dark:bg-brand-orange/10">
<div className="mb-8 rounded-2xl border-2 border-dashed border-brand-orange/30 bg-brand-orange/5 p-6 text-center dark:bg-brand-orange/10">
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-brand-orange/20 text-brand-orange mb-4">
<GlobeIcon className="h-7 w-7" />
</div>
@@ -432,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,19 +0,0 @@
import { Suspense } from 'react'
import ProfileSettings from '@/components/settings/ProfileSettings'
import CheckoutSuccessToast from '@/components/checkout/CheckoutSuccessToast'
export const metadata = {
title: 'Settings - Pulse',
description: 'Manage your account settings',
}
export default function SettingsPage() {
return (
<div className="min-h-screen pt-12 pb-12 px-4 sm:px-6">
<Suspense fallback={null}>
<CheckoutSuccessToast />
</Suspense>
<ProfileSettings />
</div>
)
}

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

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

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

@@ -0,0 +1,73 @@
import type { Metadata } from 'next'
import { FAVICON_SERVICE_URL } from '@/lib/utils/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,14 +197,16 @@ 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) {
return (
<div className="min-h-screen flex items-center justify-center px-4">
<div className="max-w-md w-full bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-8 shadow-lg">
<div className="max-w-md w-full bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 shadow-lg transition-shadow duration-300">
<div className="text-center mb-6">
<div className="w-12 h-12 bg-brand-orange/10 rounded-xl flex items-center justify-center mx-auto mb-4 text-brand-orange">
<ZapIcon className="w-6 h-6" />
@@ -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,
@@ -19,20 +20,20 @@ import {
import { getDateRange } from '@ciphera-net/ui'
const CHART_COLORS_LIGHT = {
border: '#E5E5E5',
axis: '#A3A3A3',
border: 'var(--color-neutral-200)',
axis: 'var(--color-neutral-400)',
tooltipBg: '#ffffff',
tooltipBorder: '#E5E5E5',
tooltipBorder: 'var(--color-neutral-200)',
}
const CHART_COLORS_DARK = {
border: '#404040',
axis: '#737373',
tooltipBg: '#262626',
tooltipBorder: '#404040',
border: 'var(--color-neutral-700)',
axis: 'var(--color-neutral-500)',
tooltipBg: 'var(--color-neutral-800)',
tooltipBorder: 'var(--color-neutral-700)',
}
const BRAND_ORANGE = '#FD5E0F'
const BRAND_ORANGE = 'var(--color-brand-orange)'
export default function FunnelReportPage() {
const params = useParams()
@@ -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)) {
@@ -225,7 +228,7 @@ export default function FunnelReportPage() {
const data = payload[0].payload;
return (
<div
className="p-3 rounded-xl shadow-lg border"
className="p-3 rounded-xl shadow-lg border transition-shadow duration-300"
style={{
backgroundColor: chartColors.tooltipBg,
borderColor: chartColors.tooltipBorder,
@@ -267,15 +270,15 @@ export default function FunnelReportPage() {
<table className="w-full text-left text-sm">
<thead className="bg-neutral-50 dark:bg-neutral-800/50 border-b border-neutral-200 dark:border-neutral-800">
<tr>
<th className="px-6 py-4 font-medium text-neutral-500 uppercase tracking-wider">Step</th>
<th className="px-6 py-4 font-medium text-neutral-500 uppercase tracking-wider text-right">Visitors</th>
<th className="px-6 py-4 font-medium text-neutral-500 uppercase tracking-wider text-right">Drop-off</th>
<th className="px-6 py-4 font-medium text-neutral-500 uppercase tracking-wider text-right">Conversion</th>
<th className="px-6 py-4 font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Step</th>
<th className="px-6 py-4 font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider text-right">Visitors</th>
<th className="px-6 py-4 font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider text-right">Drop-off</th>
<th className="px-6 py-4 font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider text-right">Conversion</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-800">
{stats.steps.map((step, i) => (
<tr key={i} className="hover:bg-neutral-50 dark:hover:bg-neutral-800/30 transition-colors">
<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">
@@ -283,7 +286,7 @@ export default function FunnelReportPage() {
</span>
<div>
<p className="font-medium text-neutral-900 dark:text-white">{step.step.name}</p>
<p className="text-neutral-500 text-xs font-mono mt-0.5">{step.step.value}</p>
<p className="text-neutral-500 dark:text-neutral-400 text-xs font-mono mt-0.5">{step.step.value}</p>
</div>
</div>
</td>

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(() => {
try {
const savedSettings = localStorage.getItem('pulse_dashboard_settings')
if (savedSettings) {
const settings = JSON.parse(savedSettings)
// 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)
}
// Dimension filters state
const searchParams = useSearchParams()
const [filters, setFilters] = useState<DimensionFilter[]>(() => {
const raw = searchParams.get('filters')
return raw ? parseFiltersFromURL(raw) : []
})
const filtersParam = useMemo(() => serializeFilters(filters), [filters])
// Restore intervals
if (settings.todayInterval) setTodayInterval(settings.todayInterval)
if (settings.multiDayInterval) setMultiDayInterval(settings.multiDayInterval)
// 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 regionNames = (() => { try { return new Intl.DisplayNames(['en'], { type: 'region' }) } catch { return null } })()
switch (dimension) {
case 'page': {
const data = await getTopPages(siteId, start, end, limit, f)
return data.map(p => ({ value: p.path, label: p.path, count: p.pageviews }))
}
case 'referrer': {
const data = await getTopReferrers(siteId, start, end, limit, f)
return data.filter(r => r.referrer && r.referrer !== '').map(r => ({ value: r.referrer, label: r.referrer, count: r.pageviews }))
}
case 'country': {
const data = await getCountries(siteId, start, end, limit, f)
return data.filter(c => c.country && c.country !== 'Unknown').map(c => ({ value: c.country, label: regionNames?.of(c.country) ?? c.country, count: c.pageviews }))
}
case 'city': {
const data = await getCities(siteId, start, end, limit, f)
return data.filter(c => c.city && c.city !== 'Unknown').map(c => ({ value: c.city, label: c.city, count: c.pageviews }))
}
case 'region': {
const data = await getRegions(siteId, start, end, limit, f)
return data.filter(r => r.region && r.region !== 'Unknown').map(r => ({ value: r.region, label: r.region, count: r.pageviews }))
}
case 'browser': {
const data = await getBrowsers(siteId, start, end, limit, f)
return data.filter(b => b.browser && b.browser !== 'Unknown').map(b => ({ value: b.browser, label: b.browser, count: b.pageviews }))
}
case 'os': {
const data = await getOS(siteId, start, end, limit, f)
return data.filter(o => o.os && o.os !== 'Unknown').map(o => ({ value: o.os, label: o.os, count: o.pageviews }))
}
case 'device': {
const data = await getDevices(siteId, start, end, limit, f)
return data.filter(d => d.device && d.device !== 'Unknown').map(d => ({ value: d.device, label: d.device, count: d.pageviews }))
}
case 'utm_source':
case 'utm_medium':
case 'utm_campaign': {
const data = await getCampaigns(siteId, start, end, limit, f)
const map = new Map<string, number>()
const field = dimension === 'utm_source' ? 'source' : dimension === 'utm_medium' ? 'medium' : 'campaign'
data.forEach(c => {
const val = c[field]
if (val) map.set(val, (map.get(val) ?? 0) + c.pageviews)
})
return [...map.entries()].map(([v, count]) => ({ value: v, label: v, count }))
}
default:
return []
}
} catch (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,103 +389,21 @@ 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) {
return (
<div className="container mx-auto px-4 py-8">
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
<p className="text-neutral-600 dark:text-neutral-400">Site not found</p>
</div>
)
@@ -245,7 +427,7 @@ export default function SiteDashboardPage() {
{site.domain}
</p>
</div>
{/* Realtime Indicator */}
<button
onClick={() => router.push(`/sites/${siteId}/realtime`)}
@@ -345,12 +527,18 @@ 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
data={dailyStats}
<Chart
data={dailyStats}
prevData={prevDailyStats}
stats={stats}
stats={stats}
prevStats={prevStats}
interval={dateRange.start === dateRange.end ? todayInterval : multiDayInterval}
dateRange={dateRange}
@@ -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,21 @@
'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'
import { useAuth } from '@/lib/auth/context'
import {
@@ -68,8 +71,12 @@ export default function SiteSettingsPage() {
// Performance insights setting
enable_performance_insights: false,
// Bot and noise filtering
filter_bots: true
filter_bots: true,
// Data retention (6 = free-tier max; safe default)
data_retention_months: 6
})
const [subscription, setSubscription] = useState<SubscriptionDetails | null>(null)
const [subscriptionLoadFailed, setSubscriptionLoadFailed] = useState(false)
const [linkCopied, setLinkCopied] = useState(false)
const [snippetCopied, setSnippetCopied] = useState(false)
const [showVerificationModal, setShowVerificationModal] = useState(false)
@@ -80,9 +87,11 @@ 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()
loadSubscription()
}, [siteId])
useEffect(() => {
@@ -91,6 +100,30 @@ export default function SiteSettingsPage() {
}
}, [activeTab, siteId])
const loadSubscription = async () => {
try {
setSubscriptionLoadFailed(false)
const sub = await getSubscription()
setSubscription(sub)
} catch (e) {
setSubscriptionLoadFailed(true)
toast.error(getAuthErrorMessage(e as Error) || 'Could not load plan limits. Showing default options.')
}
}
// * Snap data_retention_months to nearest valid option when subscription loads
useEffect(() => {
if (!subscription) return
const opts = getRetentionOptionsForPlan(subscription.plan_id)
const values = opts.map(o => o.value)
const maxVal = Math.max(...values)
setFormData(prev => {
if (values.includes(prev.data_retention_months)) return prev
const bestFit = values.filter(v => v <= prev.data_retention_months).pop() ?? maxVal
return { ...prev, data_retention_months: Math.min(bestFit, maxVal) }
})
}, [subscription])
const loadSite = async () => {
try {
setLoading(true)
@@ -111,15 +144,31 @@ export default function SiteSettingsPage() {
// Performance insights setting (default to false)
enable_performance_insights: data.enable_performance_insights ?? false,
// Bot and noise filtering (default to true)
filter_bots: data.filter_bots ?? true
filter_bots: data.filter_bots ?? true,
// 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)
}
@@ -226,12 +275,28 @@ export default function SiteSettingsPage() {
// Performance insights setting
enable_performance_insights: formData.enable_performance_insights,
// Bot and noise filtering
filter_bots: formData.filter_bots
filter_bots: formData.filter_bots,
// Data retention
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)
}
@@ -245,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')
}
}
@@ -261,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')
}
}
@@ -282,21 +347,63 @@ 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) {
return (
<div className="container mx-auto px-4 py-8">
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
<p className="text-neutral-600 dark:text-neutral-400">Site not found</p>
</div>
)
}
return (
<div className="min-h-screen pt-12 pb-12 px-4 sm:px-6">
<div className="max-w-4xl mx-auto space-y-8">
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
<div className="space-y-8">
<div>
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white">Site Settings</h1>
<p className="mt-2 text-neutral-600 dark:text-neutral-400">
@@ -394,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">
@@ -452,7 +563,7 @@ export default function SiteSettingsPage() {
<ZapIcon className="w-4 h-4" />
Verify Installation
</button>
<p className="text-xs text-neutral-500 dark:text-neutral-500">
<p className="text-xs text-neutral-500 dark:text-neutral-400">
Check if your site is sending data correctly.
</p>
</div>
@@ -460,21 +571,9 @@ export default function SiteSettingsPage() {
<div className="pt-4 border-t border-neutral-100 dark:border-neutral-800 flex justify-end">
{canEdit && (
<button
type="submit"
disabled={saving}
className="flex items-center gap-2 px-6 py-2.5 bg-neutral-900 dark:bg-white text-white dark:text-neutral-900 rounded-xl font-medium
hover:bg-neutral-800 dark:hover:bg-neutral-100 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200"
>
{saving ? (
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : (
<>
<CheckIcon className="w-4 h-4" />
Save Changes
</>
)}
</button>
<Button type="submit" disabled={saving} isLoading={saving}>
Save Changes
</Button>
)}
</div>
</form>
@@ -526,7 +625,7 @@ export default function SiteSettingsPage() {
<p className="text-sm text-neutral-500 dark:text-neutral-400">Manage who can view your dashboard.</p>
</div>
<div className="p-4 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800">
<div className="p-6 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-white dark:bg-neutral-800 rounded-lg text-neutral-400">
@@ -578,7 +677,7 @@ export default function SiteSettingsPage() {
{linkCopied ? 'Copied!' : 'Copy Link'}
</button>
</div>
<p className="mt-2 text-xs text-neutral-500">
<p className="mt-2 text-xs text-neutral-500 dark:text-neutral-400">
Share this link with others to view the dashboard.
</p>
</div>
@@ -617,7 +716,7 @@ export default function SiteSettingsPage() {
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
placeholder={site.has_password ? "Change password (leave empty to keep current)" : "Set a password"}
/>
<p className="mt-2 text-xs text-neutral-500">
<p className="mt-2 text-xs text-neutral-500 dark:text-neutral-400">
Visitors will need to enter this password to view the dashboard.
</p>
</motion.div>
@@ -631,21 +730,9 @@ export default function SiteSettingsPage() {
<div className="pt-4 border-t border-neutral-100 dark:border-neutral-800 flex justify-end">
{canEdit && (
<button
type="submit"
disabled={saving}
className="flex items-center gap-2 px-6 py-2.5 bg-neutral-900 dark:bg-white text-white dark:text-neutral-900 rounded-xl font-medium
hover:bg-neutral-800 dark:hover:bg-neutral-100 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200"
>
{saving ? (
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : (
<>
<CheckIcon className="w-4 h-4" />
Save Changes
</>
)}
</button>
<Button type="submit" disabled={saving} isLoading={saving}>
Save Changes
</Button>
)}
</div>
</form>
@@ -665,7 +752,7 @@ export default function SiteSettingsPage() {
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">Data Collection</h3>
{/* Page Paths Toggle */}
<div className="p-4 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800">
<div className="p-6 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800">
<div className="flex items-center justify-between">
<div>
<h4 className="font-medium text-neutral-900 dark:text-white">Page Paths</h4>
@@ -686,7 +773,7 @@ export default function SiteSettingsPage() {
</div>
{/* Referrers Toggle */}
<div className="p-4 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800">
<div className="p-6 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800">
<div className="flex items-center justify-between">
<div>
<h4 className="font-medium text-neutral-900 dark:text-white">Referrers</h4>
@@ -707,7 +794,7 @@ export default function SiteSettingsPage() {
</div>
{/* Device Info Toggle */}
<div className="p-4 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800">
<div className="p-6 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800">
<div className="flex items-center justify-between">
<div>
<h4 className="font-medium text-neutral-900 dark:text-white">Device Info</h4>
@@ -728,7 +815,7 @@ export default function SiteSettingsPage() {
</div>
{/* Geographic Data Dropdown */}
<div className="p-4 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800">
<div className="p-6 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800">
<div className="flex items-center justify-between">
<div>
<h4 className="font-medium text-neutral-900 dark:text-white">Geographic Data</h4>
@@ -752,7 +839,7 @@ export default function SiteSettingsPage() {
</div>
{/* Screen Resolution Toggle */}
<div className="p-4 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800">
<div className="p-6 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800">
<div className="flex items-center justify-between">
<div>
<h4 className="font-medium text-neutral-900 dark:text-white">Screen Resolution</h4>
@@ -776,7 +863,7 @@ export default function SiteSettingsPage() {
{/* Bot and noise filtering */}
<div className="space-y-4 pt-6 border-t border-neutral-100 dark:border-neutral-800">
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">Filtering</h3>
<div className="p-4 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800">
<div className="p-6 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800">
<div className="flex items-center justify-between">
<div>
<h4 className="font-medium text-neutral-900 dark:text-white">Filter bots and referrer spam</h4>
@@ -800,7 +887,7 @@ export default function SiteSettingsPage() {
{/* Performance Insights Toggle */}
<div className="space-y-4 pt-6 border-t border-neutral-100 dark:border-neutral-800">
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">Performance Insights</h3>
<div className="p-4 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800">
<div className="p-6 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800">
<div className="flex items-center justify-between">
<div>
<h4 className="font-medium text-neutral-900 dark:text-white">Performance Insights (Add-on)</h4>
@@ -821,6 +908,58 @@ export default function SiteSettingsPage() {
</div>
</div>
{/* Data Retention */}
<div className="space-y-4 pt-6 border-t border-neutral-100 dark:border-neutral-800">
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">Data Retention</h3>
{subscriptionLoadFailed && (
<div className="p-3 rounded-xl border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 flex items-center justify-between gap-3">
<p className="text-sm text-amber-800 dark:text-amber-200">
Plan limits could not be loaded. Options shown may be limited.
</p>
<button
type="button"
onClick={loadSubscription}
className="shrink-0 text-sm font-medium text-amber-800 dark:text-amber-200 hover:underline"
>
Retry
</button>
</div>
)}
<div className="p-6 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800">
<div className="flex items-center justify-between">
<div>
<h4 className="font-medium text-neutral-900 dark:text-white">Keep raw event data for</h4>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-0.5">
Events older than this are automatically deleted. Aggregated daily stats are kept permanently.
</p>
</div>
<Select
value={String(formData.data_retention_months)}
onChange={(v) => setFormData({ ...formData, data_retention_months: Number(v) })}
options={getRetentionOptionsForPlan(subscription?.plan_id).map(opt => ({
value: String(opt.value),
label: opt.label,
}))}
variant="input"
align="right"
className="min-w-[160px]"
/>
</div>
{subscription?.plan_id && subscription.plan_id !== 'free' && (
<p className="text-xs text-neutral-400 dark:text-neutral-500 mt-3">
Your {subscription.plan_id} plan supports up to {formatRetentionMonths(
getRetentionOptionsForPlan(subscription.plan_id).at(-1)?.value ?? 6
)} of data retention.
</p>
)}
{(!subscription?.plan_id || subscription.plan_id === 'free') && (
<p className="text-xs text-neutral-400 dark:text-neutral-500 mt-3">
Free plan supports up to 6 months. <a href="/pricing" className="text-brand-orange hover:underline">Upgrade</a> for longer retention.
</p>
)}
</div>
</div>
{/* Excluded Paths */}
<div className="space-y-4 pt-6 border-t border-neutral-100 dark:border-neutral-800">
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">Path Filtering</h3>
@@ -878,7 +1017,7 @@ export default function SiteSettingsPage() {
{snippetCopied ? (
<CheckIcon className="w-4 h-4 text-green-600" />
) : (
<svg className="w-4 h-4 text-neutral-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<svg className="w-4 h-4 text-neutral-500 dark:text-neutral-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
@@ -889,21 +1028,9 @@ export default function SiteSettingsPage() {
<div className="pt-4 border-t border-neutral-100 dark:border-neutral-800 flex justify-end">
{canEdit && (
<button
type="submit"
disabled={saving}
className="flex items-center gap-2 px-6 py-2.5 bg-neutral-900 dark:bg-white text-white dark:text-neutral-900 rounded-xl font-medium
hover:bg-neutral-800 dark:hover:bg-neutral-100 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200"
>
{saving ? (
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : (
<>
<CheckIcon className="w-4 h-4" />
Save Changes
</>
)}
</button>
<Button type="submit" disabled={saving} isLoading={saving}>
Save Changes
</Button>
)}
</div>
</form>
@@ -919,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 && (
@@ -986,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
/>
@@ -997,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,
@@ -34,20 +35,20 @@ import type { TooltipProps } from 'recharts'
// * Chart theme colors (consistent with main Pulse chart)
const CHART_COLORS_LIGHT = {
border: '#E5E5E5',
text: '#171717',
textMuted: '#737373',
axis: '#A3A3A3',
border: 'var(--color-neutral-200)',
text: 'var(--color-neutral-900)',
textMuted: 'var(--color-neutral-500)',
axis: 'var(--color-neutral-400)',
tooltipBg: '#ffffff',
tooltipBorder: '#E5E5E5',
tooltipBorder: 'var(--color-neutral-200)',
}
const CHART_COLORS_DARK = {
border: '#404040',
text: '#fafafa',
textMuted: '#a3a3a3',
axis: '#737373',
tooltipBg: '#262626',
tooltipBorder: '#404040',
border: 'var(--color-neutral-700)',
text: 'var(--color-neutral-50)',
textMuted: 'var(--color-neutral-400)',
axis: 'var(--color-neutral-500)',
tooltipBg: 'var(--color-neutral-800)',
tooltipBorder: 'var(--color-neutral-700)',
}
// * Status color mapping
@@ -189,7 +190,7 @@ function StatusBarTooltip({
className="fixed z-50 pointer-events-none"
style={{ left: position.x, top: position.y - 10, transform: 'translate(-50%, -100%)' }}
>
<div className="bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-xl shadow-lg px-3 py-2.5 text-xs min-w-[160px]">
<div className="bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-xl shadow-lg transition-shadow duration-300 px-3 py-2.5 text-xs min-w-40">
<div className="font-semibold text-neutral-900 dark:text-white mb-1.5">{formattedDate}</div>
{stat && stat.total_checks > 0 ? (
<div className="space-y-1">
@@ -256,7 +257,7 @@ function UptimeStatusBar({
className="relative"
onMouseLeave={() => setHoveredDay(null)}
>
<div className="flex items-center gap-[2px] w-full">
<div className="flex items-center gap-0.5 w-full">
{dateRange.map((date) => {
const stat = statsMap.get(date)
const barColor = getDayBarColor(stat)
@@ -264,7 +265,7 @@ function UptimeStatusBar({
return (
<div
key={date}
className={`flex-1 h-8 rounded-[2px] ${barColor} transition-all duration-150 hover:opacity-80 cursor-pointer min-w-[3px]`}
className={`flex-1 h-8 rounded-sm ${barColor} transition-all duration-150 hover:opacity-80 cursor-pointer min-w-[3px]`}
onMouseEnter={(e) => handleMouseEnter(e, date, stat)}
onMouseLeave={() => setHoveredDay(null)}
/>
@@ -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]
@@ -305,7 +306,7 @@ function ResponseTimeChart({ checks }: { checks: UptimeCheck[] }) {
if (!active || !payload?.length) return null
return (
<div
className="rounded-xl px-3 py-2 text-xs shadow-lg border"
className="rounded-xl px-3 py-2 text-xs shadow-lg border transition-shadow duration-300"
style={{
background: colors.tooltipBg,
borderColor: colors.tooltipBorder,
@@ -473,7 +474,7 @@ function MonitorCard({
<div className="text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider mb-1">
Status
</div>
<div className="flex items-center gap-1.5">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${getStatusDotColor(monitor.last_status)}`} />
<span className="text-sm font-medium text-neutral-900 dark:text-white">
{getStatusLabel(monitor.last_status)}
@@ -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 */}
@@ -955,7 +965,7 @@ function MonitorForm({
</svg>
</button>
{showProtocolDropdown && (
<div className="absolute top-full left-0 mt-1 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg shadow-lg z-10 min-w-[100px]">
<div className="absolute top-full left-0 mt-1 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg shadow-lg transition-shadow duration-300 z-10 min-w-[100px]">
<button
type="button"
onClick={() => handleProtocolChange('https://')}

View File

@@ -1,10 +1,12 @@
'use client'
import { useState, useEffect } from 'react'
import { logger } from '@/lib/utils/logger'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { createSite, listSites, getSite, type Site } from '@/lib/api/sites'
import { getSubscription } from '@/lib/api/billing'
import { getSitesLimitForPlan } from '@/lib/plans'
import { trackSiteCreatedFromDashboard, trackSiteCreatedScriptCopied } from '@/lib/welcomeAnalytics'
import { toast } from '@ciphera-net/ui'
import { getAuthErrorMessage } from '@ciphera-net/ui'
@@ -57,13 +59,14 @@ export default function NewSitePage() {
getSubscription()
])
if (subscription?.plan_id === 'solo' && sites.length >= 1) {
const siteLimit = subscription?.plan_id ? getSitesLimitForPlan(subscription.plan_id) : null
if (siteLimit != null && sites.length >= siteLimit) {
setAtLimit(true)
toast.error('Solo plan limit reached (1 site). Please upgrade to add more sites.')
toast.error(`${subscription.plan_id} plan limit reached (${siteLimit} site${siteLimit === 1 ? '' : 's'}). Please upgrade to add more sites.`)
router.replace('/')
}
} catch (error) {
console.error('Failed to check limits', error)
logger.error('Failed to check limits', error)
} finally {
setLimitsChecked(true)
}
@@ -85,7 +88,7 @@ export default function NewSitePage() {
sessionStorage.setItem(LAST_CREATED_SITE_KEY, JSON.stringify({ id: site.id }))
}
} catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || 'Failed to create site: ' + ((error as Error)?.message || 'Unknown error'))
toast.error(getAuthErrorMessage(error) || 'Failed to create site. Please try again.')
} finally {
setLoading(false)
}
@@ -104,8 +107,8 @@ export default function NewSitePage() {
// * Step 2: Framework picker + script (same as /welcome after adding first site)
if (createdSite) {
return (
<div className="container mx-auto px-4 py-8 max-w-2xl">
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-8">
<div className="w-full max-w-2xl mx-auto px-4 sm:px-6 py-8">
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
<div className="text-center">
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-green-500/10 text-green-600 dark:text-green-400 mb-6">
<CheckCircleIcon className="h-7 w-7" />
@@ -150,10 +153,10 @@ export default function NewSitePage() {
</div>
<div className="mt-8 flex flex-col sm:flex-row gap-3 justify-center">
<Button variant="primary" onClick={goToDashboard} className="min-w-[160px]">
<Button variant="primary" onClick={goToDashboard} className="min-w-40">
Back to dashboard
</Button>
<Button variant="secondary" onClick={() => router.push(`/sites/${createdSite.id}`)} className="min-w-[160px]">
<Button variant="secondary" onClick={() => router.push(`/sites/${createdSite.id}`)} className="min-w-40">
View {createdSite.name}
</Button>
</div>
@@ -170,7 +173,7 @@ export default function NewSitePage() {
// * Step 1: Name & domain form
return (
<div className="container mx-auto px-4 py-8 max-w-2xl">
<div className="w-full max-w-2xl mx-auto px-4 sm:px-6 py-8">
<h1 className="text-2xl font-bold mb-8 text-neutral-900 dark:text-white">
Create New Site
</h1>
@@ -189,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"
@@ -202,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)
}
@@ -332,13 +332,13 @@ function WelcomeContent() {
}
const cardClass =
'bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl shadow-sm p-8 max-w-lg mx-auto'
'bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl shadow-sm p-6 max-w-lg mx-auto'
return (
<div className="flex-1 flex flex-col items-center justify-center bg-neutral-50 dark:bg-neutral-950 px-4 py-12">
<div className="w-full max-w-lg">
<div
className="flex justify-center gap-1.5 mb-8"
className="flex justify-center gap-2 mb-8"
role="progressbar"
aria-valuenow={step}
aria-valuemin={1}
@@ -475,7 +475,7 @@ function WelcomeContent() {
<button
type="button"
onClick={() => setStep(1)}
className="flex items-center gap-1.5 text-sm text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300 mb-6 focus:outline-none focus:ring-2 focus:ring-brand-orange rounded"
className="flex items-center gap-2 text-sm text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300 mb-6 focus:outline-none focus:ring-2 focus:ring-brand-orange rounded"
aria-label="Back to welcome"
>
<ArrowLeftIcon className="h-4 w-4" />
@@ -485,7 +485,7 @@ function WelcomeContent() {
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-brand-orange/10 text-brand-orange mb-4">
<BarChartIcon className="h-7 w-7" />
</div>
<h1 className="text-xl font-bold text-neutral-900 dark:text-white">
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white">
Name your organization
</h1>
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
@@ -546,7 +546,7 @@ function WelcomeContent() {
<button
type="button"
onClick={() => setStep(2)}
className="flex items-center gap-1.5 text-sm text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300 mb-6 focus:outline-none focus:ring-2 focus:ring-brand-orange rounded"
className="flex items-center gap-2 text-sm text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300 mb-6 focus:outline-none focus:ring-2 focus:ring-brand-orange rounded"
aria-label="Back to organization"
>
<ArrowLeftIcon className="h-4 w-4" />
@@ -556,7 +556,7 @@ function WelcomeContent() {
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-green-500/10 text-green-600 dark:text-green-400 mb-4">
<CheckCircleIcon className="h-7 w-7" />
</div>
<h1 className="text-xl font-bold text-neutral-900 dark:text-white">
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white">
{showPendingCheckoutInStep3 ? 'Complete your plan' : "You're on the free plan"}
</h1>
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
@@ -631,7 +631,7 @@ function WelcomeContent() {
<button
type="button"
onClick={() => setStep(3)}
className="flex items-center gap-1.5 text-sm text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300 mb-6 focus:outline-none focus:ring-2 focus:ring-brand-orange rounded"
className="flex items-center gap-2 text-sm text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300 mb-6 focus:outline-none focus:ring-2 focus:ring-brand-orange rounded"
aria-label="Back to plan"
>
<ArrowLeftIcon className="h-4 w-4" />
@@ -641,7 +641,7 @@ function WelcomeContent() {
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-brand-orange/10 text-brand-orange mb-4">
<GlobeIcon className="h-7 w-7" />
</div>
<h1 className="text-xl font-bold text-neutral-900 dark:text-white">
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white">
Add your first site
</h1>
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
@@ -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">
@@ -759,11 +761,11 @@ function WelcomeContent() {
)}
<div className="mt-8 flex flex-col sm:flex-row gap-3 justify-center">
<Button variant="primary" onClick={goToDashboard} className="min-w-[160px]">
<Button variant="primary" onClick={goToDashboard} className="min-w-40">
Go to dashboard
</Button>
{createdSite && (
<Button variant="secondary" onClick={goToSite} className="min-w-[160px]">
<Button variant="secondary" onClick={goToSite} className="min-w-40">
View {createdSite.name}
</Button>
)}

View File

@@ -0,0 +1,63 @@
'use client'
import { Button } from '@ciphera-net/ui'
interface ErrorDisplayProps {
title?: string
message?: string
onRetry?: () => void
onGoHome?: boolean
}
/**
* Shared error UI for route-level error.tsx boundaries.
* Matches the visual style of the 404 page.
*/
export default function ErrorDisplay({
title = 'Something went wrong',
message = 'An unexpected error occurred. Please try again or go back to the dashboard.',
onRetry,
onGoHome = true,
}: ErrorDisplayProps) {
return (
<div className="relative min-h-[80vh] flex flex-col items-center justify-center overflow-hidden">
<div className="absolute inset-0 -z-10 pointer-events-none">
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[500px] h-[500px] bg-red-500/10 rounded-full blur-[128px] opacity-60" />
<div
className="absolute inset-0 bg-grid-pattern opacity-[0.02] dark:opacity-[0.05]"
style={{ maskImage: 'radial-gradient(ellipse at center, black 0%, transparent 70%)' }}
/>
</div>
<div className="text-center px-4 z-10">
<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
}
@@ -95,7 +95,7 @@ export function Footer({ LinkComponent = Link, appName = 'Pulse', isAuthenticate
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4 leading-relaxed">
Simple analytics for privacy-conscious apps.
</p>
<div className="inline-flex items-center gap-2.5 text-sm text-neutral-600 dark:text-neutral-400 mb-4">
<div className="inline-flex items-center gap-3 text-sm text-neutral-600 dark:text-neutral-400 mb-4">
<span className="flex items-center justify-center w-8 h-8 rounded-lg bg-neutral-100 dark:bg-neutral-800 shrink-0 overflow-hidden ring-1 ring-neutral-200 dark:ring-neutral-700" aria-hidden>
<SwissFlagIcon className="w-5 h-5" />
</span>

View File

@@ -47,7 +47,7 @@ export function IntegrationGuide({ integration, children }: IntegrationGuideProp
/>
</div>
<div className="flex-grow w-full max-w-4xl mx-auto px-4 pt-12 pb-10 z-10">
<div className="flex-grow w-full max-w-4xl mx-auto px-4 pt-20 pb-10 z-10">
<Link
href="/integrations"
className="inline-flex items-center text-sm text-neutral-500 hover:text-brand-orange mb-8 transition-colors"

View File

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

View File

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

View File

@@ -1,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)
}
@@ -219,10 +219,10 @@ export default function PricingSection() {
transition={{ duration: 0.5 }}
className="text-center mb-12"
>
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-6">
<h2 className="text-3xl font-bold text-neutral-900 dark:text-white mb-4">
Transparent Pricing
</h2>
<p className="text-xl text-neutral-600 dark:text-neutral-400">
<p className="text-lg text-neutral-600 dark:text-neutral-400">
Scale with your traffic. No hidden fees.
</p>
</motion.div>
@@ -232,13 +232,13 @@ export default function PricingSection() {
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.1 }}
className="max-w-6xl mx-auto border border-neutral-200 dark:border-neutral-800 rounded-3xl bg-white/50 dark:bg-neutral-900/50 backdrop-blur-xl shadow-sm overflow-hidden mb-20"
className="max-w-6xl mx-auto border border-neutral-200 dark:border-neutral-800 rounded-2xl bg-white/50 dark:bg-neutral-900/50 backdrop-blur-xl shadow-sm overflow-hidden mb-20"
>
{/* Top Toolbar */}
<div className="p-8 border-b border-neutral-200 dark:border-neutral-800 flex flex-col md:flex-row items-center justify-between gap-8 bg-neutral-50/50 dark:bg-neutral-900/50">
<div className="p-6 border-b border-neutral-200 dark:border-neutral-800 flex flex-col md:flex-row items-center justify-between gap-8 bg-neutral-50/50 dark:bg-neutral-900/50">
<div className="w-full md:w-2/3">
<div className="flex justify-between text-sm font-medium text-neutral-500 mb-4">
<div className="flex justify-between text-sm font-medium text-neutral-500 dark:text-neutral-400 mb-4">
<span>10k</span>
<span className="text-brand-orange font-bold text-lg">
Up to {currentTraffic.label} monthly pageviews
@@ -252,18 +252,22 @@ export default function PricingSection() {
step="1"
value={sliderIndex}
onChange={(e) => setSliderIndex(parseInt(e.target.value))}
className="w-full h-2 bg-neutral-200 rounded-lg appearance-none cursor-pointer dark:bg-neutral-700 accent-brand-orange"
aria-label="Monthly pageview limit"
aria-valuetext={`${currentTraffic.label} pageviews per month`}
className="w-full h-2 bg-neutral-200 rounded-lg appearance-none cursor-pointer dark:bg-neutral-700 accent-brand-orange focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-offset-2"
/>
</div>
<div className="flex flex-col items-end gap-2 shrink-0">
<span className="text-[10px] text-neutral-500 dark:text-neutral-400 font-medium uppercase tracking-wide">
<span className="text-xs text-neutral-500 dark:text-neutral-400 font-medium uppercase tracking-wide">
Get 1 month free with yearly
</span>
<div className="bg-neutral-200 dark:bg-neutral-800 p-1 rounded-lg flex">
<div className="bg-neutral-200 dark:bg-neutral-800 p-1 rounded-lg flex" role="radiogroup" aria-label="Billing interval">
<button
onClick={() => setIsYearly(false)}
className={`min-w-[88px] px-4 py-2 rounded-lg text-sm font-medium transition-all ${
role="radio"
aria-checked={!isYearly}
className={`min-w-[88px] px-4 py-2 rounded-lg text-sm font-medium transition-all focus:outline-none focus:ring-2 focus:ring-brand-orange ${
!isYearly
? 'bg-white dark:bg-neutral-700 text-neutral-900 dark:text-white shadow-sm'
: 'text-neutral-500 hover:text-neutral-900 dark:hover:text-white'
@@ -273,7 +277,9 @@ export default function PricingSection() {
</button>
<button
onClick={() => setIsYearly(true)}
className={`min-w-[88px] px-4 py-2 rounded-lg text-sm font-medium transition-all ${
role="radio"
aria-checked={isYearly}
className={`min-w-[88px] px-4 py-2 rounded-lg text-sm font-medium transition-all focus:outline-none focus:ring-2 focus:ring-brand-orange ${
isYearly
? 'bg-white dark:bg-neutral-700 text-neutral-900 dark:text-white shadow-sm'
: 'text-neutral-500 hover:text-neutral-900 dark:hover:text-white'
@@ -292,7 +298,7 @@ export default function PricingSection() {
const isTeam = plan.id === 'team'
return (
<div key={plan.id} className={`p-8 flex flex-col relative transition-colors ${isTeam ? 'bg-brand-orange/[0.02]' : 'hover:bg-neutral-50/50 dark:hover:bg-neutral-800/50'}`}>
<div key={plan.id} className={`p-6 flex flex-col relative transition-colors ${isTeam ? 'bg-brand-orange/[0.02]' : 'hover:bg-neutral-50/50 dark:hover:bg-neutral-800/50'}`}>
{isTeam && (
<>
<div className="absolute top-0 left-0 w-full h-1 bg-brand-orange" />
@@ -304,7 +310,7 @@ export default function PricingSection() {
<div className="mb-8">
<h3 className="text-lg font-bold text-neutral-900 dark:text-white mb-2">{plan.name}</h3>
<p className="text-sm text-neutral-500 min-h-[40px] mb-4">{plan.description}</p>
<p className="text-sm text-neutral-500 dark:text-neutral-400 min-h-[40px] mb-4">{plan.description}</p>
{priceDetails ? (
isYearly ? (
@@ -313,7 +319,7 @@ export default function PricingSection() {
<span className="text-4xl font-bold text-neutral-900 dark:text-white">
{priceDetails.yearlyTotal}
</span>
<span className="text-neutral-500 font-medium">/year</span>
<span className="text-neutral-500 dark:text-neutral-400 font-medium">/year</span>
</div>
<div className="flex items-center gap-2 mt-2 text-sm font-medium">
<span className="text-neutral-400 line-through decoration-neutral-400">
@@ -329,7 +335,7 @@ export default function PricingSection() {
<span className="text-4xl font-bold text-neutral-900 dark:text-white">
{priceDetails.baseMonthly}
</span>
<span className="text-neutral-500 font-medium">/mo</span>
<span className="text-neutral-500 dark:text-neutral-400 font-medium">/mo</span>
</div>
)
) : (
@@ -361,16 +367,20 @@ export default function PricingSection() {
})}
{/* Enterprise Section */}
<div className="p-8 bg-neutral-50/50 dark:bg-neutral-900/50 flex flex-col">
<div className="p-6 bg-neutral-50/50 dark:bg-neutral-900/50 flex flex-col">
<div className="mb-8">
<h3 className="text-lg font-bold text-neutral-900 dark:text-white mb-2">Enterprise</h3>
<p className="text-sm text-neutral-500 min-h-[40px] mb-4">For high volume sites and custom needs</p>
<p className="text-sm text-neutral-500 dark:text-neutral-400 min-h-[40px] mb-4">For high volume sites and custom needs</p>
<div className="text-4xl font-bold text-neutral-900 dark:text-white">
Custom
</div>
</div>
<Button variant="secondary" className="w-full mb-8 border-neutral-200 dark:border-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-800">
<Button
variant="secondary"
className="w-full mb-8"
onClick={() => { window.location.href = 'mailto:business@ciphera.net?subject=Enterprise%20Plan%20Inquiry' }}
>
Contact us
</Button>

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

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

View File

@@ -0,0 +1,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-0.5 text-xs font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-inset rounded ${className}`}
aria-label={`Sort by ${label}`}
>
{label}
{sortKey === colKey ? (
<ChevronDownIcon className={`w-3 h-3 text-brand-orange ${sortDir === 'asc' ? 'rotate-180' : ''}`} />
) : (
<span className="w-3 h-3 inline-block text-neutral-400" aria-hidden />
)}
</button>
)
return (
<>
<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-1.5"
>
<DownloadIcon className="w-3.5 h-3.5" />
Export
</Button>
)}
<Button
variant="ghost"
onClick={() => setIsBuilderOpen(true)}
className="h-8 px-3 text-xs gap-1.5"
>
<PlusIcon className="w-3.5 h-3.5" />
Build URL
</Button>
{showViewAll && (
<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-1.5 text-sm font-medium text-brand-orange hover:text-brand-orange/90 hover:underline focus:outline-none focus:ring-2 focus:ring-brand-orange/20 rounded"
>
Read documentation
<ArrowRightIcon className="w-4 h-4" />
</Link>
</div>
)}
)}
</div>
</div>
<Modal
@@ -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,35 +10,36 @@ 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 = {
brand: '#FD5E0F',
success: '#10B981', // Emerald-500
danger: '#EF4444', // Red-500
brand: 'var(--color-brand-orange)',
success: 'var(--color-success)',
danger: 'var(--color-error)',
}
const CHART_COLORS_LIGHT = {
border: '#E5E5E5',
text: '#171717',
textMuted: '#737373',
axis: '#A3A3A3',
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)',
tooltipBg: '#ffffff',
tooltipBorder: '#E5E5E5',
tooltipBorder: 'var(--color-neutral-200)',
}
const CHART_COLORS_DARK = {
border: '#404040',
text: '#fafafa',
textMuted: '#a3a3a3',
axis: '#737373',
tooltipBg: '#262626',
tooltipBorder: '#404040',
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)',
tooltipBg: 'var(--color-neutral-800)',
tooltipBorder: 'var(--color-neutral-700)',
}
export interface DailyStat {
@@ -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)
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;
}
// ─── Helpers ─────────────────────────────────────────────────────────
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"
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-1.5 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,56 +118,88 @@ 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>
)
}
export default function Chart({
data,
prevData,
stats,
prevStats,
// ─── Chart Component ─────────────────────────────────────────────────
export default function Chart({
data,
prevData,
stats,
prevStats,
interval,
dateRange,
todayInterval,
@@ -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' ? '#171717' : '#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,27 +237,24 @@ 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' })
} else {
formattedDate = new Date(item.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
}
return {
date: formattedDate,
originalDate: item.date,
@@ -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-1.5 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-0.5">
<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-1.5 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-[320px] 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-[320px] 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' ? '#262626' : '#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, ' ')}
@@ -52,7 +54,7 @@ export default function GoalStats({ goalCounts }: GoalStatsProps) {
</p>
<Link
href="/installation"
className="inline-flex items-center gap-1.5 text-sm font-medium text-brand-orange hover:text-brand-orange/90 hover:underline focus:outline-none focus:ring-2 focus:ring-brand-orange/20 rounded"
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" />

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
@@ -108,7 +109,7 @@ export default function PerformanceStats({ stats, performanceByPage, siteId, sta
const summaryText = `LCP ${Math.round(stats.lcp)} ms · CLS ${Number(stats.cls.toFixed(3))} · INP ${Math.round(stats.inp)} ms`
return (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-4">
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
{/* * One-line summary: Performance score + metric summary. Click to expand. */}
<button
type="button"
@@ -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

@@ -35,9 +35,9 @@ const WorldMap = ({ data }: WorldMapProps) => {
// Plausible-like colors based on provided SVG snippet
const isDark = resolvedTheme === 'dark'
const defaultFill = isDark ? "#262626" : "#f5f5f5" // neutral-800 / neutral-100
const defaultStroke = isDark ? "#171717" : "#ffffff" // neutral-900 / white
const brandOrange = "#FD5E0F"
const defaultFill = isDark ? "var(--color-neutral-800)" : "var(--color-neutral-100)"
const defaultStroke = isDark ? "var(--color-neutral-900)" : "#ffffff"
const brandOrange = "var(--color-brand-orange)"
return (
<div className="relative w-full">
@@ -97,7 +97,7 @@ const WorldMap = ({ data }: WorldMapProps) => {
</ComposableMap>
{tooltipContent && (
<div
className="fixed z-50 px-2 py-1 text-xs font-medium text-white bg-black/80 backdrop-blur-sm rounded shadow pointer-events-none transform -translate-x-1/2 -translate-y-full mt-[-10px]"
className="fixed z-50 px-2 py-1 text-xs font-medium text-white bg-black/80 backdrop-blur-sm rounded shadow pointer-events-none transform -translate-x-1/2 -translate-y-full -mt-2.5"
style={{ left: tooltipContent.x, top: tooltipContent.y }}
>
{tooltipContent.content}

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>
))}
@@ -235,9 +258,9 @@ export default function NotificationCenter() {
<Link
href="/org-settings?tab=notifications"
onClick={() => setOpen(false)}
className="flex items-center gap-1.5 text-sm text-neutral-500 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors"
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,
@@ -16,8 +18,8 @@ import {
OrganizationInvitation,
Organization
} from '@/lib/api/organization'
import { getSubscription, createPortalSession, getInvoices, cancelSubscription, changePlan, createCheckoutSession, SubscriptionDetails, Invoice } from '@/lib/api/billing'
import { TRAFFIC_TIERS, PLAN_ID_SOLO, getTierIndexForLimit, getLimitForTierIndex } from '@/lib/plans'
import { getSubscription, createPortalSession, getInvoices, cancelSubscription, resumeSubscription, changePlan, previewInvoice, createCheckoutSession, SubscriptionDetails, Invoice, PreviewInvoiceResult } from '@/lib/api/billing'
import { TRAFFIC_TIERS, PLAN_ID_SOLO, PLAN_ID_TEAM, PLAN_ID_BUSINESS, getTierIndexForLimit, getLimitForTierIndex, getSitesLimitForPlan } from '@/lib/plans'
import { getAuditLog, AuditLogEntry, GetAuditLogParams } from '@/lib/api/audit'
import { getNotificationSettings, updateNotificationSettings } from '@/lib/api/notification-settings'
import { toast } from '@ciphera-net/ui'
@@ -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() {
@@ -83,9 +85,13 @@ export default function OrganizationSettings() {
const [isRedirectingToPortal, setIsRedirectingToPortal] = useState(false)
const [cancelLoadingAction, setCancelLoadingAction] = useState<'period_end' | 'immediate' | null>(null)
const [showCancelPrompt, setShowCancelPrompt] = useState(false)
const [isResuming, setIsResuming] = useState(false)
const [showChangePlanModal, setShowChangePlanModal] = useState(false)
const [changePlanId, setChangePlanId] = useState<string>(PLAN_ID_SOLO)
const [changePlanTierIndex, setChangePlanTierIndex] = useState(2)
const [changePlanYearly, setChangePlanYearly] = useState(false)
const [invoicePreview, setInvoicePreview] = useState<PreviewInvoiceResult | null>(null)
const [isLoadingPreview, setIsLoadingPreview] = useState(false)
const [isChangingPlan, setIsChangingPlan] = useState(false)
const [invoices, setInvoices] = useState<Invoice[]>([])
const [isLoadingInvoices, setIsLoadingInvoices] = useState(false)
@@ -165,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)
@@ -179,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)
@@ -193,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)
}
@@ -242,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)
}
@@ -274,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)
@@ -294,10 +300,29 @@ export default function OrganizationSettings() {
}
}, [activeTab, user?.role, handleTabChange])
const hasActiveSubscription = subscription?.subscription_status === 'active' || subscription?.subscription_status === 'trialing'
useEffect(() => {
if (!showChangePlanModal || !hasActiveSubscription) {
setInvoicePreview(null)
return
}
let cancelled = false
setIsLoadingPreview(true)
setInvoicePreview(null)
const interval = changePlanYearly ? 'year' : 'month'
const limit = getLimitForTierIndex(changePlanTierIndex)
previewInvoice({ plan_id: changePlanId, interval, limit })
.then((res) => { if (!cancelled) setInvoicePreview(res ?? null) })
.catch(() => { if (!cancelled) { setInvoicePreview(null) } })
.finally(() => { if (!cancelled) setIsLoadingPreview(false) })
return () => { cancelled = true }
}, [showChangePlanModal, hasActiveSubscription, changePlanId, changePlanTierIndex, changePlanYearly])
// If no org ID, we are in personal organization context, so don't show org settings
if (!currentOrgId) {
return (
<div className="p-6 text-center text-neutral-500">
<div className="p-6 text-center text-neutral-500 dark:text-neutral-400">
<p>You are in your personal context. Switch to an Organization to manage its settings.</p>
</div>
)
@@ -308,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)
}
}
@@ -321,42 +346,60 @@ 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)
}
}
const handleResumeSubscription = async () => {
setIsResuming(true)
try {
await resumeSubscription()
toast.success('Subscription will continue. Cancellation has been undone.')
loadSubscription()
} catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || (error instanceof Error ? error.message : '') || 'Failed to resume subscription')
} finally {
setIsResuming(false)
}
}
const openChangePlanModal = () => {
const currentPlan = subscription?.plan_id
if (currentPlan === PLAN_ID_TEAM || currentPlan === PLAN_ID_BUSINESS) {
setChangePlanId(currentPlan)
} else {
setChangePlanId(PLAN_ID_SOLO)
}
if (subscription?.pageview_limit != null && subscription.pageview_limit > 0) {
setChangePlanTierIndex(getTierIndexForLimit(subscription.pageview_limit))
} else {
setChangePlanTierIndex(2)
}
setChangePlanYearly(subscription?.billing_interval === 'year')
setInvoicePreview(null)
setShowChangePlanModal(true)
}
const hasActiveSubscription = subscription?.subscription_status === 'active' || subscription?.subscription_status === 'trialing'
const handleChangePlanSubmit = async () => {
const interval = changePlanYearly ? 'year' : 'month'
const limit = getLimitForTierIndex(changePlanTierIndex)
setIsChangingPlan(true)
try {
if (hasActiveSubscription) {
await changePlan({ plan_id: PLAN_ID_SOLO, interval, limit })
await changePlan({ plan_id: changePlanId, interval, limit })
toast.success('Plan updated. Changes may take a moment to reflect.')
setShowChangePlanModal(false)
loadSubscription()
} else {
const { url } = await createCheckoutSession({ plan_id: PLAN_ID_SOLO, interval, limit })
const { url } = await createCheckoutSession({ plan_id: changePlanId, interval, limit })
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)
}
@@ -376,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)
}
}
@@ -414,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)
}
@@ -426,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')
}
}
@@ -441,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)
}
@@ -456,7 +500,7 @@ export default function OrganizationSettings() {
// handleTabChange is defined above
return (
<div className="max-w-4xl mx-auto space-y-8">
<div className="space-y-8">
<div>
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white">Organization Settings</h1>
<p className="mt-2 text-neutral-600 dark:text-neutral-400">
@@ -543,7 +587,7 @@ export default function OrganizationSettings() {
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.2 }}
className="w-full bg-white dark:bg-neutral-900 rounded-2xl border border-neutral-200 dark:border-neutral-800 p-6 md:p-8 shadow-sm"
className="w-full bg-white dark:bg-neutral-900 rounded-2xl border border-neutral-200 dark:border-neutral-800 p-6 shadow-sm"
>
{activeTab === 'general' && (
<div className="space-y-12">
@@ -560,12 +604,12 @@ 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}
disabled={!isEditing}
className={`bg-white dark:bg-neutral-900 ${!isEditing ? 'text-neutral-500' : ''}`}
className={`bg-white dark:bg-neutral-900 ${!isEditing ? 'text-neutral-500 dark:text-neutral-400' : ''}`}
/>
</div>
@@ -574,21 +618,21 @@ export default function OrganizationSettings() {
Organization Slug
</label>
<div className="flex rounded-xl shadow-sm">
<span className="inline-flex items-center px-3 rounded-l-xl border border-r-0 border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-900 text-neutral-500 text-sm">
drop.ciphera.net/
<span className="inline-flex items-center px-3 rounded-l-xl border border-r-0 border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-900 text-neutral-500 dark:text-neutral-400 text-sm">
pulse.ciphera.net/
</span>
<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}
disabled={!isEditing}
className={`rounded-l-none bg-white dark:bg-neutral-900 ${!isEditing ? 'text-neutral-500' : ''}`}
className={`rounded-l-none bg-white dark:bg-neutral-900 ${!isEditing ? 'text-neutral-500 dark:text-neutral-400' : ''}`}
/>
</div>
<p className="text-xs text-neutral-500">
<p className="text-xs text-neutral-500 dark:text-neutral-400">
Changing the slug will change your organization's URL.
</p>
</div>
@@ -629,7 +673,7 @@ export default function OrganizationSettings() {
<p className="text-sm text-neutral-500 dark:text-neutral-400">Irreversible actions for this organization.</p>
</div>
<div className="p-4 border border-red-200 dark:border-red-900/50 bg-red-50 dark:bg-red-900/10 rounded-2xl flex items-center justify-between">
<div className="p-6 border border-red-200 dark:border-red-900/50 bg-red-50 dark:bg-red-900/10 rounded-2xl flex items-center justify-between">
<div>
<h3 className="font-medium text-red-900 dark:text-red-200">Delete Organization</h3>
<p className="text-sm text-red-700 dark:text-red-300 mt-1">Permanently delete this organization and all its data.</p>
@@ -660,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"
/>
@@ -696,14 +740,12 @@ export default function OrganizationSettings() {
{/* Members List */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-neutral-500 uppercase tracking-wider">Active Members</h3>
<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">No members found.</div>
<div className="p-8 text-center text-neutral-500 dark:text-neutral-400">No members found.</div>
) : (
members.map((member) => (
<div key={member.user_id} className="p-4 flex items-center justify-between hover:bg-neutral-50 dark:hover:bg-neutral-800/50 transition-colors">
@@ -715,7 +757,7 @@ export default function OrganizationSettings() {
<div className="text-sm font-medium text-neutral-900 dark:text-white">
{member.user_email || 'Unknown User'}
</div>
<div className="text-xs text-neutral-500">
<div className="text-xs text-neutral-500 dark:text-neutral-400">
Joined {new Date(member.joined_at).toLocaleDateString()}
</div>
</div>
@@ -740,7 +782,7 @@ export default function OrganizationSettings() {
{/* Pending Invitations */}
{invitations.length > 0 && (
<div className="space-y-4">
<h3 className="text-sm font-medium text-neutral-500 uppercase tracking-wider">Pending Invitations</h3>
<h3 className="text-sm font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Pending Invitations</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">
{invitations.map((invite) => (
<div key={invite.id} className="p-4 flex items-center justify-between">
@@ -752,7 +794,7 @@ export default function OrganizationSettings() {
<div className="text-sm font-medium text-neutral-900 dark:text-white">
{invite.email}
</div>
<div className="text-xs text-neutral-500">
<div className="text-xs text-neutral-500 dark:text-neutral-400">
Invited as <span className="capitalize font-medium">{invite.role}</span> • Expires {new Date(invite.expires_at).toLocaleDateString()}
</div>
</div>
@@ -780,12 +822,13 @@ 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-8 text-center bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-200 dark:border-neutral-800">
<p className="text-neutral-500">Could not load subscription details.</p>
<div className="p-6 text-center bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-200 dark:border-neutral-800">
<p className="text-neutral-500 dark:text-neutral-400">Could not load subscription details.</p>
<Button variant="ghost" onClick={loadSubscription} className="mt-4">Retry</Button>
</div>
) : (
@@ -793,7 +836,7 @@ export default function OrganizationSettings() {
{/* Trial notice */}
{subscription.subscription_status === 'trialing' && (
<div className="p-4 bg-yellow-50 dark:bg-yellow-900/10 border border-yellow-200 dark:border-yellow-800 rounded-2xl flex flex-col sm:flex-row sm:items-center gap-3">
<div className="p-6 bg-yellow-50 dark:bg-yellow-900/10 border border-yellow-200 dark:border-yellow-800 rounded-2xl flex flex-col sm:flex-row sm:items-center gap-3">
<div className="flex-1">
<p className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
Your free trial ends on{' '}
@@ -811,21 +854,55 @@ export default function OrganizationSettings() {
</div>
)}
{/* Past due notice */}
{subscription.subscription_status === 'past_due' && (
<div className="p-6 bg-red-50 dark:bg-red-900/10 border border-red-200 dark:border-red-800 rounded-2xl flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div className="flex-1">
<p className="text-sm font-medium text-red-800 dark:text-red-200">
Payment past due
</p>
<p className="text-xs text-red-700 dark:text-red-300 mt-0.5">
We couldn't charge your payment method. Please update your billing info to avoid service interruption.
</p>
</div>
<Button
variant="secondary"
onClick={handleManageSubscription}
disabled={isRedirectingToPortal}
isLoading={isRedirectingToPortal}
className="shrink-0"
>
Update payment method
</Button>
</div>
)}
{/* Cancel-at-period-end notice */}
{subscription.cancel_at_period_end && (
<div className="p-4 bg-amber-50 dark:bg-amber-900/10 border border-amber-200 dark:border-amber-800 rounded-2xl">
<p className="text-sm font-medium text-amber-800 dark:text-amber-200">
Your subscription will end on{' '}
<span className="font-semibold">
{(() => {
const d = subscription.current_period_end ? new Date(subscription.current_period_end as string) : null
return d && !Number.isNaN(d.getTime()) ? d.toLocaleDateString(undefined, { month: 'long', day: 'numeric', year: 'numeric' }) : '—'
})()}
</span>
</p>
<p className="text-xs text-amber-700 dark:text-amber-300 mt-1">
You keep full access until then. Your data is retained for 30 days after. Use "Change plan" to resubscribe.
</p>
<div className="p-6 bg-amber-50 dark:bg-amber-900/10 border border-amber-200 dark:border-amber-800 rounded-2xl flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<p className="text-sm font-medium text-amber-800 dark:text-amber-200">
Your subscription will end on{' '}
<span className="font-semibold">
{(() => {
const d = subscription.current_period_end ? new Date(subscription.current_period_end as string) : null
return d && !Number.isNaN(d.getTime()) ? d.toLocaleDateString(undefined, { month: 'long', day: 'numeric', year: 'numeric' }) : ''
})()}
</span>
</p>
<p className="text-xs text-amber-700 dark:text-amber-300 mt-1">
You keep full access until then. Your data is retained for 30 days after. Use "Change plan" to resubscribe.
</p>
</div>
<Button
variant="secondary"
onClick={handleResumeSubscription}
disabled={isResuming}
isLoading={isResuming}
className="shrink-0"
>
Keep my subscription
</Button>
</div>
)}
@@ -842,9 +919,11 @@ export default function OrganizationSettings() {
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300'
: subscription.subscription_status === 'trialing'
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-300'
: subscription.subscription_status === 'past_due'
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300'
: 'bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300'
}`}>
{subscription.subscription_status === 'trialing' ? 'Trial' : (subscription.subscription_status || 'Free')}
{subscription.subscription_status === 'trialing' ? 'Trial' : subscription.subscription_status === 'past_due' ? 'Past Due' : (subscription.subscription_status || 'Free')}
</span>
{subscription.billing_interval && (
<span className="text-xs text-neutral-500 capitalize">
@@ -856,16 +935,33 @@ export default function OrganizationSettings() {
Change plan
</Button>
</div>
{(subscription.business_name || (subscription.tax_ids && subscription.tax_ids.length > 0)) && (
<div className="px-6 pb-2 -mt-2 space-y-1 text-sm text-neutral-500 dark:text-neutral-400">
{subscription.business_name && (
<div>Billing for: {subscription.business_name}</div>
)}
{subscription.tax_ids && subscription.tax_ids.length > 0 && (
<div>
Tax ID{subscription.tax_ids.length > 1 ? 's' : ''}:{' '}
{subscription.tax_ids.map((t) => {
const label = t.type === 'eu_vat' ? 'VAT' : t.type === 'us_ein' ? 'EIN' : t.type.replace(/_/g, ' ').toUpperCase()
return `${label} ${t.value}${t.country ? ` (${t.country})` : ''}`
}).join(', ')}
</div>
)}
</div>
)}
{/* Usage stats */}
<div className="border-t border-neutral-200 dark:border-neutral-800 px-6 py-5 grid grid-cols-2 md:grid-cols-4 gap-y-4 gap-x-6">
<div className="border-t border-neutral-200 dark:border-neutral-800 p-6 grid grid-cols-2 md:grid-cols-4 gap-y-4 gap-x-6">
<div>
<div className="text-xs text-neutral-500 uppercase tracking-wider mb-1">Sites</div>
<div className="text-lg font-semibold text-neutral-900 dark:text-white">
{typeof subscription.sites_count === 'number'
? subscription.plan_id === 'solo'
? `${subscription.sites_count} / 1`
: `${subscription.sites_count}`
? (() => {
const limit = getSitesLimitForPlan(subscription.plan_id)
return limit != null ? `${subscription.sites_count} / ${limit}` : `${subscription.sites_count}`
})()
: ''}
</div>
</div>
@@ -876,6 +972,22 @@ export default function OrganizationSettings() {
? `${subscription.pageview_usage.toLocaleString()} / ${subscription.pageview_limit.toLocaleString()}`
: ''}
</div>
{subscription.pageview_limit > 0 && typeof subscription.pageview_usage === 'number' && (
<div className="mt-2 h-1.5 w-full rounded-full bg-neutral-200 dark:bg-neutral-700 overflow-hidden">
<div
className={`h-full rounded-full transition-all ${
subscription.pageview_usage / subscription.pageview_limit >= 1
? 'bg-red-500'
: subscription.pageview_usage / subscription.pageview_limit >= 0.9
? 'bg-red-400'
: subscription.pageview_usage / subscription.pageview_limit >= 0.8
? 'bg-amber-400'
: 'bg-green-500'
}`}
style={{ width: `${Math.min(100, (subscription.pageview_usage / subscription.pageview_limit) * 100)}%` }}
/>
</div>
)}
</div>
<div>
<div className="text-xs text-neutral-500 uppercase tracking-wider mb-1">
@@ -883,8 +995,18 @@ export default function OrganizationSettings() {
</div>
<div className="text-lg font-semibold text-neutral-900 dark:text-white">
{(() => {
const d = subscription.current_period_end ? new Date(subscription.current_period_end as string) : null
return d && !Number.isNaN(d.getTime()) && d.getTime() !== 0 ? d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : '—'
const ts = subscription.next_invoice_period_end ?? subscription.current_period_end
const d = ts ? new Date(typeof ts === 'number' ? ts * 1000 : ts) : null
const dateStr = d && !Number.isNaN(d.getTime()) && d.getTime() !== 0
? d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
: ''
const amount = subscription.next_invoice_amount_due != null && subscription.next_invoice_currency
? (subscription.next_invoice_amount_due / 100).toLocaleString('en-US', {
style: 'currency',
currency: subscription.next_invoice_currency.toUpperCase(),
})
: null
return amount && dateStr !== '' ? `${dateStr} for ${amount}` : dateStr
})()}
</div>
</div>
@@ -904,7 +1026,7 @@ export default function OrganizationSettings() {
type="button"
onClick={handleManageSubscription}
disabled={isRedirectingToPortal}
className="inline-flex items-center gap-1.5 text-sm text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white transition-colors disabled:opacity-50"
className="inline-flex items-center gap-2 text-sm text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white transition-colors disabled:opacity-50 focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded"
>
<ExternalLinkIcon className="w-4 h-4" />
Payment method & invoices
@@ -914,7 +1036,7 @@ export default function OrganizationSettings() {
<button
type="button"
onClick={() => setShowCancelPrompt(true)}
className="inline-flex items-center gap-1.5 rounded-lg border border-neutral-200 dark:border-neutral-700 px-3.5 py-1.5 text-sm text-neutral-600 dark:text-neutral-400 hover:border-red-300 hover:text-red-600 dark:hover:border-red-800 dark:hover:text-red-400 transition-colors"
className="inline-flex items-center gap-2 rounded-xl border border-neutral-200 dark:border-neutral-700 px-3.5 py-1.5 text-sm text-neutral-600 dark:text-neutral-400 hover:border-red-300 hover:text-red-600 dark:hover:border-red-800 dark:hover:text-red-400 transition-colors focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
>
Cancel subscription
</button>
@@ -923,14 +1045,12 @@ export default function OrganizationSettings() {
{/* Invoice History */}
<div>
<h3 className="text-sm font-medium text-neutral-500 uppercase tracking-wider mb-3">Recent invoices</h3>
<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">No invoices found.</div>
<div className="p-8 text-center text-neutral-500 dark:text-neutral-400">No invoices found.</div>
) : (
<>
{invoices.map((invoice) => (
@@ -957,14 +1077,21 @@ export default function OrganizationSettings() {
</span>
{invoice.invoice_pdf && (
<a href={invoice.invoice_pdf} target="_blank" rel="noopener noreferrer"
className="p-1.5 text-neutral-400 hover:text-neutral-900 dark:hover:text-white rounded-lg transition-colors" title="Download PDF">
<DownloadIcon className="w-4 h-4" />
className="inline-flex items-center gap-2 px-2.5 py-1.5 text-xs font-medium text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange" title="Download PDF">
<DownloadIcon className="w-3.5 h-3.5" />
Download PDF
</a>
)}
{invoice.hosted_invoice_url && (
<a href={invoice.hosted_invoice_url} target="_blank" rel="noopener noreferrer"
className="p-1.5 text-neutral-400 hover:text-neutral-900 dark:hover:text-white rounded-lg transition-colors" title="View invoice">
<ExternalLinkIcon className="w-4 h-4" />
className={`inline-flex items-center gap-2 px-2.5 py-1.5 text-xs font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange ${
invoice.status === 'open'
? 'bg-brand-orange text-white hover:bg-brand-orange-hover'
: 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800'
}`}
title={invoice.status === 'open' ? 'Pay now' : 'View invoice'}>
<ExternalLinkIcon className="w-3.5 h-3.5" />
{invoice.status === 'open' ? 'Pay now' : 'View invoice'}
</a>
)}
</div>
@@ -990,12 +1117,10 @@ 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 uppercase tracking-wider">Notification categories</h3>
<h3 className="text-sm font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Notification categories</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">
{notificationCategories.map((cat) => (
<div
@@ -1022,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))
@@ -1117,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>
) : (
@@ -1158,7 +1281,7 @@ export default function OrganizationSettings() {
{/* Pagination */}
{auditTotal > auditPageSize && (
<div className="flex items-center justify-between px-4 py-3 border-t border-neutral-200 dark:border-neutral-800">
<span className="text-sm text-neutral-500">
<span className="text-sm text-neutral-500 dark:text-neutral-400">
{auditPage * auditPageSize + 1}{Math.min((auditPage + 1) * auditPageSize, auditTotal)} of {auditTotal}
</span>
<div className="flex gap-2">
@@ -1338,8 +1461,9 @@ export default function OrganizationSettings() {
<button
type="button"
onClick={() => setShowChangePlanModal(false)}
className="text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-400"
className="text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-400 focus:outline-none focus:ring-2 focus:ring-brand-orange rounded-lg p-1"
disabled={isChangingPlan}
aria-label="Close dialog"
>
<XIcon className="w-5 h-5" />
</button>
@@ -1348,6 +1472,41 @@ export default function OrganizationSettings() {
Choose your pageview limit and billing interval. {hasActiveSubscription ? 'Your next invoice will reflect prorations.' : 'Youll start a new subscription.'}
</p>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">Plan</label>
<div className="grid grid-cols-3 gap-2">
{([
{ id: PLAN_ID_SOLO, name: 'Solo', sites: '1 site' },
{ id: PLAN_ID_TEAM, name: 'Team', sites: 'Up to 5 sites' },
{ id: PLAN_ID_BUSINESS, name: 'Business', sites: 'Up to 10 sites' },
] as const).map((plan) => {
const isCurrentPlan = subscription?.plan_id === plan.id
const isSelected = changePlanId === plan.id
return (
<button
key={plan.id}
type="button"
onClick={() => setChangePlanId(plan.id)}
className={`relative p-3 rounded-xl border text-left transition-all focus:outline-none focus:ring-2 focus:ring-brand-orange ${
isSelected
? 'border-brand-orange bg-brand-orange/5 dark:bg-brand-orange/10'
: 'border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600'
}`}
>
<span className={`block text-sm font-semibold ${isSelected ? 'text-brand-orange' : 'text-neutral-900 dark:text-white'}`}>
{plan.name}
</span>
<span className="block text-xs text-neutral-500 mt-0.5">{plan.sites}</span>
{isCurrentPlan && (
<span className="absolute -top-2 right-2 px-1.5 py-0.5 text-xs font-medium bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 rounded-full border border-neutral-200 dark:border-neutral-700">
Current
</span>
)}
</button>
)
})}
</div>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">Pageviews per month</label>
<select
@@ -1368,20 +1527,44 @@ export default function OrganizationSettings() {
<button
type="button"
onClick={() => setChangePlanYearly(false)}
className={`flex-1 py-2 text-sm font-medium rounded-md transition-colors ${!changePlanYearly ? 'bg-brand-orange text-white' : 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800'}`}
className={`flex-1 py-2 text-sm font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange ${!changePlanYearly ? 'bg-brand-orange text-white' : 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800'}`}
>
Monthly
</button>
<button
type="button"
onClick={() => setChangePlanYearly(true)}
className={`flex-1 py-2 text-sm font-medium rounded-md transition-colors ${changePlanYearly ? 'bg-brand-orange text-white' : 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800'}`}
className={`flex-1 py-2 text-sm font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange ${changePlanYearly ? 'bg-brand-orange text-white' : 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800'}`}
>
Yearly
</button>
</div>
</div>
</div>
{hasActiveSubscription && (
<div className="mt-4 p-4 rounded-lg bg-neutral-100 dark:bg-neutral-800/50 border border-neutral-200 dark:border-neutral-700">
{isLoadingPreview ? (
<div className="flex items-center gap-2 text-sm text-neutral-600 dark:text-neutral-400">
<Spinner className="w-4 h-4" />
Calculating next invoice…
</div>
) : invoicePreview ? (
<p className="text-sm text-neutral-700 dark:text-neutral-300">
Next invoice:{' '}
{(invoicePreview.amount_due / 100).toLocaleString('en-US', {
style: 'currency',
currency: invoicePreview.currency.toUpperCase(),
})}{' '}
on {new Date(invoicePreview.period_end * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}{' '}
<span className="text-neutral-500">(prorated)</span>
</p>
) : (
<p className="text-sm text-neutral-600 dark:text-neutral-400">
Unable to calculate preview. Your next invoice will reflect prorations.
</p>
)}
</div>
)}
<div className="flex gap-2 mt-6">
<Button
onClick={handleChangePlanSubmit}

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

View File

@@ -11,7 +11,7 @@ import {
} from '@ciphera-net/ui'
import { Site } from '@/lib/api/sites'
import { getRealtime } from '@/lib/api/stats'
import { toast } from '@ciphera-net/ui'
import { toast, Button } from '@ciphera-net/ui'
interface VerificationModalProps {
isOpen: boolean
@@ -130,15 +130,12 @@ export default function VerificationModal({ isOpen, onClose, site }: Verificatio
</div>
</div>
<button
onClick={handleStartVerification}
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-neutral-900 dark:bg-white text-white dark:text-neutral-900 rounded-xl font-medium hover:opacity-90 transition-opacity"
>
<Button onClick={handleStartVerification} className="w-full justify-center">
Open Website & Verify
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<svg className="w-4 h-4 ml-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</button>
</Button>
</div>
)}
@@ -172,12 +169,9 @@ export default function VerificationModal({ isOpen, onClose, site }: Verificatio
We are successfully receiving data from your website.
</p>
</div>
<button
onClick={onClose}
className="w-full px-4 py-3 bg-neutral-900 dark:bg-white text-white dark:text-neutral-900 rounded-xl font-medium hover:opacity-90 transition-opacity"
>
<Button onClick={onClose} className="w-full justify-center">
Done
</button>
</Button>
</div>
)}
@@ -205,18 +199,12 @@ export default function VerificationModal({ isOpen, onClose, site }: Verificatio
</div>
<div className="flex gap-3">
<button
onClick={onClose}
className="flex-1 px-4 py-2.5 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 text-neutral-700 dark:text-neutral-300 rounded-xl font-medium hover:bg-neutral-50 dark:hover:bg-neutral-700 transition-colors"
>
<Button variant="secondary" onClick={onClose} className="flex-1 justify-center">
Close
</button>
<button
onClick={handleStartVerification}
className="flex-1 px-4 py-2.5 bg-neutral-900 dark:bg-white text-white dark:text-neutral-900 rounded-xl font-medium hover:opacity-90 transition-opacity"
>
</Button>
<Button onClick={handleStartVerification} className="flex-1 justify-center">
Try Again
</button>
</Button>
</div>
</div>
)}

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:**
@@ -817,9 +817,9 @@ Always test both light and dark modes:
### VS Code-Style Syntax Highlighting
```tsx
<div className="bg-[#1e1e1e] rounded-xl overflow-hidden shadow-2xl border border-neutral-800">
<div className="bg-neutral-900 rounded-xl overflow-hidden shadow-2xl border border-neutral-800">
{/* Header bar */}
<div className="flex items-center px-4 py-3 bg-[#252526] border-b border-neutral-800">
<div className="flex items-center px-4 py-3 bg-neutral-800 border-b border-neutral-800">
<div className="flex gap-2">
<div className="w-3 h-3 rounded-full bg-red-500/20" />
<div className="w-3 h-3 rounded-full bg-yellow-500/20" />
@@ -973,7 +973,6 @@ presets: [
**Dashboard:** Chart, TopPages, TopReferrers, Locations, TechSpecs, Campaigns, Goals, Performance
**Settings:** OrganizationSettings, ProfileSettings
**Sites:** SiteList, VerificationModal
**Tools:** UtmBuilder
---

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,10 @@
import { API_URL } from './client'
import apiRequest from './client'
export interface TaxID {
type: string
value: string
country?: string
}
export interface SubscriptionDetails {
plan_id: string
@@ -13,41 +19,24 @@ export interface SubscriptionDetails {
sites_count?: number
/** Pageviews in current billing period (when pageview_limit > 0). Present when backend supports usage API. */
pageview_usage?: 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()
/** Business name from Stripe Tax ID collection / business purchase flow (optional). */
business_name?: string
/** Tax IDs collected on the Stripe customer (VAT, EIN, etc.) for invoice verification. */
tax_ids?: TaxID[]
/** Next invoice amount in cents (for "Renews on X for €Y" display). */
next_invoice_amount_due?: number
/** Currency for next invoice (e.g. eur). */
next_invoice_currency?: string
/** Unix timestamp when next invoice period ends. */
next_invoice_period_end?: number
}
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',
})
}
@@ -58,20 +47,44 @@ 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 }),
})
}
/** Clears cancel_at_period_end so the subscription continues past the current period. */
export async function resumeSubscription(): Promise<{ ok: boolean }> {
return apiRequest<{ ok: boolean }>('/api/billing/resume', {
method: 'POST',
})
}
export interface ChangePlanParams {
plan_id: string
interval: string
limit: number
}
export interface PreviewInvoiceResult {
amount_due: number
currency: string
period_end: number
}
export async function previewInvoice(params: ChangePlanParams): Promise<PreviewInvoiceResult | null> {
const res = await apiRequest<PreviewInvoiceResult | Record<string, never>>('/api/billing/preview-invoice', {
method: 'POST',
body: JSON.stringify(params),
})
if (res && typeof res === 'object' && 'amount_due' in res && typeof (res as PreviewInvoiceResult).amount_due === 'number') {
return res as PreviewInvoiceResult
}
return null
}
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),
})
@@ -84,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),
})
@@ -102,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,50 +90,149 @@ 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
// * Handle legacy endpoints that already include /api/ prefix
const url = endpoint.startsWith('/api/')
const url = endpoint.startsWith('/api/')
? `${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.
// 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...
// 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'
// * For GET requests, track the promise for deduplication and cache the result
if (shouldDedupe) {
const requestKey = getRequestKey(endpoint, options)
// * Store in pending requests
pendingRequests.set(requestKey, {
promise: requestPromise as Promise<unknown>,
timestamp: Date.now(),
})
// * 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

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