Merge pull request #40 from ciphera-net/staging

Dashboard filtering, automatic tracking, chart rebuild & settings modal
This commit is contained in:
Usman
2026-03-07 01:21:04 +01:00
committed by GitHub
26 changed files with 1810 additions and 1382 deletions

View File

@@ -6,67 +6,55 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
## [Unreleased]
## [0.13.0-alpha] - 2026-03-02
## [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
- **Cleaner internal API code.** The analytics data-fetching layer has been streamlined — 13 near-identical endpoint functions were consolidated using a shared pattern, cutting roughly half the code while keeping every feature working exactly as before. This makes it easier to add new analytics endpoints in the future and reduces the chance of inconsistencies between them.
- **Simpler endpoint management on the backend.** The backend previously required adding every new analytics endpoint in two separate places — one for public dashboards and one for authenticated users. A shared route table now handles both automatically, so new endpoints only need to be defined once. This reduces the chance of one side getting out of sync with the other.
- **Modern config loading for the app.** The app's configuration file now uses proper ES module imports instead of an older CommonJS pattern. This aligns with modern JavaScript standards and enables better tooling support.
- **Complete API documentation in the backend README.** The developer documentation now lists all 80+ endpoints organized by category — ingestion, public dashboard, sites, analytics, real-time, goals, funnels, uptime, billing, notifications, admin, and audit. Previously only 12 endpoints were documented, which understated the full scope of the system.
- **Login page now shows a loading screen while redirecting.** Previously, the login page briefly showed a blank white screen before redirecting you to the sign-in page. You'll now see the Pulse logo and a "Redirecting to log in..." message, matching the signup page experience.
- **More reliable duplicate detection for goals.** When creating or updating a goal with a name that already exists, Pulse now uses the database's built-in error codes instead of checking error message text. This means duplicate goal detection works reliably regardless of database version or language settings.
- **More consistent builds across environments.** The backend Docker image now uses a pinned operating system version instead of "latest," so builds produce identical results whether run today or months from now.
- **Cleaner internal code for cookie handling.** Cookie domain logic that was duplicated in two places is now shared, reducing the chance of the two copies drifting out of sync during future changes.
- **Smoother navigation across the app.** Switching pages, changing organizations, or signing in no longer triggers unnecessary background checks. Previously, an internal effect re-ran on every navigation because it watched the entire user object for changes — now it only reacts when your authentication state or organization actually changes. This makes page transitions faster and reduces redundant network requests.
- **Faster uptime data cleanup under heavy usage.** Old uptime check records are now removed in small batches instead of all at once. Previously, a single large deletion could briefly slow down database performance when months of monitoring data had accumulated. Cleanup now runs incrementally so your dashboard stays responsive throughout.
- **More reliable automated testing.** Backend tests that verify authentication and billing no longer rely on a fragile internal shortcut that could mask real bugs. If a code change accidentally reaches the database during a test, it now fails gracefully with a clear error instead of crashing silently.
- **Database queries are now verified automatically before every release.** A new step in our release process runs all database operations against a real PostgreSQL database — including applying all schema migrations — so we catch query errors, missing columns, and data-access bugs before they reach production.
- **Safer database schema changes.** The large migration that restructured how your analytics data is stored now has a documented rollback procedure. If anything goes wrong during a database upgrade, we can revert cleanly instead of requiring manual intervention.
- **Easier setup for new developers.** Building and testing Pulse no longer requires a specific directory layout on your machine. All builds use a pre-packaged snapshot of dependencies, so you can clone the project and start working immediately without extra setup steps.
- **Faster, smarter dashboard data loading.** Your dashboard now loads each section independently using an intelligent caching strategy. Data refreshes happen automatically in the background, and when you switch tabs the app pauses updates to save resources — resuming instantly when you return. This replaces the previous approach where everything loaded in one large batch, meaning your charts, visitor maps, and stats now appear faster and update more reliably.
- **Better data accuracy across the dashboard.** All data displayed on the dashboard — pages, locations, devices, referrers, performance metrics, and goals — is now fully typed end-to-end. This eliminates an entire class of potential display bugs where data could be misinterpreted between the server and your screen.
- **Dashboard stays fast under heavy traffic.** When many users view their dashboards at the same time, the backend now limits how many data queries run in parallel so it doesn't overwhelm the database. If you navigate away while the dashboard is loading, queries are cancelled immediately instead of continuing to run in the background.
- **Improved error visibility and debugging.** When something goes wrong behind the scenes, the backend now logs detailed, structured information about what happened — including exactly which site, page, or operation was affected. This means issues are diagnosed and fixed faster, reducing any downtime or data gaps you might experience.
- **Clearer error trails across the system.** Every database operation now includes context about what was happening when an error occurred. Instead of vague failures, support can trace problems back to their exact source — so if an issue affects your analytics, it gets identified and resolved much more quickly.
- **Clearer rate limiting for analytics tracking.** When your tracking script sends too many events from the same session, Pulse now tells the script explicitly that the request was rejected. Previously, these events were silently accepted but never recorded, which could make your visitor counts look lower than expected without any visible explanation.
- **Earlier detection of configuration problems.** The server now checks your email and internal integration settings at startup and warns you immediately if anything looks incomplete. Previously, you might not discover a misconfigured email setting until a billing alert or uptime notification failed to send.
- **Safer campaign date handling.** Campaign analytics now use the same date validation as the rest of the app, including checks for invalid ranges and a maximum span of one year. Previously, campaigns used separate date parsing that skipped these checks.
- **Full React 19 type coverage.** Upgraded TypeScript type definitions to match the React 19 runtime. Previously, the type definitions lagged behind at React 18, which could hide bugs and miss new React 19 APIs during development.
- **Lower resource usage under load.** The backend now uses a single shared connection to Redis instead of opening dozens of separate ones. Previously, each rate limiter and internal component created its own connection pool, which could waste resources and risk hitting connection limits during heavy traffic.
- **More reliable billing operations.** Billing actions like changing your plan, cancelling, and viewing invoices now benefit from the same automatic session refresh, request tracing, and error handling as the rest of the app. Previously, these used a separate internal path that could fail silently if your session expired mid-action.
- **Stronger browser protection with Content Security Policy.** The app now tells your browser exactly which resources it's allowed to load — scripts, styles, images, and API connections are restricted to trusted sources only. This adds an extra layer of defence against cross-site scripting (XSS) attacks.
- **Tighter cross-origin request handling.** The backend now only accepts requests from known Pulse and Ciphera origins instead of any website. This prevents other sites from making authenticated requests on your behalf.
- **Stricter environment security checks.** Staging and other non-development deployments now refuse to start if critical secrets haven't been configured, catching configuration mistakes before they reach users.
- **Billing configuration validated at startup.** Stripe payment keys are now checked when the server starts instead of when a billing request comes in. Misconfigured payment settings surface immediately during deployment rather than silently failing when you try to manage your subscription.
- **Faster real-time visitor cleanup.** The background process that keeps active visitor counts accurate no longer briefly blocks other operations while scanning for stale sessions. Cleanup now runs incrementally so your dashboard stays responsive at all times.
- **Stricter input validation on admin pages.** The internal admin panel now validates organisation identifiers before processing requests, preventing malformed data from reaching the database.
- **Error messages no longer reveal internal details.** When you submit an invalid form, the error message now says "Invalid request body" instead of exposing internal field names and validation rules. This makes error messages cleaner while keeping your data safer.
- **Better request cancellation for billing operations.** All billing-related database operations can now be properly cancelled if you navigate away or your connection drops. Previously, some operations would continue running in the background even after you left the page.
- **More resilient event processing.** The background system that processes your analytics events now automatically recovers from unexpected errors instead of silently stopping. If something goes wrong, it restarts itself and continues processing — so you never lose incoming analytics data.
- **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
- **Rate limiting now works correctly for all visitors.** A bug in the backend meant that IP-based rate limiting — which protects against abuse on public dashboards and the tracking script — was treating all visitors as the same person. A single heavy user could hit the limit for everyone. Rate limiting now correctly identifies individual visitors, so one bad actor can't affect others.
- **More reliable list rendering across the dashboard.** Pages, referrers, locations, devices, funnels, and other data lists now use stable identifiers (like page paths and referrer URLs) instead of array positions as their React keys. This prevents potential display glitches — such as stale data appearing in the wrong row — when lists are filtered, sorted, or updated in real time.
- **Deleting a site now fully cleans up all its data.** Previously, deleting a site removed the site record but could leave behind orphaned analytics events in the database, slowly accumulating unused data. Now all events are cleaned up automatically in small batches before the site is removed, keeping your database tidy.
- **Team members can now view real-time visitor data.** Previously, only the person who originally created a site could see live visitors and session details. Now any team member in the same organization can access real-time data for all their shared sites — the same way the rest of the dashboard already works.
- **Consistent date handling across all analytics.** Funnel analytics now use the same date format (`YYYY-MM-DD`) as every other part of Pulse. Previously, funnels required a different, more technical date format, which meant date range pickers and bookmarked links didn't always work correctly between funnels and the rest of the dashboard.
- **Tracking script now works on all tracked websites.** Page views were silently failing to record when the tracking script ran on your website. Two issues were at play: the backend was rejecting analytics data sent from tracked sites, and even after that was resolved, events were silently dropped during processing because they were missing a required identifier. Both are now fixed — your dashboard receives visits from all registered domains as expected.
- **Real-time visitor count no longer stops updating.** The dashboard's live visitor counter would quickly hit a rate limit and stop refreshing, showing "Site not found" errors. The limit was too low for normal polling, especially with multiple tabs open. It now has enough headroom for typical usage.
- **Funnel details now load correctly.** Opening a funnel showed "Unable to load funnel" with a server error. An internal query was referencing data that no longer existed at that stage of processing, causing it to fail every time. Funnels now load and display step-by-step conversion data as expected.
- **App switcher and site icons now load correctly.** The Pulse, Drop, and Auth logos in the app switcher — and site favicons on the dashboard — were blocked by the browser's Content Security Policy. The policy now allows images from Ciphera's own domain and Google's favicon service, so all icons display as expected.
- **Better crash protection in goals and real-time views.** Fixed an issue where the backend could crash under rare conditions when checking permissions for goals or real-time visitor data. The app now handles unexpected values gracefully instead of crashing.
- **More reliable service health reporting.** The backend health check no longer falsely reports the service as unhealthy after sustained traffic. Previously, an internal counter grew over time and would eventually cross a fixed threshold — even under normal load — causing orchestrators to unnecessarily restart the service.
- **Session list now correctly highlights your current session.** The active sessions list in settings now properly identifies which session you're currently using. Previously, the "current session" marker never appeared due to an internal mismatch in how sessions were identified.
- **Notifications no longer fail to load on sign-in.** The notification bell now loads correctly even when the app is still setting up your workspace context. Previously, it could briefly show an error right after signing in.
- **Fixed a service worker error on first visit.** Removed leftover icon files with invalid filenames that caused a caching error in the background. This had no visible effect but produced console warnings.
- **More reliable database migrations.** The migration system no longer silently skips partially failed database updates. If an update fails, it stops immediately so the issue can be identified and fixed — rather than marking it as complete and moving on with missing changes.
### Removed
- **Cleaned up unused files.** Removed six leftover component and utility files that were no longer used anywhere in the app, along with dead backend code. This reduces clutter and keeps the codebase easier to navigate.
- **Removed unused backend code.** Deleted an old CORS middleware that was never wired up, and an 80-line legacy funnel query that had been superseded by a faster implementation. Less code means fewer things to maintain and audit.
- **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
@@ -286,7 +274,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
---
[Unreleased]: https://github.com/ciphera-net/pulse/compare/v0.13.0-alpha...HEAD
[0.13.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.12.0-alpha...v0.13.0-alpha
[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

@@ -13,6 +13,8 @@ import { getUserOrganizations, switchContext, type OrganizationMember } from '@/
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,10 +46,11 @@ 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 { openSettings } = useSettingsModal()
const [orgs, setOrgs] = useState<OrganizationMember[]>([])
const [isSwitchingOrg, setIsSwitchingOrg] = useState(() => {
if (typeof window === 'undefined') return false
@@ -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

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

@@ -2,10 +2,23 @@
import { useAuth } from '@/lib/auth/context'
import { logger } from '@/lib/utils/logger'
import { useEffect, useState, useMemo } 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 { getPerformanceByPage, type Stats, type DailyStat } from '@/lib/api/stats'
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 { Button } from '@ciphera-net/ui'
@@ -19,7 +32,12 @@ 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,
@@ -81,6 +99,109 @@ export default function SiteDashboardPage() {
const [lastUpdatedAt, setLastUpdatedAt] = useState<number | null>(null)
const [, setTick] = useState(0)
// Dimension filters state
const searchParams = useSearchParams()
const [filters, setFilters] = useState<DimensionFilter[]>(() => {
const raw = searchParams.get('filters')
return raw ? parseFiltersFromURL(raw) : []
})
const filtersParam = useMemo(() => serializeFilters(filters), [filters])
// Selected event for property breakdown
const [selectedEvent, setSelectedEvent] = useState<string | null>(null)
const handleAddFilter = useCallback((filter: DimensionFilter) => {
setFilters(prev => {
const isDuplicate = prev.some(
f => f.dimension === filter.dimension && f.operator === filter.operator && f.values.join(';') === filter.values.join(';')
)
if (isDuplicate) return prev
return [...prev, filter]
})
}, [])
const handleRemoveFilter = useCallback((index: number) => {
setFilters(prev => prev.filter((_, i) => i !== index))
}, [])
const handleClearFilters = useCallback(() => {
setFilters([])
}, [])
// Fetch full suggestion list (up to 100) when a dimension is selected in the filter dropdown
const handleFetchSuggestions = useCallback(async (dimension: string): Promise<FilterSuggestion[]> => {
const start = dateRange.start
const end = dateRange.end
const f = filtersParam || undefined
const limit = 100
try {
const regionNames = (() => { try { return new Intl.DisplayNames(['en'], { type: 'region' }) } catch { return null } })()
switch (dimension) {
case 'page': {
const data = await getTopPages(siteId, start, end, limit, f)
return data.map(p => ({ value: p.path, label: p.path, count: p.pageviews }))
}
case 'referrer': {
const data = await getTopReferrers(siteId, start, end, limit, f)
return data.filter(r => r.referrer && r.referrer !== '').map(r => ({ value: r.referrer, label: r.referrer, count: r.pageviews }))
}
case 'country': {
const data = await getCountries(siteId, start, end, limit, f)
return data.filter(c => c.country && c.country !== 'Unknown').map(c => ({ value: c.country, label: regionNames?.of(c.country) ?? c.country, count: c.pageviews }))
}
case 'city': {
const data = await getCities(siteId, start, end, limit, f)
return data.filter(c => c.city && c.city !== 'Unknown').map(c => ({ value: c.city, label: c.city, count: c.pageviews }))
}
case 'region': {
const data = await getRegions(siteId, start, end, limit, f)
return data.filter(r => r.region && r.region !== 'Unknown').map(r => ({ value: r.region, label: r.region, count: r.pageviews }))
}
case 'browser': {
const data = await getBrowsers(siteId, start, end, limit, f)
return data.filter(b => b.browser && b.browser !== 'Unknown').map(b => ({ value: b.browser, label: b.browser, count: b.pageviews }))
}
case 'os': {
const data = await getOS(siteId, start, end, limit, f)
return data.filter(o => o.os && o.os !== 'Unknown').map(o => ({ value: o.os, label: o.os, count: o.pageviews }))
}
case 'device': {
const data = await getDevices(siteId, start, end, limit, f)
return data.filter(d => d.device && d.device !== 'Unknown').map(d => ({ value: d.device, label: d.device, count: d.pageviews }))
}
case 'utm_source':
case 'utm_medium':
case 'utm_campaign': {
const data = await getCampaigns(siteId, start, end, limit, f)
const map = new Map<string, number>()
const field = dimension === 'utm_source' ? 'source' : dimension === 'utm_medium' ? 'medium' : 'campaign'
data.forEach(c => {
const val = c[field]
if (val) map.set(val, (map.get(val) ?? 0) + c.pageviews)
})
return [...map.entries()].map(([v, count]) => ({ value: v, label: v, count }))
}
default:
return []
}
} catch {
return []
}
}, [siteId, dateRange.start, dateRange.end, filtersParam])
// Sync filters to URL
useEffect(() => {
const url = new URL(window.location.href)
if (filtersParam) {
url.searchParams.set('filters', filtersParam)
} else {
url.searchParams.delete('filters')
}
window.history.replaceState({}, '', url.toString())
}, [filtersParam])
const interval = dateRange.start === dateRange.end ? todayInterval : multiDayInterval
// Previous period date range for comparison
@@ -99,13 +220,14 @@ export default function SiteDashboardPage() {
// SWR hooks - replace manual useState + useEffect + setInterval polling
// Each hook handles its own refresh interval, deduplication, and error retry
const { data: overview, isLoading: overviewLoading, error: overviewError } = useDashboardOverview(siteId, dateRange.start, dateRange.end, interval)
const { data: pages } = useDashboardPages(siteId, dateRange.start, dateRange.end)
const { data: locations } = useDashboardLocations(siteId, dateRange.start, dateRange.end)
const { data: devicesData } = useDashboardDevices(siteId, dateRange.start, dateRange.end)
const { data: referrers } = useDashboardReferrers(siteId, dateRange.start, dateRange.end)
const { data: performanceData } = useDashboardPerformance(siteId, dateRange.start, dateRange.end)
const { data: goalsData } = useDashboardGoals(siteId, dateRange.start, dateRange.end)
// 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)
@@ -117,6 +239,106 @@ export default function SiteDashboardPage() {
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) {
@@ -305,6 +527,12 @@ export default function SiteDashboardPage() {
</div>
</div>
{/* Dimension Filters */}
<div className="flex items-center gap-2 flex-wrap mb-2">
<AddFilterDropdown onAdd={handleAddFilter} suggestions={filterSuggestions} onFetchSuggestions={handleFetchSuggestions} />
<FilterBar filters={filters} onRemove={handleRemoveFilter} onClear={handleClearFilters} />
</div>
{/* Advanced Chart with Integrated Stats */}
<div className="mb-8">
<Chart
@@ -345,12 +573,14 @@ export default function SiteDashboardPage() {
collectPagePaths={site.collect_page_paths ?? true}
siteId={siteId}
dateRange={dateRange}
onFilter={handleAddFilter}
/>
<TopReferrers
referrers={referrers?.top_referrers ?? []}
collectReferrers={site.collect_referrers ?? true}
siteId={siteId}
dateRange={dateRange}
onFilter={handleAddFilter}
/>
</div>
@@ -362,6 +592,7 @@ export default function SiteDashboardPage() {
geoDataLevel={site.collect_geo_data || 'full'}
siteId={siteId}
dateRange={dateRange}
onFilter={handleAddFilter}
/>
<TechSpecs
browsers={devicesData?.browsers ?? []}
@@ -372,18 +603,34 @@ export default function SiteDashboardPage() {
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={goalsData?.goal_counts ?? []} />
<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)}

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)}
@@ -133,26 +126,48 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain,
) : hasData ? (
<>
{displayedData.map((page) => (
<div key={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">
<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 ? (

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) => (
<div key={`${item.country ?? ''}-${item.region ?? ''}-${item.city ?? ''}`} 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">

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) => (
<div key={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">
<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">

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]">
@@ -103,19 +98,40 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
) : hasData ? (
<>
{displayedReferrers.map((ref) => (
<div key={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">
<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 ? (

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

@@ -120,6 +120,7 @@ function buildQuery(
interval?: string
countryLimit?: number
sort?: string
filters?: string
},
auth?: AuthParams
): string {
@@ -130,6 +131,7 @@ function buildQuery(
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 query ? `?${query}` : ''
@@ -137,8 +139,8 @@ function buildQuery(
/** 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): Promise<T[]> =>
apiRequest<Record<string, T[]>>(`/sites/${siteId}/${path}${buildQuery({ startDate, endDate, limit })}`)
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] || [])
}
@@ -160,8 +162,8 @@ export const getCampaigns = createListFetcher<CampaignStat>('campaigns', 'campai
// ─── Stats & Realtime ───────────────────────────────────────────────
export function getStats(siteId: string, startDate?: string, endDate?: string): Promise<Stats> {
return apiRequest<Stats>(`/sites/${siteId}/stats${buildQuery({ startDate, endDate })}`)
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> {
@@ -178,8 +180,8 @@ export function getPublicRealtime(siteId: string, auth?: AuthParams): Promise<Re
// ─── Daily Stats ────────────────────────────────────────────────────
export function getDailyStats(siteId: string, startDate?: string, endDate?: string, interval?: string): Promise<DailyStat[]> {
return apiRequest<{ stats: DailyStat[] }>(`/sites/${siteId}/daily${buildQuery({ startDate, endDate, interval })}`)
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 || [])
}
@@ -302,8 +304,8 @@ export interface DashboardGoalsData {
goal_counts: GoalCountStat[]
}
export function getDashboardOverview(siteId: string, startDate?: string, endDate?: string, interval?: string): Promise<DashboardOverviewData> {
return apiRequest<DashboardOverviewData>(`/sites/${siteId}/dashboard/overview${buildQuery({ startDate, endDate, interval })}`)
export function getDashboardOverview(siteId: string, startDate?: string, endDate?: string, interval?: string, filters?: string): Promise<DashboardOverviewData> {
return apiRequest<DashboardOverviewData>(`/sites/${siteId}/dashboard/overview${buildQuery({ startDate, endDate, interval, filters })}`)
}
export function getPublicDashboardOverview(
@@ -313,8 +315,8 @@ export function getPublicDashboardOverview(
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): Promise<DashboardPagesData> {
return apiRequest<DashboardPagesData>(`/sites/${siteId}/dashboard/pages${buildQuery({ startDate, endDate, limit })}`)
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(
@@ -324,8 +326,8 @@ export function getPublicDashboardPages(
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): Promise<DashboardLocationsData> {
return apiRequest<DashboardLocationsData>(`/sites/${siteId}/dashboard/locations${buildQuery({ startDate, endDate, limit, countryLimit })}`)
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(
@@ -335,8 +337,8 @@ export function getPublicDashboardLocations(
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): Promise<DashboardDevicesData> {
return apiRequest<DashboardDevicesData>(`/sites/${siteId}/dashboard/devices${buildQuery({ startDate, endDate, limit })}`)
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(
@@ -346,8 +348,8 @@ export function getPublicDashboardDevices(
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): Promise<DashboardReferrersData> {
return apiRequest<DashboardReferrersData>(`/sites/${siteId}/dashboard/referrers${buildQuery({ startDate, endDate, limit })}`)
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(
@@ -357,8 +359,8 @@ export function getPublicDashboardReferrers(
return apiRequest<DashboardReferrersData>(`/public/sites/${siteId}/dashboard/referrers${buildQuery({ startDate, endDate, limit }, { password, captcha })}`)
}
export function getDashboardPerformance(siteId: string, startDate?: string, endDate?: string): Promise<DashboardPerformanceData> {
return apiRequest<DashboardPerformanceData>(`/sites/${siteId}/dashboard/performance${buildQuery({ startDate, endDate })}`)
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(
@@ -368,8 +370,8 @@ export function getPublicDashboardPerformance(
return apiRequest<DashboardPerformanceData>(`/public/sites/${siteId}/dashboard/performance${buildQuery({ startDate, endDate }, { password, captcha })}`)
}
export function getDashboardGoals(siteId: string, startDate?: string, endDate?: string, limit = 10): Promise<DashboardGoalsData> {
return apiRequest<DashboardGoalsData>(`/sites/${siteId}/dashboard/goals${buildQuery({ startDate, endDate, limit })}`)
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(
@@ -378,3 +380,25 @@ export function getPublicDashboardGoals(
): 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 || [])
}

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

@@ -35,14 +35,14 @@ 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, interval?: string) => getDashboardOverview(siteId, start, end, interval),
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),
@@ -94,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
@@ -144,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, interval?: string) {
export function useDashboardOverview(siteId: string, start: string, end: string, interval?: string, filters?: string) {
return useSWR<DashboardOverviewData>(
siteId && start && end ? ['dashboardOverview', siteId, start, end, interval] : null,
() => fetchers.dashboardOverview(siteId, start, end, interval),
siteId && start && end ? ['dashboardOverview', siteId, start, end, interval, filters] : null,
() => fetchers.dashboardOverview(siteId, start, end, interval, filters),
{
...dashboardSWRConfig,
refreshInterval: 60 * 1000,
@@ -157,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,
@@ -170,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,
@@ -183,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,
@@ -196,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,
@@ -209,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,
@@ -222,10 +222,10 @@ 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,

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',
}
/**

12
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.79",
"@ciphera-net/ui": "^0.0.92",
"@ducanh2912/next-pwa": "^10.2.9",
"@radix-ui/react-icons": "^1.3.0",
"@simplewebauthn/browser": "^13.2.2",
@@ -1665,9 +1665,9 @@
}
},
"node_modules/@ciphera-net/ui": {
"version": "0.0.79",
"resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.0.79/11371f19607016d14b0343a698d5f2ef22360c0c",
"integrity": "sha512-Th7jVpsf2Qjf5o8ZP1315xJ4q09fb3zo5rPwAiK7jQZGl9AIZU8qw33wJchDKJU7dwo9YetKFvP60lnqseadnA==",
"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",

View File

@@ -12,7 +12,7 @@
"test:watch": "vitest"
},
"dependencies": {
"@ciphera-net/ui": "^0.0.79",
"@ciphera-net/ui": "^0.0.92",
"@ducanh2912/next-pwa": "^10.2.9",
"@radix-ui/react-icons": "^1.3.0",
"@simplewebauthn/browser": "^13.2.2",

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