fix: frontend consistency audit — 55 files cleaned up

Consistency fixes:
- Extract getThisWeekRange/getThisMonthRange to shared lib/utils/dateRanges.ts
  (removed 4 identical copy-pasted definitions)
- Add error boundaries for behavior, cdn, search, pagespeed pages
  (4 new error.tsx files — previously fell through to generic parent error)
- Add "View setup guide" CTA to empty states on journeys and behavior pages
  (previously showed text with no actionable button)
- Fix non-lazy useState initializer in funnel detail page
- Fix Bot & Spam settings header from text-xl to text-2xl (matches all other sections)
- Add useMinimumLoading to PageSpeed skeleton (consistent with all other pages)

Cleanup:
- Remove 438 redundant dark: class prefixes (app is dark-mode only)
  text-neutral-500 dark:text-neutral-400 → text-neutral-400 (206 occurrences)
  text-neutral-900 dark:text-white → text-white (232 occurrences)
- Remove dead @stripe/react-stripe-js and @stripe/stripe-js packages
  (billing migrated to Polar, no code imports Stripe)
- Remove duplicate motion package (framer-motion is the one actually used)
This commit is contained in:
Usman Baig
2026-03-23 19:50:16 +01:00
parent eca21bf627
commit a3c1af7c95
55 changed files with 560 additions and 530 deletions

View File

@@ -28,8 +28,8 @@ export default function FilteredTrafficPage() {
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h2 className="text-xl font-semibold text-neutral-900 dark:text-white">Filtered Traffic</h2> <h2 className="text-xl font-semibold text-white">Filtered Traffic</h2>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-1"> <p className="text-sm text-neutral-400 mt-1">
{totalBlocked.toLocaleString()} spam referrers blocked in the last {days} days {totalBlocked.toLocaleString()} spam referrers blocked in the last {days} days
</p> </p>
</div> </div>
@@ -52,22 +52,22 @@ export default function FilteredTrafficPage() {
<div className="rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 shadow-sm overflow-hidden"> <div className="rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 shadow-sm overflow-hidden">
{referrers.length === 0 ? ( {referrers.length === 0 ? (
<div className="p-12 text-center text-neutral-500 dark:text-neutral-400"> <div className="p-12 text-center text-neutral-400">
No filtered referrers in this period No filtered referrers in this period
</div> </div>
) : ( ) : (
<table className="w-full text-left text-sm"> <table className="w-full text-left text-sm">
<thead className="border-b border-neutral-200 dark:border-neutral-800"> <thead className="border-b border-neutral-200 dark:border-neutral-800">
<tr> <tr>
<th className="px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">Domain</th> <th className="px-4 py-3 font-medium text-neutral-400">Domain</th>
<th className="px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">Reason</th> <th className="px-4 py-3 font-medium text-neutral-400">Reason</th>
<th className="px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400 text-right">Blocked</th> <th className="px-4 py-3 font-medium text-neutral-400 text-right">Blocked</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-800"> <tbody className="divide-y divide-neutral-200 dark:divide-neutral-800">
{referrers.map((r) => ( {referrers.map((r) => (
<tr key={`${r.domain}-${r.reason}`} className="hover:bg-neutral-50 dark:hover:bg-neutral-900/50"> <tr key={`${r.domain}-${r.reason}`} className="hover:bg-neutral-50 dark:hover:bg-neutral-900/50">
<td className="px-4 py-3 text-neutral-900 dark:text-white font-mono text-xs">{r.domain}</td> <td className="px-4 py-3 text-white font-mono text-xs">{r.domain}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${ <span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
r.reason === 'blocklist' r.reason === 'blocklist'
@@ -77,7 +77,7 @@ export default function FilteredTrafficPage() {
{r.reason} {r.reason}
</span> </span>
</td> </td>
<td className="px-4 py-3 text-right text-neutral-900 dark:text-white tabular-nums"> <td className="px-4 py-3 text-right text-white tabular-nums">
{r.count.toLocaleString()} {r.count.toLocaleString()}
</td> </td>
</tr> </tr>

View File

@@ -37,7 +37,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
return ( return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8"> <div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
<div className="mb-8 flex items-center justify-between"> <div className="mb-8 flex items-center justify-between">
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white">Pulse Admin</h1> <h1 className="text-2xl font-bold text-white">Pulse Admin</h1>
</div> </div>
{children} {children}
</div> </div>

View File

@@ -107,7 +107,7 @@ export default function AdminOrgDetailPage() {
return ( return (
<div className="space-y-6 max-w-4xl mx-auto"> <div className="space-y-6 max-w-4xl mx-auto">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white"> <h2 className="text-2xl font-bold text-white">
{org.business_name || 'Unnamed Organization'} {org.business_name || 'Unnamed Organization'}
</h2> </h2>
<span className="text-sm font-mono text-neutral-500">{org.organization_id}</span> <span className="text-sm font-mono text-neutral-500">{org.organization_id}</span>
@@ -116,7 +116,7 @@ export default function AdminOrgDetailPage() {
<div className="grid gap-6 md:grid-cols-2"> <div className="grid gap-6 md:grid-cols-2">
{/* Current Status */} {/* Current Status */}
<div className="rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-6 shadow-sm"> <div className="rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-6 shadow-sm">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white mb-4">Current Status</h3> <h3 className="text-lg font-semibold text-white mb-4">Current Status</h3>
<div className="grid grid-cols-2 gap-2 text-sm"> <div className="grid grid-cols-2 gap-2 text-sm">
<span className="text-neutral-500">Plan:</span> <span className="text-neutral-500">Plan:</span>
<span className="font-medium">{org.plan_id}</span> <span className="font-medium">{org.plan_id}</span>
@@ -145,7 +145,7 @@ export default function AdminOrgDetailPage() {
{/* Sites */} {/* Sites */}
<div className="rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-6 shadow-sm"> <div className="rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-6 shadow-sm">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white mb-4">Sites ({org.sites.length})</h3> <h3 className="text-lg font-semibold text-white mb-4">Sites ({org.sites.length})</h3>
<ul className="space-y-2 max-h-60 overflow-y-auto"> <ul className="space-y-2 max-h-60 overflow-y-auto">
{org.sites.map((site) => ( {org.sites.map((site) => (
<li key={site.id} className="flex justify-between items-center text-sm p-2 bg-neutral-50 dark:bg-neutral-900 rounded"> <li key={site.id} className="flex justify-between items-center text-sm p-2 bg-neutral-50 dark:bg-neutral-900 rounded">
@@ -160,7 +160,7 @@ export default function AdminOrgDetailPage() {
{/* Grant Plan Form */} {/* Grant Plan Form */}
<div className="rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-6 shadow-sm"> <div className="rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-6 shadow-sm">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white mb-4">Grant Plan (Manual Override)</h3> <h3 className="text-lg font-semibold text-white mb-4">Grant Plan (Manual Override)</h3>
<form onSubmit={handleGrantPlan} className="space-y-4"> <form onSubmit={handleGrantPlan} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
@@ -196,7 +196,7 @@ export default function AdminOrgDetailPage() {
type="datetime-local" type="datetime-local"
value={periodEnd} value={periodEnd}
onChange={(e) => setPeriodEnd(e.target.value)} onChange={(e) => setPeriodEnd(e.target.value)}
className="w-full px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-lg bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-offset-2" className="w-full px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-lg bg-white dark:bg-neutral-900 text-white focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-offset-2"
required required
/> />
<div className="flex gap-2 mt-1"> <div className="flex gap-2 mt-1">

View File

@@ -43,28 +43,28 @@ export default function AdminOrgsPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-neutral-900 dark:text-white">Organizations</h2> <h2 className="text-xl font-semibold text-white">Organizations</h2>
</div> </div>
<div className="rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-6 shadow-sm"> <div className="rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-6 shadow-sm">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white mb-4">All Organizations</h3> <h3 className="text-lg font-semibold text-white mb-4">All Organizations</h3>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-left text-sm"> <table className="w-full text-left text-sm">
<thead className="border-b border-neutral-200 dark:border-neutral-800"> <thead className="border-b border-neutral-200 dark:border-neutral-800">
<tr> <tr>
<th className="px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">Name</th> <th className="px-4 py-3 font-medium text-neutral-400">Name</th>
<th className="px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">Org ID</th> <th className="px-4 py-3 font-medium text-neutral-400">Org ID</th>
<th className="px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">Plan</th> <th className="px-4 py-3 font-medium text-neutral-400">Plan</th>
<th className="px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">Status</th> <th className="px-4 py-3 font-medium text-neutral-400">Status</th>
<th className="px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">Limit</th> <th className="px-4 py-3 font-medium text-neutral-400">Limit</th>
<th className="px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">Updated</th> <th className="px-4 py-3 font-medium text-neutral-400">Updated</th>
<th className="px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">Actions</th> <th className="px-4 py-3 font-medium text-neutral-400">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-800"> <tbody className="divide-y divide-neutral-200 dark:divide-neutral-800">
{orgs.map((org) => ( {orgs.map((org) => (
<tr key={org.organization_id} className="hover:bg-neutral-50 dark:hover:bg-neutral-900/50"> <tr key={org.organization_id} className="hover:bg-neutral-50 dark:hover:bg-neutral-900/50">
<td className="px-4 py-3 text-neutral-900 dark:text-white font-medium"> <td className="px-4 py-3 text-white font-medium">
{org.business_name || 'N/A'} {org.business_name || 'N/A'}
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">

View File

@@ -9,9 +9,9 @@ export default function AdminDashboard() {
href="/admin/orgs" href="/admin/orgs"
className="block transition-transform hover:scale-[1.02] rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-6 shadow-sm" className="block transition-transform hover:scale-[1.02] rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-6 shadow-sm"
> >
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">Organizations</h3> <h3 className="text-lg font-semibold text-white">Organizations</h3>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-1">Manage organization plans and limits</p> <p className="text-sm text-neutral-400 mt-1">Manage organization plans and limits</p>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-4"> <p className="text-sm text-neutral-400 mt-4">
View all organizations, check billing status, and manually grant plans. View all organizations, check billing status, and manually grant plans.
</p> </p>
</Link> </Link>
@@ -19,9 +19,9 @@ export default function AdminDashboard() {
href="/admin/filtered-traffic" href="/admin/filtered-traffic"
className="block transition-transform hover:scale-[1.02] rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-6 shadow-sm" className="block transition-transform hover:scale-[1.02] rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-6 shadow-sm"
> >
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">Filtered Traffic</h3> <h3 className="text-lg font-semibold text-white">Filtered Traffic</h3>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-1">Monitor blocked referrer spam</p> <p className="text-sm text-neutral-400 mt-1">Monitor blocked referrer spam</p>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-4"> <p className="text-sm text-neutral-400 mt-4">
View domains blocked by the spam filter and check for false positives. View domains blocked by the spam filter and check for false positives.
</p> </p>
</Link> </Link>

View File

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

View File

@@ -122,8 +122,8 @@ export default function NotificationsPage() {
)} )}
</div> </div>
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-2">Notifications</h1> <h1 className="text-2xl font-bold text-white mb-2">Notifications</h1>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-6"> <p className="text-sm text-neutral-400 mb-6">
Manage which notifications you receive in{' '} Manage which notifications you receive in{' '}
<Link href="/org-settings?tab=notifications" className="text-brand-orange hover:underline"> <Link href="/org-settings?tab=notifications" className="text-brand-orange hover:underline">
Organization Settings Notifications Organization Settings Notifications
@@ -137,7 +137,7 @@ export default function NotificationsPage() {
{error} {error}
</div> </div>
) : notifications.length === 0 ? ( ) : notifications.length === 0 ? (
<div className="p-6 text-center text-neutral-500 dark:text-neutral-400 rounded-2xl border border-neutral-200 dark:border-neutral-800"> <div className="p-6 text-center text-neutral-400 rounded-2xl border border-neutral-200 dark:border-neutral-800">
<p>No notifications yet</p> <p>No notifications yet</p>
<p className="text-sm mt-2"> <p className="text-sm mt-2">
Manage which notifications you receive in{' '} Manage which notifications you receive in{' '}
@@ -159,11 +159,11 @@ export default function NotificationsPage() {
<div className="flex gap-3"> <div className="flex gap-3">
{getTypeIcon(n.type)} {getTypeIcon(n.type)}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className={`text-sm ${!n.read ? 'font-medium' : ''} text-neutral-900 dark:text-white`}> <p className={`text-sm ${!n.read ? 'font-medium' : ''} text-white`}>
{n.title} {n.title}
</p> </p>
{n.body && ( {n.body && (
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-0.5">{n.body}</p> <p className="text-xs text-neutral-400 mt-0.5">{n.body}</p>
)} )}
<p className="text-xs text-neutral-400 dark:text-neutral-500 mt-1"> <p className="text-xs text-neutral-400 dark:text-neutral-500 mt-1">
{formatTimeAgo(n.created_at)} {formatTimeAgo(n.created_at)}
@@ -182,11 +182,11 @@ export default function NotificationsPage() {
<div className="flex gap-3"> <div className="flex gap-3">
{getTypeIcon(n.type)} {getTypeIcon(n.type)}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className={`text-sm ${!n.read ? 'font-medium' : ''} text-neutral-900 dark:text-white`}> <p className={`text-sm ${!n.read ? 'font-medium' : ''} text-white`}>
{n.title} {n.title}
</p> </p>
{n.body && ( {n.body && (
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-0.5">{n.body}</p> <p className="text-xs text-neutral-400 mt-0.5">{n.body}</p>
)} )}
<p className="text-xs text-neutral-400 dark:text-neutral-500 mt-1"> <p className="text-xs text-neutral-400 dark:text-neutral-500 mt-1">
{formatTimeAgo(n.created_at)} {formatTimeAgo(n.created_at)}

View File

@@ -47,7 +47,7 @@ export default function OnboardingPage() {
<div className="min-h-screen flex items-center justify-center bg-neutral-50 dark:bg-neutral-900 px-4"> <div className="min-h-screen flex items-center justify-center bg-neutral-50 dark:bg-neutral-900 px-4">
<div className="max-w-md w-full space-y-8"> <div className="max-w-md w-full space-y-8">
<div className="text-center"> <div className="text-center">
<h2 className="mt-6 text-2xl font-bold text-neutral-900 dark:text-white"> <h2 className="mt-6 text-2xl font-bold text-white">
Welcome to Pulse Welcome to Pulse
</h2> </h2>
<p className="mt-2 text-sm text-neutral-600 dark:text-neutral-400"> <p className="mt-2 text-sm text-neutral-600 dark:text-neutral-400">

View File

@@ -195,7 +195,7 @@ export default function PublicDashboardPage() {
<div className="w-12 h-12 bg-brand-orange/10 rounded-xl flex items-center justify-center mx-auto mb-4 text-brand-orange"> <div className="w-12 h-12 bg-brand-orange/10 rounded-xl flex items-center justify-center mx-auto mb-4 text-brand-orange">
<ZapIcon className="w-6 h-6" /> <ZapIcon className="w-6 h-6" />
</div> </div>
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-2"> <h1 className="text-2xl font-bold text-white mb-2">
Protected Dashboard Protected Dashboard
</h1> </h1>
<p className="text-neutral-600 dark:text-neutral-400"> <p className="text-neutral-600 dark:text-neutral-400">
@@ -210,7 +210,7 @@ export default function PublicDashboardPage() {
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
placeholder="Enter password" placeholder="Enter password"
className="w-full px-4 py-2 border border-neutral-300 dark:border-neutral-700 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-white focus:ring-2 focus:ring-brand-orange focus:border-transparent" className="w-full px-4 py-2 border border-neutral-300 dark:border-neutral-700 rounded-lg bg-white dark:bg-neutral-800 text-white focus:ring-2 focus:ring-brand-orange focus:border-transparent"
autoFocus autoFocus
/> />
</div> </div>
@@ -270,7 +270,7 @@ export default function PublicDashboardPage() {
<div className="w-2 h-2 rounded-full bg-brand-orange animate-pulse" /> <div className="w-2 h-2 rounded-full bg-brand-orange animate-pulse" />
<span className="text-sm font-medium text-brand-orange uppercase tracking-wider">Public Dashboard</span> <span className="text-sm font-medium text-brand-orange uppercase tracking-wider">Public Dashboard</span>
</div> </div>
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white flex items-center gap-3"> <h1 className="text-2xl font-bold text-white flex items-center gap-3">
<Image <Image
src={`${FAVICON_SERVICE_URL}?domain=${site.domain}&sz=64`} src={`${FAVICON_SERVICE_URL}?domain=${site.domain}&sz=64`}
alt={site.name} alt={site.name}

View File

@@ -0,0 +1,13 @@
'use client'
import ErrorDisplay from '@/components/ErrorDisplay'
export default function BehaviorError({ reset }: { error: Error; reset: () => void }) {
return (
<ErrorDisplay
title="Behavior data failed to load"
message="We couldn't load the frustration signals. This might be a temporary issue — try again."
onRetry={reset}
/>
)
}

View File

@@ -2,7 +2,7 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { useParams } from 'next/navigation' import { useParams } from 'next/navigation'
import { getDateRange, formatDate } from '@ciphera-net/ui' import { getDateRange, formatDate, getThisWeekRange, getThisMonthRange } from '@/lib/utils/dateRanges'
import { Select, DatePicker } from '@ciphera-net/ui' import { Select, DatePicker } from '@ciphera-net/ui'
import dynamic from 'next/dynamic' import dynamic from 'next/dynamic'
import { getRageClicks, getDeadClicks } from '@/lib/api/stats' import { getRageClicks, getDeadClicks } from '@/lib/api/stats'
@@ -15,20 +15,6 @@ import { BehaviorSkeleton, useMinimumLoading, useSkeletonFade } from '@/componen
const ScrollDepth = dynamic(() => import('@/components/dashboard/ScrollDepth')) const ScrollDepth = dynamic(() => import('@/components/dashboard/ScrollDepth'))
function getThisWeekRange(): { start: string; end: string } {
const today = new Date()
const dayOfWeek = today.getDay()
const monday = new Date(today)
monday.setDate(today.getDate() - (dayOfWeek === 0 ? 6 : dayOfWeek - 1))
return { start: formatDate(monday), end: formatDate(today) }
}
function getThisMonthRange(): { start: string; end: string } {
const today = new Date()
const firstOfMonth = new Date(today.getFullYear(), today.getMonth(), 1)
return { start: formatDate(firstOfMonth), end: formatDate(today) }
}
export default function BehaviorPage() { export default function BehaviorPage() {
const params = useParams() const params = useParams()
const siteId = params.id as string const siteId = params.id as string
@@ -74,10 +60,10 @@ export default function BehaviorPage() {
{/* Header */} {/* Header */}
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> <div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div> <div>
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-1"> <h1 className="text-2xl font-bold text-white mb-1">
Behavior Behavior
</h1> </h1>
<p className="text-sm text-neutral-500 dark:text-neutral-400"> <p className="text-sm text-neutral-400">
Frustration signals and user engagement patterns Frustration signals and user engagement patterns
</p> </p>
</div> </div>

View File

@@ -0,0 +1,13 @@
'use client'
import ErrorDisplay from '@/components/ErrorDisplay'
export default function CDNError({ reset }: { error: Error; reset: () => void }) {
return (
<ErrorDisplay
title="CDN data failed to load"
message="We couldn't load the BunnyCDN data. This might be a temporary issue — try again."
onRetry={reset}
/>
)
}

View File

@@ -177,10 +177,10 @@ export default function CDNPage() {
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-5 mb-6"> <div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-5 mb-6">
<CloudArrowUp size={40} className="text-neutral-400 dark:text-neutral-500" /> <CloudArrowUp size={40} className="text-neutral-400 dark:text-neutral-500" />
</div> </div>
<h2 className="text-xl font-semibold text-neutral-900 dark:text-white mb-2"> <h2 className="text-xl font-semibold text-white mb-2">
Connect BunnyCDN Connect BunnyCDN
</h2> </h2>
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-md mb-6"> <p className="text-sm text-neutral-400 max-w-md mb-6">
Monitor your CDN performance including bandwidth usage, cache hit rates, request volumes, and geographic distribution. Monitor your CDN performance including bandwidth usage, cache hit rates, request volumes, and geographic distribution.
</p> </p>
<Link <Link
@@ -212,10 +212,10 @@ export default function CDNPage() {
{/* Header */} {/* Header */}
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> <div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div> <div>
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-1"> <h1 className="text-2xl font-bold text-white mb-1">
CDN Analytics CDN Analytics
</h1> </h1>
<p className="text-sm text-neutral-500 dark:text-neutral-400"> <p className="text-sm text-neutral-400">
BunnyCDN performance, bandwidth, and cache metrics BunnyCDN performance, bandwidth, and cache metrics
</p> </p>
</div> </div>
@@ -281,7 +281,7 @@ export default function CDNPage() {
{/* Bandwidth chart */} {/* Bandwidth chart */}
<div className="rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-6 mb-6"> <div className="rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-6 mb-6">
<h2 className="text-sm font-semibold text-neutral-900 dark:text-white mb-4">Bandwidth</h2> <h2 className="text-sm font-semibold text-white mb-4">Bandwidth</h2>
{daily.length > 0 ? ( {daily.length > 0 ? (
<ResponsiveContainer width="100%" height={280}> <ResponsiveContainer width="100%" height={280}>
<AreaChart data={daily} margin={{ top: 4, right: 4, bottom: 0, left: 0 }}> <AreaChart data={daily} margin={{ top: 4, right: 4, bottom: 0, left: 0 }}>
@@ -317,8 +317,8 @@ export default function CDNPage() {
if (!active || !payload?.length) return null if (!active || !payload?.length) return null
return ( return (
<div className="rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 shadow-lg text-sm"> <div className="rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 shadow-lg text-sm">
<p className="text-neutral-500 dark:text-neutral-400 mb-1">{formatDateShort(label)}</p> <p className="text-neutral-400 mb-1">{formatDateShort(label)}</p>
<p className="text-neutral-900 dark:text-white font-medium"> <p className="text-white font-medium">
Total: {formatBytes(payload[0]?.value as number)} Total: {formatBytes(payload[0]?.value as number)}
</p> </p>
{payload[1] && ( {payload[1] && (
@@ -359,7 +359,7 @@ export default function CDNPage() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
{/* Requests chart */} {/* Requests chart */}
<div className="rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-6"> <div className="rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-6">
<h2 className="text-sm font-semibold text-neutral-900 dark:text-white mb-4">Requests</h2> <h2 className="text-sm font-semibold text-white mb-4">Requests</h2>
{daily.length > 0 ? ( {daily.length > 0 ? (
<ResponsiveContainer width="100%" height={220}> <ResponsiveContainer width="100%" height={220}>
<BarChart data={daily} margin={{ top: 4, right: 4, bottom: 0, left: 0 }}> <BarChart data={daily} margin={{ top: 4, right: 4, bottom: 0, left: 0 }}>
@@ -385,8 +385,8 @@ export default function CDNPage() {
if (!active || !payload?.length) return null if (!active || !payload?.length) return null
return ( return (
<div className="rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 shadow-lg text-sm"> <div className="rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 shadow-lg text-sm">
<p className="text-neutral-500 dark:text-neutral-400 mb-1">{formatDateShort(label)}</p> <p className="text-neutral-400 mb-1">{formatDateShort(label)}</p>
<p className="text-neutral-900 dark:text-white font-medium"> <p className="text-white font-medium">
{formatNumber(payload[0]?.value as number)} requests {formatNumber(payload[0]?.value as number)} requests
</p> </p>
</div> </div>
@@ -405,7 +405,7 @@ export default function CDNPage() {
{/* Errors chart */} {/* Errors chart */}
<div className="rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-6"> <div className="rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-6">
<h2 className="text-sm font-semibold text-neutral-900 dark:text-white mb-4">Errors</h2> <h2 className="text-sm font-semibold text-white mb-4">Errors</h2>
{daily.length > 0 ? ( {daily.length > 0 ? (
<ResponsiveContainer width="100%" height={220}> <ResponsiveContainer width="100%" height={220}>
<BarChart <BarChart
@@ -439,7 +439,7 @@ export default function CDNPage() {
if (!active || !payload?.length) return null if (!active || !payload?.length) return null
return ( return (
<div className="rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 shadow-lg text-sm"> <div className="rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 shadow-lg text-sm">
<p className="text-neutral-500 dark:text-neutral-400 mb-1">{formatDateShort(label)}</p> <p className="text-neutral-400 mb-1">{formatDateShort(label)}</p>
{payload.map((entry) => ( {payload.map((entry) => (
<p key={entry.name} style={{ color: entry.color }} className="font-medium"> <p key={entry.name} style={{ color: entry.color }} className="font-medium">
{entry.name}: {formatNumber(entry.value as number)} {entry.name}: {formatNumber(entry.value as number)}
@@ -464,7 +464,7 @@ export default function CDNPage() {
{/* Traffic Distribution */} {/* Traffic Distribution */}
<div className="rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-6"> <div className="rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-6">
<h2 className="text-sm font-semibold text-neutral-900 dark:text-white mb-4">Traffic Distribution</h2> <h2 className="text-sm font-semibold text-white mb-4">Traffic Distribution</h2>
{countries.length > 0 ? ( {countries.length > 0 ? (
<> <>
<div className="h-[360px] mb-8"> <div className="h-[360px] mb-8">
@@ -480,9 +480,9 @@ export default function CDNPage() {
<div className="flex items-center gap-2.5 mb-2"> <div className="flex items-center gap-2.5 mb-2">
{cc && getFlagIcon(cc)} {cc && getFlagIcon(cc)}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<span className="text-sm font-medium text-neutral-900 dark:text-white truncate block">{city}</span> <span className="text-sm font-medium text-white truncate block">{city}</span>
</div> </div>
<span className="text-sm tabular-nums text-neutral-500 dark:text-neutral-400 shrink-0"> <span className="text-sm tabular-nums text-neutral-400 shrink-0">
{formatBytes(row.bandwidth)} {formatBytes(row.bandwidth)}
</span> </span>
</div> </div>
@@ -530,13 +530,13 @@ function OverviewCard({
return ( return (
<div className="p-4 rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900"> <div className="p-4 rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900">
<p className="text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-1">{label}</p> <p className="text-xs font-medium text-neutral-400 mb-1">{label}</p>
<p className="text-2xl font-bold text-neutral-900 dark:text-white">{value}</p> <p className="text-2xl font-bold text-white">{value}</p>
{changeLabel && ( {changeLabel && (
<p className={`text-xs mt-1 font-medium ${ <p className={`text-xs mt-1 font-medium ${
isGood ? 'text-green-600 dark:text-green-400' : isGood ? 'text-green-600 dark:text-green-400' :
isBad ? 'text-red-600 dark:text-red-400' : isBad ? 'text-red-600 dark:text-red-400' :
'text-neutral-500 dark:text-neutral-400' 'text-neutral-400'
}`}> }`}>
{changeLabel} vs previous period {changeLabel} vs previous period
</p> </p>

View File

@@ -25,7 +25,7 @@ export default function FunnelReportPage() {
const [funnel, setFunnel] = useState<Funnel | null>(null) const [funnel, setFunnel] = useState<Funnel | null>(null)
const [stats, setStats] = useState<FunnelStats | null>(null) const [stats, setStats] = useState<FunnelStats | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [dateRange, setDateRange] = useState(getDateRange(30)) const [dateRange, setDateRange] = useState(() => getDateRange(30))
const [datePreset, setDatePreset] = useState<'7' | '30' | 'custom'>('30') const [datePreset, setDatePreset] = useState<'7' | '30' | 'custom'>('30')
const [isDatePickerOpen, setIsDatePickerOpen] = useState(false) const [isDatePickerOpen, setIsDatePickerOpen] = useState(false)
const [loadError, setLoadError] = useState<'not_found' | 'forbidden' | 'error' | null>(null) const [loadError, setLoadError] = useState<'not_found' | 'forbidden' | 'error' | null>(null)
@@ -154,7 +154,7 @@ export default function FunnelReportPage() {
<ChevronLeftIcon className="w-5 h-5" /> <ChevronLeftIcon className="w-5 h-5" />
</Link> </Link>
<div> <div>
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white"> <h1 className="text-2xl font-bold text-white">
{funnel.name} {funnel.name}
</h1> </h1>
{funnel.description && ( {funnel.description && (
@@ -236,7 +236,7 @@ export default function FunnelReportPage() {
{trends && trends.dates.length > 1 && ( {trends && trends.dates.length > 1 && (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden shadow-sm p-6 mb-8"> <div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden shadow-sm p-6 mb-8">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white"> <h3 className="text-lg font-semibold text-white">
Conversion Trends Conversion Trends
</h3> </h3>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
@@ -322,10 +322,10 @@ export default function FunnelReportPage() {
<table className="w-full text-left text-sm"> <table className="w-full text-left text-sm">
<thead className="bg-neutral-50 dark:bg-neutral-800/50 border-b border-neutral-200 dark:border-neutral-800"> <thead className="bg-neutral-50 dark:bg-neutral-800/50 border-b border-neutral-200 dark:border-neutral-800">
<tr> <tr>
<th className="px-6 py-4 font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Step</th> <th className="px-6 py-4 font-medium text-neutral-400 uppercase tracking-wider">Step</th>
<th className="px-6 py-4 font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider text-right">Visitors</th> <th className="px-6 py-4 font-medium text-neutral-400 uppercase tracking-wider text-right">Visitors</th>
<th className="px-6 py-4 font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider text-right">Drop-off</th> <th className="px-6 py-4 font-medium text-neutral-400 uppercase tracking-wider text-right">Drop-off</th>
<th className="px-6 py-4 font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider text-right">Conversion</th> <th className="px-6 py-4 font-medium text-neutral-400 uppercase tracking-wider text-right">Conversion</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-800"> <tbody className="divide-y divide-neutral-200 dark:divide-neutral-800">
@@ -338,13 +338,13 @@ export default function FunnelReportPage() {
{i + 1} {i + 1}
</span> </span>
<div> <div>
<p className="font-medium text-neutral-900 dark:text-white">{step.step.name}</p> <p className="font-medium text-white">{step.step.name}</p>
<p className="text-neutral-500 dark:text-neutral-400 text-xs font-mono mt-0.5">{step.step.value}</p> <p className="text-neutral-400 text-xs font-mono mt-0.5">{step.step.value}</p>
</div> </div>
</div> </div>
</td> </td>
<td className="px-6 py-4 text-right"> <td className="px-6 py-4 text-right">
<span className="font-medium text-neutral-900 dark:text-white"> <span className="font-medium text-white">
{step.visitors.toLocaleString()} {step.visitors.toLocaleString()}
</span> </span>
</td> </td>

View File

@@ -40,7 +40,7 @@ export default function FunnelsPage() {
<div className="mb-8"> <div className="mb-8">
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<div> <div>
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white"> <h1 className="text-2xl font-bold text-white">
Funnels Funnels
</h1> </h1>
<p className="text-neutral-600 dark:text-neutral-400"> <p className="text-neutral-600 dark:text-neutral-400">
@@ -65,7 +65,7 @@ export default function FunnelsPage() {
className="mb-6" className="mb-6"
unoptimized unoptimized
/> />
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white mb-2"> <h3 className="text-lg font-semibold text-white mb-2">
No funnels yet No funnels yet
</h3> </h3>
<p className="text-neutral-600 dark:text-neutral-400 mb-6 max-w-md mx-auto"> <p className="text-neutral-600 dark:text-neutral-400 mb-6 max-w-md mx-auto">
@@ -89,7 +89,7 @@ export default function FunnelsPage() {
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 hover:border-brand-orange/50 transition-colors"> <div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 hover:border-brand-orange/50 transition-colors">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h3 className="text-xl font-bold text-neutral-900 dark:text-white group-hover:text-brand-orange transition-colors"> <h3 className="text-xl font-bold text-white group-hover:text-brand-orange transition-colors">
{funnel.name} {funnel.name}
</h3> </h3>
{funnel.description && ( {funnel.description && (

View File

@@ -3,7 +3,7 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useParams } from 'next/navigation' import { useParams } from 'next/navigation'
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { getDateRange, formatDate } from '@ciphera-net/ui' import { getDateRange, formatDate, getThisWeekRange, getThisMonthRange } from '@/lib/utils/dateRanges'
import { Select, DatePicker } from '@ciphera-net/ui' import { Select, DatePicker } from '@ciphera-net/ui'
import ColumnJourney from '@/components/journeys/ColumnJourney' import ColumnJourney from '@/components/journeys/ColumnJourney'
import SankeyJourney from '@/components/journeys/SankeyJourney' import SankeyJourney from '@/components/journeys/SankeyJourney'
@@ -18,20 +18,6 @@ import {
const DEFAULT_DEPTH = 4 const DEFAULT_DEPTH = 4
function getThisWeekRange(): { start: string; end: string } {
const today = new Date()
const dayOfWeek = today.getDay()
const monday = new Date(today)
monday.setDate(today.getDate() - (dayOfWeek === 0 ? 6 : dayOfWeek - 1))
return { start: formatDate(monday), end: formatDate(today) }
}
function getThisMonthRange(): { start: string; end: string } {
const today = new Date()
const firstOfMonth = new Date(today.getFullYear(), today.getMonth(), 1)
return { start: formatDate(firstOfMonth), end: formatDate(today) }
}
export default function JourneysPage() { export default function JourneysPage() {
const params = useParams() const params = useParams()
const siteId = params.id as string const siteId = params.id as string
@@ -91,10 +77,10 @@ export default function JourneysPage() {
{/* Header */} {/* Header */}
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> <div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div> <div>
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-1"> <h1 className="text-2xl font-bold text-white mb-1">
Journeys Journeys
</h1> </h1>
<p className="text-sm text-neutral-500 dark:text-neutral-400"> <p className="text-sm text-neutral-400">
How visitors navigate through your site How visitors navigate through your site
</p> </p>
</div> </div>
@@ -143,7 +129,7 @@ export default function JourneysPage() {
<div className="flex flex-col sm:flex-row sm:items-center gap-6"> <div className="flex flex-col sm:flex-row sm:items-center gap-6">
{/* Depth slider */} {/* Depth slider */}
<div className="flex-1"> <div className="flex-1">
<div className="flex justify-between text-sm font-medium text-neutral-500 dark:text-neutral-400 mb-3"> <div className="flex justify-between text-sm font-medium text-neutral-400 mb-3">
<span>2 steps</span> <span>2 steps</span>
<span className="text-brand-orange font-bold"> <span className="text-brand-orange font-bold">
{depth} steps deep {depth} steps deep
@@ -196,7 +182,7 @@ export default function JourneysPage() {
aria-selected={viewMode === mode} aria-selected={viewMode === mode}
className={`relative px-3 py-1 text-xs font-medium transition-colors capitalize focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange rounded cursor-pointer ${ className={`relative px-3 py-1 text-xs font-medium transition-colors capitalize focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange rounded cursor-pointer ${
viewMode === mode viewMode === mode
? 'text-neutral-900 dark:text-white' ? 'text-white'
: 'text-neutral-400 dark:text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300' : 'text-neutral-400 dark:text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300'
}`} }`}
> >
@@ -232,7 +218,7 @@ export default function JourneysPage() {
{/* Footer */} {/* Footer */}
{totalSessions > 0 && ( {totalSessions > 0 && (
<div className="px-6 pb-5 text-sm text-neutral-500 dark:text-neutral-400"> <div className="px-6 pb-5 text-sm text-neutral-400">
{totalSessions.toLocaleString()} sessions tracked {totalSessions.toLocaleString()} sessions tracked
</div> </div>
)} )}

View File

@@ -17,7 +17,7 @@ import {
type Stats, type Stats,
type DailyStat, type DailyStat,
} from '@/lib/api/stats' } from '@/lib/api/stats'
import { getDateRange, formatDate } from '@ciphera-net/ui' import { getDateRange, formatDate, getThisWeekRange, getThisMonthRange } from '@/lib/utils/dateRanges'
import { toast } from '@ciphera-net/ui' import { toast } from '@ciphera-net/ui'
import { Button } from '@ciphera-net/ui' import { Button } from '@ciphera-net/ui'
import { Select, DatePicker, DownloadIcon } from '@ciphera-net/ui' import { Select, DatePicker, DownloadIcon } from '@ciphera-net/ui'
@@ -63,19 +63,6 @@ function loadSavedSettings(): {
} }
} }
function getThisWeekRange(): { start: string; end: string } {
const today = new Date()
const dayOfWeek = today.getDay()
const monday = new Date(today)
monday.setDate(today.getDate() - (dayOfWeek === 0 ? 6 : dayOfWeek - 1))
return { start: formatDate(monday), end: formatDate(today) }
}
function getThisMonthRange(): { start: string; end: string } {
const today = new Date()
const firstOfMonth = new Date(today.getFullYear(), today.getMonth(), 1)
return { start: formatDate(firstOfMonth), end: formatDate(today) }
}
function getInitialDateRange(): { start: string; end: string } { function getInitialDateRange(): { start: string; end: string } {
const settings = loadSavedSettings() const settings = loadSavedSettings()
@@ -442,7 +429,7 @@ export default function SiteDashboardPage() {
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div> <div>
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-1"> <h1 className="text-2xl font-bold text-white mb-1">
{site.name} {site.name}
</h1> </h1>
<p className="text-neutral-600 dark:text-neutral-400"> <p className="text-neutral-600 dark:text-neutral-400">

View File

@@ -0,0 +1,13 @@
'use client'
import ErrorDisplay from '@/components/ErrorDisplay'
export default function PageSpeedError({ reset }: { error: Error; reset: () => void }) {
return (
<ErrorDisplay
title="PageSpeed data failed to load"
message="We couldn't load the PageSpeed data. This might be a temporary issue — try again."
onRetry={reset}
/>
)
}

View File

@@ -9,6 +9,7 @@ import { toast, Button } from '@ciphera-net/ui'
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import ScoreGauge from '@/components/pagespeed/ScoreGauge' import ScoreGauge from '@/components/pagespeed/ScoreGauge'
import { AreaChart as VisxAreaChart, Area as VisxArea, Grid as VisxGrid, XAxis as VisxXAxis, YAxis as VisxYAxis, ChartTooltip as VisxChartTooltip } from '@/components/ui/area-chart' import { AreaChart as VisxAreaChart, Area as VisxArea, Grid as VisxGrid, XAxis as VisxXAxis, YAxis as VisxYAxis, ChartTooltip as VisxChartTooltip } from '@/components/ui/area-chart'
import { useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
// * Metric status thresholds (Google's Core Web Vitals thresholds) // * Metric status thresholds (Google's Core Web Vitals thresholds)
function getMetricStatus(metric: string, value: number | null): { label: string; color: string } { function getMetricStatus(metric: string, value: number | null): { label: string; color: string } {
@@ -223,8 +224,10 @@ export default function PageSpeedPage() {
} }
} }
// * Loading state // * Loading state with minimum display time (consistent with other pages)
if (isLoading && !latestChecks) return <PageSpeedSkeleton /> const showSkeleton = useMinimumLoading(isLoading && !latestChecks)
const fadeClass = useSkeletonFade(showSkeleton)
if (showSkeleton) return <PageSpeedSkeleton />
if (!site) return <div className="p-8 text-neutral-500">Site not found</div> if (!site) return <div className="p-8 text-neutral-500">Site not found</div>
const enabled = config?.enabled ?? false const enabled = config?.enabled ?? false
@@ -235,10 +238,10 @@ export default function PageSpeedPage() {
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8"> <div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
{/* Header */} {/* Header */}
<div className="mb-8"> <div className="mb-8">
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-1"> <h1 className="text-2xl font-bold text-white mb-1">
PageSpeed PageSpeed
</h1> </h1>
<p className="text-sm text-neutral-500 dark:text-neutral-400"> <p className="text-sm text-neutral-400">
Monitor your site&apos;s performance and Core Web Vitals Monitor your site&apos;s performance and Core Web Vitals
</p> </p>
</div> </div>
@@ -246,14 +249,14 @@ export default function PageSpeedPage() {
{/* Empty state */} {/* Empty state */}
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-12 text-center"> <div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-12 text-center">
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4 w-16 h-16 mx-auto mb-4 flex items-center justify-center"> <div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4 w-16 h-16 mx-auto mb-4 flex items-center justify-center">
<svg className="w-8 h-8 text-neutral-500 dark:text-neutral-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <svg className="w-8 h-8 text-neutral-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" /> <path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg> </svg>
</div> </div>
<h3 className="font-semibold text-neutral-900 dark:text-white mb-2"> <h3 className="font-semibold text-white mb-2">
PageSpeed monitoring is disabled PageSpeed monitoring is disabled
</h3> </h3>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-6 max-w-md mx-auto"> <p className="text-sm text-neutral-400 mb-6 max-w-md mx-auto">
Enable PageSpeed monitoring to track your site&apos;s performance scores, Core Web Vitals, and get actionable improvement suggestions. Enable PageSpeed monitoring to track your site&apos;s performance scores, Core Web Vitals, and get actionable improvement suggestions.
</p> </p>
@@ -263,7 +266,7 @@ export default function PageSpeedPage() {
<select <select
value={frequency} value={frequency}
onChange={(e) => setFrequency(e.target.value)} onChange={(e) => setFrequency(e.target.value)}
className="text-sm border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-white rounded-lg px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-neutral-900 dark:focus:ring-neutral-100" className="text-sm border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-800 text-white rounded-lg px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-neutral-900 dark:focus:ring-neutral-100"
> >
<option value="daily">Daily</option> <option value="daily">Daily</option>
<option value="weekly">Weekly</option> <option value="weekly">Weekly</option>
@@ -358,10 +361,10 @@ export default function PageSpeedPage() {
{/* Header */} {/* Header */}
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> <div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div> <div>
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-1"> <h1 className="text-2xl font-bold text-white mb-1">
PageSpeed PageSpeed
</h1> </h1>
<p className="text-sm text-neutral-500 dark:text-neutral-400"> <p className="text-sm text-neutral-400">
Performance scores and Core Web Vitals for {site.domain} Performance scores and Core Web Vitals for {site.domain}
</p> </p>
</div> </div>
@@ -443,7 +446,7 @@ export default function PageSpeedPage() {
{/* Check navigator + frequency + legend */} {/* Check navigator + frequency + legend */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 mt-6 pt-4 border-t border-neutral-100 dark:border-neutral-800"> <div className="flex flex-wrap items-center gap-x-4 gap-y-2 mt-6 pt-4 border-t border-neutral-100 dark:border-neutral-800">
<div className="flex items-center gap-2 text-sm text-neutral-500 dark:text-neutral-400"> <div className="flex items-center gap-2 text-sm text-neutral-400">
{/* Prev/Next arrows */} {/* Prev/Next arrows */}
{checkTimestamps.length > 1 && ( {checkTimestamps.length > 1 && (
<button <button
@@ -497,7 +500,7 @@ export default function PageSpeedPage() {
{/* Filmstrip — page load progression */} {/* Filmstrip — page load progression */}
{currentCheck?.filmstrip && currentCheck.filmstrip.length > 0 && ( {currentCheck?.filmstrip && currentCheck.filmstrip.length > 0 && (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 sm:p-8 mb-6 relative"> <div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 sm:p-8 mb-6 relative">
<h3 className="text-xs font-semibold text-neutral-500 dark:text-neutral-400 uppercase tracking-wider mb-4"> <h3 className="text-xs font-semibold text-neutral-400 uppercase tracking-wider mb-4">
Page Load Timeline Page Load Timeline
</h3> </h3>
<div className="flex items-center overflow-x-auto gap-1 scrollbar-none"> <div className="flex items-center overflow-x-auto gap-1 scrollbar-none">
@@ -521,7 +524,7 @@ export default function PageSpeedPage() {
{/* Section 2 — Metrics Card */} {/* Section 2 — Metrics Card */}
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 sm:p-8 mb-6"> <div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 sm:p-8 mb-6">
<h3 className="text-xs font-semibold text-neutral-500 dark:text-neutral-400 uppercase tracking-wider mb-5"> <h3 className="text-xs font-semibold text-neutral-400 uppercase tracking-wider mb-5">
Metrics Metrics
</h3> </h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-6"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-6">
@@ -529,10 +532,10 @@ export default function PageSpeedPage() {
<div key={key} className="flex items-start gap-3"> <div key={key} className="flex items-start gap-3">
<span className={`mt-1.5 inline-block w-2.5 h-2.5 rounded-full flex-shrink-0 ${getMetricDotColor(key, value)}`} /> <span className={`mt-1.5 inline-block w-2.5 h-2.5 rounded-full flex-shrink-0 ${getMetricDotColor(key, value)}`} />
<div> <div>
<div className="text-sm text-neutral-500 dark:text-neutral-400"> <div className="text-sm text-neutral-400">
{label} {label}
</div> </div>
<div className="text-2xl font-semibold text-neutral-900 dark:text-white tabular-nums"> <div className="text-2xl font-semibold text-white tabular-nums">
{formatMetricValue(key, value)} {formatMetricValue(key, value)}
</div> </div>
</div> </div>
@@ -544,7 +547,7 @@ export default function PageSpeedPage() {
{/* Section 3 — Score Trend Chart (visx) */} {/* Section 3 — Score Trend Chart (visx) */}
{chartData.length >= 2 && ( {chartData.length >= 2 && (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 sm:p-8 mb-6 overflow-hidden"> <div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 sm:p-8 mb-6 overflow-hidden">
<h3 className="text-xs font-semibold text-neutral-500 dark:text-neutral-400 uppercase tracking-wider mb-4"> <h3 className="text-xs font-semibold text-neutral-400 uppercase tracking-wider mb-4">
Performance Score Trend Performance Score Trend
</h3> </h3>
<div> <div>
@@ -597,10 +600,10 @@ export default function PageSpeedPage() {
<div className="flex items-center gap-5 mb-6"> <div className="flex items-center gap-5 mb-6">
<ScoreGauge score={scoreByGroup[group.key]} label="" size={56} /> <ScoreGauge score={scoreByGroup[group.key]} label="" size={56} />
<div> <div>
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white"> <h3 className="text-lg font-semibold text-white">
{group.label} {group.label}
</h3> </h3>
<p className="text-xs text-neutral-500 dark:text-neutral-400"> <p className="text-xs text-neutral-400">
{(() => { {(() => {
const realIssues = groupAudits.filter(a => a.score !== null && a.score !== undefined).length const realIssues = groupAudits.filter(a => a.score !== null && a.score !== undefined).length
return realIssues === 0 ? 'No issues found' : `${realIssues} issue${realIssues !== 1 ? 's' : ''} found` return realIssues === 0 ? 'No issues found' : `${realIssues} issue${realIssues !== 1 ? 's' : ''} found`
@@ -615,7 +618,7 @@ export default function PageSpeedPage() {
{groupManual.length > 0 && ( {groupManual.length > 0 && (
<details className="mt-4"> <details className="mt-4">
<summary className="cursor-pointer text-sm font-medium text-neutral-500 dark:text-neutral-400 select-none hover:text-neutral-700 dark:hover:text-neutral-300 transition-colors"> <summary className="cursor-pointer text-sm font-medium text-neutral-400 select-none hover:text-neutral-700 dark:hover:text-neutral-300 transition-colors">
<span className="ml-1">Additional items to manually check ({groupManual.length})</span> <span className="ml-1">Additional items to manually check ({groupManual.length})</span>
</summary> </summary>
<div className="mt-2 divide-y divide-neutral-100 dark:divide-neutral-800"> <div className="mt-2 divide-y divide-neutral-100 dark:divide-neutral-800">
@@ -626,7 +629,7 @@ export default function PageSpeedPage() {
{groupPassed.length > 0 && ( {groupPassed.length > 0 && (
<details className="mt-4"> <details className="mt-4">
<summary className="cursor-pointer text-sm font-medium text-neutral-500 dark:text-neutral-400 select-none hover:text-neutral-700 dark:hover:text-neutral-300 transition-colors"> <summary className="cursor-pointer text-sm font-medium text-neutral-400 select-none hover:text-neutral-700 dark:hover:text-neutral-300 transition-colors">
<span className="ml-1">{groupPassed.length} passed audit{groupPassed.length !== 1 ? 's' : ''}</span> <span className="ml-1">{groupPassed.length} passed audit{groupPassed.length !== 1 ? 's' : ''}</span>
</summary> </summary>
<div className="mt-2 divide-y divide-neutral-100 dark:divide-neutral-800"> <div className="mt-2 divide-y divide-neutral-100 dark:divide-neutral-800">
@@ -738,7 +741,7 @@ function AuditRow({ audit }: { audit: AuditSummary }) {
<details className="group"> <details className="group">
<summary className="flex items-center gap-3 py-3 px-2 rounded-lg hover:bg-neutral-50 dark:hover:bg-neutral-800/50 cursor-pointer list-none"> <summary className="flex items-center gap-3 py-3 px-2 rounded-lg hover:bg-neutral-50 dark:hover:bg-neutral-800/50 cursor-pointer list-none">
<AuditSeverityIcon score={audit.score} /> <AuditSeverityIcon score={audit.score} />
<span className="font-medium text-sm text-neutral-900 dark:text-white flex-1 min-w-0 truncate">{audit.title}</span> <span className="font-medium text-sm text-white flex-1 min-w-0 truncate">{audit.title}</span>
{audit.display_value && ( {audit.display_value && (
<span className="text-xs text-neutral-500 dark:text-neutral-500 flex-shrink-0 tabular-nums">{audit.display_value}</span> <span className="text-xs text-neutral-500 dark:text-neutral-500 flex-shrink-0 tabular-nums">{audit.display_value}</span>
)} )}
@@ -754,7 +757,7 @@ function AuditRow({ audit }: { audit: AuditSummary }) {
<div className="pl-8 pr-2 pb-3 pt-1"> <div className="pl-8 pr-2 pb-3 pt-1">
{/* Description with parsed markdown links */} {/* Description with parsed markdown links */}
{audit.description && ( {audit.description && (
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-3 leading-relaxed"> <p className="text-xs text-neutral-400 mb-3 leading-relaxed">
<AuditDescription text={audit.description} /> <AuditDescription text={audit.description} />
</p> </p>
)} )}
@@ -822,15 +825,15 @@ function AuditItem({ item }: { item: Record<string, any> }) {
{/* Content */} {/* Content */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
{label && ( {label && (
<div className="font-medium text-neutral-900 dark:text-white text-xs mb-0.5"> <div className="font-medium text-white text-xs mb-0.5">
{label} {label}
</div> </div>
)} )}
{url && ( {url && (
<div className="font-mono text-xs text-neutral-500 dark:text-neutral-400 break-all">{url}</div> <div className="font-mono text-xs text-neutral-400 break-all">{url}</div>
)} )}
{text && ( {text && (
<div className="text-xs text-neutral-500 dark:text-neutral-400 mt-0.5">{text}</div> <div className="text-xs text-neutral-400 mt-0.5">{text}</div>
)} )}
{item.node?.snippet && ( {item.node?.snippet && (
<code className="text-xs bg-neutral-100 dark:bg-neutral-800 px-1.5 py-0.5 rounded break-all mt-1 inline-block">{item.node.snippet}</code> <code className="text-xs bg-neutral-100 dark:bg-neutral-800 px-1.5 py-0.5 rounded break-all mt-1 inline-block">{item.node.snippet}</code>

View File

@@ -0,0 +1,13 @@
'use client'
import ErrorDisplay from '@/components/ErrorDisplay'
export default function SearchError({ reset }: { error: Error; reset: () => void }) {
return (
<ErrorDisplay
title="Search Console data failed to load"
message="We couldn't load the Google Search Console data. This might be a temporary issue — try again."
onRetry={reset}
/>
)
}

View File

@@ -3,7 +3,8 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useParams } from 'next/navigation' import { useParams } from 'next/navigation'
import Link from 'next/link' import Link from 'next/link'
import { getDateRange, formatDate, Select, DatePicker } from '@ciphera-net/ui' import { Select, DatePicker } from '@ciphera-net/ui'
import { getDateRange, formatDate, getThisWeekRange, getThisMonthRange } from '@/lib/utils/dateRanges'
import { CaretDown, CaretUp, MagnifyingGlass, ArrowSquareOut } from '@phosphor-icons/react' import { CaretDown, CaretUp, MagnifyingGlass, ArrowSquareOut } from '@phosphor-icons/react'
import { useDashboard, useGSCStatus, useGSCOverview, useGSCTopQueries, useGSCTopPages, useGSCNewQueries } from '@/lib/swr/dashboard' import { useDashboard, useGSCStatus, useGSCOverview, useGSCTopQueries, useGSCTopPages, useGSCNewQueries } from '@/lib/swr/dashboard'
import { getGSCQueryPages, getGSCPageQueries } from '@/lib/api/gsc' import { getGSCQueryPages, getGSCPageQueries } from '@/lib/api/gsc'
@@ -13,20 +14,6 @@ import ClicksImpressionsChart from '@/components/search/ClicksImpressionsChart'
// ─── Helpers ──────────────────────────────────────────────────── // ─── Helpers ────────────────────────────────────────────────────
function getThisWeekRange(): { start: string; end: string } {
const today = new Date()
const dayOfWeek = today.getDay()
const monday = new Date(today)
monday.setDate(today.getDate() - (dayOfWeek === 0 ? 6 : dayOfWeek - 1))
return { start: formatDate(monday), end: formatDate(today) }
}
function getThisMonthRange(): { start: string; end: string } {
const today = new Date()
const firstOfMonth = new Date(today.getFullYear(), today.getMonth(), 1)
return { start: formatDate(firstOfMonth), end: formatDate(today) }
}
const formatPosition = (pos: number) => pos.toFixed(1) const formatPosition = (pos: number) => pos.toFixed(1)
const formatCTR = (ctr: number) => (ctr * 100).toFixed(1) + '%' const formatCTR = (ctr: number) => (ctr * 100).toFixed(1) + '%'
@@ -179,10 +166,10 @@ export default function SearchConsolePage() {
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-5 mb-6"> <div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-5 mb-6">
<MagnifyingGlass size={40} className="text-neutral-400 dark:text-neutral-500" /> <MagnifyingGlass size={40} className="text-neutral-400 dark:text-neutral-500" />
</div> </div>
<h2 className="text-xl font-semibold text-neutral-900 dark:text-white mb-2"> <h2 className="text-xl font-semibold text-white mb-2">
Connect Google Search Console Connect Google Search Console
</h2> </h2>
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-md mb-6"> <p className="text-sm text-neutral-400 max-w-md mb-6">
See how your site performs in Google Search. View top queries, pages, click-through rates, and average position data. See how your site performs in Google Search. View top queries, pages, click-through rates, and average position data.
</p> </p>
<Link <Link
@@ -215,10 +202,10 @@ export default function SearchConsolePage() {
{/* Header */} {/* Header */}
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> <div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div> <div>
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-1"> <h1 className="text-2xl font-bold text-white mb-1">
Search Console Search Console
</h1> </h1>
<p className="text-sm text-neutral-500 dark:text-neutral-400"> <p className="text-sm text-neutral-400">
Google Search performance, queries, and page rankings Google Search performance, queries, and page rankings
</p> </p>
</div> </div>
@@ -296,9 +283,9 @@ export default function SearchConsolePage() {
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-3 mb-6"> <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-3 mb-6">
{topQueries.queries.slice(0, 5).map((q) => ( {topQueries.queries.slice(0, 5).map((q) => (
<div key={q.query} className="rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-3"> <div key={q.query} className="rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-3">
<p className="text-xs text-neutral-500 dark:text-neutral-400 truncate mb-1">{q.query}</p> <p className="text-xs text-neutral-400 truncate mb-1">{q.query}</p>
<div className="flex items-baseline gap-1.5"> <div className="flex items-baseline gap-1.5">
<p className="text-lg font-semibold text-neutral-900 dark:text-white">{q.position.toFixed(1)}</p> <p className="text-lg font-semibold text-white">{q.position.toFixed(1)}</p>
<p className="text-xs text-neutral-400">pos</p> <p className="text-xs text-neutral-400">pos</p>
</div> </div>
<p className="text-xs text-neutral-500 mt-0.5">{q.clicks} {q.clicks === 1 ? 'click' : 'clicks'}</p> <p className="text-xs text-neutral-500 mt-0.5">{q.clicks} {q.clicks === 1 ? 'click' : 'clicks'}</p>
@@ -322,8 +309,8 @@ export default function SearchConsolePage() {
onClick={() => { setActiveView('queries'); setExpandedQuery(null); setExpandedData([]) }} onClick={() => { setActiveView('queries'); setExpandedQuery(null); setExpandedData([]) }}
className={`px-4 py-1.5 text-sm font-medium rounded-md transition-all cursor-pointer ${ className={`px-4 py-1.5 text-sm font-medium rounded-md transition-all cursor-pointer ${
activeView === 'queries' activeView === 'queries'
? 'bg-white dark:bg-neutral-700 text-neutral-900 dark:text-white shadow-sm' ? 'bg-white dark:bg-neutral-700 text-white shadow-sm'
: 'text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300' : 'text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300'
}`} }`}
> >
Top Queries Top Queries
@@ -332,8 +319,8 @@ export default function SearchConsolePage() {
onClick={() => { setActiveView('pages'); setExpandedPage(null); setExpandedData([]) }} onClick={() => { setActiveView('pages'); setExpandedPage(null); setExpandedData([]) }}
className={`px-4 py-1.5 text-sm font-medium rounded-md transition-all cursor-pointer ${ className={`px-4 py-1.5 text-sm font-medium rounded-md transition-all cursor-pointer ${
activeView === 'pages' activeView === 'pages'
? 'bg-white dark:bg-neutral-700 text-neutral-900 dark:text-white shadow-sm' ? 'bg-white dark:bg-neutral-700 text-white shadow-sm'
: 'text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300' : 'text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300'
}`} }`}
> >
Top Pages Top Pages
@@ -347,12 +334,12 @@ export default function SearchConsolePage() {
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b border-neutral-200 dark:border-neutral-800"> <tr className="border-b border-neutral-200 dark:border-neutral-800">
<th className="text-left px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400 w-8" /> <th className="text-left px-4 py-3 font-medium text-neutral-400 w-8" />
<th className="text-left px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">Query</th> <th className="text-left px-4 py-3 font-medium text-neutral-400">Query</th>
<th className="text-right px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">Clicks</th> <th className="text-right px-4 py-3 font-medium text-neutral-400">Clicks</th>
<th className="text-right px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">Impressions</th> <th className="text-right px-4 py-3 font-medium text-neutral-400">Impressions</th>
<th className="text-right px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">CTR</th> <th className="text-right px-4 py-3 font-medium text-neutral-400">CTR</th>
<th className="text-right px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">Position</th> <th className="text-right px-4 py-3 font-medium text-neutral-400">Position</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -369,7 +356,7 @@ export default function SearchConsolePage() {
)) ))
) : queries.length === 0 ? ( ) : queries.length === 0 ? (
<tr> <tr>
<td colSpan={6} className="px-4 py-12 text-center text-neutral-500 dark:text-neutral-400"> <td colSpan={6} className="px-4 py-12 text-center text-neutral-400">
No query data available for this period. No query data available for this period.
</td> </td>
</tr> </tr>
@@ -391,7 +378,7 @@ export default function SearchConsolePage() {
{/* Pagination */} {/* Pagination */}
{queriesTotal > PAGE_SIZE && ( {queriesTotal > PAGE_SIZE && (
<div className="flex items-center justify-between px-4 py-3 border-t border-neutral-200 dark:border-neutral-800"> <div className="flex items-center justify-between px-4 py-3 border-t border-neutral-200 dark:border-neutral-800">
<p className="text-sm text-neutral-500 dark:text-neutral-400"> <p className="text-sm text-neutral-400">
Showing {queryPage * PAGE_SIZE + 1}-{Math.min((queryPage + 1) * PAGE_SIZE, queriesTotal)} of {queriesTotal.toLocaleString()} Showing {queryPage * PAGE_SIZE + 1}-{Math.min((queryPage + 1) * PAGE_SIZE, queriesTotal)} of {queriesTotal.toLocaleString()}
</p> </p>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -421,12 +408,12 @@ export default function SearchConsolePage() {
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b border-neutral-200 dark:border-neutral-800"> <tr className="border-b border-neutral-200 dark:border-neutral-800">
<th className="text-left px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400 w-8" /> <th className="text-left px-4 py-3 font-medium text-neutral-400 w-8" />
<th className="text-left px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">Page</th> <th className="text-left px-4 py-3 font-medium text-neutral-400">Page</th>
<th className="text-right px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">Clicks</th> <th className="text-right px-4 py-3 font-medium text-neutral-400">Clicks</th>
<th className="text-right px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">Impressions</th> <th className="text-right px-4 py-3 font-medium text-neutral-400">Impressions</th>
<th className="text-right px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">CTR</th> <th className="text-right px-4 py-3 font-medium text-neutral-400">CTR</th>
<th className="text-right px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">Position</th> <th className="text-right px-4 py-3 font-medium text-neutral-400">Position</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -443,7 +430,7 @@ export default function SearchConsolePage() {
)) ))
) : pages.length === 0 ? ( ) : pages.length === 0 ? (
<tr> <tr>
<td colSpan={6} className="px-4 py-12 text-center text-neutral-500 dark:text-neutral-400"> <td colSpan={6} className="px-4 py-12 text-center text-neutral-400">
No page data available for this period. No page data available for this period.
</td> </td>
</tr> </tr>
@@ -465,7 +452,7 @@ export default function SearchConsolePage() {
{/* Pagination */} {/* Pagination */}
{pagesTotal > PAGE_SIZE && ( {pagesTotal > PAGE_SIZE && (
<div className="flex items-center justify-between px-4 py-3 border-t border-neutral-200 dark:border-neutral-800"> <div className="flex items-center justify-between px-4 py-3 border-t border-neutral-200 dark:border-neutral-800">
<p className="text-sm text-neutral-500 dark:text-neutral-400"> <p className="text-sm text-neutral-400">
Showing {pagePage * PAGE_SIZE + 1}-{Math.min((pagePage + 1) * PAGE_SIZE, pagesTotal)} of {pagesTotal.toLocaleString()} Showing {pagePage * PAGE_SIZE + 1}-{Math.min((pagePage + 1) * PAGE_SIZE, pagesTotal)} of {pagesTotal.toLocaleString()}
</p> </p>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -522,13 +509,13 @@ function OverviewCard({
return ( return (
<div className="p-4 rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900"> <div className="p-4 rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900">
<p className="text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-1">{label}</p> <p className="text-xs font-medium text-neutral-400 mb-1">{label}</p>
<p className="text-2xl font-bold text-neutral-900 dark:text-white">{value}</p> <p className="text-2xl font-bold text-white">{value}</p>
{change && ( {change && (
<p className={`text-xs mt-1 font-medium ${ <p className={`text-xs mt-1 font-medium ${
isPositive ? 'text-green-600 dark:text-green-400' : isPositive ? 'text-green-600 dark:text-green-400' :
isNegative ? 'text-red-600 dark:text-red-400' : isNegative ? 'text-red-600 dark:text-red-400' :
'text-neutral-500 dark:text-neutral-400' 'text-neutral-400'
}`}> }`}>
{change.label} vs previous period {change.label} vs previous period
</p> </p>
@@ -560,7 +547,7 @@ function QueryRow({
<td className="px-4 py-3 text-neutral-400 dark:text-neutral-500"> <td className="px-4 py-3 text-neutral-400 dark:text-neutral-500">
<Caret size={14} /> <Caret size={14} />
</td> </td>
<td className="px-4 py-3 text-neutral-900 dark:text-white font-medium">{row.query}</td> <td className="px-4 py-3 text-white font-medium">{row.query}</td>
<td className="px-4 py-3 text-right text-neutral-700 dark:text-neutral-300 tabular-nums">{row.clicks.toLocaleString()}</td> <td className="px-4 py-3 text-right text-neutral-700 dark:text-neutral-300 tabular-nums">{row.clicks.toLocaleString()}</td>
<td className="px-4 py-3 text-right text-neutral-700 dark:text-neutral-300 tabular-nums">{row.impressions.toLocaleString()}</td> <td className="px-4 py-3 text-right text-neutral-700 dark:text-neutral-300 tabular-nums">{row.impressions.toLocaleString()}</td>
<td className="px-4 py-3 text-right text-neutral-700 dark:text-neutral-300 tabular-nums">{formatCTR(row.ctr)}</td> <td className="px-4 py-3 text-right text-neutral-700 dark:text-neutral-300 tabular-nums">{formatCTR(row.ctr)}</td>
@@ -576,7 +563,7 @@ function QueryRow({
))} ))}
</div> </div>
) : expandedData.length === 0 ? ( ) : expandedData.length === 0 ? (
<p className="text-sm text-neutral-500 dark:text-neutral-400 py-1">No pages found for this query.</p> <p className="text-sm text-neutral-400 py-1">No pages found for this query.</p>
) : ( ) : (
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
@@ -631,7 +618,7 @@ function PageRow({
<td className="px-4 py-3 text-neutral-400 dark:text-neutral-500"> <td className="px-4 py-3 text-neutral-400 dark:text-neutral-500">
<Caret size={14} /> <Caret size={14} />
</td> </td>
<td className="px-4 py-3 text-neutral-900 dark:text-white font-medium max-w-md truncate" title={row.page}>{row.page}</td> <td className="px-4 py-3 text-white font-medium max-w-md truncate" title={row.page}>{row.page}</td>
<td className="px-4 py-3 text-right text-neutral-700 dark:text-neutral-300 tabular-nums">{row.clicks.toLocaleString()}</td> <td className="px-4 py-3 text-right text-neutral-700 dark:text-neutral-300 tabular-nums">{row.clicks.toLocaleString()}</td>
<td className="px-4 py-3 text-right text-neutral-700 dark:text-neutral-300 tabular-nums">{row.impressions.toLocaleString()}</td> <td className="px-4 py-3 text-right text-neutral-700 dark:text-neutral-300 tabular-nums">{row.impressions.toLocaleString()}</td>
<td className="px-4 py-3 text-right text-neutral-700 dark:text-neutral-300 tabular-nums">{formatCTR(row.ctr)}</td> <td className="px-4 py-3 text-right text-neutral-700 dark:text-neutral-300 tabular-nums">{formatCTR(row.ctr)}</td>
@@ -647,7 +634,7 @@ function PageRow({
))} ))}
</div> </div>
) : expandedData.length === 0 ? ( ) : expandedData.length === 0 ? (
<p className="text-sm text-neutral-500 dark:text-neutral-400 py-1">No queries found for this page.</p> <p className="text-sm text-neutral-400 py-1">No queries found for this page.</p>
) : ( ) : (
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>

View File

@@ -738,9 +738,9 @@ export default function SiteSettingsPage() {
<div className="space-y-8"> <div className="space-y-8">
<div> <div>
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white">Site Settings</h1> <h1 className="text-2xl font-bold text-white">Site Settings</h1>
<p className="mt-2 text-neutral-600 dark:text-neutral-400"> <p className="mt-2 text-neutral-600 dark:text-neutral-400">
Manage settings for <span className="font-medium text-neutral-900 dark:text-white">{site.domain}</span> Manage settings for <span className="font-medium text-white">{site.domain}</span>
</p> </p>
</div> </div>
@@ -860,8 +860,8 @@ export default function SiteSettingsPage() {
<div className="space-y-12"> <div className="space-y-12">
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
<div> <div>
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-1">General Configuration</h2> <h2 className="text-2xl font-bold text-white mb-1">General Configuration</h2>
<p className="text-sm text-neutral-500 dark:text-neutral-400">Update your site details and tracking script.</p> <p className="text-sm text-neutral-400">Update your site details and tracking script.</p>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
@@ -907,17 +907,17 @@ export default function SiteSettingsPage() {
type="text" type="text"
value={site.domain} value={site.domain}
disabled disabled
className="w-full px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-lg bg-neutral-100 dark:bg-neutral-800/50 text-neutral-500 dark:text-neutral-400 cursor-not-allowed" className="w-full px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-lg bg-neutral-100 dark:bg-neutral-800/50 text-neutral-400 cursor-not-allowed"
/> />
<p className="text-xs text-neutral-500 dark:text-neutral-400"> <p className="text-xs text-neutral-400">
Domain cannot be changed after creation Domain cannot be changed after creation
</p> </p>
</div> </div>
</div> </div>
<div className="pt-4 border-t border-neutral-100 dark:border-neutral-800"> <div className="pt-4 border-t border-neutral-100 dark:border-neutral-800">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white mb-2">Tracking Script</h3> <h3 className="text-lg font-semibold text-white mb-2">Tracking Script</h3>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-4"> <p className="text-sm text-neutral-400 mb-4">
Add this script to your website to start tracking visitors. Choose your framework for setup instructions. Add this script to your website to start tracking visitors. Choose your framework for setup instructions.
</p> </p>
<ScriptSetupBlock <ScriptSetupBlock
@@ -945,7 +945,7 @@ export default function SiteSettingsPage() {
{site.is_verified ? <CheckIcon className="w-4 h-4" /> : <ZapIcon className="w-4 h-4" />} {site.is_verified ? <CheckIcon className="w-4 h-4" /> : <ZapIcon className="w-4 h-4" />}
{site.is_verified ? 'Verified' : 'Verify Installation'} {site.is_verified ? 'Verified' : 'Verify Installation'}
</button> </button>
<p className="text-xs text-neutral-500 dark:text-neutral-400"> <p className="text-xs text-neutral-400">
{site.is_verified ? 'Your site is sending data correctly.' : 'Check if your site is sending data correctly.'} {site.is_verified ? 'Your site is sending data correctly.' : 'Check if your site is sending data correctly.'}
</p> </p>
</div> </div>
@@ -964,7 +964,7 @@ export default function SiteSettingsPage() {
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h2 className="text-2xl font-bold text-red-600 dark:text-red-500 mb-1">Danger Zone</h2> <h2 className="text-2xl font-bold text-red-600 dark:text-red-500 mb-1">Danger Zone</h2>
<p className="text-sm text-neutral-500 dark:text-neutral-400">Irreversible actions for your site.</p> <p className="text-sm text-neutral-400">Irreversible actions for your site.</p>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
@@ -1003,8 +1003,8 @@ export default function SiteSettingsPage() {
<div className="space-y-12"> <div className="space-y-12">
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
<div> <div>
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-1">Visibility Settings</h2> <h2 className="text-2xl font-bold text-white mb-1">Visibility Settings</h2>
<p className="text-sm text-neutral-500 dark:text-neutral-400">Manage who can view your dashboard.</p> <p className="text-sm text-neutral-400">Manage who can view your dashboard.</p>
</div> </div>
<div className="p-6 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800"> <div className="p-6 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800">
@@ -1014,8 +1014,8 @@ export default function SiteSettingsPage() {
<GlobeIcon className="w-6 h-6" /> <GlobeIcon className="w-6 h-6" />
</div> </div>
<div> <div>
<h3 className="font-medium text-neutral-900 dark:text-white">Public Dashboard</h3> <h3 className="font-medium text-white">Public Dashboard</h3>
<p className="text-sm text-neutral-500 dark:text-neutral-400"> <p className="text-sm text-neutral-400">
Allow anyone with the link to view this dashboard Allow anyone with the link to view this dashboard
</p> </p>
</div> </div>
@@ -1041,7 +1041,7 @@ export default function SiteSettingsPage() {
className="mt-6 pt-6 border-t border-neutral-200 dark:border-neutral-800 overflow-hidden space-y-6" className="mt-6 pt-6 border-t border-neutral-200 dark:border-neutral-800 overflow-hidden space-y-6"
> >
<div> <div>
<label className="block text-sm font-medium mb-2 text-neutral-900 dark:text-white"> <label className="block text-sm font-medium mb-2 text-white">
Public Link Public Link
</label> </label>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -1054,12 +1054,12 @@ export default function SiteSettingsPage() {
<button <button
type="button" type="button"
onClick={copyLink} onClick={copyLink}
className="px-4 py-2 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 text-neutral-900 dark:text-white rounded-xl font-medium hover:bg-neutral-50 dark:hover:bg-neutral-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:ring-offset-2" className="px-4 py-2 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 text-white rounded-xl font-medium hover:bg-neutral-50 dark:hover:bg-neutral-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:ring-offset-2"
> >
{linkCopied ? 'Copied!' : 'Copy Link'} {linkCopied ? 'Copied!' : 'Copy Link'}
</button> </button>
</div> </div>
<p className="mt-2 text-xs text-neutral-500 dark:text-neutral-400"> <p className="mt-2 text-xs text-neutral-400">
Share this link with others to view the dashboard. Share this link with others to view the dashboard.
</p> </p>
</div> </div>
@@ -1154,8 +1154,8 @@ export default function SiteSettingsPage() {
<div className="space-y-12"> <div className="space-y-12">
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
<div> <div>
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-1">Data & Privacy</h2> <h2 className="text-2xl font-bold text-white mb-1">Data & Privacy</h2>
<p className="text-sm text-neutral-500 dark:text-neutral-400">Control what visitor data is collected. Less data = more privacy.</p> <p className="text-sm text-neutral-400">Control what visitor data is collected. Less data = more privacy.</p>
</div> </div>
{/* Data Collection Controls */} {/* Data Collection Controls */}
@@ -1166,8 +1166,8 @@ export default function SiteSettingsPage() {
<div className="p-6 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800"> <div className="p-6 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h4 className="font-medium text-neutral-900 dark:text-white">Page Paths</h4> <h4 className="font-medium text-white">Page Paths</h4>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-0.5"> <p className="text-sm text-neutral-400 mt-0.5">
Track which pages visitors view Track which pages visitors view
</p> </p>
</div> </div>
@@ -1187,8 +1187,8 @@ export default function SiteSettingsPage() {
<div className="p-6 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800"> <div className="p-6 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h4 className="font-medium text-neutral-900 dark:text-white">Referrers</h4> <h4 className="font-medium text-white">Referrers</h4>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-0.5"> <p className="text-sm text-neutral-400 mt-0.5">
Track where visitors come from Track where visitors come from
</p> </p>
</div> </div>
@@ -1208,8 +1208,8 @@ export default function SiteSettingsPage() {
<div className="p-6 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800"> <div className="p-6 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h4 className="font-medium text-neutral-900 dark:text-white">Device Info</h4> <h4 className="font-medium text-white">Device Info</h4>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-0.5"> <p className="text-sm text-neutral-400 mt-0.5">
Track browser, OS, and device type Track browser, OS, and device type
</p> </p>
</div> </div>
@@ -1229,8 +1229,8 @@ export default function SiteSettingsPage() {
<div className="p-6 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800"> <div className="p-6 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h4 className="font-medium text-neutral-900 dark:text-white">Geographic Data</h4> <h4 className="font-medium text-white">Geographic Data</h4>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-0.5"> <p className="text-sm text-neutral-400 mt-0.5">
Control location tracking granularity Control location tracking granularity
</p> </p>
</div> </div>
@@ -1253,8 +1253,8 @@ export default function SiteSettingsPage() {
<div className="p-6 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800"> <div className="p-6 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h4 className="font-medium text-neutral-900 dark:text-white">Screen Resolution</h4> <h4 className="font-medium text-white">Screen Resolution</h4>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-0.5"> <p className="text-sm text-neutral-400 mt-0.5">
Track visitor screen sizes Track visitor screen sizes
</p> </p>
</div> </div>
@@ -1277,8 +1277,8 @@ export default function SiteSettingsPage() {
<div className="p-6 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800"> <div className="p-6 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h4 className="font-medium text-neutral-900 dark:text-white">Hide unknown locations</h4> <h4 className="font-medium text-white">Hide unknown locations</h4>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-0.5"> <p className="text-sm text-neutral-400 mt-0.5">
Exclude entries where geographic data could not be resolved from location stats Exclude entries where geographic data could not be resolved from location stats
</p> </p>
</div> </div>
@@ -1315,8 +1315,8 @@ export default function SiteSettingsPage() {
<div className="p-6 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800"> <div className="p-6 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h4 className="font-medium text-neutral-900 dark:text-white">Keep raw event data for</h4> <h4 className="font-medium text-white">Keep raw event data for</h4>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-0.5"> <p className="text-sm text-neutral-400 mt-0.5">
Events older than this are automatically deleted. Aggregated daily stats are kept permanently. Events older than this are automatically deleted. Aggregated daily stats are kept permanently.
</p> </p>
</div> </div>
@@ -1353,8 +1353,8 @@ export default function SiteSettingsPage() {
<div className="p-6 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800"> <div className="p-6 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h4 className="font-medium text-neutral-900 dark:text-white">Check frequency</h4> <h4 className="font-medium text-white">Check frequency</h4>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-0.5"> <p className="text-sm text-neutral-400 mt-0.5">
How often PageSpeed Insights runs automated checks on your site. How often PageSpeed Insights runs automated checks on your site.
</p> </p>
</div> </div>
@@ -1406,7 +1406,7 @@ export default function SiteSettingsPage() {
focus:border-brand-orange focus:ring-4 focus:ring-brand-orange/10 outline-none transition-all duration-200 dark:text-white font-mono text-sm" focus:border-brand-orange focus:ring-4 focus:ring-brand-orange/10 outline-none transition-all duration-200 dark:text-white font-mono text-sm"
/> />
</div> </div>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-2"> <p className="text-sm text-neutral-400 mt-2">
Enter paths to exclude from tracking (one per line). Supports wildcards (e.g., /admin/*). Enter paths to exclude from tracking (one per line). Supports wildcards (e.g., /admin/*).
</p> </p>
</div> </div>
@@ -1417,7 +1417,7 @@ export default function SiteSettingsPage() {
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300"> <h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
For your privacy policy For your privacy policy
</h3> </h3>
<p className="text-sm text-neutral-500 dark:text-neutral-400"> <p className="text-sm text-neutral-400">
Copy the text below into your site&apos;s Privacy Policy to describe your use of Pulse. Copy the text below into your site&apos;s Privacy Policy to describe your use of Pulse.
It updates automatically based on your saved settings above. It updates automatically based on your saved settings above.
</p> </p>
@@ -1445,7 +1445,7 @@ export default function SiteSettingsPage() {
{snippetCopied ? ( {snippetCopied ? (
<CheckIcon className="w-4 h-4 text-green-600" /> <CheckIcon className="w-4 h-4 text-green-600" />
) : ( ) : (
<svg className="w-4 h-4 text-neutral-500 dark:text-neutral-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg className="w-4 h-4 text-neutral-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /> <rect x="9" y="9" width="13" height="13" rx="2" ry="2" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /> <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg> </svg>
@@ -1468,7 +1468,7 @@ export default function SiteSettingsPage() {
{activeTab === 'bot' && ( {activeTab === 'bot' && (
<div className="flex-1 space-y-6"> <div className="flex-1 space-y-6">
<div> <div>
<h2 className="text-xl font-bold text-white mb-1">Bot & Spam</h2> <h2 className="text-2xl font-bold text-white mb-1">Bot & Spam</h2>
<p className="text-neutral-400 text-sm">Manage automated and manual bot filtering.</p> <p className="text-neutral-400 text-sm">Manage automated and manual bot filtering.</p>
</div> </div>
@@ -1627,8 +1627,8 @@ export default function SiteSettingsPage() {
{activeTab === 'goals' && ( {activeTab === 'goals' && (
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-1">Goals & Events</h2> <h2 className="text-2xl font-bold text-white mb-1">Goals & Events</h2>
<p className="text-sm text-neutral-500 dark:text-neutral-400"> <p className="text-sm text-neutral-400">
Define goals to label custom events (e.g. signup, purchase). Track with <code className="px-1.5 py-0.5 rounded bg-neutral-200 dark:bg-neutral-700 text-xs">pulse.track(&apos;event_name&apos;)</code> in your snippet. Define goals to label custom events (e.g. signup, purchase). Track with <code className="px-1.5 py-0.5 rounded bg-neutral-200 dark:bg-neutral-700 text-xs">pulse.track(&apos;event_name&apos;)</code> in your snippet.
</p> </p>
</div> </div>
@@ -1643,7 +1643,7 @@ export default function SiteSettingsPage() {
)} )}
<div className="space-y-2"> <div className="space-y-2">
{goals.length === 0 ? ( {goals.length === 0 ? (
<div className="p-6 rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-900/50 text-center text-neutral-500 dark:text-neutral-400 text-sm"> <div className="p-6 rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-900/50 text-center text-neutral-400 text-sm">
No goals yet. Add a goal to give custom events a display name in the dashboard. No goals yet. Add a goal to give custom events a display name in the dashboard.
</div> </div>
) : ( ) : (
@@ -1653,8 +1653,8 @@ export default function SiteSettingsPage() {
className="flex items-center justify-between py-3 px-4 rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-neutral-50/50 dark:bg-neutral-900/50" className="flex items-center justify-between py-3 px-4 rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-neutral-50/50 dark:bg-neutral-900/50"
> >
<div> <div>
<span className="font-medium text-neutral-900 dark:text-white">{goal.name}</span> <span className="font-medium text-white">{goal.name}</span>
<span className="text-neutral-500 dark:text-neutral-400 text-sm ml-2">({goal.event_name})</span> <span className="text-neutral-400 text-sm ml-2">({goal.event_name})</span>
</div> </div>
{canEdit && ( {canEdit && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -1686,16 +1686,16 @@ export default function SiteSettingsPage() {
{activeTab === 'notifications' && ( {activeTab === 'notifications' && (
<div className="space-y-8"> <div className="space-y-8">
<div> <div>
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-1">Notifications</h2> <h2 className="text-2xl font-bold text-white mb-1">Notifications</h2>
<p className="text-sm text-neutral-500 dark:text-neutral-400">Configure how you receive reports and alerts.</p> <p className="text-sm text-neutral-400">Configure how you receive reports and alerts.</p>
</div> </div>
{/* Reports subsection */} {/* Reports subsection */}
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h3 className="text-base font-medium text-neutral-900 dark:text-white">Reports</h3> <h3 className="text-base font-medium text-white">Reports</h3>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-0.5">Automatically deliver analytics reports via email or webhooks.</p> <p className="text-sm text-neutral-400 mt-0.5">Automatically deliver analytics reports via email or webhooks.</p>
</div> </div>
{canEdit && ( {canEdit && (
<Button onClick={() => { setEditingSchedule(null); resetReportForm(); setReportModalOpen(true) }}> <Button onClick={() => { setEditingSchedule(null); resetReportForm(); setReportModalOpen(true) }}>
@@ -1711,7 +1711,7 @@ export default function SiteSettingsPage() {
))} ))}
</div> </div>
) : reportSchedules.length === 0 ? ( ) : reportSchedules.length === 0 ? (
<div className="p-6 rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-900/50 text-center text-neutral-500 dark:text-neutral-400 text-sm"> <div className="p-6 rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-900/50 text-center text-neutral-400 text-sm">
No scheduled reports yet. Add a report to automatically receive analytics summaries. No scheduled reports yet. Add a report to automatically receive analytics summaries.
</div> </div>
) : ( ) : (
@@ -1732,7 +1732,7 @@ export default function SiteSettingsPage() {
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-neutral-900 dark:text-white"> <span className="font-medium text-white">
{getChannelLabel(schedule.channel)} {getChannelLabel(schedule.channel)}
</span> </span>
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-brand-orange/10 text-brand-orange"> <span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-brand-orange/10 text-brand-orange">
@@ -1742,7 +1742,7 @@ export default function SiteSettingsPage() {
{getReportTypeLabel(schedule.report_type)} {getReportTypeLabel(schedule.report_type)}
</span> </span>
</div> </div>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-1 truncate"> <p className="text-sm text-neutral-400 mt-1 truncate">
{schedule.channel === 'email' {schedule.channel === 'email'
? (schedule.channel_config as EmailConfig).recipients.join(', ') ? (schedule.channel_config as EmailConfig).recipients.join(', ')
: (schedule.channel_config as WebhookConfig).url} : (schedule.channel_config as WebhookConfig).url}
@@ -1821,8 +1821,8 @@ export default function SiteSettingsPage() {
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h3 className="text-base font-medium text-neutral-900 dark:text-white">Alerts</h3> <h3 className="text-base font-medium text-white">Alerts</h3>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-0.5">Get notified when your site goes down or recovers.</p> <p className="text-sm text-neutral-400 mt-0.5">Get notified when your site goes down or recovers.</p>
</div> </div>
{canEdit && ( {canEdit && (
<Button onClick={() => { setEditingAlert(null); resetAlertForm(); setAlertModalOpen(true) }}> <Button onClick={() => { setEditingAlert(null); resetAlertForm(); setAlertModalOpen(true) }}>
@@ -1838,7 +1838,7 @@ export default function SiteSettingsPage() {
))} ))}
</div> </div>
) : alertSchedules.length === 0 ? ( ) : alertSchedules.length === 0 ? (
<div className="p-6 rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-900/50 text-center text-neutral-500 dark:text-neutral-400 text-sm"> <div className="p-6 rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-900/50 text-center text-neutral-400 text-sm">
No alert channels configured. Add a channel to receive uptime alerts when your site goes down or recovers. No alert channels configured. Add a channel to receive uptime alerts when your site goes down or recovers.
</div> </div>
) : ( ) : (
@@ -1859,14 +1859,14 @@ export default function SiteSettingsPage() {
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-neutral-900 dark:text-white"> <span className="font-medium text-white">
{getChannelLabel(schedule.channel)} {getChannelLabel(schedule.channel)}
</span> </span>
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-emerald-500/10 text-emerald-500"> <span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-emerald-500/10 text-emerald-500">
Uptime Alert Uptime Alert
</span> </span>
</div> </div>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-1 truncate"> <p className="text-sm text-neutral-400 mt-1 truncate">
{schedule.channel === 'email' {schedule.channel === 'email'
? (schedule.channel_config as EmailConfig).recipients.join(', ') ? (schedule.channel_config as EmailConfig).recipients.join(', ')
: (schedule.channel_config as WebhookConfig).url} : (schedule.channel_config as WebhookConfig).url}
@@ -1940,8 +1940,8 @@ export default function SiteSettingsPage() {
{activeTab === 'integrations' && ( {activeTab === 'integrations' && (
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-1">Integrations</h2> <h2 className="text-2xl font-bold text-white mb-1">Integrations</h2>
<p className="text-sm text-neutral-500 dark:text-neutral-400">Connect external services to enrich your analytics data.</p> <p className="text-sm text-neutral-400">Connect external services to enrich your analytics data.</p>
</div> </div>
{/* Google Search Console */} {/* Google Search Console */}
@@ -1958,7 +1958,7 @@ export default function SiteSettingsPage() {
</svg> </svg>
</div> </div>
<div> <div>
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">Google Search Console</h3> <h3 className="text-lg font-semibold text-white">Google Search Console</h3>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mt-1"> <p className="text-sm text-neutral-600 dark:text-neutral-400 mt-1">
See which search queries bring visitors to your site, with impressions, clicks, CTR, and ranking position. See which search queries bring visitors to your site, with impressions, clicks, CTR, and ranking position.
</p> </p>
@@ -1968,7 +1968,7 @@ export default function SiteSettingsPage() {
<svg className="w-4 h-4 text-neutral-400 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor"> <svg className="w-4 h-4 text-neutral-400 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z" /> <path strokeLinecap="round" strokeLinejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z" />
</svg> </svg>
<p className="text-xs text-neutral-500 dark:text-neutral-400"> <p className="text-xs text-neutral-400">
Pulse only requests read-only access. Your tokens are encrypted at rest and all data can be fully removed at any time. Pulse only requests read-only access. Your tokens are encrypted at rest and all data can be fully removed at any time.
</p> </p>
</div> </div>
@@ -2005,7 +2005,7 @@ export default function SiteSettingsPage() {
</svg> </svg>
</div> </div>
<div> <div>
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">Google Search Console</h3> <h3 className="text-lg font-semibold text-white">Google Search Console</h3>
<div className="flex items-center gap-2 mt-1.5"> <div className="flex items-center gap-2 mt-1.5">
<span className={`inline-flex items-center gap-1.5 text-xs font-medium ${ <span className={`inline-flex items-center gap-1.5 text-xs font-medium ${
gscStatus.status === 'active' gscStatus.status === 'active'
@@ -2031,28 +2031,28 @@ export default function SiteSettingsPage() {
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{gscStatus.google_email && ( {gscStatus.google_email && (
<div className="p-3 bg-white dark:bg-neutral-800/50 rounded-lg border border-neutral-200 dark:border-neutral-700"> <div className="p-3 bg-white dark:bg-neutral-800/50 rounded-lg border border-neutral-200 dark:border-neutral-700">
<p className="text-xs text-neutral-500 dark:text-neutral-400">Google Account</p> <p className="text-xs text-neutral-400">Google Account</p>
<p className="text-sm font-medium text-neutral-900 dark:text-white mt-0.5 truncate">{gscStatus.google_email}</p> <p className="text-sm font-medium text-white mt-0.5 truncate">{gscStatus.google_email}</p>
</div> </div>
)} )}
{gscStatus.gsc_property && ( {gscStatus.gsc_property && (
<div className="p-3 bg-white dark:bg-neutral-800/50 rounded-lg border border-neutral-200 dark:border-neutral-700"> <div className="p-3 bg-white dark:bg-neutral-800/50 rounded-lg border border-neutral-200 dark:border-neutral-700">
<p className="text-xs text-neutral-500 dark:text-neutral-400">Property</p> <p className="text-xs text-neutral-400">Property</p>
<p className="text-sm font-medium text-neutral-900 dark:text-white mt-0.5 truncate">{gscStatus.gsc_property}</p> <p className="text-sm font-medium text-white mt-0.5 truncate">{gscStatus.gsc_property}</p>
</div> </div>
)} )}
{gscStatus.last_synced_at && ( {gscStatus.last_synced_at && (
<div className="p-3 bg-white dark:bg-neutral-800/50 rounded-lg border border-neutral-200 dark:border-neutral-700"> <div className="p-3 bg-white dark:bg-neutral-800/50 rounded-lg border border-neutral-200 dark:border-neutral-700">
<p className="text-xs text-neutral-500 dark:text-neutral-400">Last Synced</p> <p className="text-xs text-neutral-400">Last Synced</p>
<p className="text-sm font-medium text-neutral-900 dark:text-white mt-0.5"> <p className="text-sm font-medium text-white mt-0.5">
{new Date(gscStatus.last_synced_at).toLocaleString('en-GB')} {new Date(gscStatus.last_synced_at).toLocaleString('en-GB')}
</p> </p>
</div> </div>
)} )}
{gscStatus.created_at && ( {gscStatus.created_at && (
<div className="p-3 bg-white dark:bg-neutral-800/50 rounded-lg border border-neutral-200 dark:border-neutral-700"> <div className="p-3 bg-white dark:bg-neutral-800/50 rounded-lg border border-neutral-200 dark:border-neutral-700">
<p className="text-xs text-neutral-500 dark:text-neutral-400">Connected Since</p> <p className="text-xs text-neutral-400">Connected Since</p>
<p className="text-sm font-medium text-neutral-900 dark:text-white mt-0.5"> <p className="text-sm font-medium text-white mt-0.5">
{new Date(gscStatus.created_at).toLocaleString('en-GB')} {new Date(gscStatus.created_at).toLocaleString('en-GB')}
</p> </p>
</div> </div>
@@ -2121,7 +2121,7 @@ export default function SiteSettingsPage() {
</svg> </svg>
</div> </div>
<div> <div>
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">BunnyCDN</h3> <h3 className="text-lg font-semibold text-white">BunnyCDN</h3>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mt-1"> <p className="text-sm text-neutral-600 dark:text-neutral-400 mt-1">
Monitor CDN performance with bandwidth usage, cache hit rates, response times, and geographic distribution. Monitor CDN performance with bandwidth usage, cache hit rates, response times, and geographic distribution.
</p> </p>
@@ -2131,7 +2131,7 @@ export default function SiteSettingsPage() {
<svg className="w-4 h-4 text-neutral-400 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor"> <svg className="w-4 h-4 text-neutral-400 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z" /> <path strokeLinecap="round" strokeLinejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z" />
</svg> </svg>
<p className="text-xs text-neutral-500 dark:text-neutral-400"> <p className="text-xs text-neutral-400">
Your API key is encrypted at rest and only used to fetch read-only statistics. You can disconnect at any time. Your API key is encrypted at rest and only used to fetch read-only statistics. You can disconnect at any time.
</p> </p>
</div> </div>
@@ -2147,7 +2147,7 @@ export default function SiteSettingsPage() {
setBunnySelectedZone(null) setBunnySelectedZone(null)
}} }}
placeholder="BunnyCDN API key" placeholder="BunnyCDN API key"
className="flex-1 px-4 py-2.5 border border-neutral-200 dark:border-neutral-800 rounded-xl bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white text-sm placeholder:text-neutral-400" className="flex-1 px-4 py-2.5 border border-neutral-200 dark:border-neutral-800 rounded-xl bg-white dark:bg-neutral-900 text-white text-sm placeholder:text-neutral-400"
/> />
<button <button
onClick={async () => { onClick={async () => {
@@ -2190,7 +2190,7 @@ export default function SiteSettingsPage() {
const zone = bunnyPullZones.find(z => z.id === Number(e.target.value)) const zone = bunnyPullZones.find(z => z.id === Number(e.target.value))
setBunnySelectedZone(zone || null) setBunnySelectedZone(zone || null)
}} }}
className="w-full px-4 py-2.5 border border-neutral-200 dark:border-neutral-800 rounded-xl bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white text-sm" className="w-full px-4 py-2.5 border border-neutral-200 dark:border-neutral-800 rounded-xl bg-white dark:bg-neutral-900 text-white text-sm"
> >
{bunnyPullZones.map((zone) => ( {bunnyPullZones.map((zone) => (
<option key={zone.id} value={zone.id}>{zone.name}</option> <option key={zone.id} value={zone.id}>{zone.name}</option>
@@ -2252,7 +2252,7 @@ export default function SiteSettingsPage() {
</svg> </svg>
</div> </div>
<div> <div>
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">BunnyCDN</h3> <h3 className="text-lg font-semibold text-white">BunnyCDN</h3>
<div className="flex items-center gap-2 mt-1.5"> <div className="flex items-center gap-2 mt-1.5">
<span className={`inline-flex items-center gap-1.5 text-xs font-medium ${ <span className={`inline-flex items-center gap-1.5 text-xs font-medium ${
bunnyStatus.status === 'active' bunnyStatus.status === 'active'
@@ -2278,22 +2278,22 @@ export default function SiteSettingsPage() {
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{bunnyStatus.pull_zone_name && ( {bunnyStatus.pull_zone_name && (
<div className="p-3 bg-white dark:bg-neutral-800/50 rounded-lg border border-neutral-200 dark:border-neutral-700"> <div className="p-3 bg-white dark:bg-neutral-800/50 rounded-lg border border-neutral-200 dark:border-neutral-700">
<p className="text-xs text-neutral-500 dark:text-neutral-400">Pull Zone</p> <p className="text-xs text-neutral-400">Pull Zone</p>
<p className="text-sm font-medium text-neutral-900 dark:text-white mt-0.5 truncate">{bunnyStatus.pull_zone_name}</p> <p className="text-sm font-medium text-white mt-0.5 truncate">{bunnyStatus.pull_zone_name}</p>
</div> </div>
)} )}
{bunnyStatus.last_synced_at && ( {bunnyStatus.last_synced_at && (
<div className="p-3 bg-white dark:bg-neutral-800/50 rounded-lg border border-neutral-200 dark:border-neutral-700"> <div className="p-3 bg-white dark:bg-neutral-800/50 rounded-lg border border-neutral-200 dark:border-neutral-700">
<p className="text-xs text-neutral-500 dark:text-neutral-400">Last Synced</p> <p className="text-xs text-neutral-400">Last Synced</p>
<p className="text-sm font-medium text-neutral-900 dark:text-white mt-0.5"> <p className="text-sm font-medium text-white mt-0.5">
{new Date(bunnyStatus.last_synced_at).toLocaleString('en-GB')} {new Date(bunnyStatus.last_synced_at).toLocaleString('en-GB')}
</p> </p>
</div> </div>
)} )}
{bunnyStatus.created_at && ( {bunnyStatus.created_at && (
<div className="p-3 bg-white dark:bg-neutral-800/50 rounded-lg border border-neutral-200 dark:border-neutral-700"> <div className="p-3 bg-white dark:bg-neutral-800/50 rounded-lg border border-neutral-200 dark:border-neutral-700">
<p className="text-xs text-neutral-500 dark:text-neutral-400">Connected Since</p> <p className="text-xs text-neutral-400">Connected Since</p>
<p className="text-sm font-medium text-neutral-900 dark:text-white mt-0.5"> <p className="text-sm font-medium text-white mt-0.5">
{new Date(bunnyStatus.created_at).toLocaleString('en-GB')} {new Date(bunnyStatus.created_at).toLocaleString('en-GB')}
</p> </p>
</div> </div>
@@ -2354,7 +2354,7 @@ export default function SiteSettingsPage() {
onChange={(e) => setGoalForm({ ...goalForm, name: e.target.value })} onChange={(e) => setGoalForm({ ...goalForm, name: e.target.value })}
placeholder="e.g. Signups" placeholder="e.g. Signups"
autoFocus autoFocus
className="w-full px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-lg bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white" className="w-full px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-lg bg-white dark:bg-neutral-900 text-white"
required required
/> />
</div> </div>
@@ -2366,11 +2366,11 @@ export default function SiteSettingsPage() {
onChange={(e) => setGoalForm({ ...goalForm, event_name: e.target.value })} onChange={(e) => setGoalForm({ ...goalForm, event_name: e.target.value })}
placeholder="e.g. signup_click (letters, numbers, underscores only)" placeholder="e.g. signup_click (letters, numbers, underscores only)"
maxLength={64} maxLength={64}
className="w-full px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-lg bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white" className="w-full px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-lg bg-white dark:bg-neutral-900 text-white"
required required
/> />
<div className="flex justify-between mt-1"> <div className="flex justify-between mt-1">
<p className="text-xs text-neutral-500 dark:text-neutral-400">Letters, numbers, and underscores only. Spaces become underscores.</p> <p className="text-xs text-neutral-400">Letters, numbers, and underscores only. Spaces become underscores.</p>
<span className={`text-xs tabular-nums ${goalForm.event_name.length > 56 ? 'text-amber-500' : 'text-neutral-400'}`}>{goalForm.event_name.length}/64</span> <span className={`text-xs tabular-nums ${goalForm.event_name.length > 56 ? 'text-amber-500' : 'text-neutral-400'}`}>{goalForm.event_name.length}/64</span>
</div> </div>
{editingGoal && goalForm.event_name.trim().toLowerCase().replace(/\s+/g, '_') !== editingGoal.event_name && ( {editingGoal && goalForm.event_name.trim().toLowerCase().replace(/\s+/g, '_') !== editingGoal.event_name && (
@@ -2423,10 +2423,10 @@ export default function SiteSettingsPage() {
value={reportForm.recipients} value={reportForm.recipients}
onChange={(e) => setReportForm({ ...reportForm, recipients: e.target.value })} onChange={(e) => setReportForm({ ...reportForm, recipients: e.target.value })}
placeholder="email1@example.com, email2@example.com" placeholder="email1@example.com, email2@example.com"
className="w-full px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-lg bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white" className="w-full px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-lg bg-white dark:bg-neutral-900 text-white"
required required
/> />
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">Comma-separated email addresses.</p> <p className="text-xs text-neutral-400 mt-1">Comma-separated email addresses.</p>
</div> </div>
) : ( ) : (
<div> <div>
@@ -2438,7 +2438,7 @@ export default function SiteSettingsPage() {
value={reportForm.webhookUrl} value={reportForm.webhookUrl}
onChange={(e) => setReportForm({ ...reportForm, webhookUrl: e.target.value })} onChange={(e) => setReportForm({ ...reportForm, webhookUrl: e.target.value })}
placeholder="https://hooks.example.com/..." placeholder="https://hooks.example.com/..."
className="w-full px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-lg bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white" className="w-full px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-lg bg-white dark:bg-neutral-900 text-white"
required required
/> />
</div> </div>
@@ -2582,10 +2582,10 @@ export default function SiteSettingsPage() {
value={alertForm.recipients} value={alertForm.recipients}
onChange={(e) => setAlertForm({ ...alertForm, recipients: e.target.value })} onChange={(e) => setAlertForm({ ...alertForm, recipients: e.target.value })}
placeholder="email1@example.com, email2@example.com" placeholder="email1@example.com, email2@example.com"
className="w-full px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-lg bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white" className="w-full px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-lg bg-white dark:bg-neutral-900 text-white"
required required
/> />
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">Comma-separated email addresses.</p> <p className="text-xs text-neutral-400 mt-1">Comma-separated email addresses.</p>
</div> </div>
) : ( ) : (
<div> <div>
@@ -2597,14 +2597,14 @@ export default function SiteSettingsPage() {
value={alertForm.webhookUrl} value={alertForm.webhookUrl}
onChange={(e) => setAlertForm({ ...alertForm, webhookUrl: e.target.value })} onChange={(e) => setAlertForm({ ...alertForm, webhookUrl: e.target.value })}
placeholder="https://hooks.example.com/..." placeholder="https://hooks.example.com/..."
className="w-full px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-lg bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white" className="w-full px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-lg bg-white dark:bg-neutral-900 text-white"
required required
/> />
</div> </div>
)} )}
<div className="p-3 rounded-lg bg-neutral-50 dark:bg-neutral-800/50 border border-neutral-200 dark:border-neutral-700"> <div className="p-3 rounded-lg bg-neutral-50 dark:bg-neutral-800/50 border border-neutral-200 dark:border-neutral-700">
<p className="text-xs text-neutral-500 dark:text-neutral-400"> <p className="text-xs text-neutral-400">
Alerts are sent automatically when your site goes down or recovers. No schedule configuration needed. Alerts are sent automatically when your site goes down or recovers. No schedule configuration needed.
</p> </p>
</div> </div>

View File

@@ -100,7 +100,7 @@ function getOverallStatusTextColor(status: string): string {
case 'down': case 'down':
return 'text-red-600 dark:text-red-400' return 'text-red-600 dark:text-red-400'
default: default:
return 'text-neutral-500 dark:text-neutral-400' return 'text-neutral-400'
} }
} }
@@ -168,22 +168,22 @@ function StatusBarTooltip({
style={{ left: position.x, top: position.y - 10, transform: 'translate(-50%, -100%)' }} style={{ left: position.x, top: position.y - 10, transform: 'translate(-50%, -100%)' }}
> >
<div className="bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-xl shadow-lg transition-shadow duration-300 px-3 py-2.5 text-xs min-w-40"> <div className="bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-xl shadow-lg transition-shadow duration-300 px-3 py-2.5 text-xs min-w-40">
<div className="font-semibold text-neutral-900 dark:text-white mb-1.5">{formattedDate}</div> <div className="font-semibold text-white mb-1.5">{formattedDate}</div>
{stat && stat.total_checks > 0 ? ( {stat && stat.total_checks > 0 ? (
<div className="space-y-1"> <div className="space-y-1">
<div className="flex justify-between gap-4"> <div className="flex justify-between gap-4">
<span className="text-neutral-500 dark:text-neutral-400">Uptime</span> <span className="text-neutral-400">Uptime</span>
<span className="font-medium text-neutral-900 dark:text-white"> <span className="font-medium text-white">
{formatUptime(stat.uptime_percentage)} {formatUptime(stat.uptime_percentage)}
</span> </span>
</div> </div>
<div className="flex justify-between gap-4"> <div className="flex justify-between gap-4">
<span className="text-neutral-500 dark:text-neutral-400">Checks</span> <span className="text-neutral-400">Checks</span>
<span className="font-medium text-neutral-900 dark:text-white">{stat.total_checks}</span> <span className="font-medium text-white">{stat.total_checks}</span>
</div> </div>
<div className="flex justify-between gap-4"> <div className="flex justify-between gap-4">
<span className="text-neutral-500 dark:text-neutral-400">Avg Response</span> <span className="text-neutral-400">Avg Response</span>
<span className="font-medium text-neutral-900 dark:text-white"> <span className="font-medium text-white">
{formatMs(Math.round(stat.avg_response_time_ms))} {formatMs(Math.round(stat.avg_response_time_ms))}
</span> </span>
</div> </div>
@@ -275,7 +275,7 @@ function ResponseTimeChart({ checks }: { checks: UptimeCheck[] }) {
return ( return (
<div className="mt-4"> <div className="mt-4">
<h4 className="text-xs font-semibold text-neutral-500 dark:text-neutral-400 uppercase tracking-wider mb-3"> <h4 className="text-xs font-semibold text-neutral-400 uppercase tracking-wider mb-3">
Response Time Response Time
</h4> </h4>
<ChartContainer config={responseTimeChartConfig} className="h-40"> <ChartContainer config={responseTimeChartConfig} className="h-40">
@@ -406,10 +406,10 @@ export default function UptimePage() {
<div className={`w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}> <div className={`w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
{/* Header */} {/* Header */}
<div className="mb-8"> <div className="mb-8">
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-1"> <h1 className="text-2xl font-bold text-white mb-1">
Uptime Uptime
</h1> </h1>
<p className="text-sm text-neutral-500 dark:text-neutral-400"> <p className="text-sm text-neutral-400">
Monitor your site&apos;s availability and response time Monitor your site&apos;s availability and response time
</p> </p>
</div> </div>
@@ -417,14 +417,14 @@ export default function UptimePage() {
{/* Empty state */} {/* Empty state */}
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-12 text-center"> <div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-12 text-center">
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4 w-16 h-16 mx-auto mb-4 flex items-center justify-center"> <div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4 w-16 h-16 mx-auto mb-4 flex items-center justify-center">
<svg className="w-8 h-8 text-neutral-500 dark:text-neutral-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <svg className="w-8 h-8 text-neutral-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> <path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg> </svg>
</div> </div>
<h3 className="font-semibold text-neutral-900 dark:text-white mb-2"> <h3 className="font-semibold text-white mb-2">
Uptime monitoring is disabled Uptime monitoring is disabled
</h3> </h3>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-6 max-w-md mx-auto"> <p className="text-sm text-neutral-400 mb-6 max-w-md mx-auto">
Enable uptime monitoring to track your site&apos;s availability and response time around the clock. Enable uptime monitoring to track your site&apos;s availability and response time around the clock.
</p> </p>
{canEdit && ( {canEdit && (
@@ -446,10 +446,10 @@ export default function UptimePage() {
{/* Header */} {/* Header */}
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> <div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div> <div>
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-1"> <h1 className="text-2xl font-bold text-white mb-1">
Uptime Uptime
</h1> </h1>
<p className="text-sm text-neutral-500 dark:text-neutral-400"> <p className="text-sm text-neutral-400">
Monitor your site&apos;s availability and response time Monitor your site&apos;s availability and response time
</p> </p>
</div> </div>
@@ -471,7 +471,7 @@ export default function UptimePage() {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className={`w-3.5 h-3.5 rounded-full ${getStatusDotColor(overallStatus)}`} /> <div className={`w-3.5 h-3.5 rounded-full ${getStatusDotColor(overallStatus)}`} />
<div> <div>
<span className="font-semibold text-neutral-900 dark:text-white text-lg"> <span className="font-semibold text-white text-lg">
{site.name} {site.name}
</span> </span>
<span className={`text-sm font-medium ml-3 ${getOverallStatusTextColor(overallStatus)}`}> <span className={`text-sm font-medium ml-3 ${getOverallStatusTextColor(overallStatus)}`}>
@@ -480,11 +480,11 @@ export default function UptimePage() {
</div> </div>
</div> </div>
<div className="text-right"> <div className="text-right">
<span className="text-sm font-semibold text-neutral-900 dark:text-white"> <span className="text-sm font-semibold text-white">
{formatUptime(overallUptime)} uptime {formatUptime(overallUptime)} uptime
</span> </span>
{monitor && ( {monitor && (
<div className="text-xs text-neutral-500 dark:text-neutral-400"> <div className="text-xs text-neutral-400">
Last checked {formatTimeAgo(monitor.monitor.last_checked_at)} Last checked {formatTimeAgo(monitor.monitor.last_checked_at)}
</div> </div>
)} )}
@@ -495,7 +495,7 @@ export default function UptimePage() {
{/* 90-day uptime bar */} {/* 90-day uptime bar */}
{monitor && ( {monitor && (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-5 mb-6"> <div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-5 mb-6">
<h3 className="text-xs font-semibold text-neutral-500 dark:text-neutral-400 uppercase tracking-wider mb-3"> <h3 className="text-xs font-semibold text-neutral-400 uppercase tracking-wider mb-3">
90-Day Availability 90-Day Availability
</h3> </h3>
<UptimeStatusBar dailyStats={monitor.daily_stats} /> <UptimeStatusBar dailyStats={monitor.daily_stats} />
@@ -512,39 +512,39 @@ export default function UptimePage() {
{/* Monitor details grid */} {/* Monitor details grid */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-5"> <div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-5">
<div> <div>
<div className="text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider mb-1"> <div className="text-xs font-medium text-neutral-400 uppercase tracking-wider mb-1">
Status Status
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${getStatusDotColor(monitor.monitor.last_status)}`} /> <div className={`w-2 h-2 rounded-full ${getStatusDotColor(monitor.monitor.last_status)}`} />
<span className="text-sm font-medium text-neutral-900 dark:text-white"> <span className="text-sm font-medium text-white">
{getStatusLabel(monitor.monitor.last_status)} {getStatusLabel(monitor.monitor.last_status)}
</span> </span>
</div> </div>
</div> </div>
<div> <div>
<div className="text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider mb-1"> <div className="text-xs font-medium text-neutral-400 uppercase tracking-wider mb-1">
Response Time Response Time
</div> </div>
<span className="text-sm font-medium text-neutral-900 dark:text-white"> <span className="text-sm font-medium text-white">
{formatMs(monitor.monitor.last_response_time_ms)} {formatMs(monitor.monitor.last_response_time_ms)}
</span> </span>
</div> </div>
<div> <div>
<div className="text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider mb-1"> <div className="text-xs font-medium text-neutral-400 uppercase tracking-wider mb-1">
Check Interval Check Interval
</div> </div>
<span className="text-sm font-medium text-neutral-900 dark:text-white"> <span className="text-sm font-medium text-white">
{monitor.monitor.check_interval_seconds >= 60 {monitor.monitor.check_interval_seconds >= 60
? `${Math.floor(monitor.monitor.check_interval_seconds / 60)}m` ? `${Math.floor(monitor.monitor.check_interval_seconds / 60)}m`
: `${monitor.monitor.check_interval_seconds}s`} : `${monitor.monitor.check_interval_seconds}s`}
</span> </span>
</div> </div>
<div> <div>
<div className="text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider mb-1"> <div className="text-xs font-medium text-neutral-400 uppercase tracking-wider mb-1">
Overall Uptime Overall Uptime
</div> </div>
<span className="text-sm font-medium text-neutral-900 dark:text-white"> <span className="text-sm font-medium text-white">
{formatUptime(monitor.overall_uptime)} {formatUptime(monitor.overall_uptime)}
</span> </span>
</div> </div>
@@ -559,7 +559,7 @@ export default function UptimePage() {
{/* Recent checks */} {/* Recent checks */}
<div className="mt-5"> <div className="mt-5">
<h4 className="text-xs font-semibold text-neutral-500 dark:text-neutral-400 uppercase tracking-wider mb-3"> <h4 className="text-xs font-semibold text-neutral-400 uppercase tracking-wider mb-3">
Recent Checks Recent Checks
</h4> </h4>
<div className="space-y-1.5 max-h-48 overflow-y-auto"> <div className="space-y-1.5 max-h-48 overflow-y-auto">
@@ -576,7 +576,7 @@ export default function UptimePage() {
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{check.status_code && ( {check.status_code && (
<span className="text-xs text-neutral-500 dark:text-neutral-400"> <span className="text-xs text-neutral-400">
{check.status_code} {check.status_code}
</span> </span>
)} )}

View File

@@ -113,7 +113,7 @@ export default function NewSitePage() {
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-green-500/10 text-green-600 dark:text-green-400 mb-6"> <div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-green-500/10 text-green-600 dark:text-green-400 mb-6">
<CheckCircleIcon className="h-7 w-7" /> <CheckCircleIcon className="h-7 w-7" />
</div> </div>
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white"> <h2 className="text-2xl font-bold text-white">
Site created Site created
</h2> </h2>
<p className="mt-2 text-neutral-600 dark:text-neutral-400"> <p className="mt-2 text-neutral-600 dark:text-neutral-400">
@@ -137,7 +137,7 @@ export default function NewSitePage() {
> >
<span className="text-brand-orange">Verify installation</span> <span className="text-brand-orange">Verify installation</span>
</button> </button>
<p className="text-xs text-neutral-500 dark:text-neutral-400"> <p className="text-xs text-neutral-400">
Check if your site is sending data correctly. Check if your site is sending data correctly.
</p> </p>
</div> </div>
@@ -146,7 +146,7 @@ export default function NewSitePage() {
<button <button
type="button" type="button"
onClick={handleBackToForm} onClick={handleBackToForm}
className="text-sm text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300 underline" className="text-sm text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300 underline"
> >
Edit site details Edit site details
</button> </button>
@@ -174,7 +174,7 @@ export default function NewSitePage() {
// * Step 1: Name & domain form // * Step 1: Name & domain form
return ( return (
<div className="w-full max-w-2xl mx-auto px-4 sm:px-6 py-8"> <div className="w-full max-w-2xl mx-auto px-4 sm:px-6 py-8">
<h1 className="text-2xl font-bold mb-8 text-neutral-900 dark:text-white"> <h1 className="text-2xl font-bold mb-8 text-white">
Create New Site Create New Site
</h1> </h1>
@@ -186,7 +186,7 @@ export default function NewSitePage() {
<form onSubmit={handleSubmit} className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6"> <form onSubmit={handleSubmit} className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
<div className="mb-4"> <div className="mb-4">
<label htmlFor="name" className="block text-sm font-medium mb-2 text-neutral-900 dark:text-white"> <label htmlFor="name" className="block text-sm font-medium mb-2 text-white">
Site Name Site Name
</label> </label>
<Input <Input
@@ -201,7 +201,7 @@ export default function NewSitePage() {
</div> </div>
<div className="mb-6"> <div className="mb-6">
<label htmlFor="domain" className="block text-sm font-medium mb-2 text-neutral-900 dark:text-white"> <label htmlFor="domain" className="block text-sm font-medium mb-2 text-white">
Domain Domain
</label> </label>
<Input <Input

View File

@@ -378,10 +378,10 @@ function WelcomeContent() {
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-brand-orange/20 to-brand-orange/5 text-brand-orange mb-5 shadow-sm"> <div className="mx-auto flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-brand-orange/20 to-brand-orange/5 text-brand-orange mb-5 shadow-sm">
<BarChartIcon className="h-8 w-8" /> <BarChartIcon className="h-8 w-8" />
</div> </div>
<h2 className="text-2xl font-bold tracking-tight text-neutral-900 dark:text-white"> <h2 className="text-2xl font-bold tracking-tight text-white">
Choose your organization Choose your organization
</h2> </h2>
<p className="mt-2 text-sm text-neutral-500 dark:text-neutral-400 max-w-sm mx-auto"> <p className="mt-2 text-sm text-neutral-400 max-w-sm mx-auto">
Continue with an existing one or create a new organization. Continue with an existing one or create a new organization.
</p> </p>
</div> </div>
@@ -413,7 +413,7 @@ function WelcomeContent() {
> >
{initial} {initial}
</div> </div>
<span className="flex-1 font-medium text-neutral-900 dark:text-white truncate"> <span className="flex-1 font-medium text-white truncate">
{org.organization_name || 'Organization'} {org.organization_name || 'Organization'}
</span> </span>
{isCurrent && ( {isCurrent && (
@@ -443,7 +443,7 @@ function WelcomeContent() {
alt="Welcome to Pulse" alt="Welcome to Pulse"
className="w-48 h-auto mx-auto mb-6" className="w-48 h-auto mx-auto mb-6"
/> />
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white"> <h1 className="text-2xl font-bold text-white">
Welcome to Pulse Welcome to Pulse
</h1> </h1>
<p className="mt-2 text-neutral-600 dark:text-neutral-400"> <p className="mt-2 text-neutral-600 dark:text-neutral-400">
@@ -475,7 +475,7 @@ function WelcomeContent() {
<button <button
type="button" type="button"
onClick={() => setStep(1)} onClick={() => setStep(1)}
className="flex items-center gap-2 text-sm text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300 mb-6 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange rounded" className="flex items-center gap-2 text-sm text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300 mb-6 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange rounded"
aria-label="Back to welcome" aria-label="Back to welcome"
> >
<ArrowLeftIcon className="h-4 w-4" /> <ArrowLeftIcon className="h-4 w-4" />
@@ -485,7 +485,7 @@ function WelcomeContent() {
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-brand-orange/10 text-brand-orange mb-4"> <div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-brand-orange/10 text-brand-orange mb-4">
<BarChartIcon className="h-7 w-7" /> <BarChartIcon className="h-7 w-7" />
</div> </div>
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white"> <h1 className="text-2xl font-bold text-white">
Name your organization Name your organization
</h1> </h1>
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400"> <p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
@@ -520,7 +520,7 @@ function WelcomeContent() {
onChange={(e) => setOrgSlug(e.target.value)} onChange={(e) => setOrgSlug(e.target.value)}
className="w-full" className="w-full"
/> />
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400"> <p className="mt-1 text-xs text-neutral-400">
Used in your organization URL. Used in your organization URL.
</p> </p>
</div> </div>
@@ -546,7 +546,7 @@ function WelcomeContent() {
<button <button
type="button" type="button"
onClick={() => setStep(2)} onClick={() => setStep(2)}
className="flex items-center gap-2 text-sm text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300 mb-6 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange rounded" className="flex items-center gap-2 text-sm text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300 mb-6 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange rounded"
aria-label="Back to organization" aria-label="Back to organization"
> >
<ArrowLeftIcon className="h-4 w-4" /> <ArrowLeftIcon className="h-4 w-4" />
@@ -556,7 +556,7 @@ function WelcomeContent() {
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-green-500/10 text-green-600 dark:text-green-400 mb-4"> <div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-green-500/10 text-green-600 dark:text-green-400 mb-4">
<CheckCircleIcon className="h-7 w-7" /> <CheckCircleIcon className="h-7 w-7" />
</div> </div>
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white"> <h1 className="text-2xl font-bold text-white">
{showPendingCheckoutInStep3 ? 'Complete your plan' : "You're on the free plan"} {showPendingCheckoutInStep3 ? 'Complete your plan' : "You're on the free plan"}
</h1> </h1>
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400"> <p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
@@ -631,7 +631,7 @@ function WelcomeContent() {
<button <button
type="button" type="button"
onClick={() => setStep(3)} onClick={() => setStep(3)}
className="flex items-center gap-2 text-sm text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300 mb-6 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange rounded" className="flex items-center gap-2 text-sm text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300 mb-6 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange rounded"
aria-label="Back to plan" aria-label="Back to plan"
> >
<ArrowLeftIcon className="h-4 w-4" /> <ArrowLeftIcon className="h-4 w-4" />
@@ -643,7 +643,7 @@ function WelcomeContent() {
alt="Add your first site" alt="Add your first site"
className="w-44 h-auto mx-auto mb-4" className="w-44 h-auto mx-auto mb-4"
/> />
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white"> <h1 className="text-2xl font-bold text-white">
Add your first site Add your first site
</h1> </h1>
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400"> <p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
@@ -730,7 +730,7 @@ function WelcomeContent() {
alt="All set" alt="All set"
className="w-44 h-auto mx-auto mb-6" className="w-44 h-auto mx-auto mb-6"
/> />
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white"> <h1 className="text-2xl font-bold text-white">
You're all set You're all set
</h1> </h1>
<p className="mt-2 text-neutral-600 dark:text-neutral-400"> <p className="mt-2 text-neutral-600 dark:text-neutral-400">
@@ -758,7 +758,7 @@ function WelcomeContent() {
> >
<span className="text-brand-orange">Verify installation</span> <span className="text-brand-orange">Verify installation</span>
</button> </button>
<p className="text-xs text-neutral-500 dark:text-neutral-400"> <p className="text-xs text-neutral-400">
Check if your site is sending data correctly. Check if your site is sending data correctly.
</p> </p>
</div> </div>

View File

@@ -36,7 +36,7 @@ export default function ErrorDisplay({
className="w-56 h-auto mx-auto mb-8" className="w-56 h-auto mx-auto mb-8"
/> />
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-4"> <h2 className="text-2xl font-bold text-white mb-4">
{title} {title}
</h2> </h2>
<p className="text-lg text-neutral-600 dark:text-neutral-400 max-w-md mx-auto mb-10 leading-relaxed"> <p className="text-lg text-neutral-600 dark:text-neutral-400 max-w-md mx-auto mb-10 leading-relaxed">

View File

@@ -48,7 +48,7 @@ export function Footer({ LinkComponent = Link, appName = 'Pulse', isAuthenticate
<footer className="w-full py-8 mt-auto border-t border-neutral-100 dark:border-neutral-800 bg-white/50 dark:bg-neutral-900/50 backdrop-blur-sm"> <footer className="w-full py-8 mt-auto border-t border-neutral-100 dark:border-neutral-800 bg-white/50 dark:bg-neutral-900/50 backdrop-blur-sm">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex flex-col md:flex-row justify-between items-center gap-4"> <div className="flex flex-col md:flex-row justify-between items-center gap-4">
<div className="text-sm text-neutral-500 dark:text-neutral-400"> <div className="text-sm text-neutral-400">
© 2024-{year} Ciphera. All rights reserved. © 2024-{year} Ciphera. All rights reserved.
</div> </div>
<div className="flex gap-6 text-sm font-medium text-neutral-600 dark:text-neutral-300"> <div className="flex gap-6 text-sm font-medium text-neutral-600 dark:text-neutral-300">
@@ -88,7 +88,7 @@ export function Footer({ LinkComponent = Link, appName = 'Pulse', isAuthenticate
loading="lazy" loading="lazy"
className="w-9 h-9 object-contain group-hover:scale-105 transition-transform duration-300" className="w-9 h-9 object-contain group-hover:scale-105 transition-transform duration-300"
/> />
<span className="text-xl font-bold text-neutral-900 dark:text-white group-hover:text-brand-orange transition-colors duration-300"> <span className="text-xl font-bold text-white group-hover:text-brand-orange transition-colors duration-300">
Pulse Pulse
</span> </span>
</Link> </Link>
@@ -125,7 +125,7 @@ export function Footer({ LinkComponent = Link, appName = 'Pulse', isAuthenticate
{/* * Products */} {/* * Products */}
<div> <div>
<h4 className="font-semibold text-neutral-900 dark:text-white mb-4">Products</h4> <h4 className="font-semibold text-white mb-4">Products</h4>
<ul className="space-y-3"> <ul className="space-y-3">
{footerLinks.products.map((link) => ( {footerLinks.products.map((link) => (
<li key={link.name}> <li key={link.name}>
@@ -153,7 +153,7 @@ export function Footer({ LinkComponent = Link, appName = 'Pulse', isAuthenticate
{/* * Company */} {/* * Company */}
<div> <div>
<h4 className="font-semibold text-neutral-900 dark:text-white mb-4">Company</h4> <h4 className="font-semibold text-white mb-4">Company</h4>
<ul className="space-y-3"> <ul className="space-y-3">
{footerLinks.company.map((link) => ( {footerLinks.company.map((link) => (
<li key={link.name}> <li key={link.name}>
@@ -181,7 +181,7 @@ export function Footer({ LinkComponent = Link, appName = 'Pulse', isAuthenticate
{/* * Resources */} {/* * Resources */}
<div> <div>
<h4 className="font-semibold text-neutral-900 dark:text-white mb-4">Resources</h4> <h4 className="font-semibold text-white mb-4">Resources</h4>
<ul className="space-y-3"> <ul className="space-y-3">
{footerLinks.resources.map((link) => ( {footerLinks.resources.map((link) => (
<li key={link.name}> <li key={link.name}>
@@ -209,7 +209,7 @@ export function Footer({ LinkComponent = Link, appName = 'Pulse', isAuthenticate
{/* * Legal */} {/* * Legal */}
<div> <div>
<h4 className="font-semibold text-neutral-900 dark:text-white mb-4">Legal</h4> <h4 className="font-semibold text-white mb-4">Legal</h4>
<ul className="space-y-3"> <ul className="space-y-3">
{footerLinks.legal.map((link) => ( {footerLinks.legal.map((link) => (
<li key={link.name}> <li key={link.name}>
@@ -232,10 +232,10 @@ export function Footer({ LinkComponent = Link, appName = 'Pulse', isAuthenticate
{/* * Bottom bar */} {/* * Bottom bar */}
<div className="flex flex-col sm:flex-row items-center justify-between gap-4"> <div className="flex flex-col sm:flex-row items-center justify-between gap-4">
<p className="text-sm text-neutral-500 dark:text-neutral-400"> <p className="text-sm text-neutral-400">
© 2024-{year} Ciphera. All rights reserved. © 2024-{year} Ciphera. All rights reserved.
</p> </p>
<p className="text-sm text-neutral-500 dark:text-neutral-400"> <p className="text-sm text-neutral-400">
Where Privacy Still Exists Where Privacy Still Exists
</p> </p>
</div> </div>

View File

@@ -34,11 +34,11 @@ export default function FrustrationByPageTable({ pages, loading }: FrustrationBy
return ( return (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 mb-8"> <div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 mb-8">
<div className="flex items-center justify-between mb-1"> <div className="flex items-center justify-between mb-1">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white"> <h3 className="text-lg font-semibold text-white">
Frustration by Page Frustration by Page
</h3> </h3>
</div> </div>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-4"> <p className="text-sm text-neutral-400 mb-4">
Pages with the most frustration signals Pages with the most frustration signals
</p> </p>
@@ -72,7 +72,7 @@ export default function FrustrationByPageTable({ pages, loading }: FrustrationBy
style={{ width: `${barWidth}%` }} style={{ width: `${barWidth}%` }}
/> />
<span <span
className="relative text-sm text-neutral-900 dark:text-white truncate max-w-[200px] sm:max-w-[300px]" className="relative text-sm text-white truncate max-w-[200px] sm:max-w-[300px]"
title={page.page_path} title={page.page_path}
> >
{page.page_path} {page.page_path}
@@ -84,7 +84,7 @@ export default function FrustrationByPageTable({ pages, loading }: FrustrationBy
<span className="w-12 text-right text-sm tabular-nums text-neutral-600 dark:text-neutral-400"> <span className="w-12 text-right text-sm tabular-nums text-neutral-600 dark:text-neutral-400">
{formatNumber(page.dead_clicks)} {formatNumber(page.dead_clicks)}
</span> </span>
<span className="w-12 text-right text-sm font-semibold tabular-nums text-neutral-900 dark:text-white"> <span className="w-12 text-right text-sm font-semibold tabular-nums text-white">
{formatNumber(page.total)} {formatNumber(page.total)}
</span> </span>
<span className="w-16 text-right text-sm tabular-nums text-neutral-600 dark:text-neutral-400"> <span className="w-16 text-right text-sm tabular-nums text-neutral-600 dark:text-neutral-400">
@@ -99,14 +99,17 @@ export default function FrustrationByPageTable({ pages, loading }: FrustrationBy
) : ( ) : (
<div className="flex flex-col items-center justify-center text-center px-6 py-8 gap-4 min-h-[200px]"> <div className="flex flex-col items-center justify-center text-center px-6 py-8 gap-4 min-h-[200px]">
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4"> <div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
<Files className="w-8 h-8 text-neutral-500 dark:text-neutral-400" /> <Files className="w-8 h-8 text-neutral-400" />
</div> </div>
<h4 className="font-semibold text-neutral-900 dark:text-white"> <h4 className="font-semibold text-white">
No frustration signals detected No frustration signals detected
</h4> </h4>
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-md"> <p className="text-sm text-neutral-400 max-w-md">
Page-level frustration data will appear here once rage clicks or dead clicks are detected on your site. Page-level frustration data will appear here once rage clicks or dead clicks are detected on your site.
</p> </p>
<a href="/installation" target="_blank" rel="noopener noreferrer" className="mt-1 text-sm font-medium text-brand-orange hover:underline">
View setup guide
</a>
</div> </div>
)} )}
</div> </div>

View File

@@ -31,7 +31,7 @@ function ChangeIndicator({ change }: { change: ReturnType<typeof pctChange> }) {
? 'text-red-600 dark:text-red-400' ? 'text-red-600 dark:text-red-400'
: isDown : isDown
? 'text-green-600 dark:text-green-400' ? 'text-green-600 dark:text-green-400'
: 'text-neutral-500 dark:text-neutral-400' : 'text-neutral-400'
}`} }`}
> >
{isUp ? '+' : ''}{change.value}% {isUp ? '+' : ''}{change.value}%
@@ -71,11 +71,11 @@ export default function FrustrationSummaryCards({ data, loading }: FrustrationSu
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-8"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-8">
{/* Rage Clicks */} {/* Rage Clicks */}
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6"> <div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
<p className="text-sm font-medium text-neutral-500 dark:text-neutral-400 mb-1"> <p className="text-sm font-medium text-neutral-400 mb-1">
Rage Clicks Rage Clicks
</p> </p>
<div className="flex items-baseline gap-2"> <div className="flex items-baseline gap-2">
<span className="text-2xl font-bold text-neutral-900 dark:text-white tabular-nums"> <span className="text-2xl font-bold text-white tabular-nums">
{data.rage_clicks.toLocaleString()} {data.rage_clicks.toLocaleString()}
</span> </span>
<ChangeIndicator change={rageChange} /> <ChangeIndicator change={rageChange} />
@@ -87,11 +87,11 @@ export default function FrustrationSummaryCards({ data, loading }: FrustrationSu
{/* Dead Clicks */} {/* Dead Clicks */}
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6"> <div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
<p className="text-sm font-medium text-neutral-500 dark:text-neutral-400 mb-1"> <p className="text-sm font-medium text-neutral-400 mb-1">
Dead Clicks Dead Clicks
</p> </p>
<div className="flex items-baseline gap-2"> <div className="flex items-baseline gap-2">
<span className="text-2xl font-bold text-neutral-900 dark:text-white tabular-nums"> <span className="text-2xl font-bold text-white tabular-nums">
{data.dead_clicks.toLocaleString()} {data.dead_clicks.toLocaleString()}
</span> </span>
<ChangeIndicator change={deadChange} /> <ChangeIndicator change={deadChange} />
@@ -103,10 +103,10 @@ export default function FrustrationSummaryCards({ data, loading }: FrustrationSu
{/* Total Frustration Signals */} {/* Total Frustration Signals */}
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6"> <div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
<p className="text-sm font-medium text-neutral-500 dark:text-neutral-400 mb-1"> <p className="text-sm font-medium text-neutral-400 mb-1">
Total Signals Total Signals
</p> </p>
<span className="text-2xl font-bold text-neutral-900 dark:text-white tabular-nums"> <span className="text-2xl font-bold text-white tabular-nums">
{totalSignals.toLocaleString()} {totalSignals.toLocaleString()}
</span> </span>
{topPage ? ( {topPage ? (

View File

@@ -53,7 +53,7 @@ function SelectorCell({ selector }: { selector: string }) {
className="flex items-center gap-1 min-w-0 group/copy cursor-pointer" className="flex items-center gap-1 min-w-0 group/copy cursor-pointer"
title={selector} title={selector}
> >
<span className="text-sm font-mono text-neutral-900 dark:text-white truncate"> <span className="text-sm font-mono text-white truncate">
{selector} {selector}
</span> </span>
<span className="opacity-0 group-hover/copy:opacity-100 transition-opacity shrink-0"> <span className="opacity-0 group-hover/copy:opacity-100 transition-opacity shrink-0">
@@ -145,7 +145,7 @@ export default function FrustrationTable({
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col"> <div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
<div className="flex items-center justify-between mb-1"> <div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white"> <h3 className="text-lg font-semibold text-white">
{title} {title}
</h3> </h3>
{showViewAll && ( {showViewAll && (
@@ -159,7 +159,7 @@ export default function FrustrationTable({
)} )}
</div> </div>
</div> </div>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-4"> <p className="text-sm text-neutral-400 mb-4">
{description} {description}
</p> </p>
@@ -182,15 +182,18 @@ export default function FrustrationTable({
alt="No frustration signals" alt="No frustration signals"
className="w-44 h-auto mb-1" className="w-44 h-auto mb-1"
/> />
<h4 className="font-semibold text-neutral-900 dark:text-white"> <h4 className="font-semibold text-white">
No {title.toLowerCase()} detected No {title.toLowerCase()} detected
</h4> </h4>
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-md"> <p className="text-sm text-neutral-400 max-w-md">
Frustration tracking requires the add-on script. Add it after your core Pulse script: Frustration tracking requires the add-on script. Add it after your core Pulse script:
</p> </p>
<code className="text-xs bg-neutral-100 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300 px-3 py-2 rounded-lg font-mono break-all"> <code className="text-xs bg-neutral-100 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300 px-3 py-2 rounded-lg font-mono break-all">
{'<script defer src="https://pulse.ciphera.net/script.frustration.js"></script>'} {'<script defer src="https://pulse.ciphera.net/script.frustration.js"></script>'}
</code> </code>
<a href="/installation" target="_blank" rel="noopener noreferrer" className="mt-1 text-sm font-medium text-brand-orange hover:underline">
View setup guide
</a>
</div> </div>
)} )}
</div> </div>
@@ -214,7 +217,7 @@ export default function FrustrationTable({
))} ))}
</div> </div>
) : ( ) : (
<p className="text-sm text-neutral-500 dark:text-neutral-400 py-8 text-center"> <p className="text-sm text-neutral-400 py-8 text-center">
No data available No data available
</p> </p>
)} )}

View File

@@ -59,7 +59,7 @@ function CustomTooltip({ active, payload }: { active?: boolean; payload?: Array<
className="h-2.5 w-2.5 shrink-0 rounded-full" className="h-2.5 w-2.5 shrink-0 rounded-full"
style={{ backgroundColor: item.fill }} style={{ backgroundColor: item.fill }}
/> />
<span className="text-neutral-500 dark:text-neutral-400"> <span className="text-neutral-400">
{LABELS[item.type] ?? item.type} {LABELS[item.type] ?? item.type}
</span> </span>
<span className="font-mono font-medium tabular-nums text-neutral-900 dark:text-neutral-50"> <span className="font-mono font-medium tabular-nums text-neutral-900 dark:text-neutral-50">
@@ -93,21 +93,21 @@ export default function FrustrationTrend({ summary, loading }: FrustrationTrendP
return ( return (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col"> <div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
<div className="flex items-center justify-between mb-1"> <div className="flex items-center justify-between mb-1">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white"> <h3 className="text-lg font-semibold text-white">
Frustration Trend Frustration Trend
</h3> </h3>
</div> </div>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-4"> <p className="text-sm text-neutral-400 mb-4">
Rage vs. dead click breakdown Rage vs. dead click breakdown
</p> </p>
<div className="flex-1 min-h-[270px] flex flex-col items-center justify-center text-center px-6 py-8 gap-4"> <div className="flex-1 min-h-[270px] flex flex-col items-center justify-center text-center px-6 py-8 gap-4">
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4"> <div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
<TrendUp className="w-8 h-8 text-neutral-500 dark:text-neutral-400" /> <TrendUp className="w-8 h-8 text-neutral-400" />
</div> </div>
<h4 className="font-semibold text-neutral-900 dark:text-white"> <h4 className="font-semibold text-white">
No trend data yet No trend data yet
</h4> </h4>
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-md"> <p className="text-sm text-neutral-400 max-w-md">
Frustration trend data will appear here once rage clicks or dead clicks are detected on your site. Frustration trend data will appear here once rage clicks or dead clicks are detected on your site.
</p> </p>
</div> </div>
@@ -118,11 +118,11 @@ export default function FrustrationTrend({ summary, loading }: FrustrationTrendP
return ( return (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col"> <div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
<div className="flex items-center justify-between mb-1"> <div className="flex items-center justify-between mb-1">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white"> <h3 className="text-lg font-semibold text-white">
Frustration Trend Frustration Trend
</h3> </h3>
</div> </div>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-4"> <p className="text-sm text-neutral-400 mb-4">
{hasPrevious {hasPrevious
? 'Rage and dead clicks split across current and previous period' ? 'Rage and dead clicks split across current and previous period'
: 'Rage vs. dead click breakdown'} : 'Rage vs. dead click breakdown'}

View File

@@ -322,7 +322,7 @@ export default function Chart({
> >
<div className={cn('text-[10px] font-semibold uppercase tracking-widest mb-2', metric === m.key ? 'text-brand-orange' : 'text-neutral-400 dark:text-neutral-500')}>{m.label}</div> <div className={cn('text-[10px] font-semibold uppercase tracking-widest mb-2', metric === m.key ? 'text-brand-orange' : 'text-neutral-400 dark:text-neutral-500')}>{m.label}</div>
<div className="flex items-baseline gap-2"> <div className="flex items-baseline gap-2">
<AnimatedNumber value={m.value} format={m.format} className="text-2xl font-bold text-neutral-900 dark:text-white" /> <AnimatedNumber value={m.value} format={m.format} className="text-2xl font-bold text-white" />
{m.change !== null && ( {m.change !== null && (
<span className={cn('flex items-center gap-0.5 text-sm font-semibold', m.isPositive ? 'text-[#10B981]' : 'text-[#EF4444]')}> <span className={cn('flex items-center gap-0.5 text-sm font-semibold', m.isPositive ? 'text-[#10B981]' : 'text-[#EF4444]')}>
{m.isPositive ? <ArrowUpRight weight="bold" className="size-3.5" /> : <ArrowDownRight weight="bold" className="size-3.5" />} {m.isPositive ? <ArrowUpRight weight="bold" className="size-3.5" /> : <ArrowDownRight weight="bold" className="size-3.5" />}
@@ -357,7 +357,7 @@ export default function Chart({
{/* Toolbar */} {/* Toolbar */}
<div className="flex items-center justify-between gap-3 mb-4 px-2"> <div className="flex items-center justify-between gap-3 mb-4 px-2">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-xs font-medium text-neutral-500 dark:text-neutral-400"> <span className="text-xs font-medium text-neutral-400">
{METRIC_CONFIGS.find((m) => m.key === metric)?.label} {METRIC_CONFIGS.find((m) => m.key === metric)?.label}
</span> </span>
</div> </div>
@@ -526,7 +526,7 @@ export default function Chart({
<span className="font-medium text-neutral-400 dark:text-neutral-500"> <span className="font-medium text-neutral-400 dark:text-neutral-500">
{ANNOTATION_LABELS[a.category] || 'Note'} &middot; {formatEU(a.date)}{a.time ? ` at ${a.time}` : ''} {ANNOTATION_LABELS[a.category] || 'Note'} &middot; {formatEU(a.date)}{a.time ? ` at ${a.time}` : ''}
</span> </span>
<p className="text-neutral-900 dark:text-white">{a.text}</p> <p className="text-white">{a.text}</p>
</div> </div>
</div> </div>
))} ))}
@@ -593,16 +593,16 @@ export default function Chart({
{annotationForm.visible && ( {annotationForm.visible && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-black/20 dark:bg-black/40 rounded-2xl"> <div className="absolute inset-0 z-50 flex items-center justify-center bg-black/20 dark:bg-black/40 rounded-2xl">
<div className="bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-xl shadow-xl p-5 w-[340px] max-w-[90%]"> <div className="bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-xl shadow-xl p-5 w-[340px] max-w-[90%]">
<h3 className="text-sm font-semibold text-neutral-900 dark:text-white mb-3"> <h3 className="text-sm font-semibold text-white mb-3">
{annotationForm.editingId ? 'Edit annotation' : 'Add annotation'} {annotationForm.editingId ? 'Edit annotation' : 'Add annotation'}
</h3> </h3>
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
<label className="block text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-1">Date</label> <label className="block text-xs font-medium text-neutral-400 mb-1">Date</label>
<button <button
type="button" type="button"
onClick={() => setCalendarOpen(true)} onClick={() => setCalendarOpen(true)}
className="w-full px-3 py-1.5 text-sm rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-orange/30 text-left flex items-center justify-between" className="w-full px-3 py-1.5 text-sm rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 text-white focus:outline-none focus:ring-2 focus:ring-brand-orange/30 text-left flex items-center justify-between"
> >
<span>{annotationForm.date ? formatEU(annotationForm.date) : 'Select date'}</span> <span>{annotationForm.date ? formatEU(annotationForm.date) : 'Select date'}</span>
<svg className="w-4 h-4 text-neutral-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg className="w-4 h-4 text-neutral-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -611,7 +611,7 @@ export default function Chart({
</button> </button>
</div> </div>
<div> <div>
<label className="block text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-1"> <label className="block text-xs font-medium text-neutral-400 mb-1">
Time <span className="text-neutral-400 dark:text-neutral-500">(optional)</span> Time <span className="text-neutral-400 dark:text-neutral-500">(optional)</span>
</label> </label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -619,7 +619,7 @@ export default function Chart({
type="time" type="time"
value={annotationForm.time} value={annotationForm.time}
onChange={(e) => setAnnotationForm((f) => ({ ...f, time: e.target.value }))} onChange={(e) => setAnnotationForm((f) => ({ ...f, time: e.target.value }))}
className="flex-1 px-3 py-1.5 text-sm rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-orange/30" className="flex-1 px-3 py-1.5 text-sm rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 text-white focus:outline-none focus:ring-2 focus:ring-brand-orange/30"
/> />
{annotationForm.time && ( {annotationForm.time && (
<button <button
@@ -634,20 +634,20 @@ export default function Chart({
</div> </div>
</div> </div>
<div> <div>
<label className="block text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-1">Note</label> <label className="block text-xs font-medium text-neutral-400 mb-1">Note</label>
<input <input
type="text" type="text"
value={annotationForm.text} value={annotationForm.text}
onChange={(e) => setAnnotationForm((f) => ({ ...f, text: e.target.value.slice(0, 200) }))} onChange={(e) => setAnnotationForm((f) => ({ ...f, text: e.target.value.slice(0, 200) }))}
placeholder="e.g. Launched new homepage" placeholder="e.g. Launched new homepage"
maxLength={200} maxLength={200}
className="w-full px-3 py-1.5 text-sm rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-orange/30" className="w-full px-3 py-1.5 text-sm rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 text-white focus:outline-none focus:ring-2 focus:ring-brand-orange/30"
autoFocus autoFocus
/> />
<span className="text-[10px] text-neutral-400 mt-0.5 block text-right">{annotationForm.text.length}/200</span> <span className="text-[10px] text-neutral-400 mt-0.5 block text-right">{annotationForm.text.length}/200</span>
</div> </div>
<div> <div>
<label className="block text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-1">Category</label> <label className="block text-xs font-medium text-neutral-400 mb-1">Category</label>
<Select <Select
value={annotationForm.category} value={annotationForm.category}
onChange={(v) => setAnnotationForm((f) => ({ ...f, category: v }))} onChange={(v) => setAnnotationForm((f) => ({ ...f, category: v }))}
@@ -675,7 +675,7 @@ export default function Chart({
<button <button
type="button" type="button"
onClick={() => setAnnotationForm({ visible: false, date: '', time: '', text: '', category: 'other' })} onClick={() => setAnnotationForm({ visible: false, date: '', time: '', text: '', category: 'other' })}
className="px-3 py-1.5 text-xs font-medium text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200 cursor-pointer" className="px-3 py-1.5 text-xs font-medium text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200 cursor-pointer"
> >
Cancel Cancel
</button> </button>

View File

@@ -480,7 +480,7 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to
{/* Progress Bar */} {/* Progress Bar */}
{(isExporting || exportDone) && ( {(isExporting || exportDone) && (
<div className="space-y-2 pt-2"> <div className="space-y-2 pt-2">
<div className="flex items-center justify-between text-xs text-neutral-500 dark:text-neutral-400"> <div className="flex items-center justify-between text-xs text-neutral-400">
<span>{exportDone ? 'Export complete' : exportProgress.label}</span> <span>{exportDone ? 'Export complete' : exportProgress.label}</span>
<span>{exportDone ? '100%' : `${Math.round((exportProgress.step / exportProgress.total) * 100)}%`}</span> <span>{exportDone ? '100%' : `${Math.round((exportProgress.step / exportProgress.total) * 100)}%`}</span>
</div> </div>

View File

@@ -29,7 +29,7 @@ export default function FilterBar({ filters, onRemove, onClear }: FilterBarProps
{filters.length > 1 && ( {filters.length > 1 && (
<button <button
onClick={onClear} onClick={onClear}
className="px-2 py-1.5 text-xs font-medium text-neutral-500 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white transition-colors cursor-pointer" className="px-2 py-1.5 text-xs font-medium text-neutral-400 hover:text-neutral-900 dark:hover:text-white transition-colors cursor-pointer"
> >
Clear all Clear all
</button> </button>

View File

@@ -46,7 +46,7 @@ export default function SiteNav({ siteId }: SiteNavProps) {
tabIndex={isActive(tab.href) ? 0 : -1} tabIndex={isActive(tab.href) ? 0 : -1}
className={`relative shrink-0 whitespace-nowrap px-3 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange rounded-t cursor-pointer -mb-px ${ className={`relative shrink-0 whitespace-nowrap px-3 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange rounded-t cursor-pointer -mb-px ${
isActive(tab.href) isActive(tab.href)
? 'text-neutral-900 dark:text-white' ? 'text-white'
: 'text-neutral-400 dark:text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300' : 'text-neutral-400 dark:text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300'
}`} }`}
> >

View File

@@ -97,7 +97,7 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ArrowSquareOut className="w-5 h-5 text-neutral-400 dark:text-neutral-500" weight="bold" /> <ArrowSquareOut className="w-5 h-5 text-neutral-400 dark:text-neutral-500" weight="bold" />
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white"> <h3 className="text-lg font-semibold text-white">
Referrers Referrers
</h3> </h3>
{showViewAll && ( {showViewAll && (
@@ -115,7 +115,7 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
<div className="space-y-2 flex-1 min-h-[270px]"> <div className="space-y-2 flex-1 min-h-[270px]">
{!collectReferrers ? ( {!collectReferrers ? (
<div className="h-full flex flex-col items-center justify-center text-center px-4"> <div className="h-full flex flex-col items-center justify-center text-center px-4">
<p className="text-neutral-500 dark:text-neutral-400 text-sm">Referrer tracking is disabled in site settings</p> <p className="text-neutral-400 text-sm">Referrer tracking is disabled in site settings</p>
</div> </div>
) : hasData ? ( ) : hasData ? (
<> <>
@@ -132,7 +132,7 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
className="absolute inset-y-0.5 left-0.5 bg-brand-orange/15 dark:bg-brand-orange/40 rounded-md transition-all" className="absolute inset-y-0.5 left-0.5 bg-brand-orange/15 dark:bg-brand-orange/40 rounded-md transition-all"
style={{ width: `${barWidth}%` }} style={{ width: `${barWidth}%` }}
/> />
<div className="relative flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3"> <div className="relative flex-1 truncate text-white flex items-center gap-3">
{renderReferrerIcon(ref.referrer)} {renderReferrerIcon(ref.referrer)}
<span className="truncate" title={ref.referrer}>{getReferrerDisplayName(ref.referrer)}</span> <span className="truncate" title={ref.referrer}>{getReferrerDisplayName(ref.referrer)}</span>
</div> </div>
@@ -154,12 +154,12 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
) : ( ) : (
<div className="h-full flex flex-col items-center justify-center text-center px-6 py-8 gap-3"> <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"> <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" /> <GlobeIcon className="w-8 h-8 text-neutral-400" />
</div> </div>
<h4 className="font-semibold text-neutral-900 dark:text-white"> <h4 className="font-semibold text-white">
No referrers yet No referrers yet
</h4> </h4>
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs"> <p className="text-sm text-neutral-400 max-w-xs">
Traffic sources will appear here when visitors come from external sites. Traffic sources will appear here when visitors come from external sites.
</p> </p>
<Link <Link
@@ -186,7 +186,7 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
value={modalSearch} value={modalSearch}
onChange={(e) => setModalSearch(e.target.value)} onChange={(e) => setModalSearch(e.target.value)}
placeholder="Search referrers..." placeholder="Search referrers..."
className="w-full px-3 py-2 mb-3 text-sm bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg text-neutral-900 dark:text-white placeholder-neutral-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-brand-orange/50" className="w-full px-3 py-2 mb-3 text-sm bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg text-white placeholder-neutral-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-brand-orange/50"
/> />
</div> </div>
<div className="max-h-[80vh]"> <div className="max-h-[80vh]">
@@ -208,7 +208,7 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
onClick={() => { if (onFilter) { onFilter({ dimension: 'referrer', operator: 'is', values: [ref.referrer] }); setIsModalOpen(false) } }} onClick={() => { if (onFilter) { onFilter({ dimension: 'referrer', operator: 'is', values: [ref.referrer] }); setIsModalOpen(false) } }}
className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 transition-colors${onFilter ? ' cursor-pointer' : ''}`} className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 transition-colors${onFilter ? ' cursor-pointer' : ''}`}
> >
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3"> <div className="flex-1 truncate text-white flex items-center gap-3">
{renderReferrerIcon(ref.referrer)} {renderReferrerIcon(ref.referrer)}
<span className="truncate" title={ref.referrer}>{getReferrerDisplayName(ref.referrer)}</span> <span className="truncate" title={ref.referrer}>{getReferrerDisplayName(ref.referrer)}</span>
</div> </div>

View File

@@ -50,7 +50,7 @@ export default function BreakdownDrawer({ siteId, funnelId, stepIndex, stepName,
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-neutral-200 dark:border-neutral-800"> <div className="flex items-center justify-between px-6 py-4 border-b border-neutral-200 dark:border-neutral-800">
<div> <div>
<h3 className="font-semibold text-neutral-900 dark:text-white">Step Breakdown</h3> <h3 className="font-semibold text-white">Step Breakdown</h3>
<p className="text-sm text-neutral-500">{stepName}</p> <p className="text-sm text-neutral-500">{stepName}</p>
</div> </div>
<button onClick={onClose} className="p-2 text-neutral-400 hover:text-neutral-600 rounded-lg"> <button onClick={onClose} className="p-2 text-neutral-400 hover:text-neutral-600 rounded-lg">
@@ -91,7 +91,7 @@ export default function BreakdownDrawer({ siteId, funnelId, stepIndex, stepName,
<div className="space-y-2"> <div className="space-y-2">
{breakdown.entries.map(entry => ( {breakdown.entries.map(entry => (
<div key={entry.value} className="flex items-center justify-between py-2 px-3 rounded-lg hover:bg-neutral-50 dark:hover:bg-neutral-800/50"> <div key={entry.value} className="flex items-center justify-between py-2 px-3 rounded-lg hover:bg-neutral-50 dark:hover:bg-neutral-800/50">
<span className="text-sm text-neutral-900 dark:text-white truncate mr-4"> <span className="text-sm text-white truncate mr-4">
{entry.value || '(unknown)'} {entry.value || '(unknown)'}
</span> </span>
<div className="flex items-center gap-4 text-sm shrink-0"> <div className="flex items-center gap-4 text-sm shrink-0">

View File

@@ -206,7 +206,7 @@ export default function FunnelForm({ siteId, initialData, onSubmit, submitLabel,
Back to Funnels Back to Funnels
</Link> </Link>
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-2"> <h1 className="text-2xl font-bold text-white mb-2">
{initialData ? 'Edit Funnel' : 'Create New Funnel'} {initialData ? 'Edit Funnel' : 'Create New Funnel'}
</h1> </h1>
<p className="text-neutral-600 dark:text-neutral-400"> <p className="text-neutral-600 dark:text-neutral-400">
@@ -252,7 +252,7 @@ export default function FunnelForm({ siteId, initialData, onSubmit, submitLabel,
{/* Steps */} {/* Steps */}
<div className="space-y-4 mb-6"> <div className="space-y-4 mb-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white"> <h3 className="text-lg font-semibold text-white">
Funnel Steps Funnel Steps
</h3> </h3>
</div> </div>

View File

@@ -166,7 +166,7 @@ function ColumnHeader({
{column.index === 0 ? 'Entry' : `Step ${column.index}`} {column.index === 0 ? 'Entry' : `Step ${column.index}`}
</span> </span>
<div className="flex items-baseline gap-1.5"> <div className="flex items-baseline gap-1.5">
<span className="text-sm font-semibold text-neutral-900 dark:text-white tabular-nums"> <span className="text-sm font-semibold text-white tabular-nums">
{column.totalSessions.toLocaleString()} visitors {column.totalSessions.toLocaleString()} visitors
</span> </span>
{column.dropOffPercent !== 0 && ( {column.dropOffPercent !== 0 && (
@@ -235,10 +235,10 @@ function PageRow({
<span <span
className={`relative flex-1 truncate text-sm ${ className={`relative flex-1 truncate text-sm ${
isSelected isSelected
? 'text-neutral-900 dark:text-white font-medium' ? 'text-white font-medium'
: isOther : isOther
? 'italic text-neutral-400 dark:text-neutral-500' ? 'italic text-neutral-400 dark:text-neutral-500'
: 'text-neutral-900 dark:text-white' : 'text-white'
}`} }`}
> >
{isOther ? page.path : smartLabel(page.path)} {isOther ? page.path : smartLabel(page.path)}
@@ -561,12 +561,15 @@ export default function ColumnJourney({
alt="No journey data" alt="No journey data"
className="w-52 h-auto mb-2" className="w-52 h-auto mb-2"
/> />
<h4 className="font-semibold text-neutral-900 dark:text-white"> <h4 className="font-semibold text-white">
No journey data yet No journey data yet
</h4> </h4>
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs"> <p className="text-sm text-neutral-400 max-w-xs">
Navigation flows will appear here as visitors browse through your site. Navigation flows will appear here as visitors browse through your site.
</p> </p>
<a href="/installation" target="_blank" rel="noopener noreferrer" className="mt-2 text-sm font-medium text-brand-orange hover:underline">
View setup guide
</a>
</div> </div>
) )
} }

View File

@@ -510,14 +510,17 @@ export default function SankeyJourney({
return ( return (
<div className="h-[400px] flex flex-col items-center justify-center text-center px-6 py-8 gap-3"> <div className="h-[400px] 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"> <div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
<TreeStructure className="w-8 h-8 text-neutral-500 dark:text-neutral-400" /> <TreeStructure className="w-8 h-8 text-neutral-400" />
</div> </div>
<h4 className="font-semibold text-neutral-900 dark:text-white"> <h4 className="font-semibold text-white">
No journey data yet No journey data yet
</h4> </h4>
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs"> <p className="text-sm text-neutral-400 max-w-xs">
Navigation flows will appear here as visitors browse through your site. Navigation flows will appear here as visitors browse through your site.
</p> </p>
<a href="/installation" target="_blank" rel="noopener noreferrer" className="mt-2 text-sm font-medium text-brand-orange hover:underline">
View setup guide
</a>
</div> </div>
) )
} }
@@ -528,7 +531,7 @@ export default function SankeyJourney({
<div className="flex items-center gap-2 mb-3 px-3 py-2 rounded-lg bg-brand-orange/10 text-sm"> <div className="flex items-center gap-2 mb-3 px-3 py-2 rounded-lg bg-brand-orange/10 text-sm">
<span className="text-neutral-700 dark:text-neutral-300"> <span className="text-neutral-700 dark:text-neutral-300">
Showing flows through{' '} Showing flows through{' '}
<span className="font-medium text-neutral-900 dark:text-white"> <span className="font-medium text-white">
{filterPath} {filterPath}
</span> </span>
</span> </span>

View File

@@ -38,11 +38,11 @@ export default function TopPathsTable({ paths, loading }: TopPathsTableProps) {
return ( return (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6"> <div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
<div className="mb-1"> <div className="mb-1">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white"> <h3 className="text-lg font-semibold text-white">
Top Paths Top Paths
</h3> </h3>
</div> </div>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-5"> <p className="text-sm text-neutral-400 mb-5">
Most common navigation paths across sessions Most common navigation paths across sessions
</p> </p>
@@ -83,7 +83,7 @@ export default function TopPathsTable({ paths, loading }: TopPathsTableProps) {
</span> </span>
) : ( ) : (
<span <span
className="text-sm text-neutral-900 dark:text-white truncate" className="text-sm text-white truncate"
title={page} title={page}
> >
{smartLabel(page)} {smartLabel(page)}
@@ -113,12 +113,12 @@ export default function TopPathsTable({ paths, loading }: TopPathsTableProps) {
) : ( ) : (
<div className="flex flex-col items-center justify-center text-center px-6 py-8 gap-3"> <div className="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"> <div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
<Path className="w-8 h-8 text-neutral-500 dark:text-neutral-400" /> <Path className="w-8 h-8 text-neutral-400" />
</div> </div>
<h4 className="font-semibold text-neutral-900 dark:text-white"> <h4 className="font-semibold text-white">
No path data yet No path data yet
</h4> </h4>
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs"> <p className="text-sm text-neutral-400 max-w-xs">
Common navigation paths will appear here as visitors browse your site. Common navigation paths will appear here as visitors browse your site.
</p> </p>
</div> </div>

View File

@@ -212,7 +212,7 @@ export default function NotificationCenter({ anchor = 'bottom', variant = 'defau
style={anchor === 'right' && fixedPos ? { left: fixedPos.left, top: fixedPos.top, bottom: fixedPos.bottom } : undefined} style={anchor === 'right' && fixedPos ? { left: fixedPos.left, top: fixedPos.top, bottom: fixedPos.bottom } : undefined}
> >
<div className="flex items-center justify-between px-4 py-3 border-b border-neutral-200 dark:border-neutral-700"> <div className="flex items-center justify-between px-4 py-3 border-b border-neutral-200 dark:border-neutral-700">
<h3 className="font-semibold text-neutral-900 dark:text-white">Notifications</h3> <h3 className="font-semibold text-white">Notifications</h3>
{unreadCount > 0 && ( {unreadCount > 0 && (
<button <button
type="button" type="button"
@@ -243,7 +243,7 @@ export default function NotificationCenter({ anchor = 'bottom', variant = 'defau
<div className="p-6 text-center text-red-500 text-sm">{error}</div> <div className="p-6 text-center text-red-500 text-sm">{error}</div>
)} )}
{!loading && !error && (notifications?.length ?? 0) === 0 && ( {!loading && !error && (notifications?.length ?? 0) === 0 && (
<div className="p-6 text-center text-neutral-500 dark:text-neutral-400 text-sm"> <div className="p-6 text-center text-neutral-400 text-sm">
No notifications yet No notifications yet
</div> </div>
)} )}
@@ -260,11 +260,11 @@ export default function NotificationCenter({ anchor = 'bottom', variant = 'defau
<div className="flex gap-3"> <div className="flex gap-3">
{getTypeIcon(n.type)} {getTypeIcon(n.type)}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className={`text-sm ${!n.read ? 'font-medium' : ''} text-neutral-900 dark:text-white`}> <p className={`text-sm ${!n.read ? 'font-medium' : ''} text-white`}>
{n.title} {n.title}
</p> </p>
{n.body && ( {n.body && (
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-0.5 line-clamp-2"> <p className="text-xs text-neutral-400 mt-0.5 line-clamp-2">
{n.body} {n.body}
</p> </p>
)} )}
@@ -283,11 +283,11 @@ export default function NotificationCenter({ anchor = 'bottom', variant = 'defau
<div className="flex gap-3"> <div className="flex gap-3">
{getTypeIcon(n.type)} {getTypeIcon(n.type)}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className={`text-sm ${!n.read ? 'font-medium' : ''} text-neutral-900 dark:text-white`}> <p className={`text-sm ${!n.read ? 'font-medium' : ''} text-white`}>
{n.title} {n.title}
</p> </p>
{n.body && ( {n.body && (
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-0.5 line-clamp-2"> <p className="text-xs text-neutral-400 mt-0.5 line-clamp-2">
{n.body} {n.body}
</p> </p>
)} )}
@@ -315,7 +315,7 @@ export default function NotificationCenter({ anchor = 'bottom', variant = 'defau
<Link <Link
href="/org-settings?tab=notifications" href="/org-settings?tab=notifications"
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
className="flex items-center gap-2 text-sm text-neutral-500 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors" className="flex items-center gap-2 text-sm text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors"
> >
<SettingsIcon className="w-4 h-4" aria-hidden="true" /> <SettingsIcon className="w-4 h-4" aria-hidden="true" />
Manage settings Manage settings

View File

@@ -31,19 +31,19 @@ function CustomTooltip({ active, payload, label }: TooltipProps) {
return ( return (
<div className="rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-800 p-3 shadow-sm shadow-black/5 min-w-[140px]"> <div className="rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-800 p-3 shadow-sm shadow-black/5 min-w-[140px]">
<div className="text-xs text-neutral-500 dark:text-neutral-400 mb-1.5">{label}</div> <div className="text-xs text-neutral-400 mb-1.5">{label}</div>
{clicks && ( {clicks && (
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<div className="size-1.5 rounded-full" style={{ backgroundColor: '#FD5E0F' }} /> <div className="size-1.5 rounded-full" style={{ backgroundColor: '#FD5E0F' }} />
<span className="text-neutral-500 dark:text-neutral-400">Clicks:</span> <span className="text-neutral-400">Clicks:</span>
<span className="font-semibold text-neutral-900 dark:text-white">{clicks.value.toLocaleString()}</span> <span className="font-semibold text-white">{clicks.value.toLocaleString()}</span>
</div> </div>
)} )}
{impressions && ( {impressions && (
<div className="flex items-center gap-2 text-sm mt-1"> <div className="flex items-center gap-2 text-sm mt-1">
<div className="size-1.5 rounded-full" style={{ backgroundColor: '#9CA3AF' }} /> <div className="size-1.5 rounded-full" style={{ backgroundColor: '#9CA3AF' }} />
<span className="text-neutral-500 dark:text-neutral-400">Impressions:</span> <span className="text-neutral-400">Impressions:</span>
<span className="font-semibold text-neutral-900 dark:text-white">{impressions.value.toLocaleString()}</span> <span className="font-semibold text-white">{impressions.value.toLocaleString()}</span>
</div> </div>
)} )}
</div> </div>

View File

@@ -311,7 +311,7 @@ export default function OrganizationSettings() {
// If no org ID, we are in personal organization context, so don't show org settings // If no org ID, we are in personal organization context, so don't show org settings
if (!currentOrgId) { if (!currentOrgId) {
return ( return (
<div className="p-6 text-center text-neutral-500 dark:text-neutral-400"> <div className="p-6 text-center text-neutral-400">
<p>You are in your personal context. Switch to an Organization to manage its settings.</p> <p>You are in your personal context. Switch to an Organization to manage its settings.</p>
</div> </div>
) )
@@ -490,7 +490,7 @@ export default function OrganizationSettings() {
return ( return (
<div className="space-y-8"> <div className="space-y-8">
<div> <div>
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white">Organization Settings</h1> <h1 className="text-2xl font-bold text-white">Organization Settings</h1>
<p className="mt-2 text-neutral-600 dark:text-neutral-400"> <p className="mt-2 text-neutral-600 dark:text-neutral-400">
Manage your organization workspace and members. Manage your organization workspace and members.
</p> </p>
@@ -580,8 +580,8 @@ export default function OrganizationSettings() {
{activeTab === 'general' && ( {activeTab === 'general' && (
<div className="space-y-12"> <div className="space-y-12">
<div> <div>
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-1">General Information</h2> <h2 className="text-2xl font-bold text-white mb-1">General Information</h2>
<p className="text-sm text-neutral-500 dark:text-neutral-400">Basic details about your organization.</p> <p className="text-sm text-neutral-400">Basic details about your organization.</p>
</div> </div>
<form onSubmit={handleUpdateOrg} className="space-y-4"> <form onSubmit={handleUpdateOrg} className="space-y-4">
@@ -597,7 +597,7 @@ export default function OrganizationSettings() {
minLength={2} minLength={2}
maxLength={50} maxLength={50}
disabled={!isEditing} disabled={!isEditing}
className={`bg-white dark:bg-neutral-900 ${!isEditing ? 'text-neutral-500 dark:text-neutral-400' : ''}`} className={`bg-white dark:bg-neutral-900 ${!isEditing ? 'text-neutral-400' : ''}`}
/> />
</div> </div>
@@ -606,7 +606,7 @@ export default function OrganizationSettings() {
Organization Slug Organization Slug
</label> </label>
<div className="flex rounded-xl shadow-sm"> <div className="flex rounded-xl shadow-sm">
<span className="inline-flex items-center px-3 rounded-l-xl border border-r-0 border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-900 text-neutral-500 dark:text-neutral-400 text-sm"> <span className="inline-flex items-center px-3 rounded-l-xl border border-r-0 border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-900 text-neutral-400 text-sm">
pulse.ciphera.net/ pulse.ciphera.net/
</span> </span>
<Input <Input
@@ -617,10 +617,10 @@ export default function OrganizationSettings() {
minLength={3} minLength={3}
maxLength={30} maxLength={30}
disabled={!isEditing} disabled={!isEditing}
className={`rounded-l-none bg-white dark:bg-neutral-900 ${!isEditing ? 'text-neutral-500 dark:text-neutral-400' : ''}`} className={`rounded-l-none bg-white dark:bg-neutral-900 ${!isEditing ? 'text-neutral-400' : ''}`}
/> />
</div> </div>
<p className="text-xs text-neutral-500 dark:text-neutral-400"> <p className="text-xs text-neutral-400">
Changing the slug will change your organization's URL. Changing the slug will change your organization's URL.
</p> </p>
</div> </div>
@@ -658,7 +658,7 @@ export default function OrganizationSettings() {
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h2 className="text-2xl font-bold text-red-600 dark:text-red-500 mb-1">Danger Zone</h2> <h2 className="text-2xl font-bold text-red-600 dark:text-red-500 mb-1">Danger Zone</h2>
<p className="text-sm text-neutral-500 dark:text-neutral-400">Irreversible actions for this organization.</p> <p className="text-sm text-neutral-400">Irreversible actions for this organization.</p>
</div> </div>
<div className="p-6 border border-red-200 dark:border-red-900/50 bg-red-50 dark:bg-red-900/10 rounded-2xl flex items-center justify-between"> <div className="p-6 border border-red-200 dark:border-red-900/50 bg-red-50 dark:bg-red-900/10 rounded-2xl flex items-center justify-between">
@@ -696,11 +696,11 @@ export default function OrganizationSettings() {
<div className="space-y-12"> <div className="space-y-12">
{/* Invite Section */} {/* Invite Section */}
<div> <div>
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-1">Organization Members</h2> <h2 className="text-2xl font-bold text-white mb-1">Organization Members</h2>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-6">Manage who has access to this organization.</p> <p className="text-sm text-neutral-400 mb-6">Manage who has access to this organization.</p>
<div className="bg-neutral-50 dark:bg-neutral-900/50 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-4"> <div className="bg-neutral-50 dark:bg-neutral-900/50 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-4">
<h3 className="text-sm font-medium text-neutral-900 dark:text-white mb-3">Invite New Member</h3> <h3 className="text-sm font-medium text-white mb-3">Invite New Member</h3>
<form onSubmit={handleSendInvite} className="flex gap-3 items-end"> <form onSubmit={handleSendInvite} className="flex gap-3 items-end">
<div className="flex-1"> <div className="flex-1">
<Input <Input
@@ -744,12 +744,12 @@ export default function OrganizationSettings() {
{/* Members List */} {/* Members List */}
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-sm font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Active Members</h3> <h3 className="text-sm font-medium text-neutral-400 uppercase tracking-wider">Active Members</h3>
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden divide-y divide-neutral-200 dark:divide-neutral-800"> <div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden divide-y divide-neutral-200 dark:divide-neutral-800">
{isLoadingMembers ? ( {isLoadingMembers ? (
<MembersListSkeleton /> <MembersListSkeleton />
) : members.length === 0 ? ( ) : members.length === 0 ? (
<div className="p-8 text-center text-neutral-500 dark:text-neutral-400">No members found.</div> <div className="p-8 text-center text-neutral-400">No members found.</div>
) : ( ) : (
members.map((member) => ( members.map((member) => (
<div key={member.user_id} className="p-4 flex items-center justify-between hover:bg-neutral-50 dark:hover:bg-neutral-800/50 transition-colors"> <div key={member.user_id} className="p-4 flex items-center justify-between hover:bg-neutral-50 dark:hover:bg-neutral-800/50 transition-colors">
@@ -758,10 +758,10 @@ export default function OrganizationSettings() {
{member.user_email?.[0].toUpperCase() || '?'} {member.user_email?.[0].toUpperCase() || '?'}
</div> </div>
<div> <div>
<div className="text-sm font-medium text-neutral-900 dark:text-white"> <div className="text-sm font-medium text-white">
{member.user_email || 'Unknown User'} {member.user_email || 'Unknown User'}
</div> </div>
<div className="text-xs text-neutral-500 dark:text-neutral-400"> <div className="text-xs text-neutral-400">
Joined {formatDate(new Date(member.joined_at))} Joined {formatDate(new Date(member.joined_at))}
</div> </div>
</div> </div>
@@ -786,7 +786,7 @@ export default function OrganizationSettings() {
{/* Pending Invitations */} {/* Pending Invitations */}
{invitations.length > 0 && ( {invitations.length > 0 && (
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-sm font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Pending Invitations</h3> <h3 className="text-sm font-medium text-neutral-400 uppercase tracking-wider">Pending Invitations</h3>
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden divide-y divide-neutral-200 dark:divide-neutral-800"> <div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden divide-y divide-neutral-200 dark:divide-neutral-800">
{invitations.map((invite) => ( {invitations.map((invite) => (
<div key={invite.id} className="p-4 flex items-center justify-between"> <div key={invite.id} className="p-4 flex items-center justify-between">
@@ -795,10 +795,10 @@ export default function OrganizationSettings() {
<div className="w-2 h-2 rounded-full bg-neutral-400 animate-pulse"></div> <div className="w-2 h-2 rounded-full bg-neutral-400 animate-pulse"></div>
</div> </div>
<div> <div>
<div className="text-sm font-medium text-neutral-900 dark:text-white"> <div className="text-sm font-medium text-white">
{invite.email} {invite.email}
</div> </div>
<div className="text-xs text-neutral-500 dark:text-neutral-400"> <div className="text-xs text-neutral-400">
Invited as <span className="capitalize font-medium">{invite.role}</span> • Expires {formatDate(new Date(invite.expires_at))} Invited as <span className="capitalize font-medium">{invite.role}</span> • Expires {formatDate(new Date(invite.expires_at))}
</div> </div>
</div> </div>
@@ -821,8 +821,8 @@ export default function OrganizationSettings() {
{activeTab === 'billing' && ( {activeTab === 'billing' && (
<div className="space-y-8"> <div className="space-y-8">
<div> <div>
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-1">Billing & Subscription</h2> <h2 className="text-2xl font-bold text-white mb-1">Billing & Subscription</h2>
<p className="text-sm text-neutral-500 dark:text-neutral-400">Manage your plan, usage, and payment details.</p> <p className="text-sm text-neutral-400">Manage your plan, usage, and payment details.</p>
</div> </div>
{isLoadingSubscription ? ( {isLoadingSubscription ? (
@@ -832,7 +832,7 @@ export default function OrganizationSettings() {
</div> </div>
) : !subscription ? ( ) : !subscription ? (
<div className="p-6 text-center bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-200 dark:border-neutral-800"> <div className="p-6 text-center bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-200 dark:border-neutral-800">
<p className="text-neutral-500 dark:text-neutral-400">Could not load subscription details.</p> <p className="text-neutral-400">Could not load subscription details.</p>
<Button variant="ghost" onClick={loadSubscription} className="mt-4">Retry</Button> <Button variant="ghost" onClick={loadSubscription} className="mt-4">Retry</Button>
</div> </div>
) : ( ) : (
@@ -915,7 +915,7 @@ export default function OrganizationSettings() {
{/* Plan header */} {/* Plan header */}
<div className="p-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> <div className="p-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-xl font-bold text-neutral-900 dark:text-white capitalize"> <span className="text-xl font-bold text-white capitalize">
{subscription.plan_id?.startsWith('price_') ? 'Pro' : (subscription.plan_id === 'free' || !subscription.plan_id ? 'Free' : subscription.plan_id)} Plan {subscription.plan_id?.startsWith('price_') ? 'Pro' : (subscription.plan_id === 'free' || !subscription.plan_id ? 'Free' : subscription.plan_id)} Plan
</span> </span>
<span className={`px-2.5 py-0.5 rounded-full text-xs font-medium capitalize ${ <span className={`px-2.5 py-0.5 rounded-full text-xs font-medium capitalize ${
@@ -940,7 +940,7 @@ export default function OrganizationSettings() {
</Button> </Button>
</div> </div>
{(subscription.business_name || subscription.tax_id) && ( {(subscription.business_name || subscription.tax_id) && (
<div className="px-6 pb-2 -mt-2 space-y-1 text-sm text-neutral-500 dark:text-neutral-400"> <div className="px-6 pb-2 -mt-2 space-y-1 text-sm text-neutral-400">
{subscription.business_name && ( {subscription.business_name && (
<div>Billing for: {subscription.business_name}</div> <div>Billing for: {subscription.business_name}</div>
)} )}
@@ -956,7 +956,7 @@ export default function OrganizationSettings() {
<div className="border-t border-neutral-200 dark:border-neutral-800 p-6 grid grid-cols-2 md:grid-cols-4 gap-y-4 gap-x-6"> <div className="border-t border-neutral-200 dark:border-neutral-800 p-6 grid grid-cols-2 md:grid-cols-4 gap-y-4 gap-x-6">
<div> <div>
<div className="text-xs text-neutral-500 uppercase tracking-wider mb-1">Sites</div> <div className="text-xs text-neutral-500 uppercase tracking-wider mb-1">Sites</div>
<div className="text-lg font-semibold text-neutral-900 dark:text-white"> <div className="text-lg font-semibold text-white">
{typeof subscription.sites_count === 'number' {typeof subscription.sites_count === 'number'
? (() => { ? (() => {
const limit = getSitesLimitForPlan(subscription.plan_id) const limit = getSitesLimitForPlan(subscription.plan_id)
@@ -967,7 +967,7 @@ export default function OrganizationSettings() {
</div> </div>
<div> <div>
<div className="text-xs text-neutral-500 uppercase tracking-wider mb-1">Pageviews</div> <div className="text-xs text-neutral-500 uppercase tracking-wider mb-1">Pageviews</div>
<div className="text-lg font-semibold text-neutral-900 dark:text-white"> <div className="text-lg font-semibold text-white">
{subscription.pageview_limit > 0 && typeof subscription.pageview_usage === 'number' {subscription.pageview_limit > 0 && typeof subscription.pageview_usage === 'number'
? `${subscription.pageview_usage.toLocaleString()} / ${subscription.pageview_limit.toLocaleString()}` ? `${subscription.pageview_usage.toLocaleString()} / ${subscription.pageview_limit.toLocaleString()}`
: ''} : ''}
@@ -993,7 +993,7 @@ export default function OrganizationSettings() {
<div className="text-xs text-neutral-500 uppercase tracking-wider mb-1"> <div className="text-xs text-neutral-500 uppercase tracking-wider mb-1">
{subscription.subscription_status === 'trialing' ? 'Trial ends' : (subscription.cancel_at_period_end ? 'Access until' : 'Renews')} {subscription.subscription_status === 'trialing' ? 'Trial ends' : (subscription.cancel_at_period_end ? 'Access until' : 'Renews')}
</div> </div>
<div className="text-lg font-semibold text-neutral-900 dark:text-white"> <div className="text-lg font-semibold text-white">
{(() => { {(() => {
const ts = subscription.current_period_end const ts = subscription.current_period_end
const d = ts ? new Date(ts) : null const d = ts ? new Date(ts) : null
@@ -1005,7 +1005,7 @@ export default function OrganizationSettings() {
</div> </div>
<div> <div>
<div className="text-xs text-neutral-500 uppercase tracking-wider mb-1">Limit</div> <div className="text-xs text-neutral-500 uppercase tracking-wider mb-1">Limit</div>
<div className="text-lg font-semibold text-neutral-900 dark:text-white"> <div className="text-lg font-semibold text-white">
{subscription.pageview_limit > 0 ? `${subscription.pageview_limit.toLocaleString()} / mo` : 'Unlimited'} {subscription.pageview_limit > 0 ? `${subscription.pageview_limit.toLocaleString()} / mo` : 'Unlimited'}
</div> </div>
</div> </div>
@@ -1038,19 +1038,19 @@ export default function OrganizationSettings() {
{/* Order History */} {/* Order History */}
<div> <div>
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white mb-3">Recent orders</h3> <h3 className="text-lg font-semibold text-white mb-3">Recent orders</h3>
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden divide-y divide-neutral-200 dark:divide-neutral-800"> <div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden divide-y divide-neutral-200 dark:divide-neutral-800">
{isLoadingInvoices ? ( {isLoadingInvoices ? (
<InvoicesListSkeleton /> <InvoicesListSkeleton />
) : orders.length === 0 ? ( ) : orders.length === 0 ? (
<div className="p-8 text-center text-neutral-500 dark:text-neutral-400">No orders found.</div> <div className="p-8 text-center text-neutral-400">No orders found.</div>
) : ( ) : (
<> <>
{orders.map((order) => ( {orders.map((order) => (
<div key={order.id} className="px-4 py-3 flex items-center justify-between hover:bg-neutral-50 dark:hover:bg-neutral-800/50 transition-colors"> <div key={order.id} className="px-4 py-3 flex items-center justify-between hover:bg-neutral-50 dark:hover:bg-neutral-800/50 transition-colors">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div> <div>
<span className="font-medium text-sm text-neutral-900 dark:text-white"> <span className="font-medium text-sm text-white">
{(order.total_amount / 100).toLocaleString('en-US', { style: 'currency', currency: order.currency.toUpperCase() })} {(order.total_amount / 100).toLocaleString('en-US', { style: 'currency', currency: order.currency.toUpperCase() })}
</span> </span>
<span className="text-xs text-neutral-500 ml-2"> <span className="text-xs text-neutral-500 ml-2">
@@ -1084,8 +1084,8 @@ export default function OrganizationSettings() {
{activeTab === 'notifications' && ( {activeTab === 'notifications' && (
<div className="space-y-8"> <div className="space-y-8">
<div> <div>
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-1">Notification Settings</h2> <h2 className="text-2xl font-bold text-white mb-1">Notification Settings</h2>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-6"> <p className="text-sm text-neutral-400 mb-6">
Choose which notification types you want to receive. These apply to the notification center for owners and admins. Choose which notification types you want to receive. These apply to the notification center for owners and admins.
</p> </p>
</div> </div>
@@ -1094,7 +1094,7 @@ export default function OrganizationSettings() {
<SettingsFormSkeleton /> <SettingsFormSkeleton />
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-sm font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Notification categories</h3> <h3 className="text-sm font-medium text-neutral-400 uppercase tracking-wider">Notification categories</h3>
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden divide-y divide-neutral-200 dark:divide-neutral-800"> <div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden divide-y divide-neutral-200 dark:divide-neutral-800">
{notificationCategories.map((cat) => ( {notificationCategories.map((cat) => (
<div <div
@@ -1102,8 +1102,8 @@ export default function OrganizationSettings() {
className="p-4 flex flex-col sm:flex-row sm:items-center justify-between gap-4 hover:bg-neutral-50 dark:hover:bg-neutral-800/50 transition-colors" className="p-4 flex flex-col sm:flex-row sm:items-center justify-between gap-4 hover:bg-neutral-50 dark:hover:bg-neutral-800/50 transition-colors"
> >
<div className="flex-1"> <div className="flex-1">
<p className="text-sm font-medium text-neutral-900 dark:text-white">{cat.label}</p> <p className="text-sm font-medium text-white">{cat.label}</p>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-0.5">{cat.description}</p> <p className="text-sm text-neutral-400 mt-0.5">{cat.description}</p>
</div> </div>
<div className="flex items-center shrink-0"> <div className="flex items-center shrink-0">
<button <button
@@ -1149,8 +1149,8 @@ export default function OrganizationSettings() {
{activeTab === 'audit' && ( {activeTab === 'audit' && (
<div className="space-y-12"> <div className="space-y-12">
<div> <div>
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-1">Audit log</h2> <h2 className="text-2xl font-bold text-white mb-1">Audit log</h2>
<p className="text-sm text-neutral-500 dark:text-neutral-400">Who did what and when for this organization.</p> <p className="text-sm text-neutral-400">Who did what and when for this organization.</p>
</div> </div>
{/* Advanced Filters */} {/* Advanced Filters */}
@@ -1163,7 +1163,7 @@ export default function OrganizationSettings() {
placeholder="e.g. 8a2b3c" placeholder="e.g. 8a2b3c"
value={auditLogIdFilter} value={auditLogIdFilter}
onChange={(e) => setAuditLogIdFilter(e.target.value)} onChange={(e) => setAuditLogIdFilter(e.target.value)}
className="w-full px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 text-sm text-neutral-900 dark:text-white focus:ring-2 focus:ring-brand-orange outline-none transition-all" className="w-full px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 text-sm text-white focus:ring-2 focus:ring-brand-orange outline-none transition-all"
/> />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
@@ -1173,7 +1173,7 @@ export default function OrganizationSettings() {
placeholder="e.g. site_created" placeholder="e.g. site_created"
value={auditActionFilter} value={auditActionFilter}
onChange={(e) => setAuditActionFilter(e.target.value)} onChange={(e) => setAuditActionFilter(e.target.value)}
className="w-full px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 text-sm text-neutral-900 dark:text-white focus:ring-2 focus:ring-brand-orange outline-none transition-all" className="w-full px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 text-sm text-white focus:ring-2 focus:ring-brand-orange outline-none transition-all"
/> />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
@@ -1182,7 +1182,7 @@ export default function OrganizationSettings() {
type="date" type="date"
value={auditStartDate} value={auditStartDate}
onChange={(e) => setAuditStartDate(e.target.value)} onChange={(e) => setAuditStartDate(e.target.value)}
className="w-full px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 text-sm text-neutral-900 dark:text-white focus:ring-2 focus:ring-brand-orange outline-none transition-all" className="w-full px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 text-sm text-white focus:ring-2 focus:ring-brand-orange outline-none transition-all"
/> />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
@@ -1191,7 +1191,7 @@ export default function OrganizationSettings() {
type="date" type="date"
value={auditEndDate} value={auditEndDate}
onChange={(e) => setAuditEndDate(e.target.value)} onChange={(e) => setAuditEndDate(e.target.value)}
className="w-full px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 text-sm text-neutral-900 dark:text-white focus:ring-2 focus:ring-brand-orange outline-none transition-all" className="w-full px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 text-sm text-white focus:ring-2 focus:ring-brand-orange outline-none transition-all"
/> />
</div> </div>
</div> </div>
@@ -1240,10 +1240,10 @@ export default function OrganizationSettings() {
<td className="px-4 py-3 text-neutral-600 dark:text-neutral-400 whitespace-nowrap"> <td className="px-4 py-3 text-neutral-600 dark:text-neutral-400 whitespace-nowrap">
{formatDateTime(new Date(entry.occurred_at))} {formatDateTime(new Date(entry.occurred_at))}
</td> </td>
<td className="px-4 py-3 text-neutral-900 dark:text-white whitespace-nowrap" title={entry.actor_email || entry.actor_id || 'System'}> <td className="px-4 py-3 text-white whitespace-nowrap" title={entry.actor_email || entry.actor_id || 'System'}>
{entry.actor_email || entry.actor_id || 'System'} {entry.actor_email || entry.actor_id || 'System'}
</td> </td>
<td className="px-4 py-3 font-medium text-neutral-900 dark:text-white whitespace-nowrap" title={entry.action}>{entry.action}</td> <td className="px-4 py-3 font-medium text-white whitespace-nowrap" title={entry.action}>{entry.action}</td>
<td className="px-4 py-3 text-neutral-600 dark:text-neutral-400">{entry.resource_type}</td> <td className="px-4 py-3 text-neutral-600 dark:text-neutral-400">{entry.resource_type}</td>
</tr> </tr>
))} ))}
@@ -1255,7 +1255,7 @@ export default function OrganizationSettings() {
{/* Pagination */} {/* Pagination */}
{auditTotal > auditPageSize && ( {auditTotal > auditPageSize && (
<div className="flex items-center justify-between px-4 py-3 border-t border-neutral-200 dark:border-neutral-800"> <div className="flex items-center justify-between px-4 py-3 border-t border-neutral-200 dark:border-neutral-800">
<span className="text-sm text-neutral-500 dark:text-neutral-400"> <span className="text-sm text-neutral-400">
{auditPage * auditPageSize + 1}{Math.min((auditPage + 1) * auditPageSize, auditTotal)} of {auditTotal} {auditPage * auditPageSize + 1}{Math.min((auditPage + 1) * auditPageSize, auditTotal)} of {auditTotal}
</span> </span>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -1361,7 +1361,7 @@ export default function OrganizationSettings() {
value={deleteConfirm} value={deleteConfirm}
onChange={(e) => setDeleteConfirm(e.target.value)} onChange={(e) => setDeleteConfirm(e.target.value)}
autoComplete="off" autoComplete="off"
className="w-full px-3 py-2 text-sm border border-neutral-300 dark:border-neutral-700 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-white placeholder-neutral-400 focus:outline-none focus:ring-2 focus:ring-red-500 dark:focus:ring-red-400" className="w-full px-3 py-2 text-sm border border-neutral-300 dark:border-neutral-700 rounded-lg bg-white dark:bg-neutral-800 text-white placeholder-neutral-400 focus:outline-none focus:ring-2 focus:ring-red-500 dark:focus:ring-red-400"
placeholder="DELETE" placeholder="DELETE"
/> />
</div> </div>
@@ -1410,7 +1410,7 @@ export default function OrganizationSettings() {
className="bg-white dark:bg-neutral-900 rounded-2xl shadow-2xl max-w-md w-full p-6 border border-neutral-200 dark:border-neutral-800" className="bg-white dark:bg-neutral-900 rounded-2xl shadow-2xl max-w-md w-full p-6 border border-neutral-200 dark:border-neutral-800"
> >
<div className="flex justify-between items-center mb-4"> <div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">Cancel subscription?</h3> <h3 className="text-lg font-semibold text-white">Cancel subscription?</h3>
<button <button
onClick={() => setShowCancelPrompt(false)} onClick={() => setShowCancelPrompt(false)}
className="text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-400" className="text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-400"
@@ -1464,7 +1464,7 @@ export default function OrganizationSettings() {
className="bg-white dark:bg-neutral-900 rounded-2xl shadow-2xl max-w-md w-full p-6 border border-neutral-200 dark:border-neutral-800" className="bg-white dark:bg-neutral-900 rounded-2xl shadow-2xl max-w-md w-full p-6 border border-neutral-200 dark:border-neutral-800"
> >
<div className="flex justify-between items-center mb-4"> <div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">Change plan</h3> <h3 className="text-lg font-semibold text-white">Change plan</h3>
<button <button
type="button" type="button"
onClick={() => setShowChangePlanModal(false)} onClick={() => setShowChangePlanModal(false)}
@@ -1500,7 +1500,7 @@ export default function OrganizationSettings() {
: 'border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600' : 'border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600'
}`} }`}
> >
<span className={`block text-sm font-semibold ${isSelected ? 'text-brand-orange' : 'text-neutral-900 dark:text-white'}`}> <span className={`block text-sm font-semibold ${isSelected ? 'text-brand-orange' : 'text-white'}`}>
{plan.name} {plan.name}
</span> </span>
<span className="block text-xs text-neutral-500 mt-0.5">{plan.sites}</span> <span className="block text-xs text-neutral-500 mt-0.5">{plan.sites}</span>
@@ -1519,7 +1519,7 @@ export default function OrganizationSettings() {
<select <select
value={changePlanTierIndex} value={changePlanTierIndex}
onChange={(e) => setChangePlanTierIndex(Number(e.target.value))} onChange={(e) => setChangePlanTierIndex(Number(e.target.value))}
className="w-full px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white focus:ring-2 focus:ring-brand-orange outline-none" className="w-full px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 text-white focus:ring-2 focus:ring-brand-orange outline-none"
> >
{TRAFFIC_TIERS.map((tier, idx) => ( {TRAFFIC_TIERS.map((tier, idx) => (
<option key={tier.value} value={idx}> <option key={tier.value} value={idx}>

View File

@@ -38,7 +38,7 @@ function getEventColor(eventType: string, outcome: string): string {
if (eventType === '2fa_disabled') return 'text-amber-500 dark:text-amber-400 bg-amber-50 dark:bg-amber-950/30' if (eventType === '2fa_disabled') return 'text-amber-500 dark:text-amber-400 bg-amber-50 dark:bg-amber-950/30'
if (eventType === 'account_deleted') return 'text-red-500 dark:text-red-400 bg-red-50 dark:bg-red-950/30' if (eventType === 'account_deleted') return 'text-red-500 dark:text-red-400 bg-red-50 dark:bg-red-950/30'
if (eventType === 'recovery_codes_regenerated') return 'text-amber-500 dark:text-amber-400 bg-amber-50 dark:bg-amber-950/30' if (eventType === 'recovery_codes_regenerated') return 'text-amber-500 dark:text-amber-400 bg-amber-50 dark:bg-amber-950/30'
return 'text-neutral-500 dark:text-neutral-400 bg-neutral-100 dark:bg-neutral-800' return 'text-neutral-400 bg-neutral-100 dark:bg-neutral-800'
} }
function getMethodLabel(entry: AuditLogEntry): string | null { function getMethodLabel(entry: AuditLogEntry): string | null {
@@ -120,8 +120,8 @@ export default function SecurityActivityCard() {
return ( return (
<div> <div>
<h2 className="text-xl font-semibold text-neutral-900 dark:text-white mb-1">Security Activity</h2> <h2 className="text-xl font-semibold text-white mb-1">Security Activity</h2>
<p className="text-neutral-500 dark:text-neutral-400 text-sm mb-6"> <p className="text-neutral-400 text-sm mb-6">
Recent security events on your account{totalCount > 0 ? ` (${totalCount})` : ''} Recent security events on your account{totalCount > 0 ? ` (${totalCount})` : ''}
</p> </p>
@@ -138,7 +138,7 @@ export default function SecurityActivityCard() {
<svg className="w-12 h-12 mx-auto mb-3 text-neutral-300 dark:text-neutral-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-12 h-12 mx-auto mb-3 text-neutral-300 dark:text-neutral-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
</svg> </svg>
<p className="text-neutral-500 dark:text-neutral-400">No activity recorded yet.</p> <p className="text-neutral-400">No activity recorded yet.</p>
</div> </div>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
@@ -165,11 +165,11 @@ export default function SecurityActivityCard() {
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-neutral-900 dark:text-white text-sm"> <span className="font-medium text-white text-sm">
{label} {label}
</span> </span>
{method && ( {method && (
<span className="text-xs px-1.5 py-0.5 rounded bg-neutral-100 dark:bg-neutral-800 text-neutral-500 dark:text-neutral-400"> <span className="text-xs px-1.5 py-0.5 rounded bg-neutral-100 dark:bg-neutral-800 text-neutral-400">
{method} {method}
</span> </span>
)} )}
@@ -179,7 +179,7 @@ export default function SecurityActivityCard() {
</span> </span>
)} )}
</div> </div>
<div className="flex items-center gap-2 mt-0.5 text-xs text-neutral-500 dark:text-neutral-400 flex-wrap"> <div className="flex items-center gap-2 mt-0.5 text-xs text-neutral-400 flex-wrap">
{reason && <span>{reason}</span>} {reason && <span>{reason}</span>}
{reason && (deviceStr || entry.ip_address) && <span>&middot;</span>} {reason && (deviceStr || entry.ip_address) && <span>&middot;</span>}
{deviceStr && <span>{deviceStr}</span>} {deviceStr && <span>{deviceStr}</span>}
@@ -189,7 +189,7 @@ export default function SecurityActivityCard() {
</div> </div>
<div className="flex-shrink-0 text-right"> <div className="flex-shrink-0 text-right">
<span className="text-xs text-neutral-500 dark:text-neutral-400" title={formatDateTimeFull(new Date(entry.created_at))}> <span className="text-xs text-neutral-400" title={formatDateTimeFull(new Date(entry.created_at))}>
{formatRelativeTime(entry.created_at)} {formatRelativeTime(entry.created_at)}
</span> </span>
</div> </div>

View File

@@ -72,7 +72,7 @@ function NotificationCenterPlaceholder() {
return ( return (
<div className="text-center max-w-md mx-auto py-8"> <div className="text-center max-w-md mx-auto py-8">
<BellIcon className="w-12 h-12 text-neutral-300 mx-auto mb-4" /> <BellIcon className="w-12 h-12 text-neutral-300 mx-auto mb-4" />
<h3 className="text-lg font-medium text-neutral-900 dark:text-white mb-2">Notification Center</h3> <h3 className="text-lg font-medium text-white mb-2">Notification Center</h3>
<p className="text-sm text-neutral-500 mb-4">View and manage all your notifications in one place.</p> <p className="text-sm text-neutral-500 mb-4">View and manage all your notifications in one place.</p>
<Link href="/notifications" className="inline-flex items-center gap-2 px-4 py-2 bg-brand-orange text-white rounded-lg hover:bg-brand-orange/90 transition-colors"> <Link href="/notifications" className="inline-flex items-center gap-2 px-4 py-2 bg-brand-orange text-white rounded-lg hover:bg-brand-orange/90 transition-colors">
Open Notification Center Open Notification Center

View File

@@ -56,8 +56,8 @@ export default function TrustedDevicesCard() {
return ( return (
<div> <div>
<h2 className="text-xl font-semibold text-neutral-900 dark:text-white mb-1">Trusted Devices</h2> <h2 className="text-xl font-semibold text-white mb-1">Trusted Devices</h2>
<p className="text-neutral-500 dark:text-neutral-400 text-sm mb-6"> <p className="text-neutral-400 text-sm mb-6">
Devices that have signed in to your account. Removing a device means the next sign-in from it will trigger a new device alert. Devices that have signed in to your account. Removing a device means the next sign-in from it will trigger a new device alert.
</p> </p>
@@ -74,7 +74,7 @@ export default function TrustedDevicesCard() {
<svg className="w-12 h-12 mx-auto mb-3 text-neutral-300 dark:text-neutral-600" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24"> <svg className="w-12 h-12 mx-auto mb-3 text-neutral-300 dark:text-neutral-600" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25A2.25 2.25 0 015.25 3h13.5A2.25 2.25 0 0121 5.25z" /> <path strokeLinecap="round" strokeLinejoin="round" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25A2.25 2.25 0 015.25 3h13.5A2.25 2.25 0 0121 5.25z" />
</svg> </svg>
<p className="text-neutral-500 dark:text-neutral-400">No trusted devices yet. They appear after you sign in.</p> <p className="text-neutral-400">No trusted devices yet. They appear after you sign in.</p>
</div> </div>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
@@ -83,7 +83,7 @@ export default function TrustedDevicesCard() {
key={device.id} key={device.id}
className="flex items-center gap-3 rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-4 py-3" className="flex items-center gap-3 rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-4 py-3"
> >
<div className="flex-shrink-0 w-9 h-9 rounded-lg flex items-center justify-center bg-neutral-100 dark:bg-neutral-800 text-neutral-500 dark:text-neutral-400"> <div className="flex-shrink-0 w-9 h-9 rounded-lg flex items-center justify-center bg-neutral-100 dark:bg-neutral-800 text-neutral-400">
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24"> <svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d={getDeviceIcon(device.display_hint)} /> <path strokeLinecap="round" strokeLinejoin="round" d={getDeviceIcon(device.display_hint)} />
</svg> </svg>
@@ -91,7 +91,7 @@ export default function TrustedDevicesCard() {
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-medium text-neutral-900 dark:text-white text-sm truncate"> <span className="font-medium text-white text-sm truncate">
{device.display_hint || 'Unknown device'} {device.display_hint || 'Unknown device'}
</span> </span>
{device.is_current && ( {device.is_current && (
@@ -100,7 +100,7 @@ export default function TrustedDevicesCard() {
</span> </span>
)} )}
</div> </div>
<div className="flex items-center gap-2 mt-0.5 text-xs text-neutral-500 dark:text-neutral-400"> <div className="flex items-center gap-2 mt-0.5 text-xs text-neutral-400">
<span title={formatDateTimeFull(new Date(device.first_seen_at))}> <span title={formatDateTimeFull(new Date(device.first_seen_at))}>
First seen {formatRelativeTime(device.first_seen_at)} First seen {formatRelativeTime(device.first_seen_at)}
</span> </span>

View File

@@ -124,7 +124,7 @@ export default function DeleteSiteModal({ open, onClose, onDeleted, siteName, si
value={deleteConfirm} value={deleteConfirm}
onChange={(e) => setDeleteConfirm(e.target.value)} onChange={(e) => setDeleteConfirm(e.target.value)}
autoComplete="off" autoComplete="off"
className="w-full px-3 py-2 text-sm border border-neutral-300 dark:border-neutral-700 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-white placeholder-neutral-400 focus:outline-none focus:ring-2 focus:ring-red-500 dark:focus:ring-red-400" className="w-full px-3 py-2 text-sm border border-neutral-300 dark:border-neutral-700 rounded-lg bg-white dark:bg-neutral-800 text-white placeholder-neutral-400 focus:outline-none focus:ring-2 focus:ring-red-500 dark:focus:ring-red-400"
placeholder="DELETE" placeholder="DELETE"
/> />
</div> </div>
@@ -187,7 +187,7 @@ export default function DeleteSiteModal({ open, onClose, onDeleted, siteName, si
value={permanentConfirm} value={permanentConfirm}
onChange={(e) => setPermanentConfirm(e.target.value)} onChange={(e) => setPermanentConfirm(e.target.value)}
autoComplete="off" autoComplete="off"
className="w-full px-3 py-2 text-sm border border-neutral-300 dark:border-neutral-700 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-white placeholder-neutral-400 focus:outline-none focus:ring-2 focus:ring-red-500 dark:focus:ring-red-400" className="w-full px-3 py-2 text-sm border border-neutral-300 dark:border-neutral-700 rounded-lg bg-white dark:bg-neutral-800 text-white placeholder-neutral-400 focus:outline-none focus:ring-2 focus:ring-red-500 dark:focus:ring-red-400"
placeholder={siteDomain} placeholder={siteDomain}
/> />
</div> </div>

View File

@@ -167,7 +167,7 @@ export default function ScriptSetupBlock({
{/* ── Feature toggles ─────────────────────────────────────────────── */} {/* ── Feature toggles ─────────────────────────────────────────────── */}
<div className="mt-6"> <div className="mt-6">
<h4 className="text-sm font-semibold text-neutral-900 dark:text-white mb-3"> <h4 className="text-sm font-semibold text-white mb-3">
Features Features
</h4> </h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
@@ -177,10 +177,10 @@ export default function ScriptSetupBlock({
className="flex items-center justify-between rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-4 py-3" className="flex items-center justify-between rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-4 py-3"
> >
<div className="min-w-0 mr-3"> <div className="min-w-0 mr-3">
<span className="text-sm font-medium text-neutral-900 dark:text-white block"> <span className="text-sm font-medium text-white block">
{f.label} {f.label}
</span> </span>
<span className="text-xs text-neutral-500 dark:text-neutral-400"> <span className="text-xs text-neutral-400">
{f.description} {f.description}
</span> </span>
</div> </div>
@@ -191,10 +191,10 @@ export default function ScriptSetupBlock({
{/* * Frustration — full-width, visually distinct as add-on */} {/* * Frustration — full-width, visually distinct as add-on */}
<div className="mt-3 flex items-center justify-between rounded-xl border border-dashed border-neutral-300 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-900/50 px-4 py-3"> <div className="mt-3 flex items-center justify-between rounded-xl border border-dashed border-neutral-300 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-900/50 px-4 py-3">
<div className="min-w-0 mr-3"> <div className="min-w-0 mr-3">
<span className="text-sm font-medium text-neutral-900 dark:text-white block"> <span className="text-sm font-medium text-white block">
Frustration tracking Frustration tracking
</span> </span>
<span className="text-xs text-neutral-500 dark:text-neutral-400"> <span className="text-xs text-neutral-400">
Rage clicks &amp; dead clicks &middot; Loads separate add-on script Rage clicks &amp; dead clicks &middot; Loads separate add-on script
</span> </span>
</div> </div>
@@ -204,15 +204,15 @@ export default function ScriptSetupBlock({
{/* ── Storage + TTL ───────────────────────────────────────────────── */} {/* ── Storage + TTL ───────────────────────────────────────────────── */}
<div className="mt-6"> <div className="mt-6">
<h4 className="text-sm font-semibold text-neutral-900 dark:text-white mb-1"> <h4 className="text-sm font-semibold text-white mb-1">
Visitor identity Visitor identity
</h4> </h4>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-3"> <p className="text-xs text-neutral-400 mb-3">
How returning visitors are recognized. Stricter settings increase privacy but may raise unique visitor counts. How returning visitors are recognized. Stricter settings increase privacy but may raise unique visitor counts.
</p> </p>
<div className="flex items-end gap-3"> <div className="flex items-end gap-3">
<div className="min-w-0"> <div className="min-w-0">
<label className="text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-1 block"> <label className="text-xs font-medium text-neutral-400 mb-1 block">
Recognition Recognition
</label> </label>
<Select <Select
@@ -224,7 +224,7 @@ export default function ScriptSetupBlock({
</div> </div>
{storage === 'local' && ( {storage === 'local' && (
<div> <div>
<label className="text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-1 block"> <label className="text-xs font-medium text-neutral-400 mb-1 block">
Reset after Reset after
</label> </label>
<Select <Select
@@ -242,14 +242,14 @@ export default function ScriptSetupBlock({
{showFrameworkPicker && ( {showFrameworkPicker && (
<div className="mt-6"> <div className="mt-6">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-semibold text-neutral-900 dark:text-white"> <h4 className="text-sm font-semibold text-white">
Setup guide Setup guide
</h4> </h4>
<Link <Link
href="/integrations" href="/integrations"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-xs font-medium text-neutral-500 dark:text-neutral-400 hover:text-brand-orange transition-colors" className="text-xs font-medium text-neutral-400 hover:text-brand-orange transition-colors"
> >
All integrations All integrations
</Link> </Link>

View File

@@ -46,8 +46,8 @@ function SiteCard({ site, stats, statsLoading, onDelete, canDelete }: SiteCardPr
/> />
</div> </div>
<div> <div>
<h3 className="font-semibold text-neutral-900 dark:text-white">{site.name}</h3> <h3 className="font-semibold text-white">{site.name}</h3>
<div className="flex items-center gap-1 text-sm text-neutral-500 dark:text-neutral-400"> <div className="flex items-center gap-1 text-sm text-neutral-400">
{site.domain} {site.domain}
<a <a
href={`https://${site.domain}`} href={`https://${site.domain}`}
@@ -84,13 +84,13 @@ function SiteCard({ site, stats, statsLoading, onDelete, canDelete }: SiteCardPr
<div className="mb-6 grid grid-cols-2 gap-4 rounded-lg bg-neutral-50 p-3 dark:bg-neutral-800/50"> <div className="mb-6 grid grid-cols-2 gap-4 rounded-lg bg-neutral-50 p-3 dark:bg-neutral-800/50">
<div> <div>
<p className="text-xs text-neutral-500">Visitors (24h)</p> <p className="text-xs text-neutral-500">Visitors (24h)</p>
<p className="font-mono text-lg font-medium text-neutral-900 dark:text-white"> <p className="font-mono text-lg font-medium text-white">
{statsLoading ? '--' : formatNumber(visitors24h)} {statsLoading ? '--' : formatNumber(visitors24h)}
</p> </p>
</div> </div>
<div> <div>
<p className="text-xs text-neutral-500">Pageviews</p> <p className="text-xs text-neutral-500">Pageviews</p>
<p className="font-mono text-lg font-medium text-neutral-900 dark:text-white"> <p className="font-mono text-lg font-medium text-white">
{statsLoading ? '--' : formatNumber(pageviews)} {statsLoading ? '--' : formatNumber(pageviews)}
</p> </p>
</div> </div>
@@ -144,8 +144,8 @@ export default function SiteList({ sites, siteStats, loading, onDelete }: SiteLi
className="mb-6" className="mb-6"
unoptimized unoptimized
/> />
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">No sites yet</h3> <h3 className="text-lg font-semibold text-white">No sites yet</h3>
<p className="mt-2 text-sm text-neutral-500 dark:text-neutral-400 mb-4">Create your first site to get started.</p> <p className="mt-2 text-sm text-neutral-400 mb-4">Create your first site to get started.</p>
<Link href="/sites/new"> <Link href="/sites/new">
<Button variant="primary" className="text-sm"> <Button variant="primary" className="text-sm">
Add your first site Add your first site
@@ -176,8 +176,8 @@ export default function SiteList({ sites, siteStats, loading, onDelete }: SiteLi
<div className="mb-3 rounded-full bg-neutral-200 p-3 dark:bg-neutral-800"> <div className="mb-3 rounded-full bg-neutral-200 p-3 dark:bg-neutral-800">
<BookOpenIcon className="h-6 w-6 text-neutral-500" /> <BookOpenIcon className="h-6 w-6 text-neutral-500" />
</div> </div>
<h3 className="font-semibold text-neutral-900 dark:text-white">Need help setup?</h3> <h3 className="font-semibold text-white">Need help setup?</h3>
<p className="mb-4 text-sm text-neutral-500 dark:text-neutral-400">Check our documentation for installation guides.</p> <p className="mb-4 text-sm text-neutral-400">Check our documentation for installation guides.</p>
<Link href="https://docs.ciphera.net" target="_blank" className="text-sm font-medium text-brand-orange hover:underline"> <Link href="https://docs.ciphera.net" target="_blank" className="text-sm font-medium text-brand-orange hover:underline">
Read Documentation &rarr; Read Documentation &rarr;
</Link> </Link>

View File

@@ -105,7 +105,7 @@ export default function VerificationModal({ isOpen, onClose, site, onVerified }:
> >
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-neutral-100 dark:border-neutral-800"> <div className="flex items-center justify-between px-6 py-4 border-b border-neutral-100 dark:border-neutral-800">
<h3 className="font-semibold text-neutral-900 dark:text-white"> <h3 className="font-semibold text-white">
Verify Installation Verify Installation
</h3> </h3>
<button <button
@@ -148,10 +148,10 @@ export default function VerificationModal({ isOpen, onClose, site, onVerified }:
<div className="absolute inset-0 w-16 h-16 border-4 border-brand-orange border-t-transparent rounded-full animate-spin" /> <div className="absolute inset-0 w-16 h-16 border-4 border-brand-orange border-t-transparent rounded-full animate-spin" />
</div> </div>
<div className="text-center space-y-1"> <div className="text-center space-y-1">
<h4 className="font-medium text-neutral-900 dark:text-white"> <h4 className="font-medium text-white">
Checking connection... Checking connection...
</h4> </h4>
<p className="text-sm text-neutral-500 dark:text-neutral-400"> <p className="text-sm text-neutral-400">
Waiting for signal from {site.domain} Waiting for signal from {site.domain}
</p> </p>
</div> </div>
@@ -164,10 +164,10 @@ export default function VerificationModal({ isOpen, onClose, site, onVerified }:
<CheckCircleIcon className="w-8 h-8" /> <CheckCircleIcon className="w-8 h-8" />
</div> </div>
<div className="text-center space-y-1"> <div className="text-center space-y-1">
<h4 className="text-xl font-bold text-neutral-900 dark:text-white"> <h4 className="text-xl font-bold text-white">
You're all set! You're all set!
</h4> </h4>
<p className="text-neutral-500 dark:text-neutral-400"> <p className="text-neutral-400">
We are successfully receiving data from your website. We are successfully receiving data from your website.
</p> </p>
</div> </div>
@@ -189,7 +189,7 @@ export default function VerificationModal({ isOpen, onClose, site, onVerified }:
</div> </div>
<div className="p-4 bg-neutral-50 dark:bg-neutral-800/50 rounded-xl border border-neutral-100 dark:border-neutral-800"> <div className="p-4 bg-neutral-50 dark:bg-neutral-800/50 rounded-xl border border-neutral-100 dark:border-neutral-800">
<p className="text-sm font-medium text-neutral-900 dark:text-white mb-2"> <p className="text-sm font-medium text-white mb-2">
Troubleshooting Checklist: Troubleshooting Checklist:
</p> </p>
<ul className="text-sm text-neutral-600 dark:text-neutral-400 space-y-1 list-disc list-inside"> <ul className="text-sm text-neutral-600 dark:text-neutral-400 space-y-1 list-disc list-inside">

View File

@@ -125,7 +125,7 @@ export default function UtmBuilder({ initialSiteId }: UtmBuilderProps) {
{/* Site Selector */} {/* Site Selector */}
{sites.length > 0 && ( {sites.length > 0 && (
<div> <div>
<label className="block text-sm font-medium mb-1 text-neutral-900 dark:text-white">Select Site</label> <label className="block text-sm font-medium mb-1 text-white">Select Site</label>
<Select <Select
value={selectedSiteId} value={selectedSiteId}
onChange={handleSiteChange} onChange={handleSiteChange}
@@ -138,7 +138,7 @@ export default function UtmBuilder({ initialSiteId }: UtmBuilderProps) {
)} )}
<div> <div>
<label className="block text-sm font-medium mb-1.5 text-neutral-900 dark:text-white">Website URL *</label> <label className="block text-sm font-medium mb-1.5 text-white">Website URL *</label>
{selectedSite ? ( {selectedSite ? (
<div className="flex rounded-xl shadow-sm transition-all duration-200 focus-within:ring-4 focus-within:ring-brand-orange/10 focus-within:border-brand-orange hover:border-brand-orange/50 border border-neutral-200 dark:border-neutral-800"> <div className="flex rounded-xl shadow-sm transition-all duration-200 focus-within:ring-4 focus-within:ring-brand-orange/10 focus-within:border-brand-orange hover:border-brand-orange/50 border border-neutral-200 dark:border-neutral-800">
<span className="inline-flex items-center px-4 rounded-l-xl border-r border-neutral-200 dark:border-neutral-800 bg-neutral-100 dark:bg-neutral-900 text-neutral-500 text-sm select-none"> <span className="inline-flex items-center px-4 rounded-l-xl border-r border-neutral-200 dark:border-neutral-800 bg-neutral-100 dark:bg-neutral-900 text-neutral-500 text-sm select-none">
@@ -146,7 +146,7 @@ export default function UtmBuilder({ initialSiteId }: UtmBuilderProps) {
</span> </span>
<input <input
type="text" type="text"
className="flex-1 min-w-0 block w-full px-4 py-3 rounded-none rounded-r-xl bg-neutral-50/50 dark:bg-neutral-900/50 outline-none transition-all text-neutral-900 dark:text-white text-sm placeholder:text-neutral-400" className="flex-1 min-w-0 block w-full px-4 py-3 rounded-none rounded-r-xl bg-neutral-50/50 dark:bg-neutral-900/50 outline-none transition-all text-white text-sm placeholder:text-neutral-400"
placeholder="/blog/post-1" placeholder="/blog/post-1"
value={getCurrentPath()} value={getCurrentPath()}
onChange={handlePathChange} onChange={handlePathChange}
@@ -167,7 +167,7 @@ export default function UtmBuilder({ initialSiteId }: UtmBuilderProps) {
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium mb-1.5 text-neutral-900 dark:text-white">Source *</label> <label className="block text-sm font-medium mb-1.5 text-white">Source *</label>
<Input <Input
name="source" name="source"
placeholder="google, newsletter" placeholder="google, newsletter"
@@ -176,7 +176,7 @@ export default function UtmBuilder({ initialSiteId }: UtmBuilderProps) {
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1.5 text-neutral-900 dark:text-white">Medium *</label> <label className="block text-sm font-medium mb-1.5 text-white">Medium *</label>
<Input <Input
name="medium" name="medium"
placeholder="cpc, email" placeholder="cpc, email"
@@ -186,7 +186,7 @@ export default function UtmBuilder({ initialSiteId }: UtmBuilderProps) {
</div> </div>
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1.5 text-neutral-900 dark:text-white">Campaign Name *</label> <label className="block text-sm font-medium mb-1.5 text-white">Campaign Name *</label>
<Input <Input
name="campaign" name="campaign"
placeholder="spring_sale" placeholder="spring_sale"

View File

@@ -2310,7 +2310,7 @@ export default defineConfig({
</p> </p>
<details> <details>
<summary className="cursor-pointer text-sm text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300"> <summary className="cursor-pointer text-sm text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300">
Advanced: override domain or configure options Advanced: override domain or configure options
</summary> </summary>
<p className="mt-3 text-sm text-neutral-600 dark:text-neutral-400"> <p className="mt-3 text-sm text-neutral-600 dark:text-neutral-400">

20
lib/utils/dateRanges.ts Normal file
View File

@@ -0,0 +1,20 @@
import { getDateRange, formatDate } from '@ciphera-net/ui'
/** Mondaytoday range for "This week" option */
export function getThisWeekRange(): { start: string; end: string } {
const today = new Date()
const dayOfWeek = today.getDay()
const monday = new Date(today)
monday.setDate(today.getDate() - (dayOfWeek === 0 ? 6 : dayOfWeek - 1))
return { start: formatDate(monday), end: formatDate(today) }
}
/** 1st of monthtoday range for "This month" option */
export function getThisMonthRange(): { start: string; end: string } {
const today = new Date()
const firstOfMonth = new Date(today.getFullYear(), today.getMonth(), 1)
return { start: formatDate(firstOfMonth), end: formatDate(today) }
}
// Re-export for convenience
export { getDateRange, formatDate }

View File

@@ -20,8 +20,6 @@
"@radix-ui/react-navigation-menu": "^1.2.14", "@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@simplewebauthn/browser": "^13.2.2", "@simplewebauthn/browser": "^13.2.2",
"@stripe/react-stripe-js": "^5.6.0",
"@stripe/stripe-js": "^8.7.0",
"@tanstack/react-virtual": "^3.13.21", "@tanstack/react-virtual": "^3.13.21",
"@types/d3": "^7.4.3", "@types/d3": "^7.4.3",
"@visx/curve": "^3.12.0", "@visx/curve": "^3.12.0",
@@ -44,7 +42,6 @@
"jspdf": "^4.0.0", "jspdf": "^4.0.0",
"jspdf-autotable": "^5.0.7", "jspdf-autotable": "^5.0.7",
"lucide-react": "^0.577.0", "lucide-react": "^0.577.0",
"motion": "^12.35.2",
"next": "^16.1.1", "next": "^16.1.1",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "^19.2.3", "react": "^19.2.3",