Release 0.13.0-alpha #39
65
CHANGELOG.md
@@ -6,6 +6,68 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.13.0-alpha] - 2026-03-02
|
||||
|
||||
### Improved
|
||||
|
||||
- **Cleaner internal API code.** The analytics data-fetching layer has been streamlined — 13 near-identical endpoint functions were consolidated using a shared pattern, cutting roughly half the code while keeping every feature working exactly as before. This makes it easier to add new analytics endpoints in the future and reduces the chance of inconsistencies between them.
|
||||
- **Simpler endpoint management on the backend.** The backend previously required adding every new analytics endpoint in two separate places — one for public dashboards and one for authenticated users. A shared route table now handles both automatically, so new endpoints only need to be defined once. This reduces the chance of one side getting out of sync with the other.
|
||||
- **Modern config loading for the app.** The app's configuration file now uses proper ES module imports instead of an older CommonJS pattern. This aligns with modern JavaScript standards and enables better tooling support.
|
||||
- **Complete API documentation in the backend README.** The developer documentation now lists all 80+ endpoints organized by category — ingestion, public dashboard, sites, analytics, real-time, goals, funnels, uptime, billing, notifications, admin, and audit. Previously only 12 endpoints were documented, which understated the full scope of the system.
|
||||
- **Login page now shows a loading screen while redirecting.** Previously, the login page briefly showed a blank white screen before redirecting you to the sign-in page. You'll now see the Pulse logo and a "Redirecting to log in..." message, matching the signup page experience.
|
||||
- **More reliable duplicate detection for goals.** When creating or updating a goal with a name that already exists, Pulse now uses the database's built-in error codes instead of checking error message text. This means duplicate goal detection works reliably regardless of database version or language settings.
|
||||
- **More consistent builds across environments.** The backend Docker image now uses a pinned operating system version instead of "latest," so builds produce identical results whether run today or months from now.
|
||||
- **Cleaner internal code for cookie handling.** Cookie domain logic that was duplicated in two places is now shared, reducing the chance of the two copies drifting out of sync during future changes.
|
||||
- **Smoother navigation across the app.** Switching pages, changing organizations, or signing in no longer triggers unnecessary background checks. Previously, an internal effect re-ran on every navigation because it watched the entire user object for changes — now it only reacts when your authentication state or organization actually changes. This makes page transitions faster and reduces redundant network requests.
|
||||
- **Faster uptime data cleanup under heavy usage.** Old uptime check records are now removed in small batches instead of all at once. Previously, a single large deletion could briefly slow down database performance when months of monitoring data had accumulated. Cleanup now runs incrementally so your dashboard stays responsive throughout.
|
||||
- **More reliable automated testing.** Backend tests that verify authentication and billing no longer rely on a fragile internal shortcut that could mask real bugs. If a code change accidentally reaches the database during a test, it now fails gracefully with a clear error instead of crashing silently.
|
||||
- **Database queries are now verified automatically before every release.** A new step in our release process runs all database operations against a real PostgreSQL database — including applying all schema migrations — so we catch query errors, missing columns, and data-access bugs before they reach production.
|
||||
- **Safer database schema changes.** The large migration that restructured how your analytics data is stored now has a documented rollback procedure. If anything goes wrong during a database upgrade, we can revert cleanly instead of requiring manual intervention.
|
||||
- **Easier setup for new developers.** Building and testing Pulse no longer requires a specific directory layout on your machine. All builds use a pre-packaged snapshot of dependencies, so you can clone the project and start working immediately without extra setup steps.
|
||||
- **Faster, smarter dashboard data loading.** Your dashboard now loads each section independently using an intelligent caching strategy. Data refreshes happen automatically in the background, and when you switch tabs the app pauses updates to save resources — resuming instantly when you return. This replaces the previous approach where everything loaded in one large batch, meaning your charts, visitor maps, and stats now appear faster and update more reliably.
|
||||
- **Better data accuracy across the dashboard.** All data displayed on the dashboard — pages, locations, devices, referrers, performance metrics, and goals — is now fully typed end-to-end. This eliminates an entire class of potential display bugs where data could be misinterpreted between the server and your screen.
|
||||
- **Dashboard stays fast under heavy traffic.** When many users view their dashboards at the same time, the backend now limits how many data queries run in parallel so it doesn't overwhelm the database. If you navigate away while the dashboard is loading, queries are cancelled immediately instead of continuing to run in the background.
|
||||
- **Improved error visibility and debugging.** When something goes wrong behind the scenes, the backend now logs detailed, structured information about what happened — including exactly which site, page, or operation was affected. This means issues are diagnosed and fixed faster, reducing any downtime or data gaps you might experience.
|
||||
- **Clearer error trails across the system.** Every database operation now includes context about what was happening when an error occurred. Instead of vague failures, support can trace problems back to their exact source — so if an issue affects your analytics, it gets identified and resolved much more quickly.
|
||||
- **Clearer rate limiting for analytics tracking.** When your tracking script sends too many events from the same session, Pulse now tells the script explicitly that the request was rejected. Previously, these events were silently accepted but never recorded, which could make your visitor counts look lower than expected without any visible explanation.
|
||||
- **Earlier detection of configuration problems.** The server now checks your email and internal integration settings at startup and warns you immediately if anything looks incomplete. Previously, you might not discover a misconfigured email setting until a billing alert or uptime notification failed to send.
|
||||
- **Safer campaign date handling.** Campaign analytics now use the same date validation as the rest of the app, including checks for invalid ranges and a maximum span of one year. Previously, campaigns used separate date parsing that skipped these checks.
|
||||
- **Full React 19 type coverage.** Upgraded TypeScript type definitions to match the React 19 runtime. Previously, the type definitions lagged behind at React 18, which could hide bugs and miss new React 19 APIs during development.
|
||||
- **Lower resource usage under load.** The backend now uses a single shared connection to Redis instead of opening dozens of separate ones. Previously, each rate limiter and internal component created its own connection pool, which could waste resources and risk hitting connection limits during heavy traffic.
|
||||
- **More reliable billing operations.** Billing actions like changing your plan, cancelling, and viewing invoices now benefit from the same automatic session refresh, request tracing, and error handling as the rest of the app. Previously, these used a separate internal path that could fail silently if your session expired mid-action.
|
||||
- **Stronger browser protection with Content Security Policy.** The app now tells your browser exactly which resources it's allowed to load — scripts, styles, images, and API connections are restricted to trusted sources only. This adds an extra layer of defence against cross-site scripting (XSS) attacks.
|
||||
- **Tighter cross-origin request handling.** The backend now only accepts requests from known Pulse and Ciphera origins instead of any website. This prevents other sites from making authenticated requests on your behalf.
|
||||
- **Stricter environment security checks.** Staging and other non-development deployments now refuse to start if critical secrets haven't been configured, catching configuration mistakes before they reach users.
|
||||
- **Billing configuration validated at startup.** Stripe payment keys are now checked when the server starts instead of when a billing request comes in. Misconfigured payment settings surface immediately during deployment rather than silently failing when you try to manage your subscription.
|
||||
- **Faster real-time visitor cleanup.** The background process that keeps active visitor counts accurate no longer briefly blocks other operations while scanning for stale sessions. Cleanup now runs incrementally so your dashboard stays responsive at all times.
|
||||
- **Stricter input validation on admin pages.** The internal admin panel now validates organisation identifiers before processing requests, preventing malformed data from reaching the database.
|
||||
- **Error messages no longer reveal internal details.** When you submit an invalid form, the error message now says "Invalid request body" instead of exposing internal field names and validation rules. This makes error messages cleaner while keeping your data safer.
|
||||
- **Better request cancellation for billing operations.** All billing-related database operations can now be properly cancelled if you navigate away or your connection drops. Previously, some operations would continue running in the background even after you left the page.
|
||||
- **More resilient event processing.** The background system that processes your analytics events now automatically recovers from unexpected errors instead of silently stopping. If something goes wrong, it restarts itself and continues processing — so you never lose incoming analytics data.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Rate limiting now works correctly for all visitors.** A bug in the backend meant that IP-based rate limiting — which protects against abuse on public dashboards and the tracking script — was treating all visitors as the same person. A single heavy user could hit the limit for everyone. Rate limiting now correctly identifies individual visitors, so one bad actor can't affect others.
|
||||
- **More reliable list rendering across the dashboard.** Pages, referrers, locations, devices, funnels, and other data lists now use stable identifiers (like page paths and referrer URLs) instead of array positions as their React keys. This prevents potential display glitches — such as stale data appearing in the wrong row — when lists are filtered, sorted, or updated in real time.
|
||||
- **Deleting a site now fully cleans up all its data.** Previously, deleting a site removed the site record but could leave behind orphaned analytics events in the database, slowly accumulating unused data. Now all events are cleaned up automatically in small batches before the site is removed, keeping your database tidy.
|
||||
- **Team members can now view real-time visitor data.** Previously, only the person who originally created a site could see live visitors and session details. Now any team member in the same organization can access real-time data for all their shared sites — the same way the rest of the dashboard already works.
|
||||
- **Consistent date handling across all analytics.** Funnel analytics now use the same date format (`YYYY-MM-DD`) as every other part of Pulse. Previously, funnels required a different, more technical date format, which meant date range pickers and bookmarked links didn't always work correctly between funnels and the rest of the dashboard.
|
||||
- **Tracking script now works on all tracked websites.** Page views were silently failing to record when the tracking script ran on your website. Two issues were at play: the backend was rejecting analytics data sent from tracked sites, and even after that was resolved, events were silently dropped during processing because they were missing a required identifier. Both are now fixed — your dashboard receives visits from all registered domains as expected.
|
||||
- **Real-time visitor count no longer stops updating.** The dashboard's live visitor counter would quickly hit a rate limit and stop refreshing, showing "Site not found" errors. The limit was too low for normal polling, especially with multiple tabs open. It now has enough headroom for typical usage.
|
||||
- **Funnel details now load correctly.** Opening a funnel showed "Unable to load funnel" with a server error. An internal query was referencing data that no longer existed at that stage of processing, causing it to fail every time. Funnels now load and display step-by-step conversion data as expected.
|
||||
- **App switcher and site icons now load correctly.** The Pulse, Drop, and Auth logos in the app switcher — and site favicons on the dashboard — were blocked by the browser's Content Security Policy. The policy now allows images from Ciphera's own domain and Google's favicon service, so all icons display as expected.
|
||||
- **Better crash protection in goals and real-time views.** Fixed an issue where the backend could crash under rare conditions when checking permissions for goals or real-time visitor data. The app now handles unexpected values gracefully instead of crashing.
|
||||
- **More reliable service health reporting.** The backend health check no longer falsely reports the service as unhealthy after sustained traffic. Previously, an internal counter grew over time and would eventually cross a fixed threshold — even under normal load — causing orchestrators to unnecessarily restart the service.
|
||||
- **Session list now correctly highlights your current session.** The active sessions list in settings now properly identifies which session you're currently using. Previously, the "current session" marker never appeared due to an internal mismatch in how sessions were identified.
|
||||
- **Notifications no longer fail to load on sign-in.** The notification bell now loads correctly even when the app is still setting up your workspace context. Previously, it could briefly show an error right after signing in.
|
||||
- **Fixed a service worker error on first visit.** Removed leftover icon files with invalid filenames that caused a caching error in the background. This had no visible effect but produced console warnings.
|
||||
- **More reliable database migrations.** The migration system no longer silently skips partially failed database updates. If an update fails, it stops immediately so the issue can be identified and fixed — rather than marking it as complete and moving on with missing changes.
|
||||
|
||||
### Removed
|
||||
|
||||
- **Cleaned up unused files.** Removed six leftover component and utility files that were no longer used anywhere in the app, along with dead backend code. This reduces clutter and keeps the codebase easier to navigate.
|
||||
- **Removed unused backend code.** Deleted an old CORS middleware that was never wired up, and an 80-line legacy funnel query that had been superseded by a faster implementation. Less code means fewer things to maintain and audit.
|
||||
|
||||
## [0.12.0-alpha] - 2026-03-01
|
||||
|
||||
### Added
|
||||
@@ -223,7 +285,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/compare/v0.12.0-alpha...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,7 +9,7 @@ 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'
|
||||
@@ -48,7 +48,7 @@ export default function LayoutContent({ children }: { children: React.ReactNode
|
||||
const auth = useAuth()
|
||||
const router = useRouter()
|
||||
const isOnline = useOnlineStatus()
|
||||
const [orgs, setOrgs] = useState<any[]>([])
|
||||
const [orgs, setOrgs] = useState<OrganizationMember[]>([])
|
||||
const [isSwitchingOrg, setIsSwitchingOrg] = useState(() => {
|
||||
if (typeof window === 'undefined') return false
|
||||
return sessionStorage.getItem(ORG_SWITCH_KEY) === 'true'
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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,13 @@
|
||||
|
||||
import { useAuth } from '@/lib/auth/context'
|
||||
import { logger } from '@/lib/utils/logger'
|
||||
import { useCallback, useEffect, useState, useRef } from 'react'
|
||||
import { useEffect, useState, useMemo } from 'react'
|
||||
import { useParams, useRouter } 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, 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'
|
||||
@@ -22,6 +20,45 @@ import Chart from '@/components/dashboard/Chart'
|
||||
import PerformanceStats from '@/components/dashboard/PerformanceStats'
|
||||
import GoalStats from '@/components/dashboard/GoalStats'
|
||||
import Campaigns from '@/components/dashboard/Campaigns'
|
||||
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 +68,75 @@ 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)
|
||||
}
|
||||
const interval = dateRange.start === dateRange.end ? todayInterval : multiDayInterval
|
||||
|
||||
// Restore intervals
|
||||
if (settings.todayInterval) setTodayInterval(settings.todayInterval)
|
||||
if (settings.multiDayInterval) setMultiDayInterval(settings.multiDayInterval)
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Failed to load dashboard settings', e)
|
||||
} finally {
|
||||
setIsSettingsLoaded(true)
|
||||
// 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
|
||||
const { data: overview, isLoading: overviewLoading, error: overviewError } = useDashboardOverview(siteId, dateRange.start, dateRange.end, interval)
|
||||
const { data: pages } = useDashboardPages(siteId, dateRange.start, dateRange.end)
|
||||
const { data: locations } = useDashboardLocations(siteId, dateRange.start, dateRange.end)
|
||||
const { data: devicesData } = useDashboardDevices(siteId, dateRange.start, dateRange.end)
|
||||
const { data: referrers } = useDashboardReferrers(siteId, dateRange.start, dateRange.end)
|
||||
const { data: performanceData } = useDashboardPerformance(siteId, dateRange.start, dateRange.end)
|
||||
const { data: goalsData } = useDashboardGoals(siteId, dateRange.start, dateRange.end)
|
||||
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 ?? []
|
||||
|
||||
// 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 +153,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 +167,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 +205,7 @@ export default function SiteDashboardPage() {
|
||||
{site.domain}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Realtime Indicator */}
|
||||
<button
|
||||
onClick={() => router.push(`/sites/${siteId}/realtime`)}
|
||||
@@ -414,10 +307,10 @@ export default function SiteDashboardPage() {
|
||||
|
||||
{/* 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 +326,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,16 +338,16 @@ 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}
|
||||
/>
|
||||
<TopReferrers
|
||||
referrers={topReferrers}
|
||||
referrers={referrers?.top_referrers ?? []}
|
||||
collectReferrers={site.collect_referrers ?? true}
|
||||
siteId={siteId}
|
||||
dateRange={dateRange}
|
||||
@@ -463,18 +356,18 @@ export default function SiteDashboardPage() {
|
||||
|
||||
<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}
|
||||
/>
|
||||
<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}
|
||||
@@ -488,7 +381,7 @@ export default function SiteDashboardPage() {
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<GoalStats goalCounts={goalCounts} />
|
||||
<GoalStats goalCounts={goalsData?.goal_counts ?? []} />
|
||||
</div>
|
||||
|
||||
<DatePicker
|
||||
@@ -507,8 +400,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>
|
||||
)
|
||||
}
|
||||
@@ -132,8 +132,8 @@ 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} 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">
|
||||
<a
|
||||
href={`https://${domain.replace(/^https?:\/\//, '')}${page.path}`}
|
||||
@@ -181,8 +181,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>
|
||||
)
|
||||
}
|
||||
@@ -247,8 +247,8 @@ 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">
|
||||
{displayedData.map((item) => (
|
||||
<div key={`${item.country ?? ''}-${item.region ?? ''}-${item.city ?? ''}`} className="flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors">
|
||||
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
|
||||
{activeTab === 'countries' && <span className="shrink-0">{getFlagComponent(item.country ?? '')}</span>}
|
||||
{activeTab !== 'countries' && <span className="shrink-0">{getFlagComponent(item.country ?? '')}</span>}
|
||||
@@ -296,8 +296,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">
|
||||
|
||||
@@ -156,8 +156,8 @@ 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">
|
||||
{displayedData.map((item) => (
|
||||
<div key={item.name} className="flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors">
|
||||
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
|
||||
{item.icon && <span className="text-lg">{item.icon}</span>}
|
||||
<span className="truncate">{item.name}</span>
|
||||
@@ -198,8 +198,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>
|
||||
)
|
||||
}
|
||||
@@ -102,8 +102,8 @@ 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} 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">
|
||||
{renderReferrerIcon(ref.referrer)}
|
||||
<span className="truncate" title={ref.referrer}>{getReferrerDisplayName(ref.referrer)}</span>
|
||||
@@ -144,8 +144,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>
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
|
||||
541
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,115 @@ 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
|
||||
},
|
||||
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 (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): Promise<T[]> =>
|
||||
apiRequest<Record<string, T[]>>(`/sites/${siteId}/${path}${buildQuery({ startDate, endDate, limit })}`)
|
||||
.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): Promise<Stats> {
|
||||
return apiRequest<Stats>(`/sites/${siteId}/stats${buildQuery({ startDate, endDate })}`)
|
||||
}
|
||||
|
||||
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): Promise<DailyStat[]> {
|
||||
return apiRequest<{ stats: DailyStat[] }>(`/sites/${siteId}/daily${buildQuery({ startDate, endDate, interval })}`)
|
||||
.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 +243,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 +256,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 +270,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 +289,92 @@ 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): Promise<DashboardOverviewData> {
|
||||
return apiRequest<DashboardOverviewData>(`/sites/${siteId}/dashboard/overview${buildQuery({ startDate, endDate, interval })}`)
|
||||
}
|
||||
|
||||
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): Promise<DashboardPagesData> {
|
||||
return apiRequest<DashboardPagesData>(`/sites/${siteId}/dashboard/pages${buildQuery({ startDate, endDate, limit })}`)
|
||||
}
|
||||
|
||||
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): Promise<DashboardLocationsData> {
|
||||
return apiRequest<DashboardLocationsData>(`/sites/${siteId}/dashboard/locations${buildQuery({ startDate, endDate, limit, countryLimit })}`)
|
||||
}
|
||||
|
||||
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): Promise<DashboardDevicesData> {
|
||||
return apiRequest<DashboardDevicesData>(`/sites/${siteId}/dashboard/devices${buildQuery({ startDate, endDate, limit })}`)
|
||||
}
|
||||
|
||||
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): Promise<DashboardReferrersData> {
|
||||
return apiRequest<DashboardReferrersData>(`/sites/${siteId}/dashboard/referrers${buildQuery({ startDate, endDate, limit })}`)
|
||||
}
|
||||
|
||||
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): Promise<DashboardPerformanceData> {
|
||||
return apiRequest<DashboardPerformanceData>(`/sites/${siteId}/dashboard/performance${buildQuery({ startDate, endDate })}`)
|
||||
}
|
||||
|
||||
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): Promise<DashboardGoalsData> {
|
||||
return apiRequest<DashboardGoalsData>(`/sites/${siteId}/dashboard/goals${buildQuery({ startDate, endDate, limit })}`)
|
||||
}
|
||||
|
||||
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 })}`)
|
||||
}
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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,7 +35,7 @@ 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),
|
||||
dashboardOverview: (siteId: string, start: string, end: string, interval?: string) => getDashboardOverview(siteId, start, end, interval),
|
||||
dashboardPages: (siteId: string, start: string, end: string) => getDashboardPages(siteId, start, end),
|
||||
dashboardLocations: (siteId: string, start: string, end: string) => getDashboardLocations(siteId, start, end),
|
||||
dashboardDevices: (siteId: string, start: string, end: string) => getDashboardDevices(siteId, start, end),
|
||||
@@ -44,6 +46,8 @@ const fetchers = {
|
||||
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
|
||||
@@ -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) {
|
||||
return useSWR<DashboardOverviewData>(
|
||||
siteId && start && end ? ['dashboardOverview', siteId, start, end] : null,
|
||||
() => fetchers.dashboardOverview(siteId, start, end),
|
||||
siteId && start && end ? ['dashboardOverview', siteId, start, end, interval] : null,
|
||||
() => fetchers.dashboardOverview(siteId, start, end, interval),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
refreshInterval: 60 * 1000,
|
||||
@@ -230,5 +234,18 @@ export function useDashboardGoals(siteId: string, start: string, end: string) {
|
||||
)
|
||||
}
|
||||
|
||||
// * 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,
|
||||
dedupingInterval: 10 * 1000,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// * Re-export for convenience
|
||||
export { fetchers }
|
||||
|
||||
@@ -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
@@ -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.'
|
||||
}
|
||||
@@ -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'",
|
||||
|
The comment acknowledges this is required for Next.js bootstrap scripts, but browsers ignore the entire The recommended approach for Next.js is to use a per-request nonce (via Prompt To Fix With AI**`'unsafe-inline'` in production `script-src` neutralises XSS protection**
The comment acknowledges this is required for Next.js bootstrap scripts, but browsers ignore the entire `script-src` allowlist when `'unsafe-inline'` is present (unless a nonce or hash is also provided). This means the `script-src 'self'` restriction has no effect in production — any inline script injected by an attacker will execute freely.
The recommended approach for Next.js is to use a per-request nonce (via `generateBuildId` / middleware) and emit `script-src 'nonce-{value}'` rather than `'unsafe-inline'`. That is non-trivial to set up, but as long as `'unsafe-inline'` is present the `script-src` directive is essentially no-op from a security standpoint. At a minimum, this is worth a code comment so future contributors don't assume XSS protection is in force.
<details><summary>Prompt To Fix With AI</summary>
`````markdown
This is a comment left during a code review.
Path: next.config.ts
Line: 16
Comment:
**`'unsafe-inline'` in production `script-src` neutralises XSS protection**
The comment acknowledges this is required for Next.js bootstrap scripts, but browsers ignore the entire `script-src` allowlist when `'unsafe-inline'` is present (unless a nonce or hash is also provided). This means the `script-src 'self'` restriction has no effect in production — any inline script injected by an attacker will execute freely.
The recommended approach for Next.js is to use a per-request nonce (via `generateBuildId` / middleware) and emit `script-src 'nonce-{value}'` rather than `'unsafe-inline'`. That is non-trivial to set up, but as long as `'unsafe-inline'` is present the `script-src` directive is essentially no-op from a security standpoint. At a minimum, this is worth a code comment so future contributors don't assume XSS protection is in force.
How can I resolve this? If you propose a fix, please make it concise.
`````
</details>
|
||||
"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 },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
34
package-lock.json
generated
@@ -8,7 +8,7 @@
|
||||
"name": "pulse-frontend",
|
||||
"version": "0.12.0-alpha",
|
||||
"dependencies": {
|
||||
"@ciphera-net/ui": "^0.0.78",
|
||||
"@ciphera-net/ui": "^0.0.79",
|
||||
"@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.79",
|
||||
"resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.0.79/11371f19607016d14b0343a698d5f2ef22360c0c",
|
||||
"integrity": "sha512-Th7jVpsf2Qjf5o8ZP1315xJ4q09fb3zo5rPwAiK7jQZGl9AIZU8qw33wJchDKJU7dwo9YetKFvP60lnqseadnA==",
|
||||
"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.79",
|
||||
"@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",
|
||||
|
||||
|
Before Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 22 KiB |
Duplicate-key risk if two funnel steps share a name
step.step.nameis user-supplied. If a user creates two funnel steps with the same name (e.g. two "Homepage" steps), React will emit a duplicate-key warning and produce incorrect DOM reconciliation.A safer key combines the stable
orderfield (which is unique by definition in a funnel) with the name, or simply usesstep.step.orderalone as the key.Prompt To Fix With AI