Compare commits
52 Commits
0.12.0-alp
...
0.13.0-alp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
985978dd8f | ||
|
|
8ebd8ba9e1 | ||
|
|
dd8e101f69 | ||
|
|
ece8cda334 | ||
|
|
74ee64a560 | ||
|
|
641a3deebb | ||
|
|
77dc61e7d0 | ||
|
|
dee7089925 | ||
|
|
2acfd90bbd | ||
|
|
34e59894af | ||
|
|
7fc40f2a83 | ||
|
|
068943974e | ||
|
|
2c82c1a52a | ||
|
|
b046978256 | ||
|
|
7be30b57b5 | ||
|
|
386b4a8c44 | ||
|
|
34053004c0 | ||
|
|
0809c37067 | ||
|
|
ec96fa8a0d | ||
|
|
0865774686 | ||
|
|
5677f30f3b | ||
|
|
8b1d196812 | ||
|
|
53a0341925 | ||
|
|
e72e6f2ec5 | ||
|
|
acede8ca54 | ||
|
|
6d360cf1ac | ||
|
|
7865b41722 | ||
|
|
48cf9a1f62 | ||
|
|
f469d0d755 | ||
|
|
88956879de | ||
|
|
564c853f7f | ||
|
|
c9fd949ae1 | ||
|
|
70f46ba63c | ||
|
|
7d3f1cb10a | ||
|
|
fd1386b80d | ||
|
|
501932849b | ||
|
|
b7426d6128 | ||
|
|
dfa887147a | ||
|
|
4de4e05ccb | ||
|
|
d7eb10e815 | ||
|
|
8a7076ee1b | ||
|
|
67c9bdd3e0 | ||
|
|
3ecd2abf63 | ||
|
|
baceb6e8a8 | ||
|
|
fba1fd99c2 | ||
|
|
c9123832a5 | ||
|
|
95920e4724 | ||
|
|
67f6690258 | ||
|
|
5b388808b6 | ||
|
|
27158f7bfc | ||
|
|
bc5e20ab7b | ||
|
|
6bb23bc22a |
53
CHANGELOG.md
53
CHANGELOG.md
@@ -6,6 +6,56 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.13.0-alpha] - 2026-03-07
|
||||
|
||||
### Added
|
||||
|
||||
- **Dashboard filtering.** Filter your entire dashboard by any dimension — browser, country, page, device, OS, referrer, or UTM parameters. A single "Filter" button lets you browse dimensions, see real values from your data with visitor counts, search or type a custom value, and apply — all in a quick dropdown. Active filters appear as removable pills above your charts. Stack multiple filters to narrow things down. Filters are saved in the URL so you can bookmark or share a filtered view.
|
||||
- **Click any item to filter.** Click a referrer, browser, country, page, or any other item in your dashboard panels to instantly filter the entire dashboard to just that traffic.
|
||||
- **Hover percentages.** Hover over any item in Pages, Locations, Technology, or Referrers to see what percentage of total traffic it represents.
|
||||
- **Custom event properties.** Your custom events can now carry extra context — for example, `pulse.track('signup', { plan: 'pro', source: 'landing' })`. Click any event in Goals & Events to see a breakdown of its properties and values, no setup needed.
|
||||
- **AI traffic source identification.** Pulse recognizes visitors from ChatGPT, Perplexity, Claude, Gemini, Copilot, DeepSeek, Grok, Meta AI, You.com, and Phind. These appear in Referrers with proper icons and names instead of raw URLs.
|
||||
- **Automatic outbound link tracking.** Tracks when visitors click links to other websites. Shows up as "outbound link" events in Goals & Events — no setup needed.
|
||||
- **Automatic file download tracking.** Downloads of PDFs, ZIPs, Excel, Word, MP3s, and 20+ other formats are recorded as "file download" events automatically.
|
||||
- **Automatic 404 detection.** Detects when visitors land on pages that don't exist and records "404" events so you can find and fix broken links.
|
||||
- **Automatic scroll depth tracking.** Tracks how far visitors scroll — at 25%, 50%, 75%, and 100% — helping you understand which content keeps people reading.
|
||||
|
||||
### Improved
|
||||
|
||||
- **Chart rebuilt from scratch.** Cleaner stat cards, wider Y-axis that no longer clips labels, whole-number ticks for visitor and pageview counts, lighter grid lines, streamlined toolbar, and a properly positioned live indicator.
|
||||
- **Campaigns panel redesigned.** Clean row-based layout with UTM medium and campaign always visible below the source name. Now sits in a half-width grid next to Goals & Events.
|
||||
- **Better filter design.** Solid brand-colored filter pills that are easy to spot in light and dark mode. A funnel icon on the filter button. Click any pill to remove it.
|
||||
- **Underline tab switchers.** Pages, Locations, and Technology panels now use clean underline tabs instead of pill-style switchers.
|
||||
- **"View all" at the bottom.** The expand action on each panel is now a subtle "View all" link at the bottom of the list instead of an icon in the header.
|
||||
- **Faster dashboard loading.** Each section loads independently with smart caching. Data refreshes in the background, and switching tabs pauses updates to save resources — resuming when you return.
|
||||
- **Smoother navigation.** Switching pages, changing organizations, or signing in no longer triggers unnecessary background requests.
|
||||
- **Loading screen while redirecting to sign-in.** The login page now shows the Pulse logo and a message instead of a blank white screen.
|
||||
- **More reliable billing.** Plan changes, cancellations, and invoice views now handle session expiry and errors gracefully.
|
||||
- **Stronger browser security.** Your browser now only loads scripts, styles, and images from trusted sources, adding protection against cross-site scripting.
|
||||
- **More resilient analytics processing.** The system that processes your events now recovers automatically from unexpected errors instead of stopping silently.
|
||||
- **Dashboard stays responsive under heavy traffic.** Parallel queries are limited during peak usage, and in-progress queries are cancelled when you navigate away.
|
||||
- **Cleaner error messages.** Invalid form submissions show a simple message instead of exposing internal details.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Tracking script now works on all tracked websites.** Page views were silently failing due to two separate issues. Both are fixed — your dashboard receives visits from all registered domains as expected.
|
||||
- **Rate limiting works correctly.** A bug was treating all visitors as the same person, so one heavy user could block everyone. Each visitor is now identified individually.
|
||||
- **Real-time visitor count no longer stops updating.** The live counter would hit a rate limit and stop refreshing. It now has enough headroom for normal usage.
|
||||
- **Team members can view real-time data.** Previously only the site creator could see live visitors. Now any team member in the same organization has access.
|
||||
- **Funnel details load correctly.** Opening a funnel previously showed an error. Funnels now display step-by-step conversion data as expected.
|
||||
- **Consistent date handling.** Funnels now use the same date format as the rest of Pulse, so date pickers and bookmarked links work correctly everywhere.
|
||||
- **Deleting a site cleans up all data.** Orphaned analytics events are now removed automatically before the site is deleted.
|
||||
- **App switcher and site icons load correctly.** Logos and favicons were blocked by a security policy. Fixed by allowing images from Ciphera and Google's favicon service.
|
||||
- **Current session highlighted in settings.** The active session marker now works correctly.
|
||||
- **Notifications load on sign-in.** The notification bell no longer errors briefly after signing in.
|
||||
- **Duplicate filters no longer stack.** Clicking the same item twice no longer adds the same filter again.
|
||||
- **Campaigns respect active filters.** The Campaigns panel now filters along with everything else instead of always showing all campaigns.
|
||||
- **No duplicate "Direct" in referrer filter.** The referrer suggestions no longer show "Direct" twice.
|
||||
- **Filter dropdowns show all your data.** Previously limited to 10 items — now loads up to 100 values.
|
||||
- **Chart Y-axis shows whole numbers.** Visitor and pageview counts no longer show fractional values like "0.75 visitors".
|
||||
- **Duplicate goal names detected reliably.** Goal name uniqueness checks now work correctly regardless of your setup.
|
||||
- **Health checks stay accurate.** The backend health check no longer falsely reports the service as unhealthy after sustained traffic.
|
||||
|
||||
## [0.12.0-alpha] - 2026-03-01
|
||||
|
||||
### Added
|
||||
@@ -223,7 +273,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
|
||||
---
|
||||
|
||||
[Unreleased]: https://github.com/ciphera-net/pulse/compare/v0.12.0-alpha...HEAD
|
||||
[Unreleased]: https://github.com/ciphera-net/pulse/compare/v0.13.0-alpha...HEAD
|
||||
[0.13.0-alpha]: https://github.com/ciphera-net/pulse/releases/tag/v0.13.0-alpha
|
||||
[0.12.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.11.1-alpha...v0.12.0-alpha
|
||||
[0.11.1-alpha]: https://github.com/ciphera-net/pulse/compare/v0.11.0-alpha...v0.11.1-alpha
|
||||
[0.11.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.10.0-alpha...v0.11.0-alpha
|
||||
|
||||
@@ -2,19 +2,10 @@
|
||||
|
||||
import { cookies } from 'next/headers'
|
||||
import { logger } from '@/lib/utils/logger'
|
||||
import { getCookieDomain } from '@/lib/utils/cookies'
|
||||
|
||||
const AUTH_API_URL = process.env.NEXT_PUBLIC_AUTH_API_URL || process.env.NEXT_PUBLIC_AUTH_URL || 'http://localhost:8081'
|
||||
|
||||
// * Determine cookie domain dynamically
|
||||
// * In production (on ciphera.net), we want to share cookies with subdomains (e.g. pulse-api.ciphera.net)
|
||||
// * In local dev (localhost), we don't set a domain
|
||||
const getCookieDomain = () => {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
return '.ciphera.net'
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
interface AuthResponse {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
import { cookies } from 'next/headers'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getCookieDomain } from '@/lib/utils/cookies'
|
||||
|
||||
const AUTH_API_URL = process.env.NEXT_PUBLIC_AUTH_API_URL || process.env.NEXT_PUBLIC_AUTH_URL || 'http://localhost:8081'
|
||||
|
||||
// * Determine cookie domain dynamically
|
||||
const getCookieDomain = () => {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
return '.ciphera.net'
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export async function POST() {
|
||||
const cookieStore = await cookies()
|
||||
const refreshToken = cookieStore.get('refresh_token')?.value
|
||||
|
||||
@@ -114,7 +114,7 @@ export default function FAQPage() {
|
||||
|
||||
<div className="max-w-3xl mx-auto">
|
||||
{faqs.map((faq, index) => (
|
||||
<FAQItem key={index} faq={faq} index={index} />
|
||||
<FAQItem key={faq.question} faq={faq} index={index} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -9,10 +9,12 @@ import { useOnlineStatus } from '@/lib/hooks/useOnlineStatus'
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { logger } from '@/lib/utils/logger'
|
||||
import { getUserOrganizations, switchContext } from '@/lib/api/organization'
|
||||
import { getUserOrganizations, switchContext, type OrganizationMember } from '@/lib/api/organization'
|
||||
import { setSessionAction } from '@/app/actions/auth'
|
||||
import { LoadingOverlay } from '@ciphera-net/ui'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { SettingsModalProvider, useSettingsModal } from '@/lib/settings-modal-context'
|
||||
import SettingsModalWrapper from '@/components/settings/SettingsModalWrapper'
|
||||
|
||||
const ORG_SWITCH_KEY = 'pulse_switching_org'
|
||||
|
||||
@@ -44,11 +46,12 @@ const CIPHERA_APPS: CipheraApp[] = [
|
||||
},
|
||||
]
|
||||
|
||||
export default function LayoutContent({ children }: { children: React.ReactNode }) {
|
||||
function LayoutInner({ children }: { children: React.ReactNode }) {
|
||||
const auth = useAuth()
|
||||
const router = useRouter()
|
||||
const isOnline = useOnlineStatus()
|
||||
const [orgs, setOrgs] = useState<any[]>([])
|
||||
const { openSettings } = useSettingsModal()
|
||||
const [orgs, setOrgs] = useState<OrganizationMember[]>([])
|
||||
const [isSwitchingOrg, setIsSwitchingOrg] = useState(() => {
|
||||
if (typeof window === 'undefined') return false
|
||||
return sessionStorage.getItem(ORG_SWITCH_KEY) === 'true'
|
||||
@@ -87,7 +90,7 @@ export default function LayoutContent({ children }: { children: React.ReactNode
|
||||
const handleCreateOrganization = () => {
|
||||
router.push('/onboarding')
|
||||
}
|
||||
|
||||
|
||||
const showOfflineBar = Boolean(auth.user && !isOnline);
|
||||
const barHeightRem = 2.5;
|
||||
const headerHeightRem = 6;
|
||||
@@ -100,9 +103,9 @@ export default function LayoutContent({ children }: { children: React.ReactNode
|
||||
return (
|
||||
<>
|
||||
{auth.user && <OfflineBanner isOnline={isOnline} />}
|
||||
<Header
|
||||
auth={auth}
|
||||
LinkComponent={Link}
|
||||
<Header
|
||||
auth={auth}
|
||||
LinkComponent={Link}
|
||||
logoSrc="/pulse_icon_no_margins.png"
|
||||
appName="Pulse"
|
||||
orgs={orgs}
|
||||
@@ -117,6 +120,7 @@ export default function LayoutContent({ children }: { children: React.ReactNode
|
||||
rightSideActions={auth.user ? <NotificationCenter /> : null}
|
||||
apps={CIPHERA_APPS}
|
||||
currentAppId="pulse"
|
||||
onOpenSettings={openSettings}
|
||||
customNavItems={
|
||||
<>
|
||||
{!auth.user && (
|
||||
@@ -136,11 +140,20 @@ export default function LayoutContent({ children }: { children: React.ReactNode
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
<Footer
|
||||
<Footer
|
||||
LinkComponent={Link}
|
||||
appName="Pulse"
|
||||
isAuthenticated={!!auth.user}
|
||||
/>
|
||||
<SettingsModalWrapper />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function LayoutContent({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<SettingsModalProvider>
|
||||
<LayoutInner>{children}</LayoutInner>
|
||||
</SettingsModalProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { initiateOAuthFlow } from '@/lib/api/oauth'
|
||||
import { LoadingOverlay } from '@ciphera-net/ui'
|
||||
|
||||
export default function LoginPage() {
|
||||
useEffect(() => {
|
||||
@@ -9,5 +10,10 @@ export default function LoginPage() {
|
||||
initiateOAuthFlow()
|
||||
}, [])
|
||||
|
||||
return null
|
||||
return (
|
||||
<LoadingOverlay
|
||||
logoSrc="/pulse_icon_no_margins.png"
|
||||
title="Redirecting to log in..."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -78,8 +78,8 @@ function ComparisonSection() {
|
||||
{ feature: "GDPR Compliant", pulse: true, ga: "Complex" },
|
||||
{ feature: "Script Size", pulse: "< 1 KB", ga: "45 KB+" },
|
||||
{ feature: "Data Ownership", pulse: "Yours", ga: "Google's" },
|
||||
].map((row, i) => (
|
||||
<tr key={i} className="hover:bg-neutral-50/50 dark:hover:bg-neutral-800/50 transition-colors">
|
||||
].map((row) => (
|
||||
<tr key={row.feature} className="hover:bg-neutral-50/50 dark:hover:bg-neutral-800/50 transition-colors">
|
||||
<td className="p-6 text-neutral-900 dark:text-white font-medium">{row.feature}</td>
|
||||
<td className="p-6">
|
||||
{row.pulse === true ? (
|
||||
@@ -303,7 +303,7 @@ export default function HomePage() {
|
||||
{ icon: ZapIcon, title: "Lightweight", desc: "Our script is less than 1kb. It won't slow down your site or affect your SEO." }
|
||||
].map((feature, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
key={feature.title}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
|
||||
@@ -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>•</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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -278,7 +278,7 @@ export default function FunnelReportPage() {
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-800">
|
||||
{stats.steps.map((step, i) => (
|
||||
<tr key={i} className="hover:bg-neutral-50 dark:hover:bg-neutral-800/30 transition-colors">
|
||||
<tr key={step.step.name} className="hover:bg-neutral-50 dark:hover:bg-neutral-800/30 transition-colors">
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-6 h-6 rounded-full bg-neutral-100 dark:bg-neutral-800 flex items-center justify-center text-xs font-medium text-neutral-600 dark:text-neutral-400">
|
||||
|
||||
@@ -149,7 +149,7 @@ export default function CreateFunnelPage() {
|
||||
</div>
|
||||
|
||||
{steps.map((step, index) => (
|
||||
<div key={index} className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-4">
|
||||
<div key={`step-${index}`} className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="mt-3 text-neutral-400">
|
||||
<div className="w-6 h-6 rounded-full bg-neutral-100 dark:bg-neutral-800 flex items-center justify-center text-sm font-medium text-neutral-600 dark:text-neutral-400">
|
||||
|
||||
@@ -117,7 +117,7 @@ export default function FunnelsPage() {
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-4">
|
||||
{funnel.steps.map((step, i) => (
|
||||
<div key={i} className="flex items-center text-sm text-neutral-500">
|
||||
<div key={step.name} className="flex items-center text-sm text-neutral-500">
|
||||
<span className="px-2 py-1 bg-neutral-100 dark:bg-neutral-800 rounded-lg text-neutral-700 dark:text-neutral-300">
|
||||
{step.name}
|
||||
</span>
|
||||
|
||||
@@ -2,15 +2,26 @@
|
||||
|
||||
import { useAuth } from '@/lib/auth/context'
|
||||
import { logger } from '@/lib/utils/logger'
|
||||
import { useCallback, useEffect, useState, useRef } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { useCallback, useEffect, useState, useMemo } from 'react'
|
||||
import { useParams, useRouter, useSearchParams } from 'next/navigation'
|
||||
import { motion } from 'framer-motion'
|
||||
import { getSite, type Site } from '@/lib/api/sites'
|
||||
import { getStats, getRealtime, getDailyStats, getTopPages, getTopReferrers, getCountries, getCities, getRegions, getBrowsers, getOS, getDevices, getScreenResolutions, getEntryPages, getExitPages, getDashboard, getCampaigns, getPerformanceByPage, type Stats, type DailyStat, type PerformanceByPageStat } from '@/lib/api/stats'
|
||||
import { formatNumber, formatDuration, getDateRange } from '@ciphera-net/ui'
|
||||
import {
|
||||
getPerformanceByPage,
|
||||
getTopPages,
|
||||
getTopReferrers,
|
||||
getCountries,
|
||||
getCities,
|
||||
getRegions,
|
||||
getBrowsers,
|
||||
getOS,
|
||||
getDevices,
|
||||
getCampaigns,
|
||||
type Stats,
|
||||
type DailyStat,
|
||||
} from '@/lib/api/stats'
|
||||
import { getDateRange } from '@ciphera-net/ui'
|
||||
import { toast } from '@ciphera-net/ui'
|
||||
import { getAuthErrorMessage } from '@ciphera-net/ui'
|
||||
import { LoadingOverlay, Button } from '@ciphera-net/ui'
|
||||
import { Button } from '@ciphera-net/ui'
|
||||
import { Select, DatePicker, DownloadIcon } from '@ciphera-net/ui'
|
||||
import { DashboardSkeleton, useMinimumLoading } from '@/components/skeletons'
|
||||
import ExportModal from '@/components/dashboard/ExportModal'
|
||||
@@ -21,7 +32,51 @@ import TechSpecs from '@/components/dashboard/TechSpecs'
|
||||
import Chart from '@/components/dashboard/Chart'
|
||||
import PerformanceStats from '@/components/dashboard/PerformanceStats'
|
||||
import GoalStats from '@/components/dashboard/GoalStats'
|
||||
import ScrollDepth from '@/components/dashboard/ScrollDepth'
|
||||
import Campaigns from '@/components/dashboard/Campaigns'
|
||||
import FilterBar from '@/components/dashboard/FilterBar'
|
||||
import AddFilterDropdown, { type FilterSuggestion, type FilterSuggestions } from '@/components/dashboard/AddFilterDropdown'
|
||||
import EventProperties from '@/components/dashboard/EventProperties'
|
||||
import { type DimensionFilter, serializeFilters, parseFiltersFromURL } from '@/lib/filters'
|
||||
import {
|
||||
useDashboardOverview,
|
||||
useDashboardPages,
|
||||
useDashboardLocations,
|
||||
useDashboardDevices,
|
||||
useDashboardReferrers,
|
||||
useDashboardPerformance,
|
||||
useDashboardGoals,
|
||||
useRealtime,
|
||||
useStats,
|
||||
useDailyStats,
|
||||
useCampaigns,
|
||||
} from '@/lib/swr/dashboard'
|
||||
|
||||
function loadSavedSettings(): {
|
||||
type?: string
|
||||
dateRange?: { start: string; end: string }
|
||||
todayInterval?: 'minute' | 'hour'
|
||||
multiDayInterval?: 'hour' | 'day'
|
||||
} | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
try {
|
||||
const saved = localStorage.getItem('pulse_dashboard_settings')
|
||||
return saved ? JSON.parse(saved) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function getInitialDateRange(): { start: string; end: string } {
|
||||
const settings = loadSavedSettings()
|
||||
if (settings?.type === 'today') {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
return { start: today, end: today }
|
||||
}
|
||||
if (settings?.type === '7') return getDateRange(7)
|
||||
if (settings?.type === 'custom' && settings.dateRange) return settings.dateRange
|
||||
return getDateRange(30)
|
||||
}
|
||||
|
||||
export default function SiteDashboardPage() {
|
||||
const { user } = useAuth()
|
||||
@@ -31,69 +86,279 @@ export default function SiteDashboardPage() {
|
||||
const router = useRouter()
|
||||
const siteId = params.id as string
|
||||
|
||||
const [site, setSite] = useState<Site | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [stats, setStats] = useState<Stats>({ pageviews: 0, visitors: 0, bounce_rate: 0, avg_duration: 0 })
|
||||
const [prevStats, setPrevStats] = useState<Stats | undefined>(undefined)
|
||||
const [realtime, setRealtime] = useState(0)
|
||||
const [dailyStats, setDailyStats] = useState<DailyStat[]>([])
|
||||
const [prevDailyStats, setPrevDailyStats] = useState<DailyStat[] | undefined>(undefined)
|
||||
const [topPages, setTopPages] = useState<any[]>([])
|
||||
const [entryPages, setEntryPages] = useState<any[]>([])
|
||||
const [exitPages, setExitPages] = useState<any[]>([])
|
||||
const [topReferrers, setTopReferrers] = useState<any[]>([])
|
||||
const [countries, setCountries] = useState<any[]>([])
|
||||
const [cities, setCities] = useState<any[]>([])
|
||||
const [regions, setRegions] = useState<any[]>([])
|
||||
const [browsers, setBrowsers] = useState<any[]>([])
|
||||
const [os, setOS] = useState<any[]>([])
|
||||
const [devices, setDevices] = useState<any[]>([])
|
||||
const [screenResolutions, setScreenResolutions] = useState<any[]>([])
|
||||
const [performance, setPerformance] = useState<{ lcp: number, cls: number, inp: number }>({ lcp: 0, cls: 0, inp: 0 })
|
||||
const [performanceByPage, setPerformanceByPage] = useState<PerformanceByPageStat[] | null>(null)
|
||||
const [goalCounts, setGoalCounts] = useState<Array<{ event_name: string; count: number }>>([])
|
||||
const [campaigns, setCampaigns] = useState<any[]>([])
|
||||
const [dateRange, setDateRange] = useState(getDateRange(30))
|
||||
// UI state - initialized from localStorage synchronously to avoid double-fetch
|
||||
const [dateRange, setDateRange] = useState(getInitialDateRange)
|
||||
const [todayInterval, setTodayInterval] = useState<'minute' | 'hour'>(
|
||||
() => loadSavedSettings()?.todayInterval || 'hour'
|
||||
)
|
||||
const [multiDayInterval, setMultiDayInterval] = useState<'hour' | 'day'>(
|
||||
() => loadSavedSettings()?.multiDayInterval || 'day'
|
||||
)
|
||||
const [isDatePickerOpen, setIsDatePickerOpen] = useState(false)
|
||||
const [isExportModalOpen, setIsExportModalOpen] = useState(false)
|
||||
const [todayInterval, setTodayInterval] = useState<'minute' | 'hour'>('hour')
|
||||
const [multiDayInterval, setMultiDayInterval] = useState<'hour' | 'day'>('day')
|
||||
const [isSettingsLoaded, setIsSettingsLoaded] = useState(false)
|
||||
const [lastUpdatedAt, setLastUpdatedAt] = useState<number | null>(null)
|
||||
const [, setTick] = useState(0)
|
||||
|
||||
// Load settings from localStorage
|
||||
useEffect(() => {
|
||||
try {
|
||||
const savedSettings = localStorage.getItem('pulse_dashboard_settings')
|
||||
if (savedSettings) {
|
||||
const settings = JSON.parse(savedSettings)
|
||||
|
||||
// Restore date range
|
||||
if (settings.type === 'today') {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
setDateRange({ start: today, end: today })
|
||||
} else if (settings.type === '7') {
|
||||
setDateRange(getDateRange(7))
|
||||
} else if (settings.type === '30') {
|
||||
setDateRange(getDateRange(30))
|
||||
} else if (settings.type === 'custom' && settings.dateRange) {
|
||||
setDateRange(settings.dateRange)
|
||||
}
|
||||
// Dimension filters state
|
||||
const searchParams = useSearchParams()
|
||||
const [filters, setFilters] = useState<DimensionFilter[]>(() => {
|
||||
const raw = searchParams.get('filters')
|
||||
return raw ? parseFiltersFromURL(raw) : []
|
||||
})
|
||||
const filtersParam = useMemo(() => serializeFilters(filters), [filters])
|
||||
|
||||
// Restore intervals
|
||||
if (settings.todayInterval) setTodayInterval(settings.todayInterval)
|
||||
if (settings.multiDayInterval) setMultiDayInterval(settings.multiDayInterval)
|
||||
// Selected event for property breakdown
|
||||
const [selectedEvent, setSelectedEvent] = useState<string | null>(null)
|
||||
|
||||
const handleAddFilter = useCallback((filter: DimensionFilter) => {
|
||||
setFilters(prev => {
|
||||
const isDuplicate = prev.some(
|
||||
f => f.dimension === filter.dimension && f.operator === filter.operator && f.values.join(';') === filter.values.join(';')
|
||||
)
|
||||
if (isDuplicate) return prev
|
||||
return [...prev, filter]
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleRemoveFilter = useCallback((index: number) => {
|
||||
setFilters(prev => prev.filter((_, i) => i !== index))
|
||||
}, [])
|
||||
|
||||
const handleClearFilters = useCallback(() => {
|
||||
setFilters([])
|
||||
}, [])
|
||||
|
||||
// Fetch full suggestion list (up to 100) when a dimension is selected in the filter dropdown
|
||||
const handleFetchSuggestions = useCallback(async (dimension: string): Promise<FilterSuggestion[]> => {
|
||||
const start = dateRange.start
|
||||
const end = dateRange.end
|
||||
const f = filtersParam || undefined
|
||||
const limit = 100
|
||||
|
||||
try {
|
||||
const regionNames = (() => { try { return new Intl.DisplayNames(['en'], { type: 'region' }) } catch { return null } })()
|
||||
|
||||
switch (dimension) {
|
||||
case 'page': {
|
||||
const data = await getTopPages(siteId, start, end, limit, f)
|
||||
return data.map(p => ({ value: p.path, label: p.path, count: p.pageviews }))
|
||||
}
|
||||
case 'referrer': {
|
||||
const data = await getTopReferrers(siteId, start, end, limit, f)
|
||||
return data.filter(r => r.referrer && r.referrer !== '').map(r => ({ value: r.referrer, label: r.referrer, count: r.pageviews }))
|
||||
}
|
||||
case 'country': {
|
||||
const data = await getCountries(siteId, start, end, limit, f)
|
||||
return data.filter(c => c.country && c.country !== 'Unknown').map(c => ({ value: c.country, label: regionNames?.of(c.country) ?? c.country, count: c.pageviews }))
|
||||
}
|
||||
case 'city': {
|
||||
const data = await getCities(siteId, start, end, limit, f)
|
||||
return data.filter(c => c.city && c.city !== 'Unknown').map(c => ({ value: c.city, label: c.city, count: c.pageviews }))
|
||||
}
|
||||
case 'region': {
|
||||
const data = await getRegions(siteId, start, end, limit, f)
|
||||
return data.filter(r => r.region && r.region !== 'Unknown').map(r => ({ value: r.region, label: r.region, count: r.pageviews }))
|
||||
}
|
||||
case 'browser': {
|
||||
const data = await getBrowsers(siteId, start, end, limit, f)
|
||||
return data.filter(b => b.browser && b.browser !== 'Unknown').map(b => ({ value: b.browser, label: b.browser, count: b.pageviews }))
|
||||
}
|
||||
case 'os': {
|
||||
const data = await getOS(siteId, start, end, limit, f)
|
||||
return data.filter(o => o.os && o.os !== 'Unknown').map(o => ({ value: o.os, label: o.os, count: o.pageviews }))
|
||||
}
|
||||
case 'device': {
|
||||
const data = await getDevices(siteId, start, end, limit, f)
|
||||
return data.filter(d => d.device && d.device !== 'Unknown').map(d => ({ value: d.device, label: d.device, count: d.pageviews }))
|
||||
}
|
||||
case 'utm_source':
|
||||
case 'utm_medium':
|
||||
case 'utm_campaign': {
|
||||
const data = await getCampaigns(siteId, start, end, limit, f)
|
||||
const map = new Map<string, number>()
|
||||
const field = dimension === 'utm_source' ? 'source' : dimension === 'utm_medium' ? 'medium' : 'campaign'
|
||||
data.forEach(c => {
|
||||
const val = c[field]
|
||||
if (val) map.set(val, (map.get(val) ?? 0) + c.pageviews)
|
||||
})
|
||||
return [...map.entries()].map(([v, count]) => ({ value: v, label: v, count }))
|
||||
}
|
||||
default:
|
||||
return []
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Failed to load dashboard settings', e)
|
||||
} finally {
|
||||
setIsSettingsLoaded(true)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}, [siteId, dateRange.start, dateRange.end, filtersParam])
|
||||
|
||||
// Sync filters to URL
|
||||
useEffect(() => {
|
||||
const url = new URL(window.location.href)
|
||||
if (filtersParam) {
|
||||
url.searchParams.set('filters', filtersParam)
|
||||
} else {
|
||||
url.searchParams.delete('filters')
|
||||
}
|
||||
window.history.replaceState({}, '', url.toString())
|
||||
}, [filtersParam])
|
||||
|
||||
const interval = dateRange.start === dateRange.end ? todayInterval : multiDayInterval
|
||||
|
||||
// Previous period date range for comparison
|
||||
const prevRange = useMemo(() => {
|
||||
const startDate = new Date(dateRange.start)
|
||||
const endDate = new Date(dateRange.end)
|
||||
const duration = endDate.getTime() - startDate.getTime()
|
||||
if (duration === 0) {
|
||||
const prevEnd = new Date(startDate.getTime() - 24 * 60 * 60 * 1000)
|
||||
return { start: prevEnd.toISOString().split('T')[0], end: prevEnd.toISOString().split('T')[0] }
|
||||
}
|
||||
const prevEnd = new Date(startDate.getTime() - 24 * 60 * 60 * 1000)
|
||||
const prevStart = new Date(prevEnd.getTime() - duration)
|
||||
return { start: prevStart.toISOString().split('T')[0], end: prevEnd.toISOString().split('T')[0] }
|
||||
}, [dateRange])
|
||||
|
||||
// SWR hooks - replace manual useState + useEffect + setInterval polling
|
||||
// Each hook handles its own refresh interval, deduplication, and error retry
|
||||
// Filters are included in cache keys so changing filters auto-refetches
|
||||
const { data: overview, isLoading: overviewLoading, error: overviewError } = useDashboardOverview(siteId, dateRange.start, dateRange.end, interval, filtersParam || undefined)
|
||||
const { data: pages } = useDashboardPages(siteId, dateRange.start, dateRange.end, filtersParam || undefined)
|
||||
const { data: locations } = useDashboardLocations(siteId, dateRange.start, dateRange.end, filtersParam || undefined)
|
||||
const { data: devicesData } = useDashboardDevices(siteId, dateRange.start, dateRange.end, filtersParam || undefined)
|
||||
const { data: referrers } = useDashboardReferrers(siteId, dateRange.start, dateRange.end, filtersParam || undefined)
|
||||
const { data: performanceData } = useDashboardPerformance(siteId, dateRange.start, dateRange.end, filtersParam || undefined)
|
||||
const { data: goalsData } = useDashboardGoals(siteId, dateRange.start, dateRange.end, filtersParam || undefined)
|
||||
const { data: realtimeData } = useRealtime(siteId)
|
||||
const { data: prevStats } = useStats(siteId, prevRange.start, prevRange.end)
|
||||
const { data: prevDailyStats } = useDailyStats(siteId, prevRange.start, prevRange.end, interval)
|
||||
const { data: campaigns } = useCampaigns(siteId, dateRange.start, dateRange.end)
|
||||
|
||||
// Derive typed values from SWR data
|
||||
const site = overview?.site ?? null
|
||||
const stats: Stats = overview?.stats ?? { pageviews: 0, visitors: 0, bounce_rate: 0, avg_duration: 0 }
|
||||
const realtime = realtimeData?.visitors ?? overview?.realtime_visitors ?? 0
|
||||
const dailyStats: DailyStat[] = overview?.daily_stats ?? []
|
||||
|
||||
// Build filter suggestions from current dashboard data
|
||||
const filterSuggestions = useMemo<FilterSuggestions>(() => {
|
||||
const s: FilterSuggestions = {}
|
||||
|
||||
// Pages
|
||||
const topPages = pages?.top_pages ?? []
|
||||
if (topPages.length > 0) {
|
||||
s.page = topPages.map(p => ({ value: p.path, label: p.path, count: p.pageviews }))
|
||||
}
|
||||
|
||||
// Referrers
|
||||
const refs = referrers?.top_referrers ?? []
|
||||
if (refs.length > 0) {
|
||||
s.referrer = refs.filter(r => r.referrer && r.referrer !== '').map(r => ({
|
||||
value: r.referrer,
|
||||
label: r.referrer,
|
||||
count: r.pageviews,
|
||||
}))
|
||||
}
|
||||
|
||||
// Countries
|
||||
const ctrs = locations?.countries ?? []
|
||||
if (ctrs.length > 0) {
|
||||
const regionNames = (() => { try { return new Intl.DisplayNames(['en'], { type: 'region' }) } catch { return null } })()
|
||||
s.country = ctrs.filter(c => c.country && c.country !== 'Unknown').map(c => ({
|
||||
value: c.country,
|
||||
label: regionNames?.of(c.country) ?? c.country,
|
||||
count: c.pageviews,
|
||||
}))
|
||||
}
|
||||
|
||||
// Regions
|
||||
const regs = locations?.regions ?? []
|
||||
if (regs.length > 0) {
|
||||
s.region = regs.filter(r => r.region && r.region !== 'Unknown').map(r => ({
|
||||
value: r.region,
|
||||
label: r.region,
|
||||
count: r.pageviews,
|
||||
}))
|
||||
}
|
||||
|
||||
// Cities
|
||||
const cts = locations?.cities ?? []
|
||||
if (cts.length > 0) {
|
||||
s.city = cts.filter(c => c.city && c.city !== 'Unknown').map(c => ({
|
||||
value: c.city,
|
||||
label: c.city,
|
||||
count: c.pageviews,
|
||||
}))
|
||||
}
|
||||
|
||||
// Browsers
|
||||
const brs = devicesData?.browsers ?? []
|
||||
if (brs.length > 0) {
|
||||
s.browser = brs.filter(b => b.browser && b.browser !== 'Unknown').map(b => ({
|
||||
value: b.browser,
|
||||
label: b.browser,
|
||||
count: b.pageviews,
|
||||
}))
|
||||
}
|
||||
|
||||
// OS
|
||||
const oses = devicesData?.os ?? []
|
||||
if (oses.length > 0) {
|
||||
s.os = oses.filter(o => o.os && o.os !== 'Unknown').map(o => ({
|
||||
value: o.os,
|
||||
label: o.os,
|
||||
count: o.pageviews,
|
||||
}))
|
||||
}
|
||||
|
||||
// Devices
|
||||
const devs = devicesData?.devices ?? []
|
||||
if (devs.length > 0) {
|
||||
s.device = devs.filter(d => d.device && d.device !== 'Unknown').map(d => ({
|
||||
value: d.device,
|
||||
label: d.device,
|
||||
count: d.pageviews,
|
||||
}))
|
||||
}
|
||||
|
||||
// UTM from campaigns
|
||||
const camps = campaigns ?? []
|
||||
if (camps.length > 0) {
|
||||
const sources = new Map<string, number>()
|
||||
const mediums = new Map<string, number>()
|
||||
const campNames = new Map<string, number>()
|
||||
camps.forEach(c => {
|
||||
if (c.source) sources.set(c.source, (sources.get(c.source) ?? 0) + c.pageviews)
|
||||
if (c.medium) mediums.set(c.medium, (mediums.get(c.medium) ?? 0) + c.pageviews)
|
||||
if (c.campaign) campNames.set(c.campaign, (campNames.get(c.campaign) ?? 0) + c.pageviews)
|
||||
})
|
||||
if (sources.size > 0) s.utm_source = [...sources.entries()].map(([v, c]) => ({ value: v, label: v, count: c }))
|
||||
if (mediums.size > 0) s.utm_medium = [...mediums.entries()].map(([v, c]) => ({ value: v, label: v, count: c }))
|
||||
if (campNames.size > 0) s.utm_campaign = [...campNames.entries()].map(([v, c]) => ({ value: v, label: v, count: c }))
|
||||
}
|
||||
|
||||
return s
|
||||
}, [pages, referrers, locations, devicesData, campaigns])
|
||||
|
||||
// Show error toast on fetch failure
|
||||
useEffect(() => {
|
||||
if (overviewError) {
|
||||
toast.error('Failed to load dashboard analytics')
|
||||
}
|
||||
}, [overviewError])
|
||||
|
||||
// Track when data was last updated (for "Live · Xs ago" display)
|
||||
useEffect(() => {
|
||||
if (overview) setLastUpdatedAt(Date.now())
|
||||
}, [overview])
|
||||
|
||||
// Tick every 1s so "Live · Xs ago" counts in real time
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setTick((t) => t + 1), 1000)
|
||||
return () => clearInterval(timer)
|
||||
}, [])
|
||||
|
||||
// Save settings to localStorage
|
||||
const saveSettings = (type: string, newDateRange?: { start: string, end: string }) => {
|
||||
const saveSettings = (type: string, newDateRange?: { start: string; end: string }) => {
|
||||
try {
|
||||
const settings = {
|
||||
type,
|
||||
@@ -110,9 +375,6 @@ export default function SiteDashboardPage() {
|
||||
|
||||
// Save intervals when they change
|
||||
useEffect(() => {
|
||||
if (!isSettingsLoaded) return
|
||||
|
||||
// Determine current type
|
||||
let type = 'custom'
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
if (dateRange.start === today && dateRange.end === today) type = 'today'
|
||||
@@ -127,160 +389,13 @@ export default function SiteDashboardPage() {
|
||||
lastUpdated: Date.now()
|
||||
}
|
||||
localStorage.setItem('pulse_dashboard_settings', JSON.stringify(settings))
|
||||
}, [todayInterval, multiDayInterval, isSettingsLoaded]) // dateRange is handled in saveSettings/onChange
|
||||
|
||||
// * Tick every 1s so "Live · Xs ago" counts in real time
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => setTick((t) => t + 1), 1000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
const getPreviousDateRange = useCallback((start: string, end: string) => {
|
||||
const startDate = new Date(start)
|
||||
const endDate = new Date(end)
|
||||
const duration = endDate.getTime() - startDate.getTime()
|
||||
if (duration === 0) {
|
||||
const prevEnd = new Date(startDate.getTime() - 24 * 60 * 60 * 1000)
|
||||
return { start: prevEnd.toISOString().split('T')[0], end: prevEnd.toISOString().split('T')[0] }
|
||||
}
|
||||
const prevEnd = new Date(startDate.getTime() - 24 * 60 * 60 * 1000)
|
||||
const prevStart = new Date(prevEnd.getTime() - duration)
|
||||
return { start: prevStart.toISOString().split('T')[0], end: prevEnd.toISOString().split('T')[0] }
|
||||
}, [])
|
||||
|
||||
// * Visibility-aware polling intervals
|
||||
// * Historical data: 60s when visible, paused when hidden
|
||||
// * Real-time data: 5s when visible, 30s when hidden
|
||||
const [isVisible, setIsVisible] = useState(true)
|
||||
const dashboardIntervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const realtimeIntervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
// * Track visibility state
|
||||
useEffect(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
const visible = document.visibilityState === 'visible'
|
||||
setIsVisible(visible)
|
||||
}
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
return () => document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
}, [])
|
||||
|
||||
const loadData = useCallback(async (silent = false) => {
|
||||
try {
|
||||
if (!silent) setLoading(true)
|
||||
const interval = dateRange.start === dateRange.end ? todayInterval : multiDayInterval
|
||||
|
||||
const [data, prevStatsData, prevDailyStatsData, campaignsData] = await Promise.all([
|
||||
getDashboard(siteId, dateRange.start, dateRange.end, 10, interval),
|
||||
(async () => {
|
||||
const prevRange = getPreviousDateRange(dateRange.start, dateRange.end)
|
||||
return getStats(siteId, prevRange.start, prevRange.end)
|
||||
})(),
|
||||
(async () => {
|
||||
const prevRange = getPreviousDateRange(dateRange.start, dateRange.end)
|
||||
return getDailyStats(siteId, prevRange.start, prevRange.end, interval)
|
||||
})(),
|
||||
getCampaigns(siteId, dateRange.start, dateRange.end, 100),
|
||||
])
|
||||
|
||||
setSite(data.site)
|
||||
setStats(data.stats || { pageviews: 0, visitors: 0, bounce_rate: 0, avg_duration: 0 })
|
||||
setRealtime(data.realtime_visitors || 0)
|
||||
setDailyStats(Array.isArray(data.daily_stats) ? data.daily_stats : [])
|
||||
|
||||
setPrevStats(prevStatsData)
|
||||
setPrevDailyStats(prevDailyStatsData)
|
||||
|
||||
setTopPages(Array.isArray(data.top_pages) ? data.top_pages : [])
|
||||
setEntryPages(Array.isArray(data.entry_pages) ? data.entry_pages : [])
|
||||
setExitPages(Array.isArray(data.exit_pages) ? data.exit_pages : [])
|
||||
setTopReferrers(Array.isArray(data.top_referrers) ? data.top_referrers : [])
|
||||
setCountries(Array.isArray(data.countries) ? data.countries : [])
|
||||
setCities(Array.isArray(data.cities) ? data.cities : [])
|
||||
setRegions(Array.isArray(data.regions) ? data.regions : [])
|
||||
setBrowsers(Array.isArray(data.browsers) ? data.browsers : [])
|
||||
setOS(Array.isArray(data.os) ? data.os : [])
|
||||
setDevices(Array.isArray(data.devices) ? data.devices : [])
|
||||
setScreenResolutions(Array.isArray(data.screen_resolutions) ? data.screen_resolutions : [])
|
||||
setPerformance(data.performance || { lcp: 0, cls: 0, inp: 0 })
|
||||
setPerformanceByPage(data.performance_by_page ?? null)
|
||||
setGoalCounts(Array.isArray(data.goal_counts) ? data.goal_counts : [])
|
||||
setCampaigns(Array.isArray(campaignsData) ? campaignsData : [])
|
||||
setLastUpdatedAt(Date.now())
|
||||
} catch (error: unknown) {
|
||||
if (!silent) {
|
||||
toast.error(getAuthErrorMessage(error) || 'Failed to load dashboard analytics')
|
||||
}
|
||||
} finally {
|
||||
if (!silent) setLoading(false)
|
||||
}
|
||||
}, [siteId, dateRange, todayInterval, multiDayInterval])
|
||||
|
||||
const loadRealtime = useCallback(async () => {
|
||||
try {
|
||||
const data = await getRealtime(siteId)
|
||||
setRealtime(data.visitors)
|
||||
} catch (error) {
|
||||
// * Silently fail for realtime updates
|
||||
}
|
||||
}, [siteId])
|
||||
|
||||
// * Visibility-aware polling for dashboard data (historical)
|
||||
// * Refreshes every 60 seconds when tab is visible, pauses when hidden
|
||||
useEffect(() => {
|
||||
if (!isSettingsLoaded) return
|
||||
|
||||
// * Initial load
|
||||
loadData()
|
||||
|
||||
// * Clear existing interval
|
||||
if (dashboardIntervalRef.current) {
|
||||
clearInterval(dashboardIntervalRef.current)
|
||||
}
|
||||
|
||||
// * Only poll when visible (saves server resources when tab is backgrounded)
|
||||
if (isVisible) {
|
||||
dashboardIntervalRef.current = setInterval(() => {
|
||||
loadData(true)
|
||||
}, 60000) // * 60 seconds for historical data
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (dashboardIntervalRef.current) {
|
||||
clearInterval(dashboardIntervalRef.current)
|
||||
}
|
||||
}
|
||||
}, [siteId, dateRange, todayInterval, multiDayInterval, isSettingsLoaded, loadData, isVisible])
|
||||
|
||||
// * Visibility-aware polling for realtime data
|
||||
// * Refreshes every 5 seconds when visible, every 30 seconds when hidden
|
||||
useEffect(() => {
|
||||
if (!isSettingsLoaded) return
|
||||
|
||||
// * Clear existing interval
|
||||
if (realtimeIntervalRef.current) {
|
||||
clearInterval(realtimeIntervalRef.current)
|
||||
}
|
||||
|
||||
// * Different intervals based on visibility
|
||||
const interval = isVisible ? 5000 : 30000 // * 5s visible, 30s hidden
|
||||
|
||||
realtimeIntervalRef.current = setInterval(() => {
|
||||
loadRealtime()
|
||||
}, interval)
|
||||
|
||||
return () => {
|
||||
if (realtimeIntervalRef.current) {
|
||||
clearInterval(realtimeIntervalRef.current)
|
||||
}
|
||||
}
|
||||
}, [siteId, isSettingsLoaded, loadRealtime, isVisible])
|
||||
}, [todayInterval, multiDayInterval]) // eslint-disable-line react-hooks/exhaustive-deps -- dateRange saved via saveSettings
|
||||
|
||||
useEffect(() => {
|
||||
if (site?.domain) document.title = `${site.domain} | Pulse`
|
||||
}, [site?.domain])
|
||||
|
||||
const showSkeleton = useMinimumLoading(loading)
|
||||
const showSkeleton = useMinimumLoading(overviewLoading)
|
||||
|
||||
if (showSkeleton) {
|
||||
return <DashboardSkeleton />
|
||||
@@ -312,7 +427,7 @@ export default function SiteDashboardPage() {
|
||||
{site.domain}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Realtime Indicator */}
|
||||
<button
|
||||
onClick={() => router.push(`/sites/${siteId}/realtime`)}
|
||||
@@ -412,12 +527,18 @@ export default function SiteDashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dimension Filters */}
|
||||
<div className="flex items-center gap-2 flex-wrap mb-2">
|
||||
<AddFilterDropdown onAdd={handleAddFilter} suggestions={filterSuggestions} onFetchSuggestions={handleFetchSuggestions} />
|
||||
<FilterBar filters={filters} onRemove={handleRemoveFilter} onClear={handleClearFilters} />
|
||||
</div>
|
||||
|
||||
{/* Advanced Chart with Integrated Stats */}
|
||||
<div className="mb-8">
|
||||
<Chart
|
||||
data={dailyStats}
|
||||
<Chart
|
||||
data={dailyStats}
|
||||
prevData={prevDailyStats}
|
||||
stats={stats}
|
||||
stats={stats}
|
||||
prevStats={prevStats}
|
||||
interval={dateRange.start === dateRange.end ? todayInterval : multiDayInterval}
|
||||
dateRange={dateRange}
|
||||
@@ -433,8 +554,8 @@ export default function SiteDashboardPage() {
|
||||
{site.enable_performance_insights && (
|
||||
<div className="mb-8">
|
||||
<PerformanceStats
|
||||
stats={performance}
|
||||
performanceByPage={performanceByPage}
|
||||
stats={performanceData?.performance ?? { lcp: 0, cls: 0, inp: 0 }}
|
||||
performanceByPage={performanceData?.performance_by_page ?? null}
|
||||
siteId={siteId}
|
||||
startDate={dateRange.start}
|
||||
endDate={dateRange.end}
|
||||
@@ -445,52 +566,71 @@ export default function SiteDashboardPage() {
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2 mb-8">
|
||||
<ContentStats
|
||||
topPages={topPages}
|
||||
entryPages={entryPages}
|
||||
exitPages={exitPages}
|
||||
topPages={pages?.top_pages ?? []}
|
||||
entryPages={pages?.entry_pages ?? []}
|
||||
exitPages={pages?.exit_pages ?? []}
|
||||
domain={site.domain}
|
||||
collectPagePaths={site.collect_page_paths ?? true}
|
||||
siteId={siteId}
|
||||
dateRange={dateRange}
|
||||
onFilter={handleAddFilter}
|
||||
/>
|
||||
<TopReferrers
|
||||
referrers={topReferrers}
|
||||
referrers={referrers?.top_referrers ?? []}
|
||||
collectReferrers={site.collect_referrers ?? true}
|
||||
siteId={siteId}
|
||||
dateRange={dateRange}
|
||||
onFilter={handleAddFilter}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2 mb-8">
|
||||
<Locations
|
||||
countries={countries}
|
||||
cities={cities}
|
||||
regions={regions}
|
||||
countries={locations?.countries ?? []}
|
||||
cities={locations?.cities ?? []}
|
||||
regions={locations?.regions ?? []}
|
||||
geoDataLevel={site.collect_geo_data || 'full'}
|
||||
siteId={siteId}
|
||||
dateRange={dateRange}
|
||||
onFilter={handleAddFilter}
|
||||
/>
|
||||
<TechSpecs
|
||||
browsers={browsers}
|
||||
os={os}
|
||||
devices={devices}
|
||||
screenResolutions={screenResolutions}
|
||||
browsers={devicesData?.browsers ?? []}
|
||||
os={devicesData?.os ?? []}
|
||||
devices={devicesData?.devices ?? []}
|
||||
screenResolutions={devicesData?.screen_resolutions ?? []}
|
||||
collectDeviceInfo={site.collect_device_info ?? true}
|
||||
collectScreenResolution={site.collect_screen_resolution ?? true}
|
||||
siteId={siteId}
|
||||
dateRange={dateRange}
|
||||
onFilter={handleAddFilter}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Campaigns Report */}
|
||||
<div className="mb-8">
|
||||
<Campaigns siteId={siteId} dateRange={dateRange} />
|
||||
<div className="grid gap-6 lg:grid-cols-2 mb-8">
|
||||
<Campaigns siteId={siteId} dateRange={dateRange} filters={filtersParam || undefined} onFilter={handleAddFilter} />
|
||||
<GoalStats
|
||||
goalCounts={(goalsData?.goal_counts ?? []).filter(g => !/^scroll_\d+$/.test(g.event_name))}
|
||||
onSelectEvent={setSelectedEvent}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<GoalStats goalCounts={goalCounts} />
|
||||
<ScrollDepth goalCounts={goalsData?.goal_counts ?? []} totalPageviews={stats.pageviews} />
|
||||
</div>
|
||||
|
||||
{/* Event Properties Breakdown */}
|
||||
{selectedEvent && (
|
||||
<div className="mb-8">
|
||||
<EventProperties
|
||||
siteId={siteId}
|
||||
eventName={selectedEvent}
|
||||
dateRange={dateRange}
|
||||
onClose={() => setSelectedEvent(null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DatePicker
|
||||
isOpen={isDatePickerOpen}
|
||||
onClose={() => setIsDatePickerOpen(false)}
|
||||
@@ -507,8 +647,8 @@ export default function SiteDashboardPage() {
|
||||
onClose={() => setIsExportModalOpen(false)}
|
||||
data={dailyStats}
|
||||
stats={stats}
|
||||
topPages={topPages}
|
||||
topReferrers={topReferrers}
|
||||
topPages={pages?.top_pages}
|
||||
topReferrers={referrers?.top_referrers}
|
||||
campaigns={campaigns}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
interface PasswordInputProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
label?: string
|
||||
placeholder?: string
|
||||
error?: string | null
|
||||
disabled?: boolean
|
||||
required?: boolean
|
||||
className?: string
|
||||
id?: string
|
||||
autoComplete?: string
|
||||
minLength?: number
|
||||
onFocus?: () => void
|
||||
onBlur?: () => void
|
||||
}
|
||||
|
||||
export default function PasswordInput({
|
||||
value,
|
||||
onChange,
|
||||
label = 'Password',
|
||||
placeholder = 'Enter password',
|
||||
error,
|
||||
disabled = false,
|
||||
required = false,
|
||||
className = '',
|
||||
id,
|
||||
autoComplete,
|
||||
minLength,
|
||||
onFocus,
|
||||
onBlur
|
||||
}: PasswordInputProps) {
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const inputId = id || 'password-input'
|
||||
const errorId = `${inputId}-error`
|
||||
|
||||
return (
|
||||
<div className={`space-y-1.5 ${className}`}>
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className="block text-sm font-medium text-neutral-700 dark:text-neutral-300"
|
||||
>
|
||||
{label}
|
||||
{required && <span className="text-brand-orange text-xs ml-1">(Required)</span>}
|
||||
</label>
|
||||
)}
|
||||
<div className="relative group">
|
||||
<input
|
||||
id={inputId}
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
autoComplete={autoComplete}
|
||||
minLength={minLength}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
aria-invalid={!!error}
|
||||
aria-describedby={error ? errorId : undefined}
|
||||
className={`w-full pl-11 pr-12 py-3 border rounded-lg bg-neutral-50/50 dark:bg-neutral-900/50 focus:bg-white dark:focus:bg-neutral-900
|
||||
transition-all duration-200 outline-none disabled:opacity-50 disabled:cursor-not-allowed dark:text-white
|
||||
${error
|
||||
? 'border-red-300 dark:border-red-800 focus:border-red-500 focus:ring-4 focus:ring-red-500/10'
|
||||
: 'border-neutral-200 dark:border-neutral-800 hover:border-brand-orange/50 focus:border-brand-orange focus:ring-4 focus:ring-brand-orange/10'
|
||||
}`}
|
||||
/>
|
||||
|
||||
{/* Lock Icon (Left) */}
|
||||
<div className={`absolute left-3.5 top-1/2 -translate-y-1/2 pointer-events-none transition-colors duration-200
|
||||
${error ? 'text-red-400' : 'text-neutral-400 dark:text-neutral-500 group-focus-within:text-brand-orange'}`}>
|
||||
<svg aria-hidden="true" className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Toggle Visibility Button (Right) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
disabled={disabled}
|
||||
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-1.5 rounded-lg text-neutral-400 dark:text-neutral-500
|
||||
hover:text-neutral-600 dark:hover:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-all duration-200 focus:outline-none"
|
||||
>
|
||||
{showPassword ? (
|
||||
<svg aria-hidden="true" className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg aria-hidden="true" className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<p id={errorId} role="alert" className="text-xs text-red-500 font-medium ml-1">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { PlusIcon, PersonIcon, CubeIcon, CheckIcon } from '@radix-ui/react-icons'
|
||||
import { switchContext, OrganizationMember } from '@/lib/api/organization'
|
||||
import { setSessionAction } from '@/app/actions/auth'
|
||||
import { logger } from '@/lib/utils/logger'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function OrganizationSwitcher({ orgs, activeOrgId }: { orgs: OrganizationMember[], activeOrgId: string | null }) {
|
||||
const router = useRouter()
|
||||
const [switching, setSwitching] = useState<string | null>(null)
|
||||
|
||||
const handleSwitch = async (orgId: string | null) => {
|
||||
setSwitching(orgId || 'personal')
|
||||
try {
|
||||
// * If orgId is null, we can't switch context via API in the same way if strict mode is on
|
||||
// * Pulse doesn't support personal organization context.
|
||||
// * So we should probably NOT show the "Personal" option in Pulse if strict mode is enforced.
|
||||
// * However, to match Drop exactly, we might want to show it but have it fail or redirect?
|
||||
// * Let's assume for now we want to match Drop's UI structure.
|
||||
|
||||
if (!orgId) {
|
||||
// * Pulse doesn't support personal context.
|
||||
// * We could redirect to onboarding or show an error.
|
||||
// * For now, let's just return to avoid breaking.
|
||||
return
|
||||
}
|
||||
|
||||
const { access_token } = await switchContext(orgId)
|
||||
|
||||
// * Update session cookie via server action
|
||||
// * Note: switchContext only returns access_token, we keep existing refresh token
|
||||
await setSessionAction(access_token)
|
||||
|
||||
sessionStorage.setItem('pulse_switching_org', 'true')
|
||||
window.location.reload()
|
||||
|
||||
} catch (err) {
|
||||
logger.error('Failed to switch organization', err)
|
||||
setSwitching(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-b border-neutral-100 dark:border-neutral-800 pb-2 mb-2" role="group" aria-label="Organizations">
|
||||
<div className="px-3 py-2 text-xs font-medium text-neutral-500 uppercase tracking-wider" aria-hidden="true">
|
||||
Organizations
|
||||
</div>
|
||||
|
||||
{/* Personal organization - HIDDEN IN PULSE (Strict Mode) */}
|
||||
{/*
|
||||
<button
|
||||
onClick={() => handleSwitch(null)}
|
||||
className={`w-full flex items-center justify-between px-3 py-2 text-sm rounded-lg transition-colors group ${
|
||||
!activeOrgId ? 'bg-neutral-100 dark:bg-neutral-800' : 'hover:bg-neutral-50 dark:hover:bg-neutral-800/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-5 w-5 rounded bg-neutral-200 dark:bg-neutral-700 flex items-center justify-center">
|
||||
<PersonIcon className="h-3 w-3 text-neutral-500 dark:text-neutral-400" />
|
||||
</div>
|
||||
<span className="text-neutral-700 dark:text-neutral-300">Personal</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{switching === 'personal' && <span className="text-xs text-neutral-400">Loading...</span>}
|
||||
{!activeOrgId && !switching && <CheckIcon className="h-4 w-4 text-neutral-600 dark:text-neutral-400" />}
|
||||
</div>
|
||||
</button>
|
||||
*/}
|
||||
|
||||
{/* Organization list */}
|
||||
{orgs.map((org) => (
|
||||
<button
|
||||
key={org.organization_id}
|
||||
onClick={() => handleSwitch(org.organization_id)}
|
||||
aria-current={activeOrgId === org.organization_id ? 'true' : undefined}
|
||||
aria-busy={switching === org.organization_id ? 'true' : undefined}
|
||||
className={`w-full flex items-center justify-between px-3 py-2 text-sm rounded-lg transition-colors mt-1 ${
|
||||
activeOrgId === org.organization_id ? 'bg-neutral-100 dark:bg-neutral-800' : 'hover:bg-neutral-50 dark:hover:bg-neutral-800/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-5 w-5 rounded bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
|
||||
<CubeIcon className="h-3 w-3 text-blue-600 dark:text-blue-400" aria-hidden="true" />
|
||||
</div>
|
||||
<span className="text-neutral-700 dark:text-neutral-300 truncate max-w-[140px]">
|
||||
{org.organization_name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{switching === org.organization_id && <span className="text-xs text-neutral-400" aria-live="polite">Switching…</span>}
|
||||
{activeOrgId === org.organization_id && !switching && (
|
||||
<>
|
||||
<CheckIcon className="h-4 w-4 text-neutral-600 dark:text-neutral-400" aria-hidden="true" />
|
||||
<span className="sr-only">(current)</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Create New */}
|
||||
<Link
|
||||
href="/onboarding"
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-neutral-500 hover:text-blue-600 dark:text-neutral-400 dark:hover:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/10 rounded-lg transition-colors mt-1"
|
||||
>
|
||||
<div className="h-5 w-5 rounded border border-dashed border-neutral-300 dark:border-neutral-600 flex items-center justify-center" aria-hidden="true">
|
||||
<PlusIcon className="h-3 w-3" />
|
||||
</div>
|
||||
<span>Create Organization</span>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
234
components/dashboard/AddFilterDropdown.tsx
Normal file
234
components/dashboard/AddFilterDropdown.tsx
Normal 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 “{search.trim()}”
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useTabListKeyboard } from '@/lib/hooks/useTabListKeyboard'
|
||||
import { TopPage, getTopPages, getEntryPages, getExitPages } from '@/lib/api/stats'
|
||||
import { Modal, ArrowUpRightIcon, LayoutDashboardIcon } from '@ciphera-net/ui'
|
||||
import { ListSkeleton } from '@/components/skeletons'
|
||||
import { type DimensionFilter } from '@/lib/filters'
|
||||
|
||||
interface ContentStatsProps {
|
||||
topPages: TopPage[]
|
||||
@@ -16,13 +17,14 @@ interface ContentStatsProps {
|
||||
collectPagePaths?: boolean
|
||||
siteId: string
|
||||
dateRange: { start: string, end: string }
|
||||
onFilter?: (filter: DimensionFilter) => void
|
||||
}
|
||||
|
||||
type Tab = 'top_pages' | 'entry_pages' | 'exit_pages'
|
||||
|
||||
const LIMIT = 7
|
||||
|
||||
export default function ContentStats({ topPages, entryPages, exitPages, domain, collectPagePaths = true, siteId, dateRange }: ContentStatsProps) {
|
||||
export default function ContentStats({ topPages, entryPages, exitPages, domain, collectPagePaths = true, siteId, dateRange, onFilter }: ContentStatsProps) {
|
||||
const [activeTab, setActiveTab] = useState<Tab>('top_pages')
|
||||
const handleTabKeyDown = useTabListKeyboard()
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
@@ -76,6 +78,7 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain,
|
||||
}
|
||||
|
||||
const data = getData()
|
||||
const totalPageviews = data.reduce((sum, p) => sum + p.pageviews, 0)
|
||||
const hasData = data && data.length > 0
|
||||
const displayedData = hasData ? data.slice(0, LIMIT) : []
|
||||
const emptySlots = Math.max(0, LIMIT - displayedData.length)
|
||||
@@ -93,30 +96,20 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain,
|
||||
<>
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
|
||||
Content
|
||||
</h3>
|
||||
{showViewAll && (
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="text-xs font-medium text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded"
|
||||
>
|
||||
View All
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex p-1 bg-neutral-100 dark:bg-neutral-800 rounded-lg" role="tablist" aria-label="Content view tabs" onKeyDown={handleTabKeyDown}>
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
|
||||
Pages
|
||||
</h3>
|
||||
<div className="flex gap-1" role="tablist" aria-label="Pages view tabs" onKeyDown={handleTabKeyDown}>
|
||||
{(['top_pages', 'entry_pages', 'exit_pages'] as Tab[]).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
role="tab"
|
||||
aria-selected={activeTab === tab}
|
||||
className={`px-3 py-1 text-xs font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange ${
|
||||
className={`px-2.5 py-1 text-xs font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange rounded cursor-pointer border-b-2 ${
|
||||
activeTab === tab
|
||||
? 'bg-white dark:bg-neutral-700 text-neutral-900 dark:text-white shadow-sm'
|
||||
: 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white'
|
||||
? 'border-brand-orange text-neutral-900 dark:text-white'
|
||||
: 'border-transparent text-neutral-400 dark:text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300'
|
||||
}`}
|
||||
>
|
||||
{getTabLabel(tab)}
|
||||
@@ -132,27 +125,49 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain,
|
||||
</div>
|
||||
) : hasData ? (
|
||||
<>
|
||||
{displayedData.map((page, index) => (
|
||||
<div key={index} className="flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors">
|
||||
{displayedData.map((page) => (
|
||||
<div
|
||||
key={page.path}
|
||||
onClick={() => onFilter?.({ dimension: 'page', operator: 'is', values: [page.path] })}
|
||||
className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors${onFilter ? ' cursor-pointer' : ''}`}
|
||||
>
|
||||
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center">
|
||||
<span className="truncate">{page.path}</span>
|
||||
<a
|
||||
href={`https://${domain.replace(/^https?:\/\//, '')}${page.path}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:underline flex items-center"
|
||||
onClick={e => e.stopPropagation()}
|
||||
className="ml-2 flex-shrink-0"
|
||||
>
|
||||
{page.path}
|
||||
<ArrowUpRightIcon className="w-3 h-3 ml-2 text-neutral-400 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
<ArrowUpRightIcon className="w-3 h-3 text-neutral-400 opacity-0 group-hover:opacity-100 transition-opacity hover:text-brand-orange" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-neutral-600 dark:text-neutral-400 ml-4">
|
||||
{formatNumber(page.pageviews)}
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
|
||||
{totalPageviews > 0 ? `${Math.round((page.pageviews / totalPageviews) * 100)}%` : ''}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
|
||||
{formatNumber(page.pageviews)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{Array.from({ length: emptySlots }).map((_, i) => (
|
||||
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
|
||||
))}
|
||||
{showViewAll ? (
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="flex items-center justify-center gap-1.5 h-9 w-full text-xs font-medium text-neutral-400 dark:text-neutral-500 hover:text-brand-orange dark:hover:text-brand-orange transition-colors cursor-pointer rounded-lg px-2 -mx-2"
|
||||
>
|
||||
View all
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
) : (
|
||||
Array.from({ length: emptySlots }).map((_, i) => (
|
||||
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
|
||||
))
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="h-full flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
|
||||
@@ -173,7 +188,7 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain,
|
||||
<Modal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
title={`Content - ${getTabLabel(activeTab)}`}
|
||||
title={`Pages - ${getTabLabel(activeTab)}`}
|
||||
>
|
||||
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-2">
|
||||
{isLoadingFull ? (
|
||||
@@ -181,8 +196,8 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain,
|
||||
<ListSkeleton rows={10} />
|
||||
</div>
|
||||
) : (
|
||||
(fullData.length > 0 ? fullData : data).map((page, index) => (
|
||||
<div key={index} className="flex items-center justify-between py-2 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors">
|
||||
(fullData.length > 0 ? fullData : data).map((page) => (
|
||||
<div key={page.path} className="flex items-center justify-between py-2 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors">
|
||||
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center">
|
||||
<a
|
||||
href={`https://${domain.replace(/^https?:\/\//, '')}${page.path}`}
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { formatNumber } from '@ciphera-net/ui'
|
||||
import * as Flags from 'country-flag-icons/react/3x2'
|
||||
import WorldMap from './WorldMap'
|
||||
import { GlobeIcon } from '@ciphera-net/ui'
|
||||
|
||||
interface LocationProps {
|
||||
countries: Array<{ country: string; pageviews: number }>
|
||||
cities: Array<{ city: string; country: string; pageviews: number }>
|
||||
}
|
||||
|
||||
type Tab = 'countries' | 'cities'
|
||||
|
||||
export default function Locations({ countries, cities }: LocationProps) {
|
||||
const [activeTab, setActiveTab] = useState<Tab>('countries')
|
||||
|
||||
const getFlagComponent = (countryCode: string) => {
|
||||
if (!countryCode || countryCode === 'Unknown') return null
|
||||
// * The API returns 2-letter country codes (e.g. US, DE)
|
||||
// * We cast it to the flag component name
|
||||
const FlagComponent = (Flags as Record<string, React.ComponentType<{ className?: string }>>)[countryCode]
|
||||
return FlagComponent ? <FlagComponent className="w-5 h-5 rounded-sm shadow-sm" /> : null
|
||||
}
|
||||
|
||||
const getCountryName = (code: string) => {
|
||||
if (!code || code === 'Unknown') return 'Unknown'
|
||||
try {
|
||||
const regionNames = new Intl.DisplayNames(['en'], { type: 'region' })
|
||||
return regionNames.of(code) || code
|
||||
} catch (e) {
|
||||
return code
|
||||
}
|
||||
}
|
||||
|
||||
const renderContent = () => {
|
||||
if (activeTab === 'countries') {
|
||||
if (!countries || countries.length === 0) {
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
|
||||
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
|
||||
<GlobeIcon className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
|
||||
</div>
|
||||
<h4 className="font-semibold text-neutral-900 dark:text-white">
|
||||
No location data yet
|
||||
</h4>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs">
|
||||
Visitor locations will appear here based on anonymous geographic data.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<WorldMap data={countries} />
|
||||
<div className="space-y-3">
|
||||
{countries.map((country, index) => (
|
||||
<div key={index} className="flex items-center justify-between">
|
||||
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
|
||||
<span className="shrink-0">{getFlagComponent(country.country)}</span>
|
||||
<span className="truncate">{getCountryName(country.country)}</span>
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-neutral-600 dark:text-neutral-400 ml-4">
|
||||
{formatNumber(country.pageviews)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (activeTab === 'cities') {
|
||||
if (!cities || cities.length === 0) {
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
|
||||
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
|
||||
<GlobeIcon className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
|
||||
</div>
|
||||
<h4 className="font-semibold text-neutral-900 dark:text-white">
|
||||
No city data yet
|
||||
</h4>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs">
|
||||
City-level visitor data will appear as traffic grows.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{cities.map((city, index) => (
|
||||
<div key={index} className="flex items-center justify-between">
|
||||
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
|
||||
<span className="shrink-0">{getFlagComponent(city.country)}</span>
|
||||
<span className="truncate">{city.city === 'Unknown' ? 'Unknown' : city.city}</span>
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-neutral-600 dark:text-neutral-400 ml-4">
|
||||
{formatNumber(city.pageviews)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
|
||||
Locations
|
||||
</h3>
|
||||
<div className="flex p-1 bg-neutral-100 dark:bg-neutral-800 rounded-lg">
|
||||
<button
|
||||
onClick={() => setActiveTab('countries')}
|
||||
className={`px-3 py-1 text-xs font-medium rounded-lg transition-colors ${
|
||||
activeTab === 'countries'
|
||||
? 'bg-white dark:bg-neutral-700 text-neutral-900 dark:text-white shadow-sm'
|
||||
: 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Countries
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('cities')}
|
||||
className={`px-3 py-1 text-xs font-medium rounded-lg transition-colors ${
|
||||
activeTab === 'cities'
|
||||
? 'bg-white dark:bg-neutral-700 text-neutral-900 dark:text-white shadow-sm'
|
||||
: 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Cities
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{renderContent()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
108
components/dashboard/EventProperties.tsx
Normal file
108
components/dashboard/EventProperties.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
39
components/dashboard/FilterBar.tsx
Normal file
39
components/dashboard/FilterBar.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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, ' ')}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { ListSkeleton } from '@/components/skeletons'
|
||||
import { SiTorproject } from 'react-icons/si'
|
||||
import { FaUserSecret, FaSatellite } from 'react-icons/fa'
|
||||
import { getCountries, getCities, getRegions } from '@/lib/api/stats'
|
||||
import { type DimensionFilter } from '@/lib/filters'
|
||||
|
||||
interface LocationProps {
|
||||
countries: Array<{ country: string; pageviews: number }>
|
||||
@@ -20,13 +21,16 @@ interface LocationProps {
|
||||
geoDataLevel?: 'full' | 'country' | 'none'
|
||||
siteId: string
|
||||
dateRange: { start: string, end: string }
|
||||
onFilter?: (filter: DimensionFilter) => void
|
||||
}
|
||||
|
||||
type Tab = 'map' | 'countries' | 'regions' | 'cities'
|
||||
|
||||
const LIMIT = 7
|
||||
|
||||
export default function Locations({ countries, cities, regions, geoDataLevel = 'full', siteId, dateRange }: LocationProps) {
|
||||
const TAB_TO_DIMENSION: Record<string, string> = { countries: 'country', regions: 'region', cities: 'city' }
|
||||
|
||||
export default function Locations({ countries, cities, regions, geoDataLevel = 'full', siteId, dateRange, onFilter }: LocationProps) {
|
||||
const [activeTab, setActiveTab] = useState<Tab>('map')
|
||||
const handleTabKeyDown = useTabListKeyboard()
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
@@ -172,6 +176,7 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
|
||||
|
||||
const rawData = activeTab === 'map' ? [] : getData()
|
||||
const data = filterUnknown(rawData)
|
||||
const totalPageviews = data.reduce((sum, item) => sum + item.pageviews, 0)
|
||||
const hasData = activeTab === 'map'
|
||||
? (countries && filterUnknown(countries).length > 0)
|
||||
: (data && data.length > 0)
|
||||
@@ -193,30 +198,20 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
|
||||
<>
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
|
||||
Locations
|
||||
</h3>
|
||||
{showViewAll && (
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="text-xs font-medium text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded"
|
||||
>
|
||||
View All
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex p-1 bg-neutral-100 dark:bg-neutral-800 rounded-lg" role="tablist" aria-label="Location view tabs" onKeyDown={handleTabKeyDown}>
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
|
||||
Locations
|
||||
</h3>
|
||||
<div className="flex gap-1" role="tablist" aria-label="Location view tabs" onKeyDown={handleTabKeyDown}>
|
||||
{(['map', 'countries', 'regions', 'cities'] as Tab[]).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
role="tab"
|
||||
aria-selected={activeTab === tab}
|
||||
className={`px-3 py-1 text-xs font-medium rounded-lg transition-colors capitalize focus:outline-none focus:ring-2 focus:ring-brand-orange ${
|
||||
className={`px-2.5 py-1 text-xs font-medium transition-colors capitalize focus:outline-none focus:ring-2 focus:ring-brand-orange rounded cursor-pointer border-b-2 ${
|
||||
activeTab === tab
|
||||
? 'bg-white dark:bg-neutral-700 text-neutral-900 dark:text-white shadow-sm'
|
||||
: 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white'
|
||||
? 'border-brand-orange text-neutral-900 dark:text-white'
|
||||
: 'border-transparent text-neutral-400 dark:text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300'
|
||||
}`}
|
||||
>
|
||||
{tab}
|
||||
@@ -247,26 +242,50 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
|
||||
) : (
|
||||
hasData ? (
|
||||
<>
|
||||
{displayedData.map((item, index) => (
|
||||
<div key={index} className="flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors">
|
||||
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
|
||||
{activeTab === 'countries' && <span className="shrink-0">{getFlagComponent(item.country ?? '')}</span>}
|
||||
{activeTab !== 'countries' && <span className="shrink-0">{getFlagComponent(item.country ?? '')}</span>}
|
||||
|
||||
<span className="truncate">
|
||||
{activeTab === 'countries' ? getCountryName(item.country ?? '') :
|
||||
activeTab === 'regions' ? getRegionName(item.region ?? '', item.country ?? '') :
|
||||
getCityName(item.city ?? '')}
|
||||
</span>
|
||||
{displayedData.map((item) => {
|
||||
const dim = TAB_TO_DIMENSION[activeTab]
|
||||
const filterValue = activeTab === 'countries' ? item.country : activeTab === 'regions' ? item.region : item.city
|
||||
const canFilter = onFilter && dim && filterValue
|
||||
return (
|
||||
<div
|
||||
key={`${item.country ?? ''}-${item.region ?? ''}-${item.city ?? ''}`}
|
||||
onClick={() => canFilter && onFilter({ dimension: dim, operator: 'is', values: [filterValue!] })}
|
||||
className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors${canFilter ? ' cursor-pointer' : ''}`}
|
||||
>
|
||||
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
|
||||
<span className="shrink-0">{getFlagComponent(item.country ?? '')}</span>
|
||||
<span className="truncate">
|
||||
{activeTab === 'countries' ? getCountryName(item.country ?? '') :
|
||||
activeTab === 'regions' ? getRegionName(item.region ?? '', item.country ?? '') :
|
||||
getCityName(item.city ?? '')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
|
||||
{totalPageviews > 0 ? `${Math.round((item.pageviews / totalPageviews) * 100)}%` : ''}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
|
||||
{formatNumber(item.pageviews)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-neutral-600 dark:text-neutral-400 ml-4">
|
||||
{formatNumber(item.pageviews)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{Array.from({ length: emptySlots }).map((_, i) => (
|
||||
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
|
||||
))}
|
||||
)
|
||||
})}
|
||||
{showViewAll ? (
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="flex items-center justify-center gap-1.5 h-9 w-full text-xs font-medium text-neutral-400 dark:text-neutral-500 hover:text-brand-orange dark:hover:text-brand-orange transition-colors cursor-pointer rounded-lg px-2 -mx-2"
|
||||
>
|
||||
View all
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
) : (
|
||||
Array.from({ length: emptySlots }).map((_, i) => (
|
||||
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
|
||||
))
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="h-full flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
|
||||
@@ -296,8 +315,8 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
|
||||
<ListSkeleton rows={10} />
|
||||
</div>
|
||||
) : (
|
||||
(fullData.length > 0 ? fullData : data).map((item, index) => (
|
||||
<div key={index} className="flex items-center justify-between py-2 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors">
|
||||
(fullData.length > 0 ? fullData : data).map((item) => (
|
||||
<div key={`${item.country ?? ''}-${item.region ?? ''}-${item.city ?? ''}`} className="flex items-center justify-between py-2 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors">
|
||||
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
|
||||
<span className="shrink-0">{getFlagComponent(item.country ?? '')}</span>
|
||||
<span className="truncate">
|
||||
|
||||
80
components/dashboard/ScrollDepth.tsx
Normal file
80
components/dashboard/ScrollDepth.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { MdMonitor } from 'react-icons/md'
|
||||
import { Modal, GridIcon } from '@ciphera-net/ui'
|
||||
import { ListSkeleton } from '@/components/skeletons'
|
||||
import { getBrowsers, getOS, getDevices, getScreenResolutions } from '@/lib/api/stats'
|
||||
import { type DimensionFilter } from '@/lib/filters'
|
||||
|
||||
interface TechSpecsProps {
|
||||
browsers: Array<{ browser: string; pageviews: number }>
|
||||
@@ -19,13 +20,16 @@ interface TechSpecsProps {
|
||||
collectScreenResolution?: boolean
|
||||
siteId: string
|
||||
dateRange: { start: string, end: string }
|
||||
onFilter?: (filter: DimensionFilter) => void
|
||||
}
|
||||
|
||||
type Tab = 'browsers' | 'os' | 'devices' | 'screens'
|
||||
|
||||
const LIMIT = 7
|
||||
|
||||
export default function TechSpecs({ browsers, os, devices, screenResolutions, collectDeviceInfo = true, collectScreenResolution = true, siteId, dateRange }: TechSpecsProps) {
|
||||
const TAB_TO_DIMENSION: Record<string, string> = { browsers: 'browser', os: 'os', devices: 'device' }
|
||||
|
||||
export default function TechSpecs({ browsers, os, devices, screenResolutions, collectDeviceInfo = true, collectScreenResolution = true, siteId, dateRange, onFilter }: TechSpecsProps) {
|
||||
const [activeTab, setActiveTab] = useState<Tab>('browsers')
|
||||
const handleTabKeyDown = useTabListKeyboard()
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
@@ -108,6 +112,7 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
|
||||
|
||||
const rawData = getRawData()
|
||||
const data = filterUnknown(rawData)
|
||||
const totalPageviews = data.reduce((sum, item) => sum + item.pageviews, 0)
|
||||
const hasData = data && data.length > 0
|
||||
const displayedData = hasData ? data.slice(0, LIMIT) : []
|
||||
const emptySlots = Math.max(0, LIMIT - displayedData.length)
|
||||
@@ -117,30 +122,20 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
|
||||
<>
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
|
||||
Technology
|
||||
</h3>
|
||||
{showViewAll && (
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="text-xs font-medium text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded"
|
||||
>
|
||||
View All
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex p-1 bg-neutral-100 dark:bg-neutral-800 rounded-lg" role="tablist" aria-label="Technology view tabs" onKeyDown={handleTabKeyDown}>
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
|
||||
Technology
|
||||
</h3>
|
||||
<div className="flex gap-1" role="tablist" aria-label="Technology view tabs" onKeyDown={handleTabKeyDown}>
|
||||
{(['browsers', 'os', 'devices', 'screens'] as Tab[]).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
role="tab"
|
||||
aria-selected={activeTab === tab}
|
||||
className={`px-3 py-1 text-xs font-medium rounded-lg transition-colors capitalize focus:outline-none focus:ring-2 focus:ring-brand-orange ${
|
||||
className={`px-2.5 py-1 text-xs font-medium transition-colors capitalize focus:outline-none focus:ring-2 focus:ring-brand-orange rounded cursor-pointer border-b-2 ${
|
||||
activeTab === tab
|
||||
? 'bg-white dark:bg-neutral-700 text-neutral-900 dark:text-white shadow-sm'
|
||||
: 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white'
|
||||
? 'border-brand-orange text-neutral-900 dark:text-white'
|
||||
: 'border-transparent text-neutral-400 dark:text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300'
|
||||
}`}
|
||||
>
|
||||
{tab}
|
||||
@@ -156,20 +151,45 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
|
||||
</div>
|
||||
) : hasData ? (
|
||||
<>
|
||||
{displayedData.map((item, index) => (
|
||||
<div key={index} className="flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors">
|
||||
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
|
||||
{item.icon && <span className="text-lg">{item.icon}</span>}
|
||||
<span className="truncate">{item.name}</span>
|
||||
{displayedData.map((item) => {
|
||||
const dim = TAB_TO_DIMENSION[activeTab]
|
||||
const canFilter = onFilter && dim
|
||||
return (
|
||||
<div
|
||||
key={item.name}
|
||||
onClick={() => canFilter && onFilter({ dimension: dim, operator: 'is', values: [item.name] })}
|
||||
className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors${canFilter ? ' cursor-pointer' : ''}`}
|
||||
>
|
||||
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
|
||||
{item.icon && <span className="text-lg">{item.icon}</span>}
|
||||
<span className="truncate">{item.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
|
||||
{totalPageviews > 0 ? `${Math.round((item.pageviews / totalPageviews) * 100)}%` : ''}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
|
||||
{formatNumber(item.pageviews)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-neutral-600 dark:text-neutral-400 ml-4">
|
||||
{formatNumber(item.pageviews)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{Array.from({ length: emptySlots }).map((_, i) => (
|
||||
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
|
||||
))}
|
||||
)
|
||||
})}
|
||||
{showViewAll ? (
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="flex items-center justify-center gap-1.5 h-9 w-full text-xs font-medium text-neutral-400 dark:text-neutral-500 hover:text-brand-orange dark:hover:text-brand-orange transition-colors cursor-pointer rounded-lg px-2 -mx-2"
|
||||
>
|
||||
View all
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
) : (
|
||||
Array.from({ length: emptySlots }).map((_, i) => (
|
||||
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
|
||||
))
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="h-full flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
|
||||
@@ -198,8 +218,8 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
|
||||
<ListSkeleton rows={10} />
|
||||
</div>
|
||||
) : (
|
||||
(fullData.length > 0 ? fullData : data).map((item, index) => (
|
||||
<div key={index} className="flex items-center justify-between py-2 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors">
|
||||
(fullData.length > 0 ? fullData : data).map((item) => (
|
||||
<div key={item.name} className="flex items-center justify-between py-2 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors">
|
||||
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
|
||||
{item.icon && <span className="text-lg">{item.icon}</span>}
|
||||
<span className="truncate">{item.name === 'Unknown' ? 'Unknown' : item.name}</span>
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { formatNumber } from '@ciphera-net/ui'
|
||||
import { LayoutDashboardIcon } from '@ciphera-net/ui'
|
||||
|
||||
interface TopPagesProps {
|
||||
pages: Array<{ path: string; pageviews: number }>
|
||||
}
|
||||
|
||||
export default function TopPages({ pages }: TopPagesProps) {
|
||||
if (!pages || pages.length === 0) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 flex flex-col">
|
||||
<h3 className="text-lg font-semibold mb-4 text-neutral-900 dark:text-white">
|
||||
Top Pages
|
||||
</h3>
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
|
||||
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
|
||||
<LayoutDashboardIcon className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
|
||||
</div>
|
||||
<h4 className="font-semibold text-neutral-900 dark:text-white">
|
||||
No page data yet
|
||||
</h4>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs">
|
||||
Your most visited pages will appear here as traffic arrives.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
|
||||
<h3 className="text-lg font-semibold mb-4 text-neutral-900 dark:text-white">
|
||||
Top Pages
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{pages.map((page, index) => (
|
||||
<div key={index} className="flex items-center justify-between">
|
||||
<div className="flex-1 truncate text-neutral-900 dark:text-white">
|
||||
{page.path}
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-neutral-600 dark:text-neutral-400 ml-4">
|
||||
{formatNumber(page.pageviews)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -8,17 +8,19 @@ import { getReferrerDisplayName, getReferrerFavicon, getReferrerIcon, mergeRefer
|
||||
import { Modal, GlobeIcon } from '@ciphera-net/ui'
|
||||
import { ListSkeleton } from '@/components/skeletons'
|
||||
import { getTopReferrers, TopReferrer } from '@/lib/api/stats'
|
||||
import { type DimensionFilter } from '@/lib/filters'
|
||||
|
||||
interface TopReferrersProps {
|
||||
referrers: Array<{ referrer: string; pageviews: number }>
|
||||
collectReferrers?: boolean
|
||||
siteId: string
|
||||
dateRange: { start: string, end: string }
|
||||
onFilter?: (filter: DimensionFilter) => void
|
||||
}
|
||||
|
||||
const LIMIT = 7
|
||||
|
||||
export default function TopReferrers({ referrers, collectReferrers = true, siteId, dateRange }: TopReferrersProps) {
|
||||
export default function TopReferrers({ referrers, collectReferrers = true, siteId, dateRange, onFilter }: TopReferrersProps) {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
const [fullData, setFullData] = useState<TopReferrer[]>([])
|
||||
const [isLoadingFull, setIsLoadingFull] = useState(false)
|
||||
@@ -31,6 +33,7 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
|
||||
|
||||
const mergedReferrers = mergeReferrersByDisplayName(filteredReferrers)
|
||||
|
||||
const totalPageviews = mergedReferrers.reduce((sum, r) => sum + r.pageviews, 0)
|
||||
const hasData = mergedReferrers.length > 0
|
||||
const displayedReferrers = hasData ? mergedReferrers.slice(0, LIMIT) : []
|
||||
const emptySlots = Math.max(0, LIMIT - displayedReferrers.length)
|
||||
@@ -83,16 +86,8 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
|
||||
Top Referrers
|
||||
Referrers
|
||||
</h3>
|
||||
{showViewAll && (
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="text-xs font-medium text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded"
|
||||
>
|
||||
View All
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 flex-1 min-h-[270px]">
|
||||
@@ -102,20 +97,41 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
|
||||
</div>
|
||||
) : hasData ? (
|
||||
<>
|
||||
{displayedReferrers.map((ref, index) => (
|
||||
<div key={index} className="flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors">
|
||||
{displayedReferrers.map((ref) => (
|
||||
<div
|
||||
key={ref.referrer}
|
||||
onClick={() => onFilter?.({ dimension: 'referrer', operator: 'is', values: [ref.referrer] })}
|
||||
className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors${onFilter ? ' cursor-pointer' : ''}`}
|
||||
>
|
||||
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
|
||||
{renderReferrerIcon(ref.referrer)}
|
||||
<span className="truncate" title={ref.referrer}>{getReferrerDisplayName(ref.referrer)}</span>
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-neutral-600 dark:text-neutral-400 ml-4">
|
||||
{formatNumber(ref.pageviews)}
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
|
||||
{totalPageviews > 0 ? `${Math.round((ref.pageviews / totalPageviews) * 100)}%` : ''}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
|
||||
{formatNumber(ref.pageviews)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{Array.from({ length: emptySlots }).map((_, i) => (
|
||||
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
|
||||
))}
|
||||
{showViewAll ? (
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="flex items-center justify-center gap-1.5 h-9 w-full text-xs font-medium text-neutral-400 dark:text-neutral-500 hover:text-brand-orange dark:hover:text-brand-orange transition-colors cursor-pointer rounded-lg px-2 -mx-2"
|
||||
>
|
||||
View all
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
) : (
|
||||
Array.from({ length: emptySlots }).map((_, i) => (
|
||||
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
|
||||
))
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="h-full flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
|
||||
@@ -136,7 +152,7 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
|
||||
<Modal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
title="Top Referrers"
|
||||
title="Referrers"
|
||||
>
|
||||
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-2">
|
||||
{isLoadingFull ? (
|
||||
@@ -144,8 +160,8 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
|
||||
<ListSkeleton rows={10} />
|
||||
</div>
|
||||
) : (
|
||||
mergeReferrersByDisplayName(fullData.length > 0 ? fullData : filteredReferrers).map((ref, index) => (
|
||||
<div key={index} className="flex items-center justify-between py-2 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors">
|
||||
mergeReferrersByDisplayName(fullData.length > 0 ? fullData : filteredReferrers).map((ref) => (
|
||||
<div key={ref.referrer} className="flex items-center justify-between py-2 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors">
|
||||
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
|
||||
{renderReferrerIcon(ref.referrer)}
|
||||
<span className="truncate" title={ref.referrer}>{getReferrerDisplayName(ref.referrer)}</span>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
124
components/settings/SettingsModalWrapper.tsx
Normal file
124
components/settings/SettingsModalWrapper.tsx
Normal 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} />
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { API_URL } from './client'
|
||||
import apiRequest from './client'
|
||||
|
||||
export interface TaxID {
|
||||
type: string
|
||||
@@ -31,39 +31,12 @@ export interface SubscriptionDetails {
|
||||
next_invoice_period_end?: number
|
||||
}
|
||||
|
||||
async function billingFetch<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||
const url = `${API_URL}${endpoint}`
|
||||
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
credentials: 'include', // Send cookies
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.json().catch(() => ({
|
||||
error: 'Unknown error',
|
||||
message: `HTTP ${response.status}: ${response.statusText}`,
|
||||
}))
|
||||
throw new Error(errorBody.message || errorBody.error || 'Request failed')
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export async function getSubscription(): Promise<SubscriptionDetails> {
|
||||
return await billingFetch<SubscriptionDetails>('/api/billing/subscription', {
|
||||
method: 'GET',
|
||||
})
|
||||
return apiRequest<SubscriptionDetails>('/api/billing/subscription')
|
||||
}
|
||||
|
||||
export async function createPortalSession(): Promise<{ url: string }> {
|
||||
return await billingFetch<{ url: string }>('/api/billing/portal', {
|
||||
return apiRequest<{ url: string }>('/api/billing/portal', {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
@@ -74,7 +47,7 @@ export interface CancelSubscriptionParams {
|
||||
}
|
||||
|
||||
export async function cancelSubscription(params?: CancelSubscriptionParams): Promise<{ ok: boolean; at_period_end: boolean }> {
|
||||
return await billingFetch<{ ok: boolean; at_period_end: boolean }>('/api/billing/cancel', {
|
||||
return apiRequest<{ ok: boolean; at_period_end: boolean }>('/api/billing/cancel', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ at_period_end: params?.at_period_end ?? true }),
|
||||
})
|
||||
@@ -82,7 +55,7 @@ export async function cancelSubscription(params?: CancelSubscriptionParams): Pro
|
||||
|
||||
/** Clears cancel_at_period_end so the subscription continues past the current period. */
|
||||
export async function resumeSubscription(): Promise<{ ok: boolean }> {
|
||||
return await billingFetch<{ ok: boolean }>('/api/billing/resume', {
|
||||
return apiRequest<{ ok: boolean }>('/api/billing/resume', {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
@@ -100,7 +73,7 @@ export interface PreviewInvoiceResult {
|
||||
}
|
||||
|
||||
export async function previewInvoice(params: ChangePlanParams): Promise<PreviewInvoiceResult | null> {
|
||||
const res = await billingFetch<PreviewInvoiceResult | Record<string, never>>('/api/billing/preview-invoice', {
|
||||
const res = await apiRequest<PreviewInvoiceResult | Record<string, never>>('/api/billing/preview-invoice', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(params),
|
||||
})
|
||||
@@ -111,7 +84,7 @@ export async function previewInvoice(params: ChangePlanParams): Promise<PreviewI
|
||||
}
|
||||
|
||||
export async function changePlan(params: ChangePlanParams): Promise<{ ok: boolean }> {
|
||||
return await billingFetch<{ ok: boolean }>('/api/billing/change-plan', {
|
||||
return apiRequest<{ ok: boolean }>('/api/billing/change-plan', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(params),
|
||||
})
|
||||
@@ -124,7 +97,7 @@ export interface CreateCheckoutParams {
|
||||
}
|
||||
|
||||
export async function createCheckoutSession(params: CreateCheckoutParams): Promise<{ url: string }> {
|
||||
return await billingFetch<{ url: string }>('/api/billing/checkout', {
|
||||
return apiRequest<{ url: string }>('/api/billing/checkout', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(params),
|
||||
})
|
||||
@@ -142,7 +115,5 @@ export interface Invoice {
|
||||
}
|
||||
|
||||
export async function getInvoices(): Promise<Invoice[]> {
|
||||
return await billingFetch<Invoice[]>('/api/billing/invoices', {
|
||||
method: 'GET',
|
||||
})
|
||||
return apiRequest<Invoice[]>('/api/billing/invoices')
|
||||
}
|
||||
|
||||
@@ -64,27 +64,10 @@ export async function deleteFunnel(siteId: string, funnelId: string): Promise<vo
|
||||
})
|
||||
}
|
||||
|
||||
const DATE_ONLY_REGEX = /^\d{4}-\d{2}-\d{2}$/
|
||||
|
||||
/** Normalize date-only (YYYY-MM-DD) to RFC3339 for backend funnel stats API. Uses UTC for boundaries (API/server timestamps are UTC). */
|
||||
function toRFC3339Range(from: string, to: string): { from: string; to: string } {
|
||||
return {
|
||||
from: DATE_ONLY_REGEX.test(from) ? `${from}T00:00:00.000Z` : from,
|
||||
to: DATE_ONLY_REGEX.test(to) ? `${to}T23:59:59.999Z` : to,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getFunnelStats(siteId: string, funnelId: string, from?: string, to?: string): Promise<FunnelStats> {
|
||||
export async function getFunnelStats(siteId: string, funnelId: string, startDate?: string, endDate?: string): Promise<FunnelStats> {
|
||||
const params = new URLSearchParams()
|
||||
if (from && to) {
|
||||
const { from: fromRfc, to: toRfc } = toRFC3339Range(from, to)
|
||||
params.append('from', fromRfc)
|
||||
params.append('to', toRfc)
|
||||
} else if (from) {
|
||||
params.append('from', DATE_ONLY_REGEX.test(from) ? `${from}T00:00:00.000Z` : from)
|
||||
} else if (to) {
|
||||
params.append('to', DATE_ONLY_REGEX.test(to) ? `${to}T23:59:59.999Z` : to)
|
||||
}
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
const queryString = params.toString() ? `?${params.toString()}` : ''
|
||||
return apiRequest<FunnelStats>(`/sites/${siteId}/funnels/${funnelId}/stats${queryString}`)
|
||||
}
|
||||
|
||||
565
lib/api/stats.ts
565
lib/api/stats.ts
@@ -1,6 +1,8 @@
|
||||
import apiRequest from './client'
|
||||
import { Site } from './sites'
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface Stats {
|
||||
pageviews: number
|
||||
visitors: number
|
||||
@@ -11,7 +13,7 @@ export interface Stats {
|
||||
export interface TopPage {
|
||||
path: string
|
||||
pageviews: number
|
||||
visits?: number // For entry/exit pages
|
||||
visits?: number
|
||||
}
|
||||
|
||||
export interface ScreenResolutionStat {
|
||||
@@ -101,6 +103,8 @@ export interface AuthParams {
|
||||
captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string }
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────
|
||||
|
||||
function appendAuthParams(params: URLSearchParams, auth?: AuthParams) {
|
||||
if (auth?.password) params.append('password', auth.password)
|
||||
if (auth?.captcha?.captcha_id) params.append('captcha_id', auth.captcha.captcha_id)
|
||||
@@ -108,198 +112,117 @@ function appendAuthParams(params: URLSearchParams, auth?: AuthParams) {
|
||||
if (auth?.captcha?.captcha_token) params.append('captcha_token', auth.captcha.captcha_token)
|
||||
}
|
||||
|
||||
export async function getStats(siteId: string, startDate?: string, endDate?: string): Promise<Stats> {
|
||||
function buildQuery(
|
||||
opts: {
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
limit?: number
|
||||
interval?: string
|
||||
countryLimit?: number
|
||||
sort?: string
|
||||
filters?: string
|
||||
},
|
||||
auth?: AuthParams
|
||||
): string {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
if (opts.startDate) params.append('start_date', opts.startDate)
|
||||
if (opts.endDate) params.append('end_date', opts.endDate)
|
||||
if (opts.limit != null) params.append('limit', opts.limit.toString())
|
||||
if (opts.interval) params.append('interval', opts.interval)
|
||||
if (opts.countryLimit != null) params.append('country_limit', opts.countryLimit.toString())
|
||||
if (opts.sort) params.append('sort', opts.sort)
|
||||
if (opts.filters) params.append('filters', opts.filters)
|
||||
if (auth) appendAuthParams(params, auth)
|
||||
const query = params.toString()
|
||||
return apiRequest<Stats>(`/sites/${siteId}/stats${query ? `?${query}` : ''}`)
|
||||
return query ? `?${query}` : ''
|
||||
}
|
||||
|
||||
export async function getPublicStats(siteId: string, startDate?: string, endDate?: string, auth?: AuthParams): Promise<Stats> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
appendAuthParams(params, auth)
|
||||
const query = params.toString()
|
||||
return apiRequest<Stats>(`/public/sites/${siteId}/stats${query ? `?${query}` : ''}`)
|
||||
/** Factory for endpoints that return an array nested under a response key. */
|
||||
function createListFetcher<T>(path: string, field: string, defaultLimit = 10) {
|
||||
return (siteId: string, startDate?: string, endDate?: string, limit = defaultLimit, filters?: string): Promise<T[]> =>
|
||||
apiRequest<Record<string, T[]>>(`/sites/${siteId}/${path}${buildQuery({ startDate, endDate, limit, filters })}`)
|
||||
.then(r => r?.[field] || [])
|
||||
}
|
||||
|
||||
export async function getRealtime(siteId: string): Promise<RealtimeStats> {
|
||||
// ─── List Endpoints ─────────────────────────────────────────────────
|
||||
|
||||
export const getTopPages = createListFetcher<TopPage>('pages', 'pages')
|
||||
export const getTopReferrers = createListFetcher<TopReferrer>('referrers', 'referrers')
|
||||
export const getCountries = createListFetcher<CountryStat>('countries', 'countries')
|
||||
export const getCities = createListFetcher<CityStat>('cities', 'cities')
|
||||
export const getRegions = createListFetcher<RegionStat>('regions', 'regions')
|
||||
export const getBrowsers = createListFetcher<BrowserStat>('browsers', 'browsers')
|
||||
export const getOS = createListFetcher<OSStat>('os', 'os')
|
||||
export const getDevices = createListFetcher<DeviceStat>('devices', 'devices')
|
||||
export const getEntryPages = createListFetcher<TopPage>('entry-pages', 'pages')
|
||||
export const getExitPages = createListFetcher<TopPage>('exit-pages', 'pages')
|
||||
export const getScreenResolutions = createListFetcher<ScreenResolutionStat>('screen-resolutions', 'screen_resolutions')
|
||||
export const getGoalStats = createListFetcher<GoalCountStat>('goals/stats', 'goal_counts', 20)
|
||||
export const getCampaigns = createListFetcher<CampaignStat>('campaigns', 'campaigns')
|
||||
|
||||
// ─── Stats & Realtime ───────────────────────────────────────────────
|
||||
|
||||
export function getStats(siteId: string, startDate?: string, endDate?: string, filters?: string): Promise<Stats> {
|
||||
return apiRequest<Stats>(`/sites/${siteId}/stats${buildQuery({ startDate, endDate, filters })}`)
|
||||
}
|
||||
|
||||
export function getPublicStats(siteId: string, startDate?: string, endDate?: string, auth?: AuthParams): Promise<Stats> {
|
||||
return apiRequest<Stats>(`/public/sites/${siteId}/stats${buildQuery({ startDate, endDate }, auth)}`)
|
||||
}
|
||||
|
||||
export function getRealtime(siteId: string): Promise<RealtimeStats> {
|
||||
return apiRequest<RealtimeStats>(`/sites/${siteId}/realtime`)
|
||||
}
|
||||
|
||||
export async function getPublicRealtime(siteId: string, auth?: AuthParams): Promise<RealtimeStats> {
|
||||
const params = new URLSearchParams()
|
||||
appendAuthParams(params, auth)
|
||||
return apiRequest<RealtimeStats>(`/public/sites/${siteId}/realtime?${params.toString()}`)
|
||||
export function getPublicRealtime(siteId: string, auth?: AuthParams): Promise<RealtimeStats> {
|
||||
return apiRequest<RealtimeStats>(`/public/sites/${siteId}/realtime${buildQuery({}, auth)}`)
|
||||
}
|
||||
|
||||
export async function getTopPages(siteId: string, startDate?: string, endDate?: string, limit = 10): Promise<TopPage[]> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
params.append('limit', limit.toString())
|
||||
return apiRequest<{ pages: TopPage[] }>(`/sites/${siteId}/pages?${params.toString()}`).then(r => r?.pages || [])
|
||||
// ─── Daily Stats ────────────────────────────────────────────────────
|
||||
|
||||
export function getDailyStats(siteId: string, startDate?: string, endDate?: string, interval?: string, filters?: string): Promise<DailyStat[]> {
|
||||
return apiRequest<{ stats: DailyStat[] }>(`/sites/${siteId}/daily${buildQuery({ startDate, endDate, interval, filters })}`)
|
||||
.then(r => r?.stats || [])
|
||||
}
|
||||
|
||||
export async function getTopReferrers(siteId: string, startDate?: string, endDate?: string, limit = 10): Promise<TopReferrer[]> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
params.append('limit', limit.toString())
|
||||
return apiRequest<{ referrers: TopReferrer[] }>(`/sites/${siteId}/referrers?${params.toString()}`).then(r => r?.referrers || [])
|
||||
export function getPublicDailyStats(siteId: string, startDate?: string, endDate?: string, interval?: string, auth?: AuthParams): Promise<DailyStat[]> {
|
||||
return apiRequest<{ stats: DailyStat[] }>(`/public/sites/${siteId}/daily${buildQuery({ startDate, endDate, interval }, auth)}`)
|
||||
.then(r => r?.stats || [])
|
||||
}
|
||||
|
||||
export async function getCountries(siteId: string, startDate?: string, endDate?: string, limit = 10): Promise<CountryStat[]> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
params.append('limit', limit.toString())
|
||||
return apiRequest<{ countries: CountryStat[] }>(`/sites/${siteId}/countries?${params.toString()}`).then(r => r?.countries || [])
|
||||
// ─── Public Campaigns ───────────────────────────────────────────────
|
||||
|
||||
export function getPublicCampaigns(siteId: string, startDate?: string, endDate?: string, limit = 10, auth?: AuthParams): Promise<CampaignStat[]> {
|
||||
return apiRequest<{ campaigns: CampaignStat[] }>(`/public/sites/${siteId}/campaigns${buildQuery({ startDate, endDate, limit }, auth)}`)
|
||||
.then(r => r?.campaigns || [])
|
||||
}
|
||||
|
||||
export async function getCities(siteId: string, startDate?: string, endDate?: string, limit = 10): Promise<CityStat[]> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
params.append('limit', limit.toString())
|
||||
return apiRequest<{ cities: CityStat[] }>(`/sites/${siteId}/cities?${params.toString()}`).then(r => r?.cities || [])
|
||||
}
|
||||
// ─── Performance By Page ────────────────────────────────────────────
|
||||
|
||||
export async function getRegions(siteId: string, startDate?: string, endDate?: string, limit = 10): Promise<RegionStat[]> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
params.append('limit', limit.toString())
|
||||
return apiRequest<{ regions: RegionStat[] }>(`/sites/${siteId}/regions?${params.toString()}`).then(r => r?.regions || [])
|
||||
}
|
||||
|
||||
export async function getBrowsers(siteId: string, startDate?: string, endDate?: string, limit = 10): Promise<BrowserStat[]> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
params.append('limit', limit.toString())
|
||||
return apiRequest<{ browsers: BrowserStat[] }>(`/sites/${siteId}/browsers?${params.toString()}`).then(r => r?.browsers || [])
|
||||
}
|
||||
|
||||
export async function getOS(siteId: string, startDate?: string, endDate?: string, limit = 10): Promise<OSStat[]> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
params.append('limit', limit.toString())
|
||||
return apiRequest<{ os: OSStat[] }>(`/sites/${siteId}/os?${params.toString()}`).then(r => r?.os || [])
|
||||
}
|
||||
|
||||
export async function getDevices(siteId: string, startDate?: string, endDate?: string, limit = 10): Promise<DeviceStat[]> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
params.append('limit', limit.toString())
|
||||
return apiRequest<{ devices: DeviceStat[] }>(`/sites/${siteId}/devices?${params.toString()}`).then(r => r?.devices || [])
|
||||
}
|
||||
|
||||
export async function getDailyStats(siteId: string, startDate?: string, endDate?: string, interval?: string): Promise<DailyStat[]> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
if (interval) params.append('interval', interval)
|
||||
return apiRequest<{ stats: DailyStat[] }>(`/sites/${siteId}/daily?${params.toString()}`).then(r => r?.stats || [])
|
||||
}
|
||||
|
||||
export async function getPublicDailyStats(siteId: string, startDate?: string, endDate?: string, interval?: string, auth?: AuthParams): Promise<DailyStat[]> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
if (interval) params.append('interval', interval)
|
||||
appendAuthParams(params, auth)
|
||||
return apiRequest<{ stats: DailyStat[] }>(`/public/sites/${siteId}/daily?${params.toString()}`).then(r => r?.stats || [])
|
||||
}
|
||||
|
||||
export async function getEntryPages(siteId: string, startDate?: string, endDate?: string, limit = 10): Promise<TopPage[]> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
params.append('limit', limit.toString())
|
||||
return apiRequest<{ pages: TopPage[] }>(`/sites/${siteId}/entry-pages?${params.toString()}`).then(r => r?.pages || [])
|
||||
}
|
||||
|
||||
export async function getExitPages(siteId: string, startDate?: string, endDate?: string, limit = 10): Promise<TopPage[]> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
params.append('limit', limit.toString())
|
||||
return apiRequest<{ pages: TopPage[] }>(`/sites/${siteId}/exit-pages?${params.toString()}`).then(r => r?.pages || [])
|
||||
}
|
||||
|
||||
export async function getScreenResolutions(siteId: string, startDate?: string, endDate?: string, limit = 10): Promise<ScreenResolutionStat[]> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
params.append('limit', limit.toString())
|
||||
return apiRequest<{ screen_resolutions: ScreenResolutionStat[] }>(`/sites/${siteId}/screen-resolutions?${params.toString()}`).then(r => r?.screen_resolutions || [])
|
||||
}
|
||||
|
||||
export async function getPerformanceByPage(
|
||||
export function getPerformanceByPage(
|
||||
siteId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
opts?: { limit?: number; sort?: 'lcp' | 'cls' | 'inp' }
|
||||
): Promise<PerformanceByPageStat[]> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
if (opts?.limit != null) params.append('limit', String(opts.limit))
|
||||
if (opts?.sort) params.append('sort', opts.sort)
|
||||
const res = await apiRequest<{ performance_by_page: PerformanceByPageStat[] }>(
|
||||
`/sites/${siteId}/performance-by-page?${params.toString()}`
|
||||
)
|
||||
return res?.performance_by_page ?? []
|
||||
return apiRequest<{ performance_by_page: PerformanceByPageStat[] }>(
|
||||
`/sites/${siteId}/performance-by-page${buildQuery({ startDate, endDate, limit: opts?.limit, sort: opts?.sort })}`
|
||||
).then(r => r?.performance_by_page ?? [])
|
||||
}
|
||||
|
||||
export async function getPublicPerformanceByPage(
|
||||
export function getPublicPerformanceByPage(
|
||||
siteId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
opts?: { limit?: number; sort?: 'lcp' | 'cls' | 'inp' },
|
||||
auth?: AuthParams
|
||||
): Promise<PerformanceByPageStat[]> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
if (opts?.limit != null) params.append('limit', String(opts.limit))
|
||||
if (opts?.sort) params.append('sort', opts.sort)
|
||||
appendAuthParams(params, auth)
|
||||
const res = await apiRequest<{ performance_by_page: PerformanceByPageStat[] }>(
|
||||
`/public/sites/${siteId}/performance-by-page?${params.toString()}`
|
||||
)
|
||||
return res?.performance_by_page ?? []
|
||||
return apiRequest<{ performance_by_page: PerformanceByPageStat[] }>(
|
||||
`/public/sites/${siteId}/performance-by-page${buildQuery({ startDate, endDate, limit: opts?.limit, sort: opts?.sort }, auth)}`
|
||||
).then(r => r?.performance_by_page ?? [])
|
||||
}
|
||||
|
||||
export async function getGoalStats(siteId: string, startDate?: string, endDate?: string, limit = 20): Promise<GoalCountStat[]> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
params.append('limit', limit.toString())
|
||||
return apiRequest<{ goal_counts: GoalCountStat[] }>(`/sites/${siteId}/goals/stats?${params.toString()}`).then(r => r?.goal_counts || [])
|
||||
}
|
||||
|
||||
export async function getCampaigns(siteId: string, startDate?: string, endDate?: string, limit = 10): Promise<CampaignStat[]> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
params.append('limit', limit.toString())
|
||||
return apiRequest<{ campaigns: CampaignStat[] }>(`/sites/${siteId}/campaigns?${params.toString()}`).then(r => r?.campaigns || [])
|
||||
}
|
||||
|
||||
export async function getPublicCampaigns(siteId: string, startDate?: string, endDate?: string, limit = 10, auth?: AuthParams): Promise<CampaignStat[]> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
params.append('limit', limit.toString())
|
||||
appendAuthParams(params, auth)
|
||||
return apiRequest<{ campaigns: CampaignStat[] }>(`/public/sites/${siteId}/campaigns?${params.toString()}`).then(r => r?.campaigns || [])
|
||||
}
|
||||
// ─── Full Dashboard ─────────────────────────────────────────────────
|
||||
|
||||
export interface DashboardData {
|
||||
site: Site
|
||||
@@ -322,16 +245,11 @@ export interface DashboardData {
|
||||
goal_counts?: GoalCountStat[]
|
||||
}
|
||||
|
||||
export async function getDashboard(siteId: string, startDate?: string, endDate?: string, limit = 10, interval?: string): Promise<DashboardData> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
if (interval) params.append('interval', interval)
|
||||
params.append('limit', limit.toString())
|
||||
return apiRequest<DashboardData>(`/sites/${siteId}/dashboard?${params.toString()}`)
|
||||
export function getDashboard(siteId: string, startDate?: string, endDate?: string, limit = 10, interval?: string): Promise<DashboardData> {
|
||||
return apiRequest<DashboardData>(`/sites/${siteId}/dashboard${buildQuery({ startDate, endDate, limit, interval })}`)
|
||||
}
|
||||
|
||||
export async function getPublicDashboard(
|
||||
export function getPublicDashboard(
|
||||
siteId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
@@ -340,21 +258,12 @@ export async function getPublicDashboard(
|
||||
password?: string,
|
||||
captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string }
|
||||
): Promise<DashboardData> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
if (interval) params.append('interval', interval)
|
||||
|
||||
appendAuthParams(params, { password, captcha })
|
||||
|
||||
params.append('limit', limit.toString())
|
||||
return apiRequest<DashboardData>(`/public/sites/${siteId}/dashboard?${params.toString()}`)
|
||||
return apiRequest<DashboardData>(
|
||||
`/public/sites/${siteId}/dashboard${buildQuery({ startDate, endDate, limit, interval }, { password, captcha })}`
|
||||
)
|
||||
}
|
||||
|
||||
// * ============================================================================
|
||||
// * Focused Dashboard Endpoints (Fix 4.2: Efficient Data Transfer)
|
||||
// * These split the massive dashboard payload into smaller, focused chunks
|
||||
// * ============================================================================
|
||||
// ─── Focused Dashboard Endpoints ────────────────────────────────────
|
||||
|
||||
export interface DashboardOverviewData {
|
||||
site: Site
|
||||
@@ -363,109 +272,18 @@ export interface DashboardOverviewData {
|
||||
daily_stats: DailyStat[]
|
||||
}
|
||||
|
||||
export async function getDashboardOverview(
|
||||
siteId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
interval?: string
|
||||
): Promise<DashboardOverviewData> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
if (interval) params.append('interval', interval)
|
||||
return apiRequest<DashboardOverviewData>(`/sites/${siteId}/dashboard/overview?${params.toString()}`)
|
||||
}
|
||||
|
||||
export async function getPublicDashboardOverview(
|
||||
siteId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
interval?: string,
|
||||
password?: string,
|
||||
captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string }
|
||||
): Promise<DashboardOverviewData> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
if (interval) params.append('interval', interval)
|
||||
appendAuthParams(params, { password, captcha })
|
||||
return apiRequest<DashboardOverviewData>(`/public/sites/${siteId}/dashboard/overview?${params.toString()}`)
|
||||
}
|
||||
|
||||
export interface DashboardPagesData {
|
||||
top_pages: TopPage[]
|
||||
entry_pages: TopPage[]
|
||||
exit_pages: TopPage[]
|
||||
}
|
||||
|
||||
export async function getDashboardPages(
|
||||
siteId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
limit = 10
|
||||
): Promise<DashboardPagesData> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
params.append('limit', limit.toString())
|
||||
return apiRequest<DashboardPagesData>(`/sites/${siteId}/dashboard/pages?${params.toString()}`)
|
||||
}
|
||||
|
||||
export async function getPublicDashboardPages(
|
||||
siteId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
limit = 10,
|
||||
password?: string,
|
||||
captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string }
|
||||
): Promise<DashboardPagesData> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
params.append('limit', limit.toString())
|
||||
appendAuthParams(params, { password, captcha })
|
||||
return apiRequest<DashboardPagesData>(`/public/sites/${siteId}/dashboard/pages?${params.toString()}`)
|
||||
}
|
||||
|
||||
export interface DashboardLocationsData {
|
||||
countries: CountryStat[]
|
||||
cities: CityStat[]
|
||||
regions: RegionStat[]
|
||||
}
|
||||
|
||||
export async function getDashboardLocations(
|
||||
siteId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
limit = 10,
|
||||
countryLimit = 250
|
||||
): Promise<DashboardLocationsData> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
params.append('limit', limit.toString())
|
||||
params.append('country_limit', countryLimit.toString())
|
||||
return apiRequest<DashboardLocationsData>(`/sites/${siteId}/dashboard/locations?${params.toString()}`)
|
||||
}
|
||||
|
||||
export async function getPublicDashboardLocations(
|
||||
siteId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
limit = 10,
|
||||
countryLimit = 250,
|
||||
password?: string,
|
||||
captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string }
|
||||
): Promise<DashboardLocationsData> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
params.append('limit', limit.toString())
|
||||
params.append('country_limit', countryLimit.toString())
|
||||
appendAuthParams(params, { password, captcha })
|
||||
return apiRequest<DashboardLocationsData>(`/public/sites/${siteId}/dashboard/locations?${params.toString()}`)
|
||||
}
|
||||
|
||||
export interface DashboardDevicesData {
|
||||
browsers: BrowserStat[]
|
||||
os: OSStat[]
|
||||
@@ -473,127 +291,114 @@ export interface DashboardDevicesData {
|
||||
screen_resolutions: ScreenResolutionStat[]
|
||||
}
|
||||
|
||||
export async function getDashboardDevices(
|
||||
siteId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
limit = 10
|
||||
): Promise<DashboardDevicesData> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
params.append('limit', limit.toString())
|
||||
return apiRequest<DashboardDevicesData>(`/sites/${siteId}/dashboard/devices?${params.toString()}`)
|
||||
}
|
||||
|
||||
export async function getPublicDashboardDevices(
|
||||
siteId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
limit = 10,
|
||||
password?: string,
|
||||
captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string }
|
||||
): Promise<DashboardDevicesData> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
params.append('limit', limit.toString())
|
||||
appendAuthParams(params, { password, captcha })
|
||||
return apiRequest<DashboardDevicesData>(`/public/sites/${siteId}/dashboard/devices?${params.toString()}`)
|
||||
}
|
||||
|
||||
export interface DashboardReferrersData {
|
||||
top_referrers: TopReferrer[]
|
||||
}
|
||||
|
||||
export async function getDashboardReferrers(
|
||||
siteId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
limit = 10
|
||||
): Promise<DashboardReferrersData> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
params.append('limit', limit.toString())
|
||||
return apiRequest<DashboardReferrersData>(`/sites/${siteId}/dashboard/referrers?${params.toString()}`)
|
||||
}
|
||||
|
||||
export async function getPublicDashboardReferrers(
|
||||
siteId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
limit = 10,
|
||||
password?: string,
|
||||
captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string }
|
||||
): Promise<DashboardReferrersData> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
params.append('limit', limit.toString())
|
||||
appendAuthParams(params, { password, captcha })
|
||||
return apiRequest<DashboardReferrersData>(`/public/sites/${siteId}/dashboard/referrers?${params.toString()}`)
|
||||
}
|
||||
|
||||
export interface DashboardPerformanceData {
|
||||
performance?: PerformanceStats
|
||||
performance_by_page?: PerformanceByPageStat[]
|
||||
}
|
||||
|
||||
export async function getDashboardPerformance(
|
||||
siteId: string,
|
||||
startDate?: string,
|
||||
endDate?: string
|
||||
): Promise<DashboardPerformanceData> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
return apiRequest<DashboardPerformanceData>(`/sites/${siteId}/dashboard/performance?${params.toString()}`)
|
||||
}
|
||||
|
||||
export async function getPublicDashboardPerformance(
|
||||
siteId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
password?: string,
|
||||
captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string }
|
||||
): Promise<DashboardPerformanceData> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
appendAuthParams(params, { password, captcha })
|
||||
return apiRequest<DashboardPerformanceData>(`/public/sites/${siteId}/dashboard/performance?${params.toString()}`)
|
||||
}
|
||||
|
||||
export interface DashboardGoalsData {
|
||||
goal_counts: GoalCountStat[]
|
||||
}
|
||||
|
||||
export async function getDashboardGoals(
|
||||
siteId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
limit = 10
|
||||
): Promise<DashboardGoalsData> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
params.append('limit', limit.toString())
|
||||
return apiRequest<DashboardGoalsData>(`/sites/${siteId}/dashboard/goals?${params.toString()}`)
|
||||
export function getDashboardOverview(siteId: string, startDate?: string, endDate?: string, interval?: string, filters?: string): Promise<DashboardOverviewData> {
|
||||
return apiRequest<DashboardOverviewData>(`/sites/${siteId}/dashboard/overview${buildQuery({ startDate, endDate, interval, filters })}`)
|
||||
}
|
||||
|
||||
export async function getPublicDashboardGoals(
|
||||
siteId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
limit = 10,
|
||||
password?: string,
|
||||
captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string }
|
||||
): Promise<DashboardGoalsData> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
params.append('limit', limit.toString())
|
||||
appendAuthParams(params, { password, captcha })
|
||||
return apiRequest<DashboardGoalsData>(`/public/sites/${siteId}/dashboard/goals?${params.toString()}`)
|
||||
export function getPublicDashboardOverview(
|
||||
siteId: string, startDate?: string, endDate?: string, interval?: string,
|
||||
password?: string, captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string }
|
||||
): Promise<DashboardOverviewData> {
|
||||
return apiRequest<DashboardOverviewData>(`/public/sites/${siteId}/dashboard/overview${buildQuery({ startDate, endDate, interval }, { password, captcha })}`)
|
||||
}
|
||||
|
||||
export function getDashboardPages(siteId: string, startDate?: string, endDate?: string, limit = 10, filters?: string): Promise<DashboardPagesData> {
|
||||
return apiRequest<DashboardPagesData>(`/sites/${siteId}/dashboard/pages${buildQuery({ startDate, endDate, limit, filters })}`)
|
||||
}
|
||||
|
||||
export function getPublicDashboardPages(
|
||||
siteId: string, startDate?: string, endDate?: string, limit = 10,
|
||||
password?: string, captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string }
|
||||
): Promise<DashboardPagesData> {
|
||||
return apiRequest<DashboardPagesData>(`/public/sites/${siteId}/dashboard/pages${buildQuery({ startDate, endDate, limit }, { password, captcha })}`)
|
||||
}
|
||||
|
||||
export function getDashboardLocations(siteId: string, startDate?: string, endDate?: string, limit = 10, countryLimit = 250, filters?: string): Promise<DashboardLocationsData> {
|
||||
return apiRequest<DashboardLocationsData>(`/sites/${siteId}/dashboard/locations${buildQuery({ startDate, endDate, limit, countryLimit, filters })}`)
|
||||
}
|
||||
|
||||
export function getPublicDashboardLocations(
|
||||
siteId: string, startDate?: string, endDate?: string, limit = 10, countryLimit = 250,
|
||||
password?: string, captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string }
|
||||
): Promise<DashboardLocationsData> {
|
||||
return apiRequest<DashboardLocationsData>(`/public/sites/${siteId}/dashboard/locations${buildQuery({ startDate, endDate, limit, countryLimit }, { password, captcha })}`)
|
||||
}
|
||||
|
||||
export function getDashboardDevices(siteId: string, startDate?: string, endDate?: string, limit = 10, filters?: string): Promise<DashboardDevicesData> {
|
||||
return apiRequest<DashboardDevicesData>(`/sites/${siteId}/dashboard/devices${buildQuery({ startDate, endDate, limit, filters })}`)
|
||||
}
|
||||
|
||||
export function getPublicDashboardDevices(
|
||||
siteId: string, startDate?: string, endDate?: string, limit = 10,
|
||||
password?: string, captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string }
|
||||
): Promise<DashboardDevicesData> {
|
||||
return apiRequest<DashboardDevicesData>(`/public/sites/${siteId}/dashboard/devices${buildQuery({ startDate, endDate, limit }, { password, captcha })}`)
|
||||
}
|
||||
|
||||
export function getDashboardReferrers(siteId: string, startDate?: string, endDate?: string, limit = 10, filters?: string): Promise<DashboardReferrersData> {
|
||||
return apiRequest<DashboardReferrersData>(`/sites/${siteId}/dashboard/referrers${buildQuery({ startDate, endDate, limit, filters })}`)
|
||||
}
|
||||
|
||||
export function getPublicDashboardReferrers(
|
||||
siteId: string, startDate?: string, endDate?: string, limit = 10,
|
||||
password?: string, captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string }
|
||||
): Promise<DashboardReferrersData> {
|
||||
return apiRequest<DashboardReferrersData>(`/public/sites/${siteId}/dashboard/referrers${buildQuery({ startDate, endDate, limit }, { password, captcha })}`)
|
||||
}
|
||||
|
||||
export function getDashboardPerformance(siteId: string, startDate?: string, endDate?: string, filters?: string): Promise<DashboardPerformanceData> {
|
||||
return apiRequest<DashboardPerformanceData>(`/sites/${siteId}/dashboard/performance${buildQuery({ startDate, endDate, filters })}`)
|
||||
}
|
||||
|
||||
export function getPublicDashboardPerformance(
|
||||
siteId: string, startDate?: string, endDate?: string,
|
||||
password?: string, captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string }
|
||||
): Promise<DashboardPerformanceData> {
|
||||
return apiRequest<DashboardPerformanceData>(`/public/sites/${siteId}/dashboard/performance${buildQuery({ startDate, endDate }, { password, captcha })}`)
|
||||
}
|
||||
|
||||
export function getDashboardGoals(siteId: string, startDate?: string, endDate?: string, limit = 10, filters?: string): Promise<DashboardGoalsData> {
|
||||
return apiRequest<DashboardGoalsData>(`/sites/${siteId}/dashboard/goals${buildQuery({ startDate, endDate, limit, filters })}`)
|
||||
}
|
||||
|
||||
export function getPublicDashboardGoals(
|
||||
siteId: string, startDate?: string, endDate?: string, limit = 10,
|
||||
password?: string, captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string }
|
||||
): Promise<DashboardGoalsData> {
|
||||
return apiRequest<DashboardGoalsData>(`/public/sites/${siteId}/dashboard/goals${buildQuery({ startDate, endDate, limit }, { password, captcha })}`)
|
||||
}
|
||||
|
||||
// ─── Event Properties ────────────────────────────────────────────────
|
||||
|
||||
export interface EventPropertyKey {
|
||||
key: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface EventPropertyValue {
|
||||
value: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export function getEventPropertyKeys(siteId: string, eventName: string, startDate?: string, endDate?: string): Promise<EventPropertyKey[]> {
|
||||
return apiRequest<{ keys: EventPropertyKey[] }>(`/sites/${siteId}/goals/${encodeURIComponent(eventName)}/properties${buildQuery({ startDate, endDate })}`)
|
||||
.then(r => r?.keys || [])
|
||||
}
|
||||
|
||||
export function getEventPropertyValues(siteId: string, eventName: string, propName: string, startDate?: string, endDate?: string, limit = 20): Promise<EventPropertyValue[]> {
|
||||
return apiRequest<{ values: EventPropertyValue[] }>(`/sites/${siteId}/goals/${encodeURIComponent(eventName)}/properties/${encodeURIComponent(propName)}${buildQuery({ startDate, endDate, limit })}`)
|
||||
.then(r => r?.values || [])
|
||||
}
|
||||
|
||||
@@ -18,24 +18,8 @@ export interface Session {
|
||||
}
|
||||
|
||||
export async function getUserSessions(): Promise<{ sessions: Session[] }> {
|
||||
// Hash the current refresh token to identify current session
|
||||
const refreshToken = typeof window !== 'undefined' ? localStorage.getItem('refreshToken') : null
|
||||
let currentTokenHash = ''
|
||||
|
||||
if (refreshToken) {
|
||||
// Hash the refresh token using SHA-256
|
||||
const encoder = new TextEncoder()
|
||||
const data = encoder.encode(refreshToken)
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
||||
currentTokenHash = hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
return apiRequest<{ sessions: Session[] }>('/auth/user/sessions', {
|
||||
headers: currentTokenHash ? {
|
||||
'X-Current-Session-Hash': currentTokenHash,
|
||||
} : undefined,
|
||||
})
|
||||
// Current session is identified server-side via the httpOnly refresh token cookie
|
||||
return apiRequest<{ sessions: Session[] }>('/auth/user/sessions')
|
||||
}
|
||||
|
||||
export async function revokeSession(sessionId: string): Promise<void> {
|
||||
|
||||
@@ -187,10 +187,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
},
|
||||
})
|
||||
|
||||
// * Stable primitives for the effect dependency array — avoids re-running
|
||||
// * on every render when the `user` object reference changes.
|
||||
const isAuthenticated = !!user
|
||||
const userOrgId = user?.org_id
|
||||
|
||||
// * Organization Wall & Auto-Switch
|
||||
useEffect(() => {
|
||||
const checkOrg = async () => {
|
||||
if (!loading && user) {
|
||||
if (!loading && isAuthenticated) {
|
||||
if (pathname?.startsWith('/onboarding')) return
|
||||
if (pathname?.startsWith('/auth/callback')) return
|
||||
|
||||
@@ -204,7 +209,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
// * If user has organizations but no context (org_id), switch to the first one
|
||||
if (!user.org_id && organizations.length > 0) {
|
||||
if (!userOrgId && organizations.length > 0) {
|
||||
const firstOrg = organizations[0]
|
||||
|
||||
try {
|
||||
@@ -235,7 +240,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
checkOrg()
|
||||
}, [loading, user, pathname, router])
|
||||
}, [loading, isAuthenticated, userOrgId, pathname, router])
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, login, logout, refresh, refreshSession }}>
|
||||
|
||||
60
lib/filters.ts
Normal file
60
lib/filters.ts
Normal 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}`
|
||||
}
|
||||
31
lib/settings-modal-context.tsx
Normal file
31
lib/settings-modal-context.tsx
Normal 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)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
getDashboardReferrers,
|
||||
getDashboardPerformance,
|
||||
getDashboardGoals,
|
||||
getCampaigns,
|
||||
getRealtime,
|
||||
getStats,
|
||||
getDailyStats,
|
||||
@@ -20,6 +21,7 @@ import type { Site } from '@/lib/api/sites'
|
||||
import type {
|
||||
Stats,
|
||||
DailyStat,
|
||||
CampaignStat,
|
||||
DashboardOverviewData,
|
||||
DashboardPagesData,
|
||||
DashboardLocationsData,
|
||||
@@ -33,17 +35,19 @@ import type {
|
||||
const fetchers = {
|
||||
site: (siteId: string) => getSite(siteId),
|
||||
dashboard: (siteId: string, start: string, end: string) => getDashboard(siteId, start, end),
|
||||
dashboardOverview: (siteId: string, start: string, end: string) => getDashboardOverview(siteId, start, end),
|
||||
dashboardPages: (siteId: string, start: string, end: string) => getDashboardPages(siteId, start, end),
|
||||
dashboardLocations: (siteId: string, start: string, end: string) => getDashboardLocations(siteId, start, end),
|
||||
dashboardDevices: (siteId: string, start: string, end: string) => getDashboardDevices(siteId, start, end),
|
||||
dashboardReferrers: (siteId: string, start: string, end: string) => getDashboardReferrers(siteId, start, end),
|
||||
dashboardPerformance: (siteId: string, start: string, end: string) => getDashboardPerformance(siteId, start, end),
|
||||
dashboardGoals: (siteId: string, start: string, end: string) => getDashboardGoals(siteId, start, end),
|
||||
stats: (siteId: string, start: string, end: string) => getStats(siteId, start, end),
|
||||
dashboardOverview: (siteId: string, start: string, end: string, interval?: string, filters?: string) => getDashboardOverview(siteId, start, end, interval, filters),
|
||||
dashboardPages: (siteId: string, start: string, end: string, filters?: string) => getDashboardPages(siteId, start, end, undefined, filters),
|
||||
dashboardLocations: (siteId: string, start: string, end: string, filters?: string) => getDashboardLocations(siteId, start, end, undefined, undefined, filters),
|
||||
dashboardDevices: (siteId: string, start: string, end: string, filters?: string) => getDashboardDevices(siteId, start, end, undefined, filters),
|
||||
dashboardReferrers: (siteId: string, start: string, end: string, filters?: string) => getDashboardReferrers(siteId, start, end, undefined, filters),
|
||||
dashboardPerformance: (siteId: string, start: string, end: string, filters?: string) => getDashboardPerformance(siteId, start, end, filters),
|
||||
dashboardGoals: (siteId: string, start: string, end: string, filters?: string) => getDashboardGoals(siteId, start, end, undefined, filters),
|
||||
stats: (siteId: string, start: string, end: string, filters?: string) => getStats(siteId, start, end, filters),
|
||||
dailyStats: (siteId: string, start: string, end: string, interval: 'hour' | 'day' | 'minute') =>
|
||||
getDailyStats(siteId, start, end, interval),
|
||||
realtime: (siteId: string) => getRealtime(siteId),
|
||||
campaigns: (siteId: string, start: string, end: string, limit: number) =>
|
||||
getCampaigns(siteId, start, end, limit),
|
||||
}
|
||||
|
||||
// * Standard SWR config for dashboard data
|
||||
@@ -90,10 +94,10 @@ export function useDashboard(siteId: string, start: string, end: string) {
|
||||
}
|
||||
|
||||
// * Hook for stats (refreshed less frequently)
|
||||
export function useStats(siteId: string, start: string, end: string) {
|
||||
export function useStats(siteId: string, start: string, end: string, filters?: string) {
|
||||
return useSWR<Stats>(
|
||||
siteId && start && end ? ['stats', siteId, start, end] : null,
|
||||
() => fetchers.stats(siteId, start, end),
|
||||
siteId && start && end ? ['stats', siteId, start, end, filters] : null,
|
||||
() => fetchers.stats(siteId, start, end, filters),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
// * Refresh every 60 seconds for stats
|
||||
@@ -140,10 +144,10 @@ export function useRealtime(siteId: string, refreshInterval: number = 5000) {
|
||||
}
|
||||
|
||||
// * Hook for focused dashboard overview data (Fix 4.2: Efficient Data Transfer)
|
||||
export function useDashboardOverview(siteId: string, start: string, end: string) {
|
||||
export function useDashboardOverview(siteId: string, start: string, end: string, interval?: string, filters?: string) {
|
||||
return useSWR<DashboardOverviewData>(
|
||||
siteId && start && end ? ['dashboardOverview', siteId, start, end] : null,
|
||||
() => fetchers.dashboardOverview(siteId, start, end),
|
||||
siteId && start && end ? ['dashboardOverview', siteId, start, end, interval, filters] : null,
|
||||
() => fetchers.dashboardOverview(siteId, start, end, interval, filters),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
refreshInterval: 60 * 1000,
|
||||
@@ -153,10 +157,10 @@ export function useDashboardOverview(siteId: string, start: string, end: string)
|
||||
}
|
||||
|
||||
// * Hook for focused dashboard pages data
|
||||
export function useDashboardPages(siteId: string, start: string, end: string) {
|
||||
export function useDashboardPages(siteId: string, start: string, end: string, filters?: string) {
|
||||
return useSWR<DashboardPagesData>(
|
||||
siteId && start && end ? ['dashboardPages', siteId, start, end] : null,
|
||||
() => fetchers.dashboardPages(siteId, start, end),
|
||||
siteId && start && end ? ['dashboardPages', siteId, start, end, filters] : null,
|
||||
() => fetchers.dashboardPages(siteId, start, end, filters),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
refreshInterval: 60 * 1000,
|
||||
@@ -166,10 +170,10 @@ export function useDashboardPages(siteId: string, start: string, end: string) {
|
||||
}
|
||||
|
||||
// * Hook for focused dashboard locations data
|
||||
export function useDashboardLocations(siteId: string, start: string, end: string) {
|
||||
export function useDashboardLocations(siteId: string, start: string, end: string, filters?: string) {
|
||||
return useSWR<DashboardLocationsData>(
|
||||
siteId && start && end ? ['dashboardLocations', siteId, start, end] : null,
|
||||
() => fetchers.dashboardLocations(siteId, start, end),
|
||||
siteId && start && end ? ['dashboardLocations', siteId, start, end, filters] : null,
|
||||
() => fetchers.dashboardLocations(siteId, start, end, filters),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
refreshInterval: 60 * 1000,
|
||||
@@ -179,10 +183,10 @@ export function useDashboardLocations(siteId: string, start: string, end: string
|
||||
}
|
||||
|
||||
// * Hook for focused dashboard devices data
|
||||
export function useDashboardDevices(siteId: string, start: string, end: string) {
|
||||
export function useDashboardDevices(siteId: string, start: string, end: string, filters?: string) {
|
||||
return useSWR<DashboardDevicesData>(
|
||||
siteId && start && end ? ['dashboardDevices', siteId, start, end] : null,
|
||||
() => fetchers.dashboardDevices(siteId, start, end),
|
||||
siteId && start && end ? ['dashboardDevices', siteId, start, end, filters] : null,
|
||||
() => fetchers.dashboardDevices(siteId, start, end, filters),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
refreshInterval: 60 * 1000,
|
||||
@@ -192,10 +196,10 @@ export function useDashboardDevices(siteId: string, start: string, end: string)
|
||||
}
|
||||
|
||||
// * Hook for focused dashboard referrers data
|
||||
export function useDashboardReferrers(siteId: string, start: string, end: string) {
|
||||
export function useDashboardReferrers(siteId: string, start: string, end: string, filters?: string) {
|
||||
return useSWR<DashboardReferrersData>(
|
||||
siteId && start && end ? ['dashboardReferrers', siteId, start, end] : null,
|
||||
() => fetchers.dashboardReferrers(siteId, start, end),
|
||||
siteId && start && end ? ['dashboardReferrers', siteId, start, end, filters] : null,
|
||||
() => fetchers.dashboardReferrers(siteId, start, end, filters),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
refreshInterval: 60 * 1000,
|
||||
@@ -205,10 +209,10 @@ export function useDashboardReferrers(siteId: string, start: string, end: string
|
||||
}
|
||||
|
||||
// * Hook for focused dashboard performance data
|
||||
export function useDashboardPerformance(siteId: string, start: string, end: string) {
|
||||
export function useDashboardPerformance(siteId: string, start: string, end: string, filters?: string) {
|
||||
return useSWR<DashboardPerformanceData>(
|
||||
siteId && start && end ? ['dashboardPerformance', siteId, start, end] : null,
|
||||
() => fetchers.dashboardPerformance(siteId, start, end),
|
||||
siteId && start && end ? ['dashboardPerformance', siteId, start, end, filters] : null,
|
||||
() => fetchers.dashboardPerformance(siteId, start, end, filters),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
refreshInterval: 60 * 1000,
|
||||
@@ -218,10 +222,23 @@ export function useDashboardPerformance(siteId: string, start: string, end: stri
|
||||
}
|
||||
|
||||
// * Hook for focused dashboard goals data
|
||||
export function useDashboardGoals(siteId: string, start: string, end: string) {
|
||||
export function useDashboardGoals(siteId: string, start: string, end: string, filters?: string) {
|
||||
return useSWR<DashboardGoalsData>(
|
||||
siteId && start && end ? ['dashboardGoals', siteId, start, end] : null,
|
||||
() => fetchers.dashboardGoals(siteId, start, end),
|
||||
siteId && start && end ? ['dashboardGoals', siteId, start, end, filters] : null,
|
||||
() => fetchers.dashboardGoals(siteId, start, end, filters),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
refreshInterval: 60 * 1000,
|
||||
dedupingInterval: 10 * 1000,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// * Hook for campaigns data (used by export modal)
|
||||
export function useCampaigns(siteId: string, start: string, end: string, limit = 100) {
|
||||
return useSWR<CampaignStat[]>(
|
||||
siteId && start && end ? ['campaigns', siteId, start, end, limit] : null,
|
||||
() => fetchers.campaigns(siteId, start, end, limit),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
refreshInterval: 60 * 1000,
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import {
|
||||
getRequestIdFromError,
|
||||
formatErrorMessage,
|
||||
logErrorWithRequestId,
|
||||
getSupportMessage,
|
||||
} from '../errorHandler'
|
||||
import { setLastRequestId, clearLastRequestId } from '../requestId'
|
||||
|
||||
beforeEach(() => {
|
||||
clearLastRequestId()
|
||||
})
|
||||
|
||||
describe('getRequestIdFromError', () => {
|
||||
it('extracts request ID from error response body', () => {
|
||||
const errorData = { error: { request_id: 'REQ123_abc' } }
|
||||
expect(getRequestIdFromError(errorData)).toBe('REQ123_abc')
|
||||
})
|
||||
|
||||
it('falls back to last stored request ID when not in response', () => {
|
||||
setLastRequestId('REQfallback_xyz')
|
||||
expect(getRequestIdFromError({ error: {} })).toBe('REQfallback_xyz')
|
||||
})
|
||||
|
||||
it('falls back to last stored request ID when no error data', () => {
|
||||
setLastRequestId('REQfallback_xyz')
|
||||
expect(getRequestIdFromError()).toBe('REQfallback_xyz')
|
||||
})
|
||||
|
||||
it('returns null when no ID available anywhere', () => {
|
||||
expect(getRequestIdFromError()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatErrorMessage', () => {
|
||||
it('returns plain message when no request ID available', () => {
|
||||
expect(formatErrorMessage('Something failed')).toBe('Something failed')
|
||||
})
|
||||
|
||||
it('appends request ID in development mode', () => {
|
||||
const original = process.env.NODE_ENV
|
||||
process.env.NODE_ENV = 'development'
|
||||
setLastRequestId('REQ123_abc')
|
||||
|
||||
const msg = formatErrorMessage('Something failed')
|
||||
expect(msg).toContain('Something failed')
|
||||
expect(msg).toContain('REQ123_abc')
|
||||
|
||||
process.env.NODE_ENV = original
|
||||
})
|
||||
|
||||
it('appends request ID when showRequestId option is set', () => {
|
||||
setLastRequestId('REQ123_abc')
|
||||
const msg = formatErrorMessage('Something failed', undefined, { showRequestId: true })
|
||||
expect(msg).toContain('REQ123_abc')
|
||||
})
|
||||
})
|
||||
|
||||
describe('logErrorWithRequestId', () => {
|
||||
it('logs with request ID when available', () => {
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
setLastRequestId('REQ123_abc')
|
||||
|
||||
logErrorWithRequestId('TestContext', new Error('fail'))
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('REQ123_abc'),
|
||||
expect.any(Error)
|
||||
)
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
it('logs without request ID when not available', () => {
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
logErrorWithRequestId('TestContext', new Error('fail'))
|
||||
|
||||
expect(spy).toHaveBeenCalledWith('[TestContext]', expect.any(Error))
|
||||
spy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSupportMessage', () => {
|
||||
it('includes request ID when available', () => {
|
||||
const errorData = { error: { request_id: 'REQ123_abc' } }
|
||||
const msg = getSupportMessage(errorData)
|
||||
expect(msg).toContain('REQ123_abc')
|
||||
expect(msg).toContain('contact support')
|
||||
})
|
||||
|
||||
it('returns generic message when no request ID', () => {
|
||||
const msg = getSupportMessage()
|
||||
expect(msg).toBe('If this persists, please contact support.')
|
||||
})
|
||||
})
|
||||
@@ -7,23 +7,25 @@ describe('logger', () => {
|
||||
|
||||
it('calls console.error in development', async () => {
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
process.env.NODE_ENV = 'development'
|
||||
vi.stubEnv('NODE_ENV', 'development')
|
||||
|
||||
const { logger } = await import('../logger')
|
||||
logger.error('test error')
|
||||
|
||||
expect(spy).toHaveBeenCalledWith('test error')
|
||||
spy.mockRestore()
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('calls console.warn in development', async () => {
|
||||
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
process.env.NODE_ENV = 'development'
|
||||
vi.stubEnv('NODE_ENV', 'development')
|
||||
|
||||
const { logger } = await import('../logger')
|
||||
logger.warn('test warning')
|
||||
|
||||
expect(spy).toHaveBeenCalledWith('test warning')
|
||||
spy.mockRestore()
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
})
|
||||
|
||||
9
lib/utils/cookies.ts
Normal file
9
lib/utils/cookies.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
// * Determine cookie domain dynamically.
|
||||
// * In production (on ciphera.net), we share cookies across subdomains.
|
||||
// * In local dev (localhost), we don't set a domain.
|
||||
export const getCookieDomain = (): string | undefined => {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
return '.ciphera.net'
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
/**
|
||||
* Error handling utilities with Request ID extraction
|
||||
* Helps users report errors with traceable IDs for support
|
||||
*/
|
||||
|
||||
import { getLastRequestId } from './requestId'
|
||||
|
||||
interface ApiErrorResponse {
|
||||
error?: {
|
||||
code?: string
|
||||
message?: string
|
||||
details?: unknown
|
||||
request_id?: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract request ID from error response or use last known request ID
|
||||
*/
|
||||
export function getRequestIdFromError(errorData?: ApiErrorResponse): string | null {
|
||||
// * Try to get from error response body
|
||||
if (errorData?.error?.request_id) {
|
||||
return errorData.error.request_id
|
||||
}
|
||||
|
||||
// * Fallback to last request ID stored during API call
|
||||
return getLastRequestId()
|
||||
}
|
||||
|
||||
/**
|
||||
* Format error message for display with optional request ID
|
||||
* Shows request ID in development or for specific error types
|
||||
*/
|
||||
export function formatErrorMessage(
|
||||
message: string,
|
||||
errorData?: ApiErrorResponse,
|
||||
options: { showRequestId?: boolean } = {}
|
||||
): string {
|
||||
const requestId = getRequestIdFromError(errorData)
|
||||
|
||||
// * Always show request ID in development
|
||||
const isDev = process.env.NODE_ENV === 'development'
|
||||
|
||||
if (requestId && (isDev || options.showRequestId)) {
|
||||
return `${message}\n\nRequest ID: ${requestId}`
|
||||
}
|
||||
|
||||
return message
|
||||
}
|
||||
|
||||
/**
|
||||
* Log error with request ID for debugging
|
||||
*/
|
||||
export function logErrorWithRequestId(
|
||||
context: string,
|
||||
error: unknown,
|
||||
errorData?: ApiErrorResponse
|
||||
): void {
|
||||
const requestId = getRequestIdFromError(errorData)
|
||||
|
||||
if (requestId) {
|
||||
console.error(`[${context}] Request ID: ${requestId}`, error)
|
||||
} else {
|
||||
console.error(`[${context}]`, error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get support message with request ID for user reports
|
||||
*/
|
||||
export function getSupportMessage(errorData?: ApiErrorResponse): string {
|
||||
const requestId = getRequestIdFromError(errorData)
|
||||
|
||||
if (requestId) {
|
||||
return `If this persists, contact support with Request ID: ${requestId}`
|
||||
}
|
||||
|
||||
return 'If this persists, please contact support.'
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,10 +1,27 @@
|
||||
import type { NextConfig } from 'next'
|
||||
const withPWA = require("@ducanh2912/next-pwa").default({
|
||||
import withPWAInit from "@ducanh2912/next-pwa"
|
||||
|
||||
const withPWA = withPWAInit({
|
||||
dest: "public",
|
||||
register: true,
|
||||
skipWaiting: true,
|
||||
disable: process.env.NODE_ENV === "development",
|
||||
});
|
||||
})
|
||||
|
||||
// * CSP directives — restrict resource loading to known origins
|
||||
const cspDirectives = [
|
||||
"default-src 'self'",
|
||||
// Next.js requires 'unsafe-inline' for its bootstrap scripts; 'unsafe-eval' only in dev (HMR)
|
||||
`script-src 'self' 'unsafe-inline'${process.env.NODE_ENV === 'development' ? " 'unsafe-eval'" : ''}`,
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data: blob: https://www.google.com https://*.gstatic.com https://ciphera.net",
|
||||
"font-src 'self'",
|
||||
`connect-src 'self' https://*.ciphera.net https://ciphera.net https://www.google.com https://*.gstatic.com https://cdn.jsdelivr.net${process.env.NODE_ENV === 'development' ? ' http://localhost:*' : ''}`,
|
||||
"worker-src 'self'",
|
||||
"frame-src 'none'",
|
||||
"object-src 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self' https://*.ciphera.net",
|
||||
].join('; ')
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
reactStrictMode: true,
|
||||
@@ -22,6 +39,10 @@ const nextConfig: NextConfig = {
|
||||
hostname: 'www.google.com',
|
||||
pathname: '/s2/favicons**',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'ciphera.net',
|
||||
},
|
||||
],
|
||||
},
|
||||
async headers() {
|
||||
@@ -41,6 +62,7 @@ const nextConfig: NextConfig = {
|
||||
key: 'Strict-Transport-Security',
|
||||
value: 'max-age=63072000; includeSubDomains; preload',
|
||||
},
|
||||
{ key: 'Content-Security-Policy', value: cspDirectives },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
38
package-lock.json
generated
38
package-lock.json
generated
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"name": "pulse-frontend",
|
||||
"version": "0.12.0-alpha",
|
||||
"version": "0.13.0-alpha",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pulse-frontend",
|
||||
"version": "0.12.0-alpha",
|
||||
"version": "0.13.0-alpha",
|
||||
"dependencies": {
|
||||
"@ciphera-net/ui": "^0.0.78",
|
||||
"@ciphera-net/ui": "^0.0.92",
|
||||
"@ducanh2912/next-pwa": "^10.2.9",
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
"@simplewebauthn/browser": "^13.2.2",
|
||||
@@ -40,8 +40,8 @@
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/d3-scale": "^4.0.9",
|
||||
"@types/node": "^20.14.12",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-simple-maps": "^3.0.6",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"autoprefixer": "^10.4.19",
|
||||
@@ -1665,9 +1665,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@ciphera-net/ui": {
|
||||
"version": "0.0.78",
|
||||
"resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.0.78/d012b211ddba7a83f7468ec269a842709a2409b9",
|
||||
"integrity": "sha512-c9B/cZggjWnCSpICEvRBAAPgsRfjN2j3NFczQRZxRR6sZmzkwd3KzEbDfTEa2DUExBMWB4+bOgiCz+AnP2OR3g==",
|
||||
"version": "0.0.92",
|
||||
"resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.0.92/68088da543459b34ab9fe780713537713b5e0673",
|
||||
"integrity": "sha512-R+8fyvz7DhqHyJ2gai6ssRY3rE2OQlNt3ZepcpMeZouhujSNLO7KtApS4AP2qvJhKuWTnPNLj/B+kOWC3L4LMA==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
"clsx": "^2.1.0",
|
||||
@@ -4225,12 +4225,6 @@
|
||||
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/raf": {
|
||||
"version": "3.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
|
||||
@@ -4239,25 +4233,24 @@
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "18.3.28",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
|
||||
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
|
||||
"version": "19.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-dom": {
|
||||
"version": "18.3.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
|
||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0"
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-simple-maps": {
|
||||
@@ -9115,7 +9108,6 @@
|
||||
"integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@acemir/cssom": "^0.9.31",
|
||||
"@asamuzakjp/dom-selector": "^6.8.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pulse-frontend",
|
||||
"version": "0.12.0-alpha",
|
||||
"version": "0.13.0-alpha",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -12,7 +12,7 @@
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ciphera-net/ui": "^0.0.78",
|
||||
"@ciphera-net/ui": "^0.0.92",
|
||||
"@ducanh2912/next-pwa": "^10.2.9",
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
"@simplewebauthn/browser": "^13.2.2",
|
||||
@@ -50,8 +50,8 @@
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/d3-scale": "^4.0.9",
|
||||
"@types/node": "^20.14.12",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-simple-maps": "^3.0.6",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"autoprefixer": "^10.4.19",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 7.8 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 22 KiB |
117
public/script.js
117
public/script.js
@@ -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
|
||||
}
|
||||
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user