52 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
50 changed files with 2189 additions and 2639 deletions

View File

@@ -6,6 +6,56 @@ 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
@@ -223,7 +273,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
---
[Unreleased]: https://github.com/ciphera-net/pulse/compare/v0.12.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

View File

@@ -2,19 +2,10 @@
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

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

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>

View File

@@ -9,10 +9,12 @@ import { useOnlineStatus } from '@/lib/hooks/useOnlineStatus'
import Link from 'next/link'
import { useEffect, useState } from 'react'
import { logger } from '@/lib/utils/logger'
import { getUserOrganizations, switchContext } from '@/lib/api/organization'
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'
const ORG_SWITCH_KEY = 'pulse_switching_org'
@@ -44,11 +46,12 @@ const CIPHERA_APPS: CipheraApp[] = [
},
]
export default function LayoutContent({ children }: { children: React.ReactNode }) {
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'
@@ -87,7 +90,7 @@ export default function LayoutContent({ children }: { children: React.ReactNode
const handleCreateOrganization = () => {
router.push('/onboarding')
}
const showOfflineBar = Boolean(auth.user && !isOnline);
const barHeightRem = 2.5;
const headerHeightRem = 6;
@@ -100,9 +103,9 @@ export default function LayoutContent({ children }: { children: React.ReactNode
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}
@@ -117,6 +120,7 @@ export default function LayoutContent({ children }: { children: React.ReactNode
rightSideActions={auth.user ? <NotificationCenter /> : null}
apps={CIPHERA_APPS}
currentAppId="pulse"
onOpenSettings={openSettings}
customNavItems={
<>
{!auth.user && (
@@ -136,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

@@ -78,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 ? (
@@ -303,7 +303,7 @@ export default function HomePage() {
{ icon: ZapIcon, title: "Lightweight", desc: "Our script is less than 1kb. It won't slow down your site or affect your SEO." }
].map((feature, i) => (
<motion.div
key={i}
key={feature.title}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}

View File

@@ -1,532 +0,0 @@
'use client'
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { useAuth } from '@/lib/auth/context'
import ProfileSettings from '@/components/settings/ProfileSettings'
import TrustedDevicesCard from '@/components/settings/TrustedDevicesCard'
import SecurityActivityCard from '@/components/settings/SecurityActivityCard'
import { updateUserPreferences } from '@/lib/api/user'
import { motion, AnimatePresence } from 'framer-motion'
import {
UserIcon,
LockIcon,
BoxIcon,
ChevronRightIcon,
ChevronDownIcon,
ExternalLinkIcon,
} from '@ciphera-net/ui'
// Inline SVG icons not available in ciphera-ui
function BellIcon({ className }: { className?: string }) {
return (
<svg className={className} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<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" />
</svg>
)
}
// --- Types ---
type ProfileSubTab = 'profile' | 'security' | 'preferences'
type NotificationSubTab = 'security' | 'center'
type ActiveSelection =
| { section: 'profile'; subTab: ProfileSubTab }
| { section: 'notifications'; subTab: NotificationSubTab }
| { section: 'account' }
| { section: 'devices' }
| { section: 'activity' }
type ExpandableSection = 'profile' | 'notifications' | 'account'
// --- Sidebar Components ---
function SectionHeader({
expanded,
active,
onToggle,
icon: Icon,
label,
description,
hasChildren = true,
}: {
expanded: boolean
active: boolean
onToggle: () => void
icon: React.ElementType
label: string
description?: string
hasChildren?: boolean
}) {
return (
<button
onClick={onToggle}
className={`w-full flex items-start gap-3 px-4 py-3 text-left rounded-xl transition-all duration-200 ${
active
? 'bg-brand-orange/10 text-brand-orange'
: 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800'
}`}
>
<Icon className="w-5 h-5 mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<span className="font-medium">{label}</span>
{description && (
<p className={`text-xs mt-0.5 ${active ? 'text-brand-orange/70' : 'text-neutral-500'}`}>
{description}
</p>
)}
</div>
{hasChildren ? (
<ChevronDownIcon
className={`w-4 h-4 shrink-0 mt-1 transition-transform duration-200 ${
expanded ? '' : '-rotate-90'
}`}
/>
) : (
<ChevronRightIcon className={`w-4 h-4 shrink-0 mt-1 transition-transform ${active ? 'rotate-90' : ''}`} />
)}
</button>
)
}
function SubItem({
active,
onClick,
label,
external = false,
}: {
active: boolean
onClick: () => void
label: string
external?: boolean
}) {
return (
<button
onClick={onClick}
className={`w-full flex items-center gap-2.5 pl-12 pr-4 py-2 text-sm text-left rounded-lg transition-all duration-150 ${
active
? 'text-brand-orange font-medium bg-brand-orange/5'
: 'text-neutral-500 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-50 dark:hover:bg-neutral-800/50'
}`}
>
<span className="flex-1">{label}</span>
{external && <ExternalLinkIcon className="w-3 h-3 opacity-60" />}
</button>
)
}
function ExpandableSubItems({ expanded, children }: { expanded: boolean; children: React.ReactNode }) {
return (
<AnimatePresence initial={false}>
{expanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2, ease: 'easeInOut' }}
className="overflow-hidden"
>
<div className="py-1 space-y-0.5">
{children}
</div>
</motion.div>
)}
</AnimatePresence>
)
}
// --- Content Components ---
// Security Alerts Card (granular security toggles)
const SECURITY_ALERT_OPTIONS = [
{ 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 (
<div className="bg-white dark:bg-neutral-900 rounded-2xl border border-neutral-200 dark:border-neutral-800 p-6 shadow-sm">
<div className="flex items-center gap-3 mb-6">
<div className="p-2 rounded-lg bg-brand-orange/10">
<BellIcon className="w-5 h-5 text-brand-orange" />
</div>
<div>
<h2 className="text-lg font-semibold text-neutral-900 dark:text-white">Security Alerts</h2>
<p className="text-sm text-neutral-500">Choose which security events trigger email alerts</p>
</div>
</div>
<div className="space-y-4">
{SECURITY_ALERT_OPTIONS.map((item) => (
<div
key={item.key}
className={`flex items-center justify-between p-4 border rounded-xl transition-all duration-200 ${
emailNotifications[item.key]
? 'bg-orange-50 dark:bg-brand-orange/10 border-brand-orange shadow-sm'
: 'bg-white dark:bg-neutral-900 border-neutral-200 dark:border-neutral-800'
}`}
>
<div className="space-y-0.5">
<span className={`block text-sm font-medium transition-colors duration-200 ${
emailNotifications[item.key] ? 'text-brand-orange' : 'text-neutral-900 dark:text-white'
}`}>
{item.label}
</span>
<span className={`block text-xs transition-colors duration-200 ${
emailNotifications[item.key] ? 'text-brand-orange/80' : 'text-neutral-500 dark:text-neutral-400'
}`}>
{item.description}
</span>
</div>
<button
onClick={() => handleToggle(item.key)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none ${
emailNotifications[item.key] ? 'bg-brand-orange' : 'bg-neutral-200 dark:bg-neutral-700'
}`}
>
<span
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
emailNotifications[item.key] ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</div>
))}
</div>
</div>
)
}
function AccountManagementCard() {
const accountLinks = [
{
label: 'Profile & Personal Info',
description: 'Update your name, email, and avatar',
href: 'https://auth.ciphera.net/settings',
icon: UserIcon,
},
{
label: 'Security & 2FA',
description: 'Password, two-factor authentication, and passkeys',
href: 'https://auth.ciphera.net/settings?tab=security',
icon: LockIcon,
},
{
label: 'Active Sessions',
description: 'Manage devices logged into your account',
href: 'https://auth.ciphera.net/settings?tab=sessions',
icon: BoxIcon,
},
]
return (
<div className="bg-white dark:bg-neutral-900 rounded-2xl border border-neutral-200 dark:border-neutral-800 p-6 shadow-sm">
<div className="flex items-center gap-3 mb-6">
<div className="p-2 rounded-lg bg-brand-orange/10">
<UserIcon className="w-5 h-5 text-brand-orange" />
</div>
<div>
<h2 className="text-lg font-semibold text-neutral-900 dark:text-white">Ciphera Account</h2>
<p className="text-sm text-neutral-500">Manage your account across all Ciphera products</p>
</div>
</div>
<div className="space-y-3">
{accountLinks.map((link) => (
<a
key={link.label}
href={link.href}
target="_blank"
rel="noopener noreferrer"
className="flex items-start gap-3 p-3 rounded-xl border border-neutral-200 dark:border-neutral-800 hover:border-brand-orange/30 hover:bg-brand-orange/5 transition-all group"
>
<link.icon className="w-5 h-5 text-neutral-400 group-hover:text-brand-orange shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-neutral-900 dark:text-white group-hover:text-brand-orange">
{link.label}
</span>
<ExternalLinkIcon className="w-3.5 h-3.5 text-neutral-400" />
</div>
<p className="text-sm text-neutral-500 mt-0.5">{link.description}</p>
</div>
<ChevronRightIcon className="w-4 h-4 text-neutral-400 shrink-0 mt-1" />
</a>
))}
</div>
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-800">
<p className="text-xs text-neutral-500">
These settings apply to your Ciphera Account and affect all products (Drop, Pulse, and Auth).
</p>
</div>
</div>
)
}
// --- Main Settings Section ---
function AppSettingsSection() {
const [active, setActive] = useState<ActiveSelection>({ section: 'profile', subTab: 'profile' })
const [expanded, setExpanded] = useState<Set<ExpandableSection>>(new Set(['profile']))
const toggleSection = (section: ExpandableSection) => {
setExpanded(prev => {
const next = new Set(prev)
if (next.has(section)) {
next.delete(section)
} else {
next.add(section)
}
return next
})
}
const selectSubTab = (selection: ActiveSelection) => {
setActive(selection)
if ('subTab' in selection) {
setExpanded(prev => new Set(prev).add(selection.section as ExpandableSection))
}
}
const renderContent = () => {
switch (active.section) {
case 'profile':
return <ProfileSettings activeTab={active.subTab} />
case 'notifications':
if (active.subTab === 'security') return <SecurityAlertsCard />
if (active.subTab === 'center') return (
<div className="bg-white dark:bg-neutral-900 rounded-2xl border border-neutral-200 dark:border-neutral-800 p-8 shadow-sm">
<div className="text-center max-w-md mx-auto">
<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>
</div>
)
return null
case 'account':
return <AccountManagementCard />
case 'devices':
return <TrustedDevicesCard />
case 'activity':
return <SecurityActivityCard />
default:
return null
}
}
return (
<div className="flex flex-col lg:flex-row gap-8">
{/* Sidebar Navigation */}
<nav className="w-full lg:w-72 flex-shrink-0 space-y-6">
{/* Pulse Settings Section */}
<div>
<h3 className="text-xs font-semibold text-neutral-400 uppercase tracking-wider mb-3 px-4">
Pulse Settings
</h3>
<div className="space-y-1">
<div>
<SectionHeader
expanded={expanded.has('profile')}
active={active.section === 'profile'}
onToggle={() => {
toggleSection('profile')
if (!expanded.has('profile')) {
selectSubTab({ section: 'profile', subTab: 'profile' })
}
}}
icon={UserIcon}
label="Profile & Preferences"
description="Your profile and sharing defaults"
/>
<ExpandableSubItems expanded={expanded.has('profile')}>
<SubItem
active={active.section === 'profile' && active.subTab === 'profile'}
onClick={() => selectSubTab({ section: 'profile', subTab: 'profile' })}
label="Profile"
/>
<SubItem
active={active.section === 'profile' && active.subTab === 'security'}
onClick={() => selectSubTab({ section: 'profile', subTab: 'security' })}
label="Security"
/>
<SubItem
active={active.section === 'profile' && active.subTab === 'preferences'}
onClick={() => selectSubTab({ section: 'profile', subTab: 'preferences' })}
label="Preferences"
/>
</ExpandableSubItems>
</div>
{/* Notifications (expandable) */}
<div>
<SectionHeader
expanded={expanded.has('notifications')}
active={active.section === 'notifications'}
onToggle={() => {
toggleSection('notifications')
if (!expanded.has('notifications')) {
selectSubTab({ section: 'notifications', subTab: 'security' })
}
}}
icon={BellIcon}
label="Notifications"
description="Email and in-app notifications"
/>
<ExpandableSubItems expanded={expanded.has('notifications')}>
<SubItem
active={active.section === 'notifications' && active.subTab === 'security'}
onClick={() => selectSubTab({ section: 'notifications', subTab: 'security' })}
label="Security Alerts"
/>
<SubItem
active={active.section === 'notifications' && active.subTab === 'center'}
onClick={() => selectSubTab({ section: 'notifications', subTab: 'center' })}
label="Notification Center"
/>
</ExpandableSubItems>
</div>
</div>
</div>
{/* Ciphera Account Section */}
<div className="pt-4 border-t border-neutral-200 dark:border-neutral-800">
<h3 className="text-xs font-semibold text-neutral-400 uppercase tracking-wider mb-3 px-4">
Ciphera Account
</h3>
<div>
<SectionHeader
expanded={expanded.has('account')}
active={active.section === 'account' || active.section === 'devices' || active.section === 'activity'}
onToggle={() => {
toggleSection('account')
if (!expanded.has('account')) {
setActive({ section: 'account' })
}
}}
icon={LockIcon}
label="Manage Account"
description="Security, 2FA, and sessions"
/>
<ExpandableSubItems expanded={expanded.has('account')}>
<SubItem
active={false}
onClick={() => window.open('https://auth.ciphera.net/settings', '_blank')}
label="Profile & Personal Info"
external
/>
<SubItem
active={false}
onClick={() => window.open('https://auth.ciphera.net/settings?tab=security', '_blank')}
label="Security & 2FA"
external
/>
<SubItem
active={false}
onClick={() => window.open('https://auth.ciphera.net/settings?tab=sessions', '_blank')}
label="Active Sessions"
external
/>
<SubItem
active={active.section === 'devices'}
onClick={() => setActive({ section: 'devices' })}
label="Trusted Devices"
/>
<SubItem
active={active.section === 'activity'}
onClick={() => setActive({ section: 'activity' })}
label="Security Activity"
/>
</ExpandableSubItems>
</div>
</div>
</nav>
{/* Content Area */}
<div className="flex-1 min-w-0">
{renderContent()}
</div>
</div>
)
}
export default function SettingsPageClient() {
const { user } = useAuth()
return (
<div className="space-y-8">
{/* Page Header */}
<div>
<h1 className="text-3xl font-bold text-neutral-900 dark:text-white">Settings</h1>
<p className="mt-2 text-neutral-600 dark:text-neutral-400">
Manage your Pulse preferences and Ciphera account settings
</p>
</div>
{/* Breadcrumb / Context */}
<div className="flex items-center gap-2 text-sm text-neutral-500">
<span>You are signed in as</span>
<span className="font-medium text-neutral-900 dark:text-white">{user?.email}</span>
<span>&bull;</span>
<a
href="https://auth.ciphera.net/settings"
target="_blank"
rel="noopener noreferrer"
className="text-brand-orange hover:underline inline-flex items-center gap-1"
>
Manage in Ciphera Account
<ExternalLinkIcon className="w-3 h-3" />
</a>
</div>
{/* Settings Content */}
<AppSettingsSection />
</div>
)
}

View File

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

View File

@@ -278,7 +278,7 @@ export default function FunnelReportPage() {
</thead>
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-800">
{stats.steps.map((step, i) => (
<tr key={i} className="hover:bg-neutral-50 dark:hover:bg-neutral-800/30 transition-colors">
<tr key={step.step.name} className="hover:bg-neutral-50 dark:hover:bg-neutral-800/30 transition-colors">
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<span className="w-6 h-6 rounded-full bg-neutral-100 dark:bg-neutral-800 flex items-center justify-center text-xs font-medium text-neutral-600 dark:text-neutral-400">

View File

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

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

View File

@@ -2,15 +2,26 @@
import { useAuth } from '@/lib/auth/context'
import { logger } from '@/lib/utils/logger'
import { useCallback, useEffect, useState, useRef } from 'react'
import { useParams, useRouter } from 'next/navigation'
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'
@@ -21,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()
@@ -31,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) {
logger.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,
@@ -110,9 +375,6 @@ export default function SiteDashboardPage() {
// 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'
@@ -127,160 +389,13 @@ 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] }
}, [])
// * Visibility-aware polling intervals
// * Historical data: 60s when visible, paused when hidden
// * Real-time data: 5s when visible, 30s when hidden
const [isVisible, setIsVisible] = useState(true)
const dashboardIntervalRef = useRef<NodeJS.Timeout | null>(null)
const realtimeIntervalRef = useRef<NodeJS.Timeout | null>(null)
// * Track visibility state
useEffect(() => {
const handleVisibilityChange = () => {
const visible = document.visibilityState === 'visible'
setIsVisible(visible)
}
document.addEventListener('visibilitychange', handleVisibilityChange)
return () => document.removeEventListener('visibilitychange', handleVisibilityChange)
}, [])
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 dashboard analytics')
}
} 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])
// * Visibility-aware polling for dashboard data (historical)
// * Refreshes every 60 seconds when tab is visible, pauses when hidden
useEffect(() => {
if (!isSettingsLoaded) return
// * Initial load
loadData()
// * Clear existing interval
if (dashboardIntervalRef.current) {
clearInterval(dashboardIntervalRef.current)
}
// * Only poll when visible (saves server resources when tab is backgrounded)
if (isVisible) {
dashboardIntervalRef.current = setInterval(() => {
loadData(true)
}, 60000) // * 60 seconds for historical data
}
return () => {
if (dashboardIntervalRef.current) {
clearInterval(dashboardIntervalRef.current)
}
}
}, [siteId, dateRange, todayInterval, multiDayInterval, isSettingsLoaded, loadData, isVisible])
// * Visibility-aware polling for realtime data
// * Refreshes every 5 seconds when visible, every 30 seconds when hidden
useEffect(() => {
if (!isSettingsLoaded) return
// * Clear existing interval
if (realtimeIntervalRef.current) {
clearInterval(realtimeIntervalRef.current)
}
// * Different intervals based on visibility
const interval = isVisible ? 5000 : 30000 // * 5s visible, 30s hidden
realtimeIntervalRef.current = setInterval(() => {
loadRealtime()
}, interval)
return () => {
if (realtimeIntervalRef.current) {
clearInterval(realtimeIntervalRef.current)
}
}
}, [siteId, isSettingsLoaded, loadRealtime, isVisible])
}, [todayInterval, multiDayInterval]) // eslint-disable-line react-hooks/exhaustive-deps -- dateRange saved via saveSettings
useEffect(() => {
if (site?.domain) document.title = `${site.domain} | Pulse`
}, [site?.domain])
const showSkeleton = useMinimumLoading(loading)
const showSkeleton = useMinimumLoading(overviewLoading)
if (showSkeleton) {
return <DashboardSkeleton />
@@ -312,7 +427,7 @@ export default function SiteDashboardPage() {
{site.domain}
</p>
</div>
{/* Realtime Indicator */}
<button
onClick={() => router.push(`/sites/${siteId}/realtime`)}
@@ -412,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}
@@ -433,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}
@@ -445,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)}
@@ -507,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

@@ -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,116 +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 { logger } from '@/lib/utils/logger'
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) => {
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)
sessionStorage.setItem('pulse_switching_org', 'true')
window.location.reload()
} catch (err) {
logger.error('Failed to switch organization', err)
setSwitching(null)
}
}
return (
<div className="border-b border-neutral-100 dark:border-neutral-800 pb-2 mb-2" role="group" aria-label="Organizations">
<div className="px-3 py-2 text-xs font-medium text-neutral-500 uppercase tracking-wider" aria-hidden="true">
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)}
aria-current={activeOrgId === org.organization_id ? 'true' : undefined}
aria-busy={switching === org.organization_id ? 'true' : undefined}
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" aria-hidden="true" />
</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" aria-live="polite">Switching</span>}
{activeOrgId === org.organization_id && !switching && (
<>
<CheckIcon className="h-4 w-4 text-neutral-600 dark:text-neutral-400" aria-hidden="true" />
<span className="sr-only">(current)</span>
</>
)}
</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" aria-hidden="true">
<PlusIcon className="h-3 w-3" />
</div>
<span>Create Organization</span>
</Link>
</div>
)
}

View File

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

View File

@@ -5,58 +5,37 @@ 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 } from '@ciphera-net/ui'
import { TableSkeleton } from '@/components/skeletons'
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) {
logger.error(e)
@@ -65,14 +44,14 @@ export default function Campaigns({ siteId, dateRange }: CampaignsProps) {
}
}
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) {
logger.error(e)
@@ -84,29 +63,22 @@ 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)
@@ -128,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;' })
@@ -148,22 +120,6 @@ export default function Campaigns({ siteId, dateRange }: CampaignsProps) {
URL.revokeObjectURL(url)
}
const SortHeader = ({ label, colKey, className = '' }: { label: string; colKey: SortKey; className?: string }) => (
<button
type="button"
onClick={() => handleSort(colKey)}
className={`inline-flex items-center gap-1 text-xs font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-inset rounded ${className}`}
aria-label={`Sort by ${label}`}
>
{label}
{sortKey === colKey ? (
<ChevronDownIcon className={`w-3 h-3 text-brand-orange ${sortDir === 'asc' ? 'rotate-180' : ''}`} />
) : (
<span className="w-3 h-3 inline-block text-neutral-400" aria-hidden />
)}
</button>
)
return (
<>
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
@@ -171,124 +127,87 @@ export default function Campaigns({ siteId, dateRange }: CampaignsProps) {
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
Campaigns
</h3>
<div className="flex items-center gap-2">
{hasData && (
<Button
variant="ghost"
onClick={handleExportCampaigns}
className="h-8 px-3 text-xs gap-2"
>
<DownloadIcon className="w-3.5 h-3.5" />
Export
</Button>
)}
<Button
variant="ghost"
onClick={() => setIsBuilderOpen(true)}
className="h-8 px-3 text-xs gap-2"
>
<PlusIcon className="w-3.5 h-3.5" />
Build URL
</Button>
{showViewAll && (
<Button
variant="ghost"
onClick={() => setIsModalOpen(true)}
className="h-8 px-3 text-xs"
>
View All
</Button>
)}
</div>
<button
onClick={() => setIsBuilderOpen(true)}
className="text-xs font-medium text-neutral-400 dark:text-neutral-500 hover:text-brand-orange dark:hover:text-brand-orange transition-colors cursor-pointer"
>
Build URL
</button>
</div>
{isLoading ? (
<div className="space-y-2 flex-1 min-h-[270px]">
<div className="grid grid-cols-12 gap-2 mb-2 px-2">
<div className="col-span-4 h-4 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800" />
<div className="col-span-2 h-4 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800" />
<div className="col-span-2 h-4 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800" />
<div className="col-span-2 h-4 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800" />
<div className="col-span-2 h-4 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800" />
</div>
{Array.from({ length: 7 }).map((_, i) => (
<div key={`skeleton-${i}`} className="grid grid-cols-12 gap-2 h-9 px-2 -mx-2">
<div className="col-span-4 h-4 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800" />
<div className="col-span-2 h-4 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800" />
<div className="col-span-2 h-4 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800" />
<div className="col-span-2 h-4 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800" />
<div className="col-span-2 h-4 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800" />
<div className="space-y-2 flex-1 min-h-[270px]">
{isLoading ? (
<ListSkeleton rows={LIMIT} />
) : hasData ? (
<>
{displayedData.map((item) => {
return (
<div
key={`${item.source}|${item.medium}|${item.campaign}`}
onClick={() => onFilter?.({ dimension: 'utm_source', operator: 'is', values: [item.source] })}
className={`flex items-center justify-between py-1.5 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors${onFilter ? ' cursor-pointer' : ''}`}
>
<div className="flex-1 text-neutral-900 dark:text-white flex items-center gap-3 min-w-0">
{renderSourceIcon(item.source)}
<div className="min-w-0">
<div className="truncate font-medium text-sm" title={item.source}>
{getReferrerDisplayName(item.source)}
</div>
<div className="flex items-center gap-1.5 text-[11px] text-neutral-400 dark:text-neutral-500">
<span>{item.medium || '—'}</span>
<span>·</span>
<span className="truncate">{item.campaign || '—'}</span>
</div>
</div>
</div>
<div className="flex items-center gap-2 ml-4">
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
{totalVisitors > 0 ? `${Math.round((item.visitors / totalVisitors) * 100)}%` : ''}
</span>
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
{formatNumber(item.visitors)}
</span>
</div>
</div>
)
})}
{showViewAll ? (
<button
onClick={() => setIsModalOpen(true)}
className="flex items-center justify-center gap-1.5 h-9 w-full text-xs font-medium text-neutral-400 dark:text-neutral-500 hover:text-brand-orange dark:hover:text-brand-orange transition-colors cursor-pointer rounded-lg px-2 -mx-2"
>
View all
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</button>
) : (
Array.from({ length: emptySlots }).map((_, i) => (
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
))
)}
</>
) : (
<div className="h-full flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
<FaBullhorn className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
</div>
))}
</div>
) : hasData ? (
<div className="space-y-2 flex-1 min-h-[270px]">
<div className="grid grid-cols-12 gap-2 text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-2 px-2">
<div className="col-span-4">
<SortHeader label="Source" colKey="source" className="text-left" />
</div>
<div className="col-span-2">
<SortHeader label="Medium" colKey="medium" className="text-left" />
</div>
<div className="col-span-2">
<SortHeader label="Campaign" colKey="campaign" className="text-left" />
</div>
<div className="col-span-2 text-right">
<SortHeader label="Visitors" colKey="visitors" className="text-right justify-end" />
</div>
<div className="col-span-2 text-right">
<SortHeader label="Pageviews" colKey="pageviews" className="text-right justify-end" />
</div>
</div>
{displayedData.map((item) => (
<div
key={campaignRowKey(item)}
className="grid grid-cols-12 gap-2 items-center h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors text-sm"
<h4 className="font-semibold text-neutral-900 dark:text-white">
Track your marketing campaigns
</h4>
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs">
Add UTM parameters to your links to see campaign performance here.
</p>
<Link
href="/installation"
className="inline-flex items-center gap-2 text-sm font-medium text-brand-orange hover:text-brand-orange/90 hover:underline focus:outline-none focus:ring-2 focus:ring-brand-orange/20 rounded"
>
<div className="col-span-4 flex items-center gap-3 truncate">
{renderSourceIcon(item.source)}
<span className="truncate text-neutral-900 dark:text-white font-medium" title={item.source}>
{getReferrerDisplayName(item.source)}
</span>
</div>
<div className="col-span-2 truncate text-neutral-500 dark:text-neutral-400" title={item.medium}>
{item.medium || EMPTY_LABEL}
</div>
<div className="col-span-2 truncate text-neutral-500 dark:text-neutral-400" title={item.campaign}>
{item.campaign || EMPTY_LABEL}
</div>
<div className="col-span-2 text-right font-semibold text-neutral-900 dark:text-white">
{formatNumber(item.visitors)}
</div>
<div className="col-span-2 text-right text-neutral-600 dark:text-neutral-400">
{formatNumber(item.pageviews)}
</div>
</div>
))}
{Array.from({ length: emptySlots }).map((_, i) => (
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
))}
</div>
) : (
<div className="flex-1 min-h-[270px] flex flex-col items-center justify-center text-center px-6 py-8 gap-4">
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
<FaBullhorn className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
Learn more
<ArrowRightIcon className="w-4 h-4" />
</Link>
</div>
<h4 className="font-semibold text-neutral-900 dark:text-white">
Track your marketing campaigns
</h4>
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-md">
Add <code className="px-1.5 py-0.5 rounded bg-neutral-200 dark:bg-neutral-700 text-xs font-mono">utm_source</code>, <code className="px-1.5 py-0.5 rounded bg-neutral-200 dark:bg-neutral-700 text-xs font-mono">utm_medium</code>, and <code className="px-1.5 py-0.5 rounded bg-neutral-200 dark:bg-neutral-700 text-xs font-mono">utm_campaign</code> parameters to your links to see them here.
</p>
<Link
href="/installation"
className="inline-flex items-center gap-2 text-sm font-medium text-brand-orange hover:text-brand-orange/90 hover:underline focus:outline-none focus:ring-2 focus:ring-brand-orange/20 rounded"
>
Read documentation
<ArrowRightIcon className="w-4 h-4" />
</Link>
</div>
)}
)}
</div>
</div>
<Modal
@@ -296,45 +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-4">
<TableSkeleton rows={10} cols={5} />
<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,12 +10,10 @@ import {
CartesianGrid,
Tooltip,
ResponsiveContainer,
ReferenceLine,
} from 'recharts'
import type { TooltipProps } from 'recharts'
import { formatNumber, formatDuration, formatUpdatedAgo } from '@ciphera-net/ui'
import Sparkline from './Sparkline'
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 = {
@@ -26,6 +24,7 @@ const COLORS = {
const CHART_COLORS_LIGHT = {
border: 'var(--color-neutral-200)',
grid: 'var(--color-neutral-100)',
text: 'var(--color-neutral-900)',
textMuted: 'var(--color-neutral-500)',
axis: 'var(--color-neutral-400)',
@@ -35,6 +34,7 @@ const CHART_COLORS_LIGHT = {
const CHART_COLORS_DARK = {
border: 'var(--color-neutral-700)',
grid: 'var(--color-neutral-800)',
text: 'var(--color-neutral-50)',
textMuted: 'var(--color-neutral-400)',
axis: 'var(--color-neutral-500)',
@@ -68,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 transition-shadow duration-300"
style={{
backgroundColor: colors.tooltipBg,
borderColor: colors.tooltipBorder,
}}
>
<div className="text-xs font-medium" style={{ color: colors.textMuted, marginBottom: 6 }}>
{label}
</div>
<div className="flex items-baseline gap-2">
<span className="text-base font-bold" style={{ color: colors.text }}>
{formatValue(value)}
</span>
<span className="text-xs" style={{ color: colors.textMuted }}>
{metricLabel}
</span>
</div>
{hasPrev && (
<div className="mt-1.5 flex items-center gap-2 text-xs" style={{ color: colors.textMuted }}>
<span>vs {formatValue(prev as number)} {prevPeriodLabel ? `(${prevPeriodLabel})` : 'prev'}</span>
{delta !== null && (
<span
className="font-medium"
style={{
color: delta > 0 ? (metric === 'bounce_rate' ? COLORS.danger : COLORS.success) : delta < 0 ? (metric === 'bounce_rate' ? COLORS.success : COLORS.danger) : colors.textMuted,
}}
>
{delta > 0 ? '+' : ''}{delta}%
</span>
)}
</div>
)}
</div>
)
}
// * Compact Y-axis formatter: 1.5M, 12k, 99
function formatAxisValue(value: number): string {
if (value >= 1e6) return `${value / 1e6}M`
if (value >= 1000) return `${value / 1000}k`
if (value >= 1e6) return `${+(value / 1e6).toFixed(1)}M`
if (value >= 1000) return `${+(value / 1000).toFixed(1)}k`
if (!Number.isInteger(value)) return value.toFixed(1)
return String(value)
}
// * Compact duration for Y-axis ticks (avoids truncation: "5m" not "5m 0s")
function formatAxisDuration(seconds: number): string {
if (!seconds) return '0s'
const m = Math.floor(seconds / 60)
const s = Math.floor(seconds % 60)
if (m > 0) return s > 0 ? `${m}m ${s}s` : `${m}m`
if (m > 0) return s > 0 ? `${m}m${s}s` : `${m}m`
return `${s}s`
}
// * Returns human-readable label for the previous comparison period (e.g. "Feb 10" or "Jan 5 Feb 4")
function getPrevDateRangeLabel(dateRange: { start: string; end: string }): string {
const startDate = new Date(dateRange.start)
const endDate = new Date(dateRange.end)
@@ -197,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)
@@ -209,11 +118,88 @@ function getTrendContext(dateRange: { start: string; end: string }): string {
return `vs previous ${days} days`
}
export default function Chart({
data,
prevData,
stats,
prevStats,
// ─── Tooltip ─────────────────────────────────────────────────────────
function ChartTooltip({
active,
payload,
label,
metric,
metricLabel,
formatNumberFn,
showComparison,
prevPeriodLabel,
colors,
}: {
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 (!active || !payload?.length || !label) return null
const current = payload.find((p) => p.dataKey === metric) ?? payload[payload.length - 1]
const value = Number(current?.value ?? current?.payload?.[metric] ?? 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 (
<div
className="rounded-lg border px-3.5 py-2.5 shadow-lg"
style={{ backgroundColor: colors.tooltipBg, borderColor: colors.tooltipBorder }}
>
<div className="text-[11px] font-medium mb-1" style={{ color: colors.textMuted }}>
{label}
</div>
<div className="flex items-baseline gap-1.5">
<span className="text-sm font-bold" style={{ color: colors.text }}>
{formatValue(value)}
</span>
<span className="text-[11px]" style={{ color: colors.textMuted }}>
{metricLabel}
</span>
</div>
{hasPrev && (
<div className="mt-1 flex items-center gap-1.5 text-[11px]" style={{ color: colors.textMuted }}>
<span>vs {formatValue(prev)} {prevPeriodLabel ? `(${prevPeriodLabel})` : ''}</span>
{delta !== null && (
<span
className="font-medium"
style={{
color: delta > 0 ? (metric === 'bounce_rate' ? COLORS.danger : COLORS.success) : delta < 0 ? (metric === 'bounce_rate' ? COLORS.success : COLORS.danger) : colors.textMuted,
}}
>
{delta > 0 ? '+' : ''}{delta}%
</span>
)}
</div>
)}
</div>
)
}
// ─── Chart Component ─────────────────────────────────────────────────
export default function Chart({
data,
prevData,
stats,
prevStats,
interval,
dateRange,
todayInterval,
@@ -229,24 +215,21 @@ export default function Chart({
const { resolvedTheme } = useTheme()
const handleExportChart = useCallback(async () => {
if (onExportChart) {
onExportChart()
return
}
if (onExportChart) { onExportChart(); return }
if (!chartContainerRef.current) return
try {
const { toPng } = await import('html-to-image')
// Resolve the actual background color from the DOM (CSS vars don't work in html-to-image)
const bg = getComputedStyle(chartContainerRef.current).backgroundColor || (resolvedTheme === 'dark' ? '#171717' : '#ffffff')
const dataUrl = await toPng(chartContainerRef.current, {
cacheBust: true,
backgroundColor: resolvedTheme === 'dark' ? 'var(--color-neutral-900)' : '#ffffff',
backgroundColor: bg,
})
const link = document.createElement('a')
link.download = `chart-${dateRange.start}-${dateRange.end}.png`
link.href = dataUrl
link.click()
} catch {
// Fallback: do nothing if export fails
}
} catch { /* noop */ }
}, [onExportChart, dateRange, resolvedTheme])
const colors = useMemo(
@@ -254,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,
@@ -289,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
@@ -297,282 +278,201 @@ export default function Chart({
}
const metrics = [
{
id: 'visitors',
label: 'Unique Visitors',
value: formatNumber(stats.visitors),
trend: calculateTrend(stats.visitors, prevStats?.visitors),
color: COLORS.brand,
invertTrend: false,
},
{
id: 'pageviews',
label: 'Total Pageviews',
value: formatNumber(stats.pageviews),
trend: calculateTrend(stats.pageviews, prevStats?.pageviews),
color: COLORS.brand,
invertTrend: false,
},
{
id: 'bounce_rate',
label: 'Bounce Rate',
value: `${Math.round(stats.bounce_rate)}%`,
trend: calculateTrend(stats.bounce_rate, prevStats?.bounce_rate),
color: COLORS.brand,
invertTrend: true, // Lower bounce rate is better
},
{
id: 'avg_duration',
label: 'Visit Duration',
value: formatDuration(stats.avg_duration),
trend: calculateTrend(stats.avg_duration, prevStats?.avg_duration),
color: COLORS.brand,
invertTrend: false,
},
] as const
{ id: 'visitors' as const, label: 'Unique Visitors', value: formatNumber(stats.visitors), trend: calculateTrend(stats.visitors, prevStats?.visitors), invertTrend: false },
{ id: 'pageviews' as const, label: 'Total Pageviews', value: formatNumber(stats.pageviews), trend: calculateTrend(stats.pageviews, prevStats?.pageviews), invertTrend: false },
{ id: 'bounce_rate' as const, label: 'Bounce Rate', value: `${Math.round(stats.bounce_rate)}%`, trend: calculateTrend(stats.bounce_rate, prevStats?.bounce_rate), invertTrend: true },
{ id: 'avg_duration' as const, label: 'Visit Duration', value: formatDuration(stats.avg_duration), trend: calculateTrend(stats.avg_duration, prevStats?.avg_duration), invertTrend: false },
]
const activeMetric = metrics.find((m) => m.id === metric) || metrics[0]
const chartMetric = metric
const metricLabel = metrics.find(m => m.id === metric)?.label || 'visitors'
const metricLabel = activeMetric.label
const prevPeriodLabel = prevData?.length ? getPrevDateRangeLabel(dateRange) : ''
const trendContext = getTrendContext(dateRange)
const avg = chartData.length
? chartData.reduce((s, d) => s + (d[chartMetric] as number), 0) / chartData.length
: 0
const hasPrev = !!(prevData?.length && showComparison)
const hasData = data.length > 0
const hasAnyNonZero = hasData && chartData.some((d) => (d[chartMetric] as number) > 0)
const hasAnyNonZero = hasData && chartData.some((d) => (d[metric] as number) > 0)
// * In hourly view, only show X-axis labels at 12:00 AM (date + 12:00 AM).
const midnightTicks =
interval === 'hour'
? (() => {
const t = chartData
.filter((_, i) => {
const d = new Date(data[i].date)
return d.getHours() === 0 && d.getMinutes() === 0
})
.map((c) => c.date)
return t.length > 0 ? t : undefined
})()
: undefined
// Count metrics should never show decimal Y-axis ticks
const isCountMetric = metric === 'visitors' || metric === 'pageviews'
// ─── X-Axis Ticks ─────────────────────────────────────────────────
const midnightTicks = interval === 'hour'
? (() => {
const t = chartData
.filter((_, i) => { const d = new Date(data[i].date); return d.getHours() === 0 && d.getMinutes() === 0 })
.map((c) => c.date)
return t.length > 0 ? t : undefined
})()
: undefined
// * In daily view, only show the date at each day (12:00 AM / start-of-day mark), no time.
const dayTicks = interval === 'day' && chartData.length > 0 ? chartData.map((c) => c.date) : undefined
// ─── Trend Badge ──────────────────────────────────────────────────
function TrendBadge({ trend, invert }: { trend: number | null; invert: boolean }) {
if (trend === null) return <span className="text-neutral-400 dark:text-neutral-500"></span>
const effective = invert ? -trend : trend
const isPositive = effective > 0
const isNegative = effective < 0
return (
<span className={`inline-flex items-center text-xs font-medium ${isPositive ? 'text-emerald-600 dark:text-emerald-400' : isNegative ? 'text-red-500 dark:text-red-400' : 'text-neutral-400'}`}>
{isPositive ? <ArrowUpRightIcon className="w-3 h-3 mr-0.5" /> : isNegative ? <ArrowDownRightIcon className="w-3 h-3 mr-0.5" /> : null}
{Math.abs(trend)}%
</span>
)
}
// ─── Render ────────────────────────────────────────────────────────
return (
<div
ref={chartContainerRef}
className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden shadow-sm relative"
className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden relative"
role="region"
aria-label={`Analytics chart showing ${metricLabel} over time`}
>
{/* * Subtle live/updated indicator in bottom-right corner */}
{lastUpdatedAt != null && (
<div
className="absolute bottom-3 right-6 flex items-center gap-2 text-xs text-neutral-500 dark:text-neutral-400 pointer-events-none"
title="Data refreshes every 30 seconds"
>
<span className="relative flex h-1.5 w-1.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-500 opacity-75" />
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-green-500" />
</span>
Live · {formatUpdatedAgo(lastUpdatedAt)}
</div>
)}
{/* Stats Header (Interactive Tabs) */}
{/* Stat Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 divide-x divide-neutral-200 dark:divide-neutral-800 border-b border-neutral-200 dark:border-neutral-800">
{metrics.map((item) => (
<button
key={item.id}
type="button"
onClick={() => setMetric(item.id as MetricType)}
aria-pressed={metric === item.id}
aria-label={`Show ${item.label} chart`}
className={`
p-4 sm:p-6 text-left transition-colors relative group
hover:bg-neutral-50 dark:hover:bg-neutral-800/50
${metric === item.id ? 'bg-neutral-50 dark:bg-neutral-800/50' : ''}
cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-orange-500 focus-visible:ring-offset-2
`}
>
<div className={`text-xs font-semibold uppercase tracking-wider mb-1 flex items-center gap-2 ${metric === item.id ? 'text-neutral-900 dark:text-white' : 'text-neutral-500'}`}>
{item.label}
</div>
<div className="flex items-baseline gap-2 flex-wrap">
<span className="text-xl sm:text-2xl font-bold text-neutral-900 dark:text-white">
{item.value}
</span>
<span className="flex items-center text-sm font-medium">
{item.trend !== null ? (
<>
<span className={
(item.invertTrend ? -item.trend : item.trend) > 0
? 'text-emerald-600 dark:text-emerald-500'
: (item.invertTrend ? -item.trend : item.trend) < 0
? 'text-red-600 dark:text-red-500'
: 'text-neutral-500'
}>
{(item.invertTrend ? -item.trend : item.trend) > 0 ? (
<ArrowUpRightIcon className="w-3 h-3 mr-0.5 inline" />
) : (item.invertTrend ? -item.trend : item.trend) < 0 ? (
<ArrowDownRightIcon className="w-3 h-3 mr-0.5 inline" />
) : null}
{Math.abs(item.trend)}%
</span>
</>
) : (
<span className="text-neutral-500 dark:text-neutral-400"></span>
)}
</span>
</div>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-0.5">{trendContext}</p>
{hasData && (
<div className="mt-2">
<Sparkline data={chartData} dataKey={item.id} color={item.color} />
{metrics.map((item) => {
const isActive = metric === item.id
return (
<button
key={item.id}
type="button"
onClick={() => setMetric(item.id)}
aria-pressed={isActive}
aria-label={`Show ${item.label} chart`}
className={`p-4 sm:px-6 sm:py-5 text-left transition-colors relative cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange/50 ${isActive ? 'bg-neutral-50 dark:bg-neutral-800/40' : 'hover:bg-neutral-50/50 dark:hover:bg-neutral-800/20'}`}
>
<div className={`text-[11px] font-semibold uppercase tracking-wider mb-1.5 ${isActive ? 'text-neutral-900 dark:text-white' : 'text-neutral-400 dark:text-neutral-500'}`}>
{item.label}
</div>
)}
{metric === item.id && (
<div className="absolute bottom-0 left-0 right-0 h-1" style={{ backgroundColor: item.color }} />
)}
</button>
))}
<div className="flex items-baseline gap-2">
<span className="text-xl sm:text-2xl font-bold text-neutral-900 dark:text-white">
{item.value}
</span>
<TrendBadge trend={item.trend} invert={item.invertTrend} />
</div>
<p className="text-[11px] text-neutral-400 dark:text-neutral-500 mt-0.5">{trendContext}</p>
{isActive && (
<div className="absolute bottom-0 left-0 right-0 h-[3px] bg-brand-orange" />
)}
</button>
)
})}
</div>
{/* Chart Area */}
<div className="p-6">
{/* Toolbar Row */}
<div className="mb-6 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
{/* Left side: Legend */}
<div className="flex items-center">
<div className="px-4 sm:px-6 pt-4 pb-2">
{/* Toolbar */}
<div className="flex items-center justify-between gap-3 mb-4">
{/* Left: metric label + avg badge */}
<div className="flex items-center gap-3">
<span className="text-xs font-medium text-neutral-500 dark:text-neutral-400">
{metricLabel}
</span>
{hasPrev && (
<div className="flex items-center gap-4 text-xs font-medium" style={{ color: colors.textMuted }}>
<span className="flex items-center gap-2">
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: activeMetric.color }}
/>
<div className="hidden sm:flex items-center gap-3 text-[11px] font-medium text-neutral-400 dark:text-neutral-500 ml-2">
<span className="flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-brand-orange" />
Current
</span>
<span className="flex items-center gap-2">
<span
className="h-2 w-2 rounded-full border border-dashed"
style={{ borderColor: colors.axis }}
/>
<span className="flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full border border-dashed" style={{ borderColor: colors.axis }} />
Previous{prevPeriodLabel ? ` (${prevPeriodLabel})` : ''}
</span>
</div>
)}
</div>
{/* Right side: Controls */}
<div className="flex flex-wrap items-center gap-3 self-end sm:self-auto">
<div className="flex items-center gap-2">
<span className="text-xs text-neutral-500 dark:text-neutral-400">Group by:</span>
{dateRange.start === dateRange.end && (
<Select
value={todayInterval}
onChange={(value) => setTodayInterval(value as 'minute' | 'hour')}
options={[
{ value: 'minute', label: '1 min' },
{ value: 'hour', label: '1 hour' },
]}
className="min-w-[100px]"
/>
)}
{dateRange.start !== dateRange.end && (
<Select
value={multiDayInterval}
onChange={(value) => setMultiDayInterval(value as 'hour' | 'day')}
options={[
{ value: 'hour', label: '1 hour' },
{ value: 'day', label: '1 day' },
]}
className="min-w-[100px]"
/>
)}
</div>
{/* Right: controls */}
<div className="flex items-center gap-2">
{dateRange.start === dateRange.end ? (
<Select
value={todayInterval}
onChange={(value) => setTodayInterval(value as 'minute' | 'hour')}
options={[
{ value: 'minute', label: '1 min' },
{ value: 'hour', label: '1 hour' },
]}
className="min-w-[90px]"
/>
) : (
<Select
value={multiDayInterval}
onChange={(value) => setMultiDayInterval(value as 'hour' | 'day')}
options={[
{ value: 'hour', label: '1 hour' },
{ value: 'day', label: '1 day' },
]}
className="min-w-[90px]"
/>
)}
{prevData?.length ? (
<div className="flex flex-col gap-1">
<Checkbox
checked={showComparison}
onCheckedChange={setShowComparison}
label="Compare"
/>
{showComparison && prevPeriodLabel && (
<span className="text-xs text-neutral-500 dark:text-neutral-400">
({prevPeriodLabel})
</span>
)}
</div>
<Checkbox
checked={showComparison}
onCheckedChange={setShowComparison}
label="Compare"
/>
) : null}
<Button
variant="ghost"
<button
onClick={handleExportChart}
disabled={!hasData}
className="gap-2 py-1.5 px-3 text-sm text-neutral-600 dark:text-neutral-400"
className="p-1.5 text-neutral-400 dark:text-neutral-500 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors disabled:opacity-30 cursor-pointer"
title="Export chart as PNG"
>
<DownloadIcon className="w-4 h-4" />
Export chart
</Button>
{/* Vertical Separator */}
<div className="h-4 w-px bg-neutral-200 dark:bg-neutral-800" />
</button>
</div>
</div>
{!hasData ? (
<div className="flex h-80 flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-neutral-200 dark:border-neutral-700 bg-neutral-50/50 dark:bg-neutral-800/30">
<BarChartIcon className="h-12 w-12 text-neutral-300 dark:text-neutral-600" aria-hidden />
<p className="text-sm font-medium text-neutral-500 dark:text-neutral-400">
No data for this period
</p>
<p className="text-xs text-neutral-400 dark:text-neutral-500">Try a different date range</p>
<div className="flex h-72 flex-col items-center justify-center gap-2">
<BarChartIcon className="h-10 w-10 text-neutral-200 dark:text-neutral-700" aria-hidden />
<p className="text-sm text-neutral-400 dark:text-neutral-500">No data for this period</p>
</div>
) : !hasAnyNonZero ? (
<div className="flex h-80 flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-neutral-200 dark:border-neutral-700 bg-neutral-50/50 dark:bg-neutral-800/30">
<BarChartIcon className="h-12 w-12 text-neutral-300 dark:text-neutral-600" aria-hidden />
<p className="text-sm font-medium text-neutral-500 dark:text-neutral-400">
No {metricLabel.toLowerCase()} data for this period
</p>
<p className="text-xs text-neutral-400 dark:text-neutral-500">Try selecting another metric or date range</p>
<div className="flex h-72 flex-col items-center justify-center gap-2">
<BarChartIcon className="h-10 w-10 text-neutral-200 dark:text-neutral-700" aria-hidden />
<p className="text-sm text-neutral-400 dark:text-neutral-500">No {metricLabel.toLowerCase()} recorded</p>
</div>
) : (
<div className="h-[360px] w-full flex flex-col">
<div className="text-xs font-medium mb-1 flex-shrink-0" style={{ color: colors.axis }}>
{metricLabel}
</div>
<div className="flex-1 min-h-0 w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: 24, bottom: 24 }}>
<div className="h-[320px] w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData} margin={{ top: 8, right: 8, left: 0, bottom: 8 }}>
<defs>
<linearGradient id={`gradient-${metric}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={activeMetric.color} stopOpacity={0.35} />
<stop offset="50%" stopColor={activeMetric.color} stopOpacity={0.12} />
<stop offset="100%" stopColor={activeMetric.color} stopOpacity={0} />
<stop offset="0%" stopColor={COLORS.brand} stopOpacity={0.25} />
<stop offset="100%" stopColor={COLORS.brand} stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={colors.border} />
<CartesianGrid
strokeDasharray="3 3"
vertical={false}
stroke={colors.grid}
/>
<XAxis
dataKey="date"
stroke={colors.axis}
fontSize={12}
fontSize={11}
tickLine={false}
axisLine={false}
minTickGap={28}
minTickGap={40}
ticks={midnightTicks ?? dayTicks}
dy={8}
/>
<YAxis
stroke={colors.axis}
fontSize={12}
fontSize={11}
tickLine={false}
axisLine={false}
domain={[0, 'auto']}
width={24}
width={40}
allowDecimals={!isCountMetric}
tickFormatter={(val) => {
if (metric === 'bounce_rate') return `${val}%`
if (metric === 'avg_duration') return formatAxisDuration(val)
@@ -583,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}
@@ -596,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"
/>
)}
@@ -639,30 +517,42 @@ export default function Chart({
<Area
type="monotone"
baseValue={0}
dataKey={chartMetric}
stroke={activeMetric.color}
strokeWidth={2.5}
dataKey={metric}
stroke={COLORS.brand}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
fillOpacity={1}
fill={`url(#gradient-${metric})`}
dot={false}
activeDot={{
r: 5,
r: 4,
strokeWidth: 2,
fill: resolvedTheme === 'dark' ? 'var(--color-neutral-800)' : '#ffffff',
stroke: activeMetric.color,
fill: resolvedTheme === 'dark' ? 'var(--color-neutral-900)' : '#ffffff',
stroke: COLORS.brand,
}}
isAnimationActive
animationDuration={500}
animationDuration={400}
animationEasing="ease-out"
/>
</AreaChart>
</ResponsiveContainer>
</div>
</AreaChart>
</ResponsiveContainer>
</div>
)}
</div>
{/* Live indicator */}
{lastUpdatedAt != null && (
<div className="px-4 sm:px-6 pb-3 flex justify-end">
<div className="flex items-center gap-1.5 text-[11px] text-neutral-400 dark:text-neutral-500">
<span className="relative flex h-1.5 w-1.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-500 opacity-75" />
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-green-500" />
</span>
Live · {formatUpdatedAgo(lastUpdatedAt)}
</div>
</div>
)}
</div>
)
}

View File

@@ -7,6 +7,7 @@ import { useTabListKeyboard } from '@/lib/hooks/useTabListKeyboard'
import { TopPage, getTopPages, getEntryPages, getExitPages } from '@/lib/api/stats'
import { Modal, ArrowUpRightIcon, LayoutDashboardIcon } from '@ciphera-net/ui'
import { ListSkeleton } from '@/components/skeletons'
import { type DimensionFilter } from '@/lib/filters'
interface ContentStatsProps {
topPages: TopPage[]
@@ -16,13 +17,14 @@ 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)
@@ -76,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)
@@ -93,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" onKeyDown={handleTabKeyDown}>
<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)}
@@ -132,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">
@@ -173,7 +188,7 @@ 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 ? (
@@ -181,8 +196,8 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain,
<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 Record<string, React.ComponentType<{ className?: string }>>)[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

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

View File

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

View File

@@ -12,6 +12,7 @@ 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 }>
@@ -20,13 +21,16 @@ 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)
@@ -172,6 +176,7 @@ 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)
@@ -193,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" onKeyDown={handleTabKeyDown}>
<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}
@@ -247,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">
@@ -296,8 +315,8 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
<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.country ?? ''}-${item.region ?? ''}-${item.city ?? ''}`} className="flex items-center justify-between py-2 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors">
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
<span className="shrink-0">{getFlagComponent(item.country ?? '')}</span>
<span className="truncate">

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

@@ -9,6 +9,7 @@ import { MdMonitor } from 'react-icons/md'
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 }>
@@ -19,13 +20,16 @@ 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)
@@ -108,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)
@@ -117,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" onKeyDown={handleTabKeyDown}>
<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}
@@ -156,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">
@@ -198,8 +218,8 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
<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

@@ -8,17 +8,19 @@ import { getReferrerDisplayName, getReferrerFavicon, getReferrerIcon, mergeRefer
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)
@@ -31,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)
@@ -83,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]">
@@ -102,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">
@@ -136,7 +152,7 @@ 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 ? (
@@ -144,8 +160,8 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
<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

@@ -9,10 +9,12 @@ import { setup2FA, verify2FA, disable2FA, regenerateRecoveryCodes } from '@/lib/
import { registerPasskey, listPasskeys, deletePasskey } from '@/lib/api/webauthn'
interface Props {
activeTab?: 'profile' | 'security' | 'preferences'
activeTab?: 'profile' | 'security' | 'preferences' | 'danger-zone'
borderless?: boolean
hideDangerZone?: boolean
}
export default function ProfileSettings({ activeTab }: Props = {}) {
export default function ProfileSettings({ activeTab, borderless, hideDangerZone }: Props = {}) {
const { user, refresh, logout } = useAuth()
if (!user) return null
@@ -61,6 +63,8 @@ export default function ProfileSettings({ activeTab }: Props = {}) {
activeTab={activeTab}
hideNav={activeTab !== undefined}
hideNotifications
borderless={borderless}
hideDangerZone={hideDangerZone}
/>
)
}

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

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

View File

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

View File

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

View File

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

View File

@@ -187,10 +187,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
},
})
// * Stable primitives for the effect dependency array — avoids re-running
// * on every render when the `user` object reference changes.
const isAuthenticated = !!user
const userOrgId = user?.org_id
// * Organization Wall & Auto-Switch
useEffect(() => {
const checkOrg = async () => {
if (!loading && user) {
if (!loading && isAuthenticated) {
if (pathname?.startsWith('/onboarding')) return
if (pathname?.startsWith('/auth/callback')) return
@@ -204,7 +209,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}
// * If user has organizations but no context (org_id), switch to the first one
if (!user.org_id && organizations.length > 0) {
if (!userOrgId && organizations.length > 0) {
const firstOrg = organizations[0]
try {
@@ -235,7 +240,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}
checkOrg()
}, [loading, user, pathname, router])
}, [loading, isAuthenticated, userOrgId, pathname, router])
return (
<AuthContext.Provider value={{ user, loading, login, logout, refresh, refreshSession }}>

60
lib/filters.ts Normal file
View File

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

View File

@@ -0,0 +1,31 @@
'use client'
import { createContext, useContext, useState, useCallback } from 'react'
interface SettingsModalContextType {
isOpen: boolean
openSettings: () => void
closeSettings: () => void
}
const SettingsModalContext = createContext<SettingsModalContextType>({
isOpen: false,
openSettings: () => {},
closeSettings: () => {},
})
export function SettingsModalProvider({ children }: { children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(false)
const openSettings = useCallback(() => setIsOpen(true), [])
const closeSettings = useCallback(() => setIsOpen(false), [])
return (
<SettingsModalContext.Provider value={{ isOpen, openSettings, closeSettings }}>
{children}
</SettingsModalContext.Provider>
)
}
export function useSettingsModal() {
return useContext(SettingsModalContext)
}

View File

@@ -11,6 +11,7 @@ import {
getDashboardReferrers,
getDashboardPerformance,
getDashboardGoals,
getCampaigns,
getRealtime,
getStats,
getDailyStats,
@@ -20,6 +21,7 @@ import type { Site } from '@/lib/api/sites'
import type {
Stats,
DailyStat,
CampaignStat,
DashboardOverviewData,
DashboardPagesData,
DashboardLocationsData,
@@ -33,17 +35,19 @@ import type {
const fetchers = {
site: (siteId: string) => getSite(siteId),
dashboard: (siteId: string, start: string, end: string) => getDashboard(siteId, start, end),
dashboardOverview: (siteId: string, start: string, end: string) => getDashboardOverview(siteId, start, end),
dashboardPages: (siteId: string, start: string, end: string) => getDashboardPages(siteId, start, end),
dashboardLocations: (siteId: string, start: string, end: string) => getDashboardLocations(siteId, start, end),
dashboardDevices: (siteId: string, start: string, end: string) => getDashboardDevices(siteId, start, end),
dashboardReferrers: (siteId: string, start: string, end: string) => getDashboardReferrers(siteId, start, end),
dashboardPerformance: (siteId: string, start: string, end: string) => getDashboardPerformance(siteId, start, end),
dashboardGoals: (siteId: string, start: string, end: string) => getDashboardGoals(siteId, start, end),
stats: (siteId: string, start: string, end: string) => getStats(siteId, start, end),
dashboardOverview: (siteId: string, start: string, end: string, interval?: string, filters?: string) => getDashboardOverview(siteId, start, end, interval, filters),
dashboardPages: (siteId: string, start: string, end: string, filters?: string) => getDashboardPages(siteId, start, end, undefined, filters),
dashboardLocations: (siteId: string, start: string, end: string, filters?: string) => getDashboardLocations(siteId, start, end, undefined, undefined, filters),
dashboardDevices: (siteId: string, start: string, end: string, filters?: string) => getDashboardDevices(siteId, start, end, undefined, filters),
dashboardReferrers: (siteId: string, start: string, end: string, filters?: string) => getDashboardReferrers(siteId, start, end, undefined, filters),
dashboardPerformance: (siteId: string, start: string, end: string, filters?: string) => getDashboardPerformance(siteId, start, end, filters),
dashboardGoals: (siteId: string, start: string, end: string, filters?: string) => getDashboardGoals(siteId, start, end, undefined, filters),
stats: (siteId: string, start: string, end: string, filters?: string) => getStats(siteId, start, end, filters),
dailyStats: (siteId: string, start: string, end: string, interval: 'hour' | 'day' | 'minute') =>
getDailyStats(siteId, start, end, interval),
realtime: (siteId: string) => getRealtime(siteId),
campaigns: (siteId: string, start: string, end: string, limit: number) =>
getCampaigns(siteId, start, end, limit),
}
// * Standard SWR config for dashboard data
@@ -90,10 +94,10 @@ export function useDashboard(siteId: string, start: string, end: string) {
}
// * Hook for stats (refreshed less frequently)
export function useStats(siteId: string, start: string, end: string) {
export function useStats(siteId: string, start: string, end: string, filters?: string) {
return useSWR<Stats>(
siteId && start && end ? ['stats', siteId, start, end] : null,
() => fetchers.stats(siteId, start, end),
siteId && start && end ? ['stats', siteId, start, end, filters] : null,
() => fetchers.stats(siteId, start, end, filters),
{
...dashboardSWRConfig,
// * Refresh every 60 seconds for stats
@@ -140,10 +144,10 @@ export function useRealtime(siteId: string, refreshInterval: number = 5000) {
}
// * Hook for focused dashboard overview data (Fix 4.2: Efficient Data Transfer)
export function useDashboardOverview(siteId: string, start: string, end: string) {
export function useDashboardOverview(siteId: string, start: string, end: string, interval?: string, filters?: string) {
return useSWR<DashboardOverviewData>(
siteId && start && end ? ['dashboardOverview', siteId, start, end] : null,
() => fetchers.dashboardOverview(siteId, start, end),
siteId && start && end ? ['dashboardOverview', siteId, start, end, interval, filters] : null,
() => fetchers.dashboardOverview(siteId, start, end, interval, filters),
{
...dashboardSWRConfig,
refreshInterval: 60 * 1000,
@@ -153,10 +157,10 @@ export function useDashboardOverview(siteId: string, start: string, end: string)
}
// * Hook for focused dashboard pages data
export function useDashboardPages(siteId: string, start: string, end: string) {
export function useDashboardPages(siteId: string, start: string, end: string, filters?: string) {
return useSWR<DashboardPagesData>(
siteId && start && end ? ['dashboardPages', siteId, start, end] : null,
() => fetchers.dashboardPages(siteId, start, end),
siteId && start && end ? ['dashboardPages', siteId, start, end, filters] : null,
() => fetchers.dashboardPages(siteId, start, end, filters),
{
...dashboardSWRConfig,
refreshInterval: 60 * 1000,
@@ -166,10 +170,10 @@ export function useDashboardPages(siteId: string, start: string, end: string) {
}
// * Hook for focused dashboard locations data
export function useDashboardLocations(siteId: string, start: string, end: string) {
export function useDashboardLocations(siteId: string, start: string, end: string, filters?: string) {
return useSWR<DashboardLocationsData>(
siteId && start && end ? ['dashboardLocations', siteId, start, end] : null,
() => fetchers.dashboardLocations(siteId, start, end),
siteId && start && end ? ['dashboardLocations', siteId, start, end, filters] : null,
() => fetchers.dashboardLocations(siteId, start, end, filters),
{
...dashboardSWRConfig,
refreshInterval: 60 * 1000,
@@ -179,10 +183,10 @@ export function useDashboardLocations(siteId: string, start: string, end: string
}
// * Hook for focused dashboard devices data
export function useDashboardDevices(siteId: string, start: string, end: string) {
export function useDashboardDevices(siteId: string, start: string, end: string, filters?: string) {
return useSWR<DashboardDevicesData>(
siteId && start && end ? ['dashboardDevices', siteId, start, end] : null,
() => fetchers.dashboardDevices(siteId, start, end),
siteId && start && end ? ['dashboardDevices', siteId, start, end, filters] : null,
() => fetchers.dashboardDevices(siteId, start, end, filters),
{
...dashboardSWRConfig,
refreshInterval: 60 * 1000,
@@ -192,10 +196,10 @@ export function useDashboardDevices(siteId: string, start: string, end: string)
}
// * Hook for focused dashboard referrers data
export function useDashboardReferrers(siteId: string, start: string, end: string) {
export function useDashboardReferrers(siteId: string, start: string, end: string, filters?: string) {
return useSWR<DashboardReferrersData>(
siteId && start && end ? ['dashboardReferrers', siteId, start, end] : null,
() => fetchers.dashboardReferrers(siteId, start, end),
siteId && start && end ? ['dashboardReferrers', siteId, start, end, filters] : null,
() => fetchers.dashboardReferrers(siteId, start, end, filters),
{
...dashboardSWRConfig,
refreshInterval: 60 * 1000,
@@ -205,10 +209,10 @@ export function useDashboardReferrers(siteId: string, start: string, end: string
}
// * Hook for focused dashboard performance data
export function useDashboardPerformance(siteId: string, start: string, end: string) {
export function useDashboardPerformance(siteId: string, start: string, end: string, filters?: string) {
return useSWR<DashboardPerformanceData>(
siteId && start && end ? ['dashboardPerformance', siteId, start, end] : null,
() => fetchers.dashboardPerformance(siteId, start, end),
siteId && start && end ? ['dashboardPerformance', siteId, start, end, filters] : null,
() => fetchers.dashboardPerformance(siteId, start, end, filters),
{
...dashboardSWRConfig,
refreshInterval: 60 * 1000,
@@ -218,10 +222,23 @@ export function useDashboardPerformance(siteId: string, start: string, end: stri
}
// * Hook for focused dashboard goals data
export function useDashboardGoals(siteId: string, start: string, end: string) {
export function useDashboardGoals(siteId: string, start: string, end: string, filters?: string) {
return useSWR<DashboardGoalsData>(
siteId && start && end ? ['dashboardGoals', siteId, start, end] : null,
() => fetchers.dashboardGoals(siteId, start, end),
siteId && start && end ? ['dashboardGoals', siteId, start, end, filters] : null,
() => fetchers.dashboardGoals(siteId, start, end, filters),
{
...dashboardSWRConfig,
refreshInterval: 60 * 1000,
dedupingInterval: 10 * 1000,
}
)
}
// * Hook for campaigns data (used by export modal)
export function useCampaigns(siteId: string, start: string, end: string, limit = 100) {
return useSWR<CampaignStat[]>(
siteId && start && end ? ['campaigns', siteId, start, end, limit] : null,
() => fetchers.campaigns(siteId, start, end, limit),
{
...dashboardSWRConfig,
refreshInterval: 60 * 1000,

View File

@@ -1,95 +0,0 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import {
getRequestIdFromError,
formatErrorMessage,
logErrorWithRequestId,
getSupportMessage,
} from '../errorHandler'
import { setLastRequestId, clearLastRequestId } from '../requestId'
beforeEach(() => {
clearLastRequestId()
})
describe('getRequestIdFromError', () => {
it('extracts request ID from error response body', () => {
const errorData = { error: { request_id: 'REQ123_abc' } }
expect(getRequestIdFromError(errorData)).toBe('REQ123_abc')
})
it('falls back to last stored request ID when not in response', () => {
setLastRequestId('REQfallback_xyz')
expect(getRequestIdFromError({ error: {} })).toBe('REQfallback_xyz')
})
it('falls back to last stored request ID when no error data', () => {
setLastRequestId('REQfallback_xyz')
expect(getRequestIdFromError()).toBe('REQfallback_xyz')
})
it('returns null when no ID available anywhere', () => {
expect(getRequestIdFromError()).toBeNull()
})
})
describe('formatErrorMessage', () => {
it('returns plain message when no request ID available', () => {
expect(formatErrorMessage('Something failed')).toBe('Something failed')
})
it('appends request ID in development mode', () => {
const original = process.env.NODE_ENV
process.env.NODE_ENV = 'development'
setLastRequestId('REQ123_abc')
const msg = formatErrorMessage('Something failed')
expect(msg).toContain('Something failed')
expect(msg).toContain('REQ123_abc')
process.env.NODE_ENV = original
})
it('appends request ID when showRequestId option is set', () => {
setLastRequestId('REQ123_abc')
const msg = formatErrorMessage('Something failed', undefined, { showRequestId: true })
expect(msg).toContain('REQ123_abc')
})
})
describe('logErrorWithRequestId', () => {
it('logs with request ID when available', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
setLastRequestId('REQ123_abc')
logErrorWithRequestId('TestContext', new Error('fail'))
expect(spy).toHaveBeenCalledWith(
expect.stringContaining('REQ123_abc'),
expect.any(Error)
)
spy.mockRestore()
})
it('logs without request ID when not available', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
logErrorWithRequestId('TestContext', new Error('fail'))
expect(spy).toHaveBeenCalledWith('[TestContext]', expect.any(Error))
spy.mockRestore()
})
})
describe('getSupportMessage', () => {
it('includes request ID when available', () => {
const errorData = { error: { request_id: 'REQ123_abc' } }
const msg = getSupportMessage(errorData)
expect(msg).toContain('REQ123_abc')
expect(msg).toContain('contact support')
})
it('returns generic message when no request ID', () => {
const msg = getSupportMessage()
expect(msg).toBe('If this persists, please contact support.')
})
})

View File

@@ -7,23 +7,25 @@ describe('logger', () => {
it('calls console.error in development', async () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
process.env.NODE_ENV = 'development'
vi.stubEnv('NODE_ENV', 'development')
const { logger } = await import('../logger')
logger.error('test error')
expect(spy).toHaveBeenCalledWith('test error')
spy.mockRestore()
vi.unstubAllEnvs()
})
it('calls console.warn in development', async () => {
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {})
process.env.NODE_ENV = 'development'
vi.stubEnv('NODE_ENV', 'development')
const { logger } = await import('../logger')
logger.warn('test warning')
expect(spy).toHaveBeenCalledWith('test warning')
spy.mockRestore()
vi.unstubAllEnvs()
})
})

9
lib/utils/cookies.ts Normal file
View File

@@ -0,0 +1,9 @@
// * Determine cookie domain dynamically.
// * In production (on ciphera.net), we share cookies across subdomains.
// * In local dev (localhost), we don't set a domain.
export const getCookieDomain = (): string | undefined => {
if (process.env.NODE_ENV === 'production') {
return '.ciphera.net'
}
return undefined
}

View File

@@ -1,79 +0,0 @@
/**
* Error handling utilities with Request ID extraction
* Helps users report errors with traceable IDs for support
*/
import { getLastRequestId } from './requestId'
interface ApiErrorResponse {
error?: {
code?: string
message?: string
details?: unknown
request_id?: string
}
}
/**
* Extract request ID from error response or use last known request ID
*/
export function getRequestIdFromError(errorData?: ApiErrorResponse): string | null {
// * Try to get from error response body
if (errorData?.error?.request_id) {
return errorData.error.request_id
}
// * Fallback to last request ID stored during API call
return getLastRequestId()
}
/**
* Format error message for display with optional request ID
* Shows request ID in development or for specific error types
*/
export function formatErrorMessage(
message: string,
errorData?: ApiErrorResponse,
options: { showRequestId?: boolean } = {}
): string {
const requestId = getRequestIdFromError(errorData)
// * Always show request ID in development
const isDev = process.env.NODE_ENV === 'development'
if (requestId && (isDev || options.showRequestId)) {
return `${message}\n\nRequest ID: ${requestId}`
}
return message
}
/**
* Log error with request ID for debugging
*/
export function logErrorWithRequestId(
context: string,
error: unknown,
errorData?: ApiErrorResponse
): void {
const requestId = getRequestIdFromError(errorData)
if (requestId) {
console.error(`[${context}] Request ID: ${requestId}`, error)
} else {
console.error(`[${context}]`, error)
}
}
/**
* Get support message with request ID for user reports
*/
export function getSupportMessage(errorData?: ApiErrorResponse): string {
const requestId = getRequestIdFromError(errorData)
if (requestId) {
return `If this persists, contact support with Request ID: ${requestId}`
}
return 'If this persists, please contact support.'
}

View File

@@ -30,7 +30,8 @@ import {
FaGlobe
} from 'react-icons/fa'
import { FaXTwitter } from 'react-icons/fa6'
import { SiBrave } from 'react-icons/si'
import { SiBrave, SiOpenai, SiPerplexity, SiAnthropic, SiGooglegemini } from 'react-icons/si'
import { RiRobot2Fill } from 'react-icons/ri'
import { MdDeviceUnknown, MdSmartphone, MdTabletMac, MdDesktopWindows } from 'react-icons/md'
export function getBrowserIcon(browserName: string) {
@@ -79,7 +80,17 @@ export function getReferrerIcon(referrerName: string) {
if (lower.includes('github')) return <FaGithub className="text-neutral-800 dark:text-neutral-200" />
if (lower.includes('youtube')) return <FaYoutube className="text-red-600" />
if (lower.includes('reddit')) return <FaReddit className="text-orange-600" />
// AI assistants and search tools
if (lower.includes('chatgpt') || lower.includes('openai')) return <SiOpenai className="text-neutral-800 dark:text-neutral-200" />
if (lower.includes('perplexity')) return <SiPerplexity className="text-teal-600" />
if (lower.includes('claude') || lower.includes('anthropic')) return <SiAnthropic className="text-orange-500" />
if (lower.includes('gemini')) return <SiGooglegemini className="text-blue-500" />
if (lower.includes('copilot')) return <FaGlobe className="text-blue-500" />
if (lower.includes('deepseek')) return <RiRobot2Fill className="text-blue-600" />
if (lower.includes('grok') || lower.includes('x.ai')) return <FaXTwitter className="text-neutral-800 dark:text-neutral-200" />
if (lower.includes('phind')) return <RiRobot2Fill className="text-purple-600" />
if (lower.includes('you.com')) return <RiRobot2Fill className="text-indigo-600" />
// Try to use a generic globe or maybe check if it is a URL
return <FaGlobe className="text-neutral-400" />
}
@@ -111,6 +122,17 @@ const REFERRER_DISPLAY_OVERRIDES: Record<string, string> = {
quora: 'Quora',
't.co': 'X',
'x.com': 'X',
// AI assistants and search tools
openai: 'ChatGPT',
perplexity: 'Perplexity',
claude: 'Claude',
anthropic: 'Claude',
gemini: 'Gemini',
copilot: 'Copilot',
deepseek: 'DeepSeek',
grok: 'Grok',
'you': 'You.com',
phind: 'Phind',
}
/**

View File

@@ -1,10 +1,27 @@
import type { NextConfig } from 'next'
const withPWA = require("@ducanh2912/next-pwa").default({
import withPWAInit from "@ducanh2912/next-pwa"
const withPWA = withPWAInit({
dest: "public",
register: true,
skipWaiting: true,
disable: process.env.NODE_ENV === "development",
});
})
// * CSP directives — restrict resource loading to known origins
const cspDirectives = [
"default-src 'self'",
// Next.js requires 'unsafe-inline' for its bootstrap scripts; 'unsafe-eval' only in dev (HMR)
`script-src 'self' 'unsafe-inline'${process.env.NODE_ENV === 'development' ? " 'unsafe-eval'" : ''}`,
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob: https://www.google.com https://*.gstatic.com https://ciphera.net",
"font-src 'self'",
`connect-src 'self' https://*.ciphera.net https://ciphera.net https://www.google.com https://*.gstatic.com https://cdn.jsdelivr.net${process.env.NODE_ENV === 'development' ? ' http://localhost:*' : ''}`,
"worker-src 'self'",
"frame-src 'none'",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self' https://*.ciphera.net",
].join('; ')
const nextConfig: NextConfig = {
reactStrictMode: true,
@@ -22,6 +39,10 @@ const nextConfig: NextConfig = {
hostname: 'www.google.com',
pathname: '/s2/favicons**',
},
{
protocol: 'https',
hostname: 'ciphera.net',
},
],
},
async headers() {
@@ -41,6 +62,7 @@ const nextConfig: NextConfig = {
key: 'Strict-Transport-Security',
value: 'max-age=63072000; includeSubDomains; preload',
},
{ key: 'Content-Security-Policy', value: cspDirectives },
],
},
]

38
package-lock.json generated
View File

@@ -1,14 +1,14 @@
{
"name": "pulse-frontend",
"version": "0.12.0-alpha",
"version": "0.13.0-alpha",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pulse-frontend",
"version": "0.12.0-alpha",
"version": "0.13.0-alpha",
"dependencies": {
"@ciphera-net/ui": "^0.0.78",
"@ciphera-net/ui": "^0.0.92",
"@ducanh2912/next-pwa": "^10.2.9",
"@radix-ui/react-icons": "^1.3.0",
"@simplewebauthn/browser": "^13.2.2",
@@ -40,8 +40,8 @@
"@testing-library/react": "^16.3.2",
"@types/d3-scale": "^4.0.9",
"@types/node": "^20.14.12",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/react-simple-maps": "^3.0.6",
"@vitejs/plugin-react": "^5.1.4",
"autoprefixer": "^10.4.19",
@@ -1665,9 +1665,9 @@
}
},
"node_modules/@ciphera-net/ui": {
"version": "0.0.78",
"resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.0.78/d012b211ddba7a83f7468ec269a842709a2409b9",
"integrity": "sha512-c9B/cZggjWnCSpICEvRBAAPgsRfjN2j3NFczQRZxRR6sZmzkwd3KzEbDfTEa2DUExBMWB4+bOgiCz+AnP2OR3g==",
"version": "0.0.92",
"resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.0.92/68088da543459b34ab9fe780713537713b5e0673",
"integrity": "sha512-R+8fyvz7DhqHyJ2gai6ssRY3rE2OQlNt3ZepcpMeZouhujSNLO7KtApS4AP2qvJhKuWTnPNLj/B+kOWC3L4LMA==",
"dependencies": {
"@radix-ui/react-icons": "^1.3.0",
"clsx": "^2.1.0",
@@ -4225,12 +4225,6 @@
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
"license": "MIT"
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"license": "MIT"
},
"node_modules/@types/raf": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
@@ -4239,25 +4233,24 @@
"optional": true
},
"node_modules/@types/react": {
"version": "18.3.28",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
}
},
"node_modules/@types/react-dom": {
"version": "18.3.7",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^18.0.0"
"@types/react": "^19.2.0"
}
},
"node_modules/@types/react-simple-maps": {
@@ -9115,7 +9108,6 @@
"integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@acemir/cssom": "^0.9.31",
"@asamuzakjp/dom-selector": "^6.8.1",

View File

@@ -1,6 +1,6 @@
{
"name": "pulse-frontend",
"version": "0.12.0-alpha",
"version": "0.13.0-alpha",
"private": true,
"scripts": {
"dev": "next dev",
@@ -12,7 +12,7 @@
"test:watch": "vitest"
},
"dependencies": {
"@ciphera-net/ui": "^0.0.78",
"@ciphera-net/ui": "^0.0.92",
"@ducanh2912/next-pwa": "^10.2.9",
"@radix-ui/react-icons": "^1.3.0",
"@simplewebauthn/browser": "^13.2.2",
@@ -50,8 +50,8 @@
"@testing-library/react": "^16.3.2",
"@types/d3-scale": "^4.0.9",
"@types/node": "^20.14.12",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/react-simple-maps": "^3.0.6",
"@vitejs/plugin-react": "^5.1.4",
"autoprefixer": "^10.4.19",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -299,6 +299,10 @@
if (url !== lastUrl) {
lastUrl = url;
trackPageview();
// * Check for 404 after SPA navigation (deferred so title updates first)
setTimeout(check404, 100);
// * Reset scroll depth tracking for the new page
if (trackScroll) scrollFired = {};
}
}
new MutationObserver(onUrlChange).observe(document, { subtree: true, childList: true });
@@ -308,13 +312,17 @@
history.replaceState = function() { _replace.apply(this, arguments); onUrlChange(); };
// * Track popstate (browser back/forward)
window.addEventListener('popstate', trackPageview);
window.addEventListener('popstate', function() {
trackPageview();
setTimeout(check404, 100);
if (trackScroll) scrollFired = {};
});
// * Custom events / goals: validate event name (letters, numbers, underscores only; max 64 chars)
var EVENT_NAME_MAX = 64;
var EVENT_NAME_REGEX = /^[a-zA-Z0-9_]+$/;
function trackCustomEvent(eventName) {
function trackCustomEvent(eventName, props) {
if (typeof eventName !== 'string' || !eventName.trim()) return;
var name = eventName.trim().toLowerCase();
if (name.length > EVENT_NAME_MAX || !EVENT_NAME_REGEX.test(name)) {
@@ -334,6 +342,20 @@
session_id: getSessionId(),
name: name,
};
// * Attach custom properties if provided (max 30 props, key max 200 chars, value max 2000 chars)
if (props && typeof props === 'object' && !Array.isArray(props)) {
var sanitized = {};
var count = 0;
for (var key in props) {
if (!props.hasOwnProperty(key)) continue;
if (count >= 30) break;
var k = String(key).substring(0, 200);
var v = String(props[key]).substring(0, 2000);
sanitized[k] = v;
count++;
}
if (count > 0) payload.props = sanitized;
}
fetch(apiUrl + '/api/v1/events', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -346,4 +368,95 @@
window.pulse = window.pulse || {};
window.pulse.track = trackCustomEvent;
// * Auto-track 404 error pages (on by default)
// * Detects pages where document.title contains "404" or "not found"
// * Opt-out: add data-no-404 to the script tag
var track404 = !script.hasAttribute('data-no-404');
var sent404ForUrl = '';
function check404() {
if (!track404) return;
// * Only fire once per URL
var currentUrl = location.href;
if (sent404ForUrl === currentUrl) return;
if (/404|not found/i.test(document.title)) {
sent404ForUrl = currentUrl;
trackCustomEvent('404');
}
}
// * Check on initial load (deferred so SPAs can set title)
setTimeout(check404, 0);
// * Auto-track scroll depth at 25%, 50%, 75%, and 100% (on by default)
// * Each threshold fires once per pageview; resets on SPA navigation
// * Opt-out: add data-no-scroll to the script tag
var trackScroll = !script.hasAttribute('data-no-scroll');
if (trackScroll) {
var scrollThresholds = [25, 50, 75, 100];
var scrollFired = {};
var scrollTicking = false;
function checkScroll() {
var docHeight = document.documentElement.scrollHeight;
var viewHeight = window.innerHeight;
// * Page fits in viewport — nothing to scroll
if (docHeight <= viewHeight) return;
var scrollTop = window.scrollY;
var scrollPercent = Math.round((scrollTop + viewHeight) / docHeight * 100);
for (var i = 0; i < scrollThresholds.length; i++) {
var t = scrollThresholds[i];
if (!scrollFired[t] && scrollPercent >= t) {
scrollFired[t] = true;
trackCustomEvent('scroll_' + t);
}
}
scrollTicking = false;
}
window.addEventListener('scroll', function() {
if (!scrollTicking) {
scrollTicking = true;
requestAnimationFrame(checkScroll);
}
}, { passive: true });
}
// * Auto-track outbound link clicks and file downloads (on by default)
// * Opt-out: add data-no-outbound or data-no-downloads to the script tag
var trackOutbound = !script.hasAttribute('data-no-outbound');
var trackDownloads = !script.hasAttribute('data-no-downloads');
if (trackOutbound || trackDownloads) {
var FILE_EXT_REGEX = /\.(pdf|zip|gz|tar|xlsx|xls|csv|docx|doc|pptx|ppt|mp4|mp3|wav|avi|mov|exe|dmg|pkg|deb|rpm|iso|7z|rar)($|\?|#)/i;
document.addEventListener('click', function(e) {
var el = e.target;
// * Walk up from clicked element to find nearest <a> tag
while (el && el.tagName !== 'A') el = el.parentElement;
if (!el || !el.href) return;
try {
var url = new URL(el.href, location.href);
// * Skip non-http links (mailto:, tel:, javascript:, etc.)
if (url.protocol !== 'http:' && url.protocol !== 'https:') return;
// * Check file download first (download attribute or known file extension)
if (trackDownloads && (el.hasAttribute('download') || FILE_EXT_REGEX.test(url.pathname))) {
trackCustomEvent('file_download');
return;
}
// * Check outbound link (different hostname)
if (trackOutbound && url.hostname && url.hostname !== location.hostname) {
trackCustomEvent('outbound_link');
}
} catch (err) {
// * Invalid URL - skip silently
}
}, true); // * Capture phase: fires before default navigation
}
})();