Merge pull request #68 from ciphera-net/staging
PageSpeed monitoring, Polar billing, sidebar polish, frontend consistency audit
@@ -28,8 +28,8 @@ export default function FilteredTrafficPage() {
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-neutral-900 dark:text-white">Filtered Traffic</h2>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-1">
|
||||
<h2 className="text-xl font-semibold text-white">Filtered Traffic</h2>
|
||||
<p className="text-sm text-neutral-400 mt-1">
|
||||
{totalBlocked.toLocaleString()} spam referrers blocked in the last {days} days
|
||||
</p>
|
||||
</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">
|
||||
{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
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="border-b border-neutral-200 dark:border-neutral-800">
|
||||
<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-500 dark: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">Domain</th>
|
||||
<th className="px-4 py-3 font-medium text-neutral-400">Reason</th>
|
||||
<th className="px-4 py-3 font-medium text-neutral-400 text-right">Blocked</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-800">
|
||||
{referrers.map((r) => (
|
||||
<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">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
r.reason === 'blocklist'
|
||||
@@ -77,7 +77,7 @@ export default function FilteredTrafficPage() {
|
||||
{r.reason}
|
||||
</span>
|
||||
</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()}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -37,7 +37,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
return (
|
||||
<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">
|
||||
<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>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -107,7 +107,7 @@ export default function AdminOrgDetailPage() {
|
||||
return (
|
||||
<div className="space-y-6 max-w-4xl mx-auto">
|
||||
<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'}
|
||||
</h2>
|
||||
<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">
|
||||
{/* Current Status */}
|
||||
<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">
|
||||
<span className="text-neutral-500">Plan:</span>
|
||||
<span className="font-medium">{org.plan_id}</span>
|
||||
@@ -135,17 +135,17 @@ export default function AdminOrgDetailPage() {
|
||||
{org.current_period_end ? formatDateTime(new Date(org.current_period_end)) : '-'}
|
||||
</span>
|
||||
|
||||
<span className="text-neutral-500">Stripe Cust:</span>
|
||||
<span className="font-mono text-xs">{org.stripe_customer_id || '-'}</span>
|
||||
<span className="text-neutral-500">Customer ID:</span>
|
||||
<span className="font-mono text-xs">{org.billing_customer_id || '-'}</span>
|
||||
|
||||
<span className="text-neutral-500">Stripe Sub:</span>
|
||||
<span className="font-mono text-xs">{org.stripe_subscription_id || '-'}</span>
|
||||
<span className="text-neutral-500">Subscription ID:</span>
|
||||
<span className="font-mono text-xs">{org.billing_subscription_id || '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sites */}
|
||||
<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">
|
||||
{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">
|
||||
@@ -160,7 +160,7 @@ export default function AdminOrgDetailPage() {
|
||||
|
||||
{/* 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">
|
||||
<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">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
@@ -196,7 +196,7 @@ export default function AdminOrgDetailPage() {
|
||||
type="datetime-local"
|
||||
value={periodEnd}
|
||||
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
|
||||
/>
|
||||
<div className="flex gap-2 mt-1">
|
||||
|
||||
@@ -43,28 +43,28 @@ export default function AdminOrgsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<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 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">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="border-b border-neutral-200 dark:border-neutral-800">
|
||||
<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-500 dark: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-500 dark: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-500 dark: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">Name</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-400">Plan</th>
|
||||
<th className="px-4 py-3 font-medium text-neutral-400">Status</th>
|
||||
<th className="px-4 py-3 font-medium text-neutral-400">Limit</th>
|
||||
<th className="px-4 py-3 font-medium text-neutral-400">Updated</th>
|
||||
<th className="px-4 py-3 font-medium text-neutral-400">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-800">
|
||||
{orgs.map((org) => (
|
||||
<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'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
|
||||
@@ -9,9 +9,9 @@ export default function AdminDashboard() {
|
||||
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"
|
||||
>
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark: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-500 dark:text-neutral-400 mt-4">
|
||||
<h3 className="text-lg font-semibold text-white">Organizations</h3>
|
||||
<p className="text-sm text-neutral-400 mt-1">Manage organization plans and limits</p>
|
||||
<p className="text-sm text-neutral-400 mt-4">
|
||||
View all organizations, check billing status, and manually grant plans.
|
||||
</p>
|
||||
</Link>
|
||||
@@ -19,9 +19,9 @@ export default function AdminDashboard() {
|
||||
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"
|
||||
>
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark: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-500 dark:text-neutral-400 mt-4">
|
||||
<h3 className="text-lg font-semibold text-white">Filtered Traffic</h3>
|
||||
<p className="text-sm text-neutral-400 mt-1">Monitor blocked referrer spam</p>
|
||||
<p className="text-sm text-neutral-400 mt-4">
|
||||
View domains blocked by the spam filter and check for false positives.
|
||||
</p>
|
||||
</Link>
|
||||
|
||||
@@ -18,7 +18,7 @@ export default function ChangelogPage() {
|
||||
|
||||
return (
|
||||
<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
|
||||
</h1>
|
||||
<p className="text-neutral-600 dark:text-neutral-400 mb-8 text-sm">
|
||||
|
||||
@@ -14,9 +14,11 @@ export default function NotFound() {
|
||||
</div>
|
||||
|
||||
<div className="text-center px-4 z-10">
|
||||
<h1 className="text-9xl font-bold text-transparent bg-clip-text bg-gradient-to-b from-white to-neutral-500 mb-4">
|
||||
404
|
||||
</h1>
|
||||
<img
|
||||
src="/illustrations/page-not-found.svg"
|
||||
alt="Page not found"
|
||||
className="w-72 h-auto mx-auto mb-8"
|
||||
/>
|
||||
<h2 className="text-2xl font-bold text-white mb-6">
|
||||
Page not found
|
||||
</h2>
|
||||
|
||||
@@ -122,8 +122,8 @@ export default function NotificationsPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-2">Notifications</h1>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-6">
|
||||
<h1 className="text-2xl font-bold text-white mb-2">Notifications</h1>
|
||||
<p className="text-sm text-neutral-400 mb-6">
|
||||
Manage which notifications you receive in{' '}
|
||||
<Link href="/org-settings?tab=notifications" className="text-brand-orange hover:underline">
|
||||
Organization Settings → Notifications
|
||||
@@ -137,7 +137,7 @@ export default function NotificationsPage() {
|
||||
{error}
|
||||
</div>
|
||||
) : 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 className="text-sm mt-2">
|
||||
Manage which notifications you receive in{' '}
|
||||
@@ -159,11 +159,11 @@ export default function NotificationsPage() {
|
||||
<div className="flex gap-3">
|
||||
{getTypeIcon(n.type)}
|
||||
<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}
|
||||
</p>
|
||||
{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">
|
||||
{formatTimeAgo(n.created_at)}
|
||||
@@ -182,11 +182,11 @@ export default function NotificationsPage() {
|
||||
<div className="flex gap-3">
|
||||
{getTypeIcon(n.type)}
|
||||
<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}
|
||||
</p>
|
||||
{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">
|
||||
{formatTimeAgo(n.created_at)}
|
||||
|
||||
@@ -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="max-w-md w-full space-y-8">
|
||||
<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
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
|
||||
29
app/page.tsx
@@ -13,7 +13,7 @@ import { LoadingOverlay } from '@ciphera-net/ui'
|
||||
import SiteList from '@/components/sites/SiteList'
|
||||
import DeleteSiteModal from '@/components/sites/DeleteSiteModal'
|
||||
import { Button } from '@ciphera-net/ui'
|
||||
import { XIcon, GlobeIcon } from '@ciphera-net/ui'
|
||||
import { XIcon } from '@ciphera-net/ui'
|
||||
import { Cookie, ShieldCheck, Code, Lightning, ArrowRight, GithubLogo } from '@phosphor-icons/react'
|
||||
import DashboardDemo from '@/components/marketing/DashboardDemo'
|
||||
import FeatureSections from '@/components/marketing/FeatureSections'
|
||||
@@ -334,7 +334,7 @@ export default function HomePage() {
|
||||
return `${label} Plan`
|
||||
})()}
|
||||
</p>
|
||||
{(typeof subscription.sites_count === 'number' || (subscription.pageview_limit > 0 && typeof subscription.pageview_usage === 'number') || (subscription.next_invoice_amount_due != null && subscription.next_invoice_currency && !subscription.cancel_at_period_end && (subscription.subscription_status === 'active' || subscription.subscription_status === 'trialing'))) && (
|
||||
{(typeof subscription.sites_count === 'number' || (subscription.pageview_limit > 0 && typeof subscription.pageview_usage === 'number') || (!subscription.cancel_at_period_end && (subscription.subscription_status === 'active' || subscription.subscription_status === 'trialing'))) && (
|
||||
<p className="text-sm text-neutral-400 mt-1">
|
||||
{typeof subscription.sites_count === 'number' && (
|
||||
<span>Sites: {(() => {
|
||||
@@ -346,20 +346,9 @@ export default function HomePage() {
|
||||
{subscription.pageview_limit > 0 && typeof subscription.pageview_usage === 'number' && (
|
||||
<span>Pageviews: {subscription.pageview_usage.toLocaleString()}/{subscription.pageview_limit.toLocaleString()}</span>
|
||||
)}
|
||||
{subscription.next_invoice_amount_due != null && subscription.next_invoice_currency && !subscription.cancel_at_period_end && (subscription.subscription_status === 'active' || subscription.subscription_status === 'trialing') && (
|
||||
{!subscription.cancel_at_period_end && (subscription.subscription_status === 'active' || subscription.subscription_status === 'trialing') && subscription.current_period_end && (
|
||||
<span className="block mt-1">
|
||||
Renews {(() => {
|
||||
const ts = subscription.next_invoice_period_end ?? subscription.current_period_end
|
||||
const d = ts ? new Date(typeof ts === 'number' ? ts * 1000 : ts) : null
|
||||
const dateStr = d && !Number.isNaN(d.getTime()) && d.getTime() !== 0
|
||||
? formatDate(d)
|
||||
: null
|
||||
const amount = (subscription.next_invoice_amount_due / 100).toLocaleString('en-US', {
|
||||
style: 'currency',
|
||||
currency: subscription.next_invoice_currency.toUpperCase(),
|
||||
})
|
||||
return dateStr ? `${dateStr} for ${amount}` : amount
|
||||
})()}
|
||||
Renews {formatDate(new Date(subscription.current_period_end))}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
@@ -383,10 +372,12 @@ export default function HomePage() {
|
||||
</div>
|
||||
|
||||
{!sitesLoading && sites.length === 0 && (
|
||||
<div className="mb-8 rounded-2xl border-2 border-dashed border-brand-orange/30 bg-brand-orange/10 p-6 text-center">
|
||||
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-brand-orange/20 text-brand-orange mb-4">
|
||||
<GlobeIcon className="h-7 w-7" />
|
||||
</div>
|
||||
<div className="mb-8 rounded-2xl border-2 border-dashed border-brand-orange/30 bg-brand-orange/10 p-8 text-center flex flex-col items-center">
|
||||
<img
|
||||
src="/illustrations/setup-analytics.svg"
|
||||
alt="Set up your first site"
|
||||
className="w-56 h-auto mb-6"
|
||||
/>
|
||||
<h2 className="text-xl font-bold text-white mb-2">Add your first site</h2>
|
||||
<p className="text-neutral-400 mb-6 max-w-md mx-auto">
|
||||
Connect a domain to start collecting privacy-friendly analytics. You can add more sites later from the dashboard.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { FAVICON_SERVICE_URL } from '@/lib/utils/icons'
|
||||
import { FAVICON_SERVICE_URL } from '@/lib/utils/favicon'
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8082'
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import TechSpecs from '@/components/dashboard/TechSpecs'
|
||||
import { Select, DatePicker as DatePickerModal, Captcha, DownloadIcon, ZapIcon } from '@ciphera-net/ui'
|
||||
import { DashboardSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
|
||||
import ExportModal from '@/components/dashboard/ExportModal'
|
||||
import { FAVICON_SERVICE_URL } from '@/lib/utils/icons'
|
||||
import { FAVICON_SERVICE_URL } from '@/lib/utils/favicon'
|
||||
|
||||
// Helper to get date ranges
|
||||
const getDateRange = (days: number) => {
|
||||
@@ -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">
|
||||
<ZapIcon className="w-6 h-6" />
|
||||
</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
|
||||
</h1>
|
||||
<p className="text-neutral-600 dark:text-neutral-400">
|
||||
@@ -210,7 +210,7 @@ export default function PublicDashboardPage() {
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
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
|
||||
/>
|
||||
</div>
|
||||
@@ -270,7 +270,7 @@ export default function PublicDashboardPage() {
|
||||
<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>
|
||||
</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
|
||||
src={`${FAVICON_SERVICE_URL}?domain=${site.domain}&sz=64`}
|
||||
alt={site.name}
|
||||
|
||||
13
app/sites/[id]/behavior/error.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
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 dynamic from 'next/dynamic'
|
||||
import { getRageClicks, getDeadClicks } from '@/lib/api/stats'
|
||||
@@ -15,20 +15,6 @@ import { BehaviorSkeleton, useMinimumLoading, useSkeletonFade } from '@/componen
|
||||
|
||||
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() {
|
||||
const params = useParams()
|
||||
const siteId = params.id as string
|
||||
@@ -74,10 +60,10 @@ export default function BehaviorPage() {
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<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
|
||||
</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
|
||||
</p>
|
||||
</div>
|
||||
|
||||
13
app/sites/[id]/cdn/error.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -177,10 +177,10 @@ export default function CDNPage() {
|
||||
<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" />
|
||||
</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
|
||||
</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.
|
||||
</p>
|
||||
<Link
|
||||
@@ -212,10 +212,10 @@ export default function CDNPage() {
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<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
|
||||
</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
|
||||
</p>
|
||||
</div>
|
||||
@@ -281,7 +281,7 @@ export default function CDNPage() {
|
||||
|
||||
{/* Bandwidth chart */}
|
||||
<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 ? (
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<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
|
||||
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">
|
||||
<p className="text-neutral-500 dark:text-neutral-400 mb-1">{formatDateShort(label)}</p>
|
||||
<p className="text-neutral-900 dark:text-white font-medium">
|
||||
<p className="text-neutral-400 mb-1">{formatDateShort(label)}</p>
|
||||
<p className="text-white font-medium">
|
||||
Total: {formatBytes(payload[0]?.value as number)}
|
||||
</p>
|
||||
{payload[1] && (
|
||||
@@ -359,7 +359,7 @@ export default function CDNPage() {
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||
{/* Requests chart */}
|
||||
<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 ? (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<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
|
||||
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">
|
||||
<p className="text-neutral-500 dark:text-neutral-400 mb-1">{formatDateShort(label)}</p>
|
||||
<p className="text-neutral-900 dark:text-white font-medium">
|
||||
<p className="text-neutral-400 mb-1">{formatDateShort(label)}</p>
|
||||
<p className="text-white font-medium">
|
||||
{formatNumber(payload[0]?.value as number)} requests
|
||||
</p>
|
||||
</div>
|
||||
@@ -405,7 +405,7 @@ export default function CDNPage() {
|
||||
|
||||
{/* Errors chart */}
|
||||
<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 ? (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart
|
||||
@@ -439,7 +439,7 @@ export default function CDNPage() {
|
||||
if (!active || !payload?.length) return null
|
||||
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">
|
||||
<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) => (
|
||||
<p key={entry.name} style={{ color: entry.color }} className="font-medium">
|
||||
{entry.name}: {formatNumber(entry.value as number)}
|
||||
@@ -464,7 +464,7 @@ export default function CDNPage() {
|
||||
|
||||
{/* Traffic Distribution */}
|
||||
<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 ? (
|
||||
<>
|
||||
<div className="h-[360px] mb-8">
|
||||
@@ -480,9 +480,9 @@ export default function CDNPage() {
|
||||
<div className="flex items-center gap-2.5 mb-2">
|
||||
{cc && getFlagIcon(cc)}
|
||||
<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>
|
||||
<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)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -530,13 +530,13 @@ function OverviewCard({
|
||||
|
||||
return (
|
||||
<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-2xl font-bold text-neutral-900 dark:text-white">{value}</p>
|
||||
<p className="text-xs font-medium text-neutral-400 mb-1">{label}</p>
|
||||
<p className="text-2xl font-bold text-white">{value}</p>
|
||||
{changeLabel && (
|
||||
<p className={`text-xs mt-1 font-medium ${
|
||||
isGood ? 'text-green-600 dark:text-green-400' :
|
||||
isBad ? 'text-red-600 dark:text-red-400' :
|
||||
'text-neutral-500 dark:text-neutral-400'
|
||||
'text-neutral-400'
|
||||
}`}>
|
||||
{changeLabel} vs previous period
|
||||
</p>
|
||||
|
||||
@@ -25,7 +25,7 @@ export default function FunnelReportPage() {
|
||||
const [funnel, setFunnel] = useState<Funnel | null>(null)
|
||||
const [stats, setStats] = useState<FunnelStats | null>(null)
|
||||
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 [isDatePickerOpen, setIsDatePickerOpen] = useState(false)
|
||||
const [loadError, setLoadError] = useState<'not_found' | 'forbidden' | 'error' | null>(null)
|
||||
@@ -154,7 +154,7 @@ export default function FunnelReportPage() {
|
||||
<ChevronLeftIcon className="w-5 h-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white">
|
||||
<h1 className="text-2xl font-bold text-white">
|
||||
{funnel.name}
|
||||
</h1>
|
||||
{funnel.description && (
|
||||
@@ -236,7 +236,7 @@ export default function FunnelReportPage() {
|
||||
{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="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
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -322,10 +322,10 @@ export default function FunnelReportPage() {
|
||||
<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">
|
||||
<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-500 dark: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-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">Step</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-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">Conversion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-800">
|
||||
@@ -338,13 +338,13 @@ export default function FunnelReportPage() {
|
||||
{i + 1}
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-medium text-neutral-900 dark: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="font-medium text-white">{step.step.name}</p>
|
||||
<p className="text-neutral-400 text-xs font-mono mt-0.5">{step.step.value}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<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()}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useFunnels } from '@/lib/swr/dashboard'
|
||||
import { toast, PlusIcon, ArrowRightIcon, ChevronLeftIcon, TrashIcon, Button } from '@ciphera-net/ui'
|
||||
import { FunnelsListSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
|
||||
export default function FunnelsPage() {
|
||||
const params = useParams()
|
||||
@@ -39,7 +40,7 @@ export default function FunnelsPage() {
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white">
|
||||
<h1 className="text-2xl font-bold text-white">
|
||||
Funnels
|
||||
</h1>
|
||||
<p className="text-neutral-600 dark:text-neutral-400">
|
||||
@@ -55,11 +56,16 @@ export default function FunnelsPage() {
|
||||
</div>
|
||||
|
||||
{funnels.length === 0 ? (
|
||||
<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 mx-auto mb-4 w-fit">
|
||||
<ArrowRightIcon className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white mb-2">
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-12 text-center flex flex-col items-center">
|
||||
<Image
|
||||
src="/illustrations/data-trends.svg"
|
||||
alt="Create your first funnel"
|
||||
width={260}
|
||||
height={195}
|
||||
className="mb-6"
|
||||
unoptimized
|
||||
/>
|
||||
<h3 className="text-lg font-semibold text-white mb-2">
|
||||
No funnels yet
|
||||
</h3>
|
||||
<p className="text-neutral-600 dark:text-neutral-400 mb-6 max-w-md mx-auto">
|
||||
@@ -83,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="flex items-center justify-between">
|
||||
<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}
|
||||
</h3>
|
||||
{funnel.description && (
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
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 ColumnJourney from '@/components/journeys/ColumnJourney'
|
||||
import SankeyJourney from '@/components/journeys/SankeyJourney'
|
||||
@@ -18,20 +18,6 @@ import {
|
||||
|
||||
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() {
|
||||
const params = useParams()
|
||||
const siteId = params.id as string
|
||||
@@ -91,10 +77,10 @@ export default function JourneysPage() {
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<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
|
||||
</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
|
||||
</p>
|
||||
</div>
|
||||
@@ -143,7 +129,7 @@ export default function JourneysPage() {
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-6">
|
||||
{/* Depth slider */}
|
||||
<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 className="text-brand-orange font-bold">
|
||||
{depth} steps deep
|
||||
@@ -196,7 +182,7 @@ export default function JourneysPage() {
|
||||
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 ${
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
@@ -232,7 +218,7 @@ export default function JourneysPage() {
|
||||
|
||||
{/* Footer */}
|
||||
{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
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
type Stats,
|
||||
type DailyStat,
|
||||
} 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 { Button } 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 } {
|
||||
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 gap-4">
|
||||
<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}
|
||||
</h1>
|
||||
<p className="text-neutral-600 dark:text-neutral-400">
|
||||
|
||||
13
app/sites/[id]/pagespeed/error.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
902
app/sites/[id]/pagespeed/page.tsx
Normal file
@@ -0,0 +1,902 @@
|
||||
'use client'
|
||||
|
||||
import { useAuth } from '@/lib/auth/context'
|
||||
import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useSite, usePageSpeedConfig, usePageSpeedLatest, usePageSpeedHistory } from '@/lib/swr/dashboard'
|
||||
import { updatePageSpeedConfig, triggerPageSpeedCheck, getPageSpeedLatest, getPageSpeedCheck, type PageSpeedCheck, type AuditSummary } from '@/lib/api/pagespeed'
|
||||
import { toast, Button } from '@ciphera-net/ui'
|
||||
import { motion } from 'framer-motion'
|
||||
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 { useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
|
||||
|
||||
// * Metric status thresholds (Google's Core Web Vitals thresholds)
|
||||
function getMetricStatus(metric: string, value: number | null): { label: string; color: string } {
|
||||
if (value === null) return { label: '--', color: 'text-neutral-400' }
|
||||
const thresholds: Record<string, [number, number]> = {
|
||||
lcp: [2500, 4000],
|
||||
cls: [0.1, 0.25],
|
||||
tbt: [200, 600],
|
||||
fcp: [1800, 3000],
|
||||
si: [3400, 5800],
|
||||
tti: [3800, 7300],
|
||||
}
|
||||
const [good, poor] = thresholds[metric] ?? [0, 0]
|
||||
if (value <= good) return { label: 'Good', color: 'text-emerald-600 dark:text-emerald-400' }
|
||||
if (value <= poor) return { label: 'Needs Improvement', color: 'text-amber-600 dark:text-amber-400' }
|
||||
return { label: 'Poor', color: 'text-red-600 dark:text-red-400' }
|
||||
}
|
||||
|
||||
// * Format metric values for display
|
||||
function formatMetricValue(metric: string, value: number | null): string {
|
||||
if (value === null) return '--'
|
||||
if (metric === 'cls') return value.toFixed(3)
|
||||
if (value < 1000) return `${value}ms`
|
||||
return `${(value / 1000).toFixed(1)}s`
|
||||
}
|
||||
|
||||
// * Format time ago for last checked display
|
||||
function formatTimeAgo(dateString: string | null): string {
|
||||
if (!dateString) return 'Never'
|
||||
const date = new Date(dateString)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffSec = Math.floor(diffMs / 1000)
|
||||
|
||||
if (diffSec < 60) return 'just now'
|
||||
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`
|
||||
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago`
|
||||
return `${Math.floor(diffSec / 86400)}d ago`
|
||||
}
|
||||
|
||||
// * Get dot color for audit items based on score
|
||||
function getAuditDotColor(score: number | null): string {
|
||||
if (score === null) return 'bg-neutral-400'
|
||||
if (score >= 0.9) return 'bg-emerald-500'
|
||||
if (score >= 0.5) return 'bg-amber-500'
|
||||
return 'bg-red-500'
|
||||
}
|
||||
|
||||
// * Main PageSpeed page
|
||||
export default function PageSpeedPage() {
|
||||
const { user } = useAuth()
|
||||
const canEdit = user?.role === 'owner' || user?.role === 'admin'
|
||||
const params = useParams()
|
||||
const siteId = params.id as string
|
||||
|
||||
const { data: site } = useSite(siteId)
|
||||
const { data: config, mutate: mutateConfig } = usePageSpeedConfig(siteId)
|
||||
const { data: latestChecks, isLoading, mutate: mutateLatest } = usePageSpeedLatest(siteId)
|
||||
|
||||
const [strategy, setStrategy] = useState<'mobile' | 'desktop'>('mobile')
|
||||
const [running, setRunning] = useState(false)
|
||||
const [toggling, setToggling] = useState(false)
|
||||
const [frequency, setFrequency] = useState<string>('weekly')
|
||||
|
||||
const { data: historyChecks } = usePageSpeedHistory(siteId, strategy)
|
||||
|
||||
// * Check history navigation — build unique check timestamps from history data
|
||||
const [selectedCheckId, setSelectedCheckId] = useState<string | null>(null)
|
||||
const [selectedCheckData, setSelectedCheckData] = useState<PageSpeedCheck | null>(null)
|
||||
const [loadingCheck, setLoadingCheck] = useState(false)
|
||||
|
||||
// * Build unique check timestamps (each check has mobile+desktop at the same time)
|
||||
const checkTimestamps = useMemo(() => {
|
||||
if (!historyChecks?.length) return []
|
||||
const seen = new Set<string>()
|
||||
const timestamps: { id: string; checked_at: string }[] = []
|
||||
// * History is sorted ASC by checked_at, reverse for newest first
|
||||
for (let i = historyChecks.length - 1; i >= 0; i--) {
|
||||
const c = historyChecks[i]
|
||||
// * Group by minute to deduplicate mobile+desktop pairs
|
||||
const key = c.checked_at.slice(0, 16)
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key)
|
||||
timestamps.push({ id: c.id, checked_at: c.checked_at })
|
||||
}
|
||||
}
|
||||
return timestamps
|
||||
}, [historyChecks])
|
||||
|
||||
const selectedIndex = selectedCheckId
|
||||
? checkTimestamps.findIndex(t => t.id === selectedCheckId)
|
||||
: 0 // * 0 = latest
|
||||
|
||||
const canGoPrev = selectedIndex < checkTimestamps.length - 1
|
||||
const canGoNext = selectedIndex > 0
|
||||
|
||||
const handlePrevCheck = () => {
|
||||
if (!canGoPrev) return
|
||||
const next = checkTimestamps[selectedIndex + 1]
|
||||
setSelectedCheckId(next.id)
|
||||
}
|
||||
|
||||
const handleNextCheck = () => {
|
||||
if (selectedIndex <= 1) {
|
||||
// * Going back to latest
|
||||
setSelectedCheckId(null)
|
||||
setSelectedCheckData(null)
|
||||
return
|
||||
}
|
||||
const next = checkTimestamps[selectedIndex - 1]
|
||||
setSelectedCheckId(next.id)
|
||||
}
|
||||
|
||||
// * Fetch full check data when navigating to a historical check
|
||||
useEffect(() => {
|
||||
if (!selectedCheckId || !siteId) {
|
||||
setSelectedCheckData(null)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
setLoadingCheck(true)
|
||||
getPageSpeedCheck(siteId, selectedCheckId).then(data => {
|
||||
if (!cancelled) {
|
||||
setSelectedCheckData(data)
|
||||
setLoadingCheck(false)
|
||||
}
|
||||
}).catch(() => {
|
||||
if (!cancelled) setLoadingCheck(false)
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [selectedCheckId, siteId])
|
||||
|
||||
// * Determine which check to display — selected historical or latest
|
||||
const displayCheck = selectedCheckId && selectedCheckData
|
||||
? selectedCheckData
|
||||
: latestChecks?.find(c => c.strategy === strategy) ?? null
|
||||
|
||||
// * When viewing a historical check, we need both strategies — fetch the other one too
|
||||
// * For simplicity, historical view shows the selected strategy's check
|
||||
const currentCheck = displayCheck
|
||||
|
||||
// * Set document title
|
||||
useEffect(() => {
|
||||
if (site?.domain) document.title = `PageSpeed · ${site.domain} | Pulse`
|
||||
}, [site?.domain])
|
||||
|
||||
// * Sync frequency from config when loaded
|
||||
useEffect(() => {
|
||||
if (config?.frequency) setFrequency(config.frequency)
|
||||
}, [config?.frequency])
|
||||
|
||||
// * Toggle PageSpeed monitoring on/off
|
||||
const handleToggle = async (enabled: boolean) => {
|
||||
setToggling(true)
|
||||
try {
|
||||
await updatePageSpeedConfig(siteId, { enabled, frequency })
|
||||
mutateConfig()
|
||||
mutateLatest()
|
||||
toast.success(enabled ? 'PageSpeed monitoring enabled' : 'PageSpeed monitoring disabled')
|
||||
} catch {
|
||||
toast.error('Failed to update PageSpeed monitoring')
|
||||
} finally {
|
||||
setToggling(false)
|
||||
}
|
||||
}
|
||||
|
||||
// * Trigger a manual PageSpeed check
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const stopPolling = useCallback(() => {
|
||||
if (pollRef.current) {
|
||||
clearInterval(pollRef.current)
|
||||
pollRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => () => stopPolling(), [stopPolling])
|
||||
|
||||
const handleRunCheck = async () => {
|
||||
setRunning(true)
|
||||
try {
|
||||
await triggerPageSpeedCheck(siteId)
|
||||
toast.success('PageSpeed check started — results will appear in 30-60 seconds')
|
||||
|
||||
// * Poll silently without triggering SWR re-renders.
|
||||
// * Fetch latest directly and only update SWR cache once when new data arrives.
|
||||
const initialCheckedAt = latestChecks?.[0]?.checked_at
|
||||
const startedAt = Date.now()
|
||||
|
||||
stopPolling()
|
||||
pollRef.current = setInterval(async () => {
|
||||
if (Date.now() - startedAt > 120_000) {
|
||||
stopPolling()
|
||||
setRunning(false)
|
||||
toast.error('Check is taking longer than expected. Results will appear when ready.')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const fresh = await getPageSpeedLatest(siteId)
|
||||
if (fresh?.[0]?.checked_at && fresh[0].checked_at !== initialCheckedAt) {
|
||||
stopPolling()
|
||||
setRunning(false)
|
||||
mutateLatest() // * Single SWR revalidation when new data is ready
|
||||
toast.success('PageSpeed check complete')
|
||||
}
|
||||
} catch {
|
||||
// * Silent — keep polling
|
||||
}
|
||||
}, 5000)
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || 'Failed to start check')
|
||||
setRunning(false)
|
||||
}
|
||||
}
|
||||
|
||||
// * Loading state with minimum display time (consistent with other pages)
|
||||
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>
|
||||
|
||||
const enabled = config?.enabled ?? false
|
||||
|
||||
// * Disabled state — show empty state with enable toggle
|
||||
if (!enabled) {
|
||||
return (
|
||||
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-white mb-1">
|
||||
PageSpeed
|
||||
</h1>
|
||||
<p className="text-sm text-neutral-400">
|
||||
Monitor your site's performance and Core Web Vitals
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 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="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-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" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="font-semibold text-white mb-2">
|
||||
PageSpeed monitoring is disabled
|
||||
</h3>
|
||||
<p className="text-sm text-neutral-400 mb-6 max-w-md mx-auto">
|
||||
Enable PageSpeed monitoring to track your site's performance scores, Core Web Vitals, and get actionable improvement suggestions.
|
||||
</p>
|
||||
|
||||
{/* Frequency selector */}
|
||||
<div className="flex items-center justify-center gap-3 mb-6">
|
||||
<label className="text-sm text-neutral-600 dark:text-neutral-400">Check frequency:</label>
|
||||
<select
|
||||
value={frequency}
|
||||
onChange={(e) => setFrequency(e.target.value)}
|
||||
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="weekly">Weekly</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{canEdit && (
|
||||
<Button
|
||||
onClick={() => handleToggle(true)}
|
||||
disabled={toggling}
|
||||
>
|
||||
{toggling ? 'Enabling...' : 'Enable PageSpeed Monitoring'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// * Prepare chart data from history (visx needs Date objects for x-axis)
|
||||
const chartData = (historyChecks ?? []).map(c => ({
|
||||
dateObj: new Date(c.checked_at),
|
||||
score: c.performance_score ?? 0,
|
||||
}))
|
||||
|
||||
// * Parse audits into groups by Lighthouse category
|
||||
const audits = currentCheck?.audits ?? []
|
||||
const passed = audits.filter(a => a.category === 'passed')
|
||||
|
||||
const categoryGroups = [
|
||||
{ key: 'performance', label: 'Performance' },
|
||||
{ key: 'accessibility', label: 'Accessibility' },
|
||||
{ key: 'best-practices', label: 'Best Practices' },
|
||||
{ key: 'seo', label: 'SEO' },
|
||||
]
|
||||
|
||||
// * Build per-category failing audits, sorted by impact
|
||||
const auditsByGroup: Record<string, typeof audits> = {}
|
||||
const manualByGroup: Record<string, typeof audits> = {}
|
||||
for (const group of categoryGroups) {
|
||||
auditsByGroup[group.key] = audits
|
||||
.filter(a => a.category !== 'passed' && a.category !== 'manual' && a.group === group.key)
|
||||
.sort((a, b) => {
|
||||
if (a.category === 'opportunity' && b.category !== 'opportunity') return -1
|
||||
if (a.category !== 'opportunity' && b.category === 'opportunity') return 1
|
||||
if (a.category === 'opportunity' && b.category === 'opportunity') {
|
||||
return (b.savings_ms ?? 0) - (a.savings_ms ?? 0)
|
||||
}
|
||||
return 0
|
||||
})
|
||||
manualByGroup[group.key] = audits.filter(a => a.category === 'manual' && a.group === group.key)
|
||||
}
|
||||
|
||||
// * Core Web Vitals metrics
|
||||
const metrics = [
|
||||
{ key: 'fcp', label: 'First Contentful Paint', value: currentCheck?.fcp_ms ?? null },
|
||||
{ key: 'lcp', label: 'Largest Contentful Paint', value: currentCheck?.lcp_ms ?? null },
|
||||
{ key: 'tbt', label: 'Total Blocking Time', value: currentCheck?.tbt_ms ?? null },
|
||||
{ key: 'cls', label: 'Cumulative Layout Shift', value: currentCheck?.cls ?? null },
|
||||
{ key: 'si', label: 'Speed Index', value: currentCheck?.si_ms ?? null },
|
||||
{ key: 'tti', label: 'Time to Interactive', value: currentCheck?.tti_ms ?? null },
|
||||
]
|
||||
|
||||
// * All 4 category scores for the hero row
|
||||
const allScores = [
|
||||
{ key: 'performance', label: 'Performance', score: currentCheck?.performance_score ?? null },
|
||||
{ key: 'accessibility', label: 'Accessibility', score: currentCheck?.accessibility_score ?? null },
|
||||
{ key: 'best-practices', label: 'Best Practices', score: currentCheck?.best_practices_score ?? null },
|
||||
{ key: 'seo', label: 'SEO', score: currentCheck?.seo_score ?? null },
|
||||
]
|
||||
|
||||
// * Map category key to score for diagnostics section
|
||||
const scoreByGroup: Record<string, number | null> = {
|
||||
'performance': currentCheck?.performance_score ?? null,
|
||||
'accessibility': currentCheck?.accessibility_score ?? null,
|
||||
'best-practices': currentCheck?.best_practices_score ?? null,
|
||||
'seo': currentCheck?.seo_score ?? null,
|
||||
}
|
||||
|
||||
function getMetricDotColor(metric: string, value: number | null): string {
|
||||
if (value === null) return 'bg-neutral-400'
|
||||
const status = getMetricStatus(metric, value)
|
||||
if (status.label === 'Good') return 'bg-emerald-500'
|
||||
if (status.label === 'Needs Improvement') return 'bg-amber-500'
|
||||
return 'bg-red-500'
|
||||
}
|
||||
|
||||
// * Enabled state — show full PageSpeed dashboard
|
||||
return (
|
||||
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white mb-1">
|
||||
PageSpeed
|
||||
</h1>
|
||||
<p className="text-sm text-neutral-400">
|
||||
Performance scores and Core Web Vitals for {site.domain}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Mobile / Desktop toggle */}
|
||||
<div className="flex gap-1" role="tablist" aria-label="Strategy tabs">
|
||||
{(['mobile', 'desktop'] as const).map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => { setStrategy(tab); setSelectedCheckId(null); setSelectedCheckData(null) }}
|
||||
role="tab"
|
||||
aria-selected={strategy === tab}
|
||||
className={`relative px-3 py-1.5 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange rounded cursor-pointer ${
|
||||
strategy === tab
|
||||
? 'text-white'
|
||||
: 'text-neutral-400 dark:text-neutral-500 hover:text-neutral-300'
|
||||
}`}
|
||||
>
|
||||
{tab === 'mobile' ? 'Mobile' : 'Desktop'}
|
||||
{strategy === tab && (
|
||||
<motion.div
|
||||
layoutId="pagespeedStrategyTab"
|
||||
className="absolute inset-x-0 -bottom-px h-0.5 bg-brand-orange"
|
||||
transition={{ type: 'spring', stiffness: 500, damping: 35 }}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{canEdit && (
|
||||
<>
|
||||
<Button
|
||||
onClick={handleRunCheck}
|
||||
disabled={running}
|
||||
>
|
||||
{running ? 'Running...' : 'Run Check'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => handleToggle(false)}
|
||||
disabled={toggling}
|
||||
className="text-sm"
|
||||
>
|
||||
{toggling ? 'Disabling...' : 'Disable'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 1 — Score Overview: 4 equal gauges + screenshot */}
|
||||
<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="flex flex-col lg:flex-row items-center gap-8">
|
||||
{/* 4 equal gauges — click to scroll to diagnostics */}
|
||||
<div className="flex-1 flex items-center justify-center gap-6 sm:gap-8 flex-wrap">
|
||||
{allScores.map(({ key, label, score }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => document.getElementById(`diag-${key}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' })}
|
||||
className="cursor-pointer hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<ScoreGauge score={score} label={label} size={90} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Screenshot */}
|
||||
{currentCheck?.screenshot && (
|
||||
<div className="flex-shrink-0 flex items-center justify-center">
|
||||
<img
|
||||
src={currentCheck.screenshot}
|
||||
alt={`${strategy} screenshot`}
|
||||
className="rounded-lg max-h-44 w-auto border border-neutral-200 dark:border-neutral-700 object-contain"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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 items-center gap-2 text-sm text-neutral-400">
|
||||
{/* Prev/Next arrows */}
|
||||
{checkTimestamps.length > 1 && (
|
||||
<button
|
||||
onClick={handlePrevCheck}
|
||||
disabled={!canGoPrev}
|
||||
className="p-1 rounded hover:bg-neutral-100 dark:hover:bg-neutral-800 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
aria-label="Previous check"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{currentCheck?.checked_at && (
|
||||
<span className="tabular-nums">
|
||||
{selectedCheckId
|
||||
? new Date(currentCheck.checked_at).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
: `Last checked ${formatTimeAgo(currentCheck.checked_at)}`
|
||||
}
|
||||
</span>
|
||||
)}
|
||||
{checkTimestamps.length > 1 && (
|
||||
<button
|
||||
onClick={handleNextCheck}
|
||||
disabled={!canGoNext}
|
||||
className="p-1 rounded hover:bg-neutral-100 dark:hover:bg-neutral-800 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
aria-label="Next check"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{config?.frequency && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400">
|
||||
{config.frequency}
|
||||
</span>
|
||||
)}
|
||||
{loadingCheck && (
|
||||
<span className="text-xs text-neutral-400 animate-pulse">Loading...</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-x-3 text-[11px] text-neutral-400 dark:text-neutral-500 ml-auto">
|
||||
<span className="flex items-center gap-1"><span className="inline-block w-2 h-2 rounded-full bg-red-500" />0–49</span>
|
||||
<span className="flex items-center gap-1"><span className="inline-block w-2 h-2 rounded-full bg-amber-500" />50–89</span>
|
||||
<span className="flex items-center gap-1"><span className="inline-block w-2 h-2 rounded-full bg-emerald-500" />90–100</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filmstrip — page load progression */}
|
||||
{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">
|
||||
<h3 className="text-xs font-semibold text-neutral-400 uppercase tracking-wider mb-4">
|
||||
Page Load Timeline
|
||||
</h3>
|
||||
<div className="flex items-center overflow-x-auto gap-1 scrollbar-none">
|
||||
{currentCheck.filmstrip.map((frame, idx) => (
|
||||
<div key={idx} className="flex-shrink-0 text-center">
|
||||
<img
|
||||
src={frame.data}
|
||||
alt={`${frame.timing}ms`}
|
||||
className="h-24 rounded border border-neutral-200 dark:border-neutral-700 object-contain bg-neutral-50 dark:bg-neutral-800"
|
||||
/>
|
||||
<span className="text-[10px] text-neutral-400 mt-1 block">
|
||||
{frame.timing < 1000 ? `${frame.timing}ms` : `${(frame.timing / 1000).toFixed(1)}s`}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Fade indicator for horizontal scroll */}
|
||||
<div className="absolute right-0 top-0 bottom-0 w-12 bg-gradient-to-l from-white dark:from-neutral-900 to-transparent rounded-r-2xl pointer-events-none" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
<h3 className="text-xs font-semibold text-neutral-400 uppercase tracking-wider mb-5">
|
||||
Metrics
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-6">
|
||||
{metrics.map(({ key, label, value }) => (
|
||||
<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)}`} />
|
||||
<div>
|
||||
<div className="text-sm text-neutral-400">
|
||||
{label}
|
||||
</div>
|
||||
<div className="text-2xl font-semibold text-white tabular-nums">
|
||||
{formatMetricValue(key, value)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 3 — Score Trend Chart (visx) */}
|
||||
{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">
|
||||
<h3 className="text-xs font-semibold text-neutral-400 uppercase tracking-wider mb-4">
|
||||
Performance Score Trend
|
||||
</h3>
|
||||
<div>
|
||||
<VisxAreaChart
|
||||
data={chartData as Record<string, unknown>[]}
|
||||
xDataKey="dateObj"
|
||||
aspectRatio="4 / 1"
|
||||
margin={{ top: 10, right: 10, bottom: 30, left: 40 }}
|
||||
>
|
||||
<VisxGrid horizontal vertical={false} stroke="var(--chart-grid)" strokeDasharray="4,4" />
|
||||
<VisxArea
|
||||
dataKey="score"
|
||||
fill="var(--chart-line-primary)"
|
||||
fillOpacity={0.15}
|
||||
stroke="var(--chart-line-primary)"
|
||||
strokeWidth={2}
|
||||
gradientToOpacity={0}
|
||||
/>
|
||||
<VisxXAxis
|
||||
numTicks={5}
|
||||
formatLabel={(d: Date) => d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })}
|
||||
/>
|
||||
<VisxYAxis
|
||||
numTicks={5}
|
||||
formatValue={(v: number) => String(Math.round(v))}
|
||||
/>
|
||||
<VisxChartTooltip
|
||||
rows={(point: Record<string, unknown>) => [{
|
||||
label: 'Score',
|
||||
value: String(Math.round(point.score as number)),
|
||||
color: 'var(--chart-line-primary)',
|
||||
}]}
|
||||
/>
|
||||
</VisxAreaChart>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Section 4 — Diagnostics by Category */}
|
||||
{audits.length > 0 && (
|
||||
<div className="space-y-6">
|
||||
{categoryGroups.map(group => {
|
||||
const groupAudits = auditsByGroup[group.key] ?? []
|
||||
const groupPassed = passed.filter(a => a.group === group.key)
|
||||
const groupManual = manualByGroup[group.key] ?? []
|
||||
if (groupAudits.length === 0 && groupPassed.length === 0 && groupManual.length === 0) return null
|
||||
return (
|
||||
<div key={group.key} id={`diag-${group.key}`} className="scroll-mt-6 bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 sm:p-8">
|
||||
{/* Category header with gauge */}
|
||||
<div className="flex items-center gap-5 mb-6">
|
||||
<ScoreGauge score={scoreByGroup[group.key]} label="" size={56} />
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">
|
||||
{group.label}
|
||||
</h3>
|
||||
<p className="text-xs text-neutral-400">
|
||||
{(() => {
|
||||
const realIssues = groupAudits.filter(a => a.score !== null && a.score !== undefined).length
|
||||
return realIssues === 0 ? 'No issues found' : `${realIssues} issue${realIssues !== 1 ? 's' : ''} found`
|
||||
})()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{groupAudits.length > 0 && (
|
||||
<AuditsBySubGroup audits={groupAudits} />
|
||||
)}
|
||||
|
||||
{groupManual.length > 0 && (
|
||||
<details className="mt-4">
|
||||
<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>
|
||||
</summary>
|
||||
<div className="mt-2 divide-y divide-neutral-100 dark:divide-neutral-800">
|
||||
{groupManual.map(audit => <AuditRow key={audit.id} audit={audit} />)}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{groupPassed.length > 0 && (
|
||||
<details className="mt-4">
|
||||
<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>
|
||||
</summary>
|
||||
<div className="mt-2 divide-y divide-neutral-100 dark:divide-neutral-800">
|
||||
{groupPassed.map(audit => <AuditRow key={audit.id} audit={audit} />)}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// * Sort audits by severity: red (< 0.5) → orange (0.5-0.89) → empty (null) → green (>= 0.9)
|
||||
function sortBySeverity(audits: AuditSummary[]): AuditSummary[] {
|
||||
return [...audits].sort((a, b) => {
|
||||
const rank = (s: number | null | undefined) => {
|
||||
if (s === null || s === undefined) return 2 // empty circle
|
||||
if (s < 0.5) return 0 // red
|
||||
if (s < 0.9) return 1 // orange
|
||||
return 3 // green
|
||||
}
|
||||
return rank(a.score) - rank(b.score)
|
||||
})
|
||||
}
|
||||
|
||||
// * Known sub-group ordering: insights-type groups come before diagnostics-type groups
|
||||
const subGroupPriority: Record<string, number> = {
|
||||
// * Performance
|
||||
'budgets': 0, 'load-opportunities': 0, 'diagnostics': 1,
|
||||
// * Accessibility
|
||||
'a11y-names-labels': 0, 'a11y-contrast': 1, 'a11y-best-practices': 2,
|
||||
'a11y-color-contrast': 1, 'a11y-aria': 3, 'a11y-navigation': 4,
|
||||
'a11y-language': 5, 'a11y-audio-video': 6, 'a11y-tables-lists': 7,
|
||||
// * SEO
|
||||
'seo-mobile': 0, 'seo-content': 1, 'seo-crawl': 2,
|
||||
}
|
||||
|
||||
// * Group audits by sub-group within a category (e.g., "Names and Labels", "Contrast")
|
||||
function AuditsBySubGroup({ audits }: { audits: AuditSummary[] }) {
|
||||
// * Collect unique sub-groups
|
||||
const bySubGroup: Record<string, AuditSummary[]> = {}
|
||||
|
||||
for (const audit of audits) {
|
||||
const key = audit.sub_group || '__none__'
|
||||
if (!bySubGroup[key]) {
|
||||
bySubGroup[key] = []
|
||||
}
|
||||
bySubGroup[key].push(audit)
|
||||
}
|
||||
|
||||
const subGroupOrder = Object.keys(bySubGroup).sort((a, b) => {
|
||||
const pa = subGroupPriority[a] ?? 0
|
||||
const pb = subGroupPriority[b] ?? 0
|
||||
return pa - pb
|
||||
})
|
||||
|
||||
// * If no sub-groups exist, render flat list sorted by severity
|
||||
if (subGroupOrder.length === 1 && subGroupOrder[0] === '__none__') {
|
||||
return (
|
||||
<div className="divide-y divide-neutral-100 dark:divide-neutral-800">
|
||||
{sortBySeverity(audits).map(audit => <AuditRow key={audit.id} audit={audit} />)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{subGroupOrder.map(key => {
|
||||
const items = sortBySeverity(bySubGroup[key])
|
||||
const title = items[0]?.sub_group_title
|
||||
return (
|
||||
<div key={key}>
|
||||
{title && (
|
||||
<h4 className="text-[11px] font-semibold text-neutral-400 dark:text-neutral-500 uppercase tracking-wider mb-2">
|
||||
{title}
|
||||
</h4>
|
||||
)}
|
||||
<div className="divide-y divide-neutral-100 dark:divide-neutral-800">
|
||||
{items.map(audit => <AuditRow key={audit.id} audit={audit} />)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// * Severity indicator based on audit score (pagespeed.web.dev style)
|
||||
function AuditSeverityIcon({ score }: { score: number | null }) {
|
||||
if (score === null) {
|
||||
return <span className="inline-block w-2.5 h-2.5 rounded-full border-2 border-neutral-400 flex-shrink-0" aria-label="Informative" />
|
||||
}
|
||||
if (score < 0.5) {
|
||||
return <span className="inline-block w-2.5 h-2.5 rounded-full bg-red-500 flex-shrink-0" aria-label="Poor" />
|
||||
}
|
||||
if (score < 0.9) {
|
||||
return <span className="inline-block w-2.5 h-2.5 rounded-full bg-amber-500 flex-shrink-0" aria-label="Needs Improvement" />
|
||||
}
|
||||
return <span className="inline-block w-2.5 h-2.5 rounded-full bg-emerald-500 flex-shrink-0" aria-label="Good" />
|
||||
}
|
||||
|
||||
// * Expandable audit row with description and detail items
|
||||
function AuditRow({ audit }: { audit: AuditSummary }) {
|
||||
return (
|
||||
<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">
|
||||
<AuditSeverityIcon score={audit.score} />
|
||||
<span className="font-medium text-sm text-white flex-1 min-w-0 truncate">{audit.title}</span>
|
||||
{audit.display_value && (
|
||||
<span className="text-xs text-neutral-500 dark:text-neutral-500 flex-shrink-0 tabular-nums">{audit.display_value}</span>
|
||||
)}
|
||||
{audit.savings_ms != null && audit.savings_ms > 0 && !audit.display_value && (
|
||||
<span className="text-sm font-medium text-amber-600 dark:text-amber-400 flex-shrink-0 tabular-nums">
|
||||
{audit.savings_ms < 1000 ? `${Math.round(audit.savings_ms)}ms` : `${(audit.savings_ms / 1000).toFixed(1)}s`}
|
||||
</span>
|
||||
)}
|
||||
<svg className="w-4 h-4 text-neutral-400 transition-transform group-open:rotate-180 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</summary>
|
||||
<div className="pl-8 pr-2 pb-3 pt-1">
|
||||
{/* Description with parsed markdown links */}
|
||||
{audit.description && (
|
||||
<p className="text-xs text-neutral-400 mb-3 leading-relaxed">
|
||||
<AuditDescription text={audit.description} />
|
||||
</p>
|
||||
)}
|
||||
{/* Items list */}
|
||||
{audit.details && Array.isArray(audit.details) && audit.details.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{audit.details.slice(0, 10).map((item: Record<string, any>, idx: number) => (
|
||||
<AuditItem key={idx} item={item} />
|
||||
))}
|
||||
{audit.details.length > 10 && (
|
||||
<p className="text-xs text-neutral-400 mt-1">+ {audit.details.length - 10} more items</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
)
|
||||
}
|
||||
|
||||
// * Parse markdown-style links [text](url) into clickable <a> tags
|
||||
function AuditDescription({ text }: { text: string }) {
|
||||
const parts = text.split(/(\[[^\]]+\]\([^)]+\))/g)
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, i) => {
|
||||
const match = part.match(/^\[([^\]]+)\]\(([^)]+)\)$/)
|
||||
if (match) {
|
||||
return (
|
||||
<a
|
||||
key={i}
|
||||
href={match[2]}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-brand-orange hover:underline"
|
||||
>
|
||||
{match[1]}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
return <span key={i}>{part}</span>
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// * Render a single audit detail item — handles various field types from the PSI API
|
||||
function AuditItem({ item }: { item: Record<string, any> }) {
|
||||
// * Determine the primary label
|
||||
const label = item.node?.nodeLabel || item.label || item.groupLabel || item.source?.url || null
|
||||
// * URL can be in item.url or item.href
|
||||
const url = item.url || item.href || null
|
||||
// * Text content (used by SEO audits like "link text")
|
||||
const text = item.text || item.linkText || null
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-3 py-2 border-b border-neutral-100 dark:border-neutral-800 last:border-0 text-xs text-neutral-600 dark:text-neutral-400">
|
||||
{/* Element screenshot */}
|
||||
{item.node?.screenshot?.data && (
|
||||
<img
|
||||
src={item.node.screenshot.data}
|
||||
alt=""
|
||||
className="w-20 h-14 object-contain rounded border border-neutral-200 dark:border-neutral-700 flex-shrink-0 bg-neutral-50 dark:bg-neutral-800"
|
||||
/>
|
||||
)}
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{label && (
|
||||
<div className="font-medium text-white text-xs mb-0.5">
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
{url && (
|
||||
<div className="font-mono text-xs text-neutral-400 break-all">{url}</div>
|
||||
)}
|
||||
{text && (
|
||||
<div className="text-xs text-neutral-400 mt-0.5">{text}</div>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
{/* Fallback for items with only string values we haven't handled */}
|
||||
{!label && !url && !text && !item.node && item.statistic && (
|
||||
<span>{item.statistic}</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Metrics on the right */}
|
||||
<div className="flex-shrink-0 text-right space-y-0.5">
|
||||
{item.wastedBytes != null && (
|
||||
<div className="text-amber-600 dark:text-amber-400 whitespace-nowrap">
|
||||
{item.wastedBytes < 1024 ? `${item.wastedBytes} B` : `${(item.wastedBytes / 1024).toFixed(1)} KiB`}
|
||||
</div>
|
||||
)}
|
||||
{item.totalBytes != null && !item.wastedBytes && (
|
||||
<div className="whitespace-nowrap">
|
||||
{item.totalBytes < 1024 ? `${item.totalBytes} B` : `${(item.totalBytes / 1024).toFixed(1)} KiB`}
|
||||
</div>
|
||||
)}
|
||||
{item.wastedMs != null && (
|
||||
<div className="text-amber-600 dark:text-amber-400 whitespace-nowrap">
|
||||
{item.wastedMs < 1000 ? `${Math.round(item.wastedMs)}ms` : `${(item.wastedMs / 1000).toFixed(1)}s`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// * Skeleton loading state
|
||||
function PageSpeedSkeleton() {
|
||||
return (
|
||||
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8 space-y-6">
|
||||
<div className="animate-pulse space-y-2 mb-8">
|
||||
<div className="h-8 w-48 bg-neutral-200 dark:bg-neutral-700 rounded" />
|
||||
<div className="h-4 w-72 bg-neutral-200 dark:bg-neutral-700 rounded" />
|
||||
</div>
|
||||
{/* Hero skeleton */}
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 sm:p-8 animate-pulse">
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="w-40 h-40 rounded-full bg-neutral-200 dark:bg-neutral-700 flex-shrink-0" />
|
||||
<div className="flex-1 space-y-3">
|
||||
<div className="h-5 w-32 bg-neutral-200 dark:bg-neutral-700 rounded" />
|
||||
<div className="h-5 w-36 bg-neutral-200 dark:bg-neutral-700 rounded" />
|
||||
<div className="h-5 w-24 bg-neutral-200 dark:bg-neutral-700 rounded" />
|
||||
</div>
|
||||
<div className="w-48 h-36 bg-neutral-200 dark:bg-neutral-700 rounded-lg flex-shrink-0 hidden md:block" />
|
||||
</div>
|
||||
</div>
|
||||
{/* Metrics skeleton */}
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 sm:p-8 animate-pulse">
|
||||
<div className="h-3 w-16 bg-neutral-200 dark:bg-neutral-700 rounded mb-5" />
|
||||
<div className="grid grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-6">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<div className="h-3 w-32 bg-neutral-200 dark:bg-neutral-700 rounded" />
|
||||
<div className="h-7 w-20 bg-neutral-200 dark:bg-neutral-700 rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
13
app/sites/[id]/search/error.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,8 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
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 { useDashboard, useGSCStatus, useGSCOverview, useGSCTopQueries, useGSCTopPages, useGSCNewQueries } from '@/lib/swr/dashboard'
|
||||
import { getGSCQueryPages, getGSCPageQueries } from '@/lib/api/gsc'
|
||||
@@ -13,20 +14,6 @@ import ClicksImpressionsChart from '@/components/search/ClicksImpressionsChart'
|
||||
|
||||
// ─── 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 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">
|
||||
<MagnifyingGlass size={40} className="text-neutral-400 dark:text-neutral-500" />
|
||||
</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
|
||||
</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.
|
||||
</p>
|
||||
<Link
|
||||
@@ -215,10 +202,10 @@ export default function SearchConsolePage() {
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<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
|
||||
</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
|
||||
</p>
|
||||
</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">
|
||||
{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">
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
<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([]) }}
|
||||
className={`px-4 py-1.5 text-sm font-medium rounded-md transition-all cursor-pointer ${
|
||||
activeView === 'queries'
|
||||
? 'bg-white dark:bg-neutral-700 text-neutral-900 dark:text-white shadow-sm'
|
||||
: 'text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300'
|
||||
? 'bg-white dark:bg-neutral-700 text-white shadow-sm'
|
||||
: 'text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300'
|
||||
}`}
|
||||
>
|
||||
Top Queries
|
||||
@@ -332,8 +319,8 @@ export default function SearchConsolePage() {
|
||||
onClick={() => { setActiveView('pages'); setExpandedPage(null); setExpandedData([]) }}
|
||||
className={`px-4 py-1.5 text-sm font-medium rounded-md transition-all cursor-pointer ${
|
||||
activeView === 'pages'
|
||||
? 'bg-white dark:bg-neutral-700 text-neutral-900 dark:text-white shadow-sm'
|
||||
: 'text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300'
|
||||
? 'bg-white dark:bg-neutral-700 text-white shadow-sm'
|
||||
: 'text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300'
|
||||
}`}
|
||||
>
|
||||
Top Pages
|
||||
@@ -347,12 +334,12 @@ export default function SearchConsolePage() {
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<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-500 dark: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-500 dark: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-500 dark:text-neutral-400">Position</th>
|
||||
<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-400">Query</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-400">Impressions</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-400">Position</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -369,7 +356,7 @@ export default function SearchConsolePage() {
|
||||
))
|
||||
) : queries.length === 0 ? (
|
||||
<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.
|
||||
</td>
|
||||
</tr>
|
||||
@@ -391,7 +378,7 @@ export default function SearchConsolePage() {
|
||||
{/* Pagination */}
|
||||
{queriesTotal > PAGE_SIZE && (
|
||||
<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()}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
@@ -421,12 +408,12 @@ export default function SearchConsolePage() {
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<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-500 dark: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-500 dark: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-500 dark:text-neutral-400">Position</th>
|
||||
<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-400">Page</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-400">Impressions</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-400">Position</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -443,7 +430,7 @@ export default function SearchConsolePage() {
|
||||
))
|
||||
) : pages.length === 0 ? (
|
||||
<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.
|
||||
</td>
|
||||
</tr>
|
||||
@@ -465,7 +452,7 @@ export default function SearchConsolePage() {
|
||||
{/* Pagination */}
|
||||
{pagesTotal > PAGE_SIZE && (
|
||||
<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()}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
@@ -522,13 +509,13 @@ function OverviewCard({
|
||||
|
||||
return (
|
||||
<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-2xl font-bold text-neutral-900 dark:text-white">{value}</p>
|
||||
<p className="text-xs font-medium text-neutral-400 mb-1">{label}</p>
|
||||
<p className="text-2xl font-bold text-white">{value}</p>
|
||||
{change && (
|
||||
<p className={`text-xs mt-1 font-medium ${
|
||||
isPositive ? 'text-green-600 dark:text-green-400' :
|
||||
isNegative ? 'text-red-600 dark:text-red-400' :
|
||||
'text-neutral-500 dark:text-neutral-400'
|
||||
'text-neutral-400'
|
||||
}`}>
|
||||
{change.label} vs previous period
|
||||
</p>
|
||||
@@ -560,7 +547,7 @@ function QueryRow({
|
||||
<td className="px-4 py-3 text-neutral-400 dark:text-neutral-500">
|
||||
<Caret size={14} />
|
||||
</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.impressions.toLocaleString()}</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>
|
||||
) : 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">
|
||||
<thead>
|
||||
@@ -631,7 +618,7 @@ function PageRow({
|
||||
<td className="px-4 py-3 text-neutral-400 dark:text-neutral-500">
|
||||
<Caret size={14} />
|
||||
</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.impressions.toLocaleString()}</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>
|
||||
) : 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">
|
||||
<thead>
|
||||
|
||||
@@ -21,7 +21,8 @@ import { Select, Modal, Button } from '@ciphera-net/ui'
|
||||
import { APP_URL } from '@/lib/api/client'
|
||||
import { generatePrivacySnippet } from '@/lib/utils/privacySnippet'
|
||||
import { useUnsavedChanges } from '@/lib/hooks/useUnsavedChanges'
|
||||
import { useSite, useGoals, useReportSchedules, useAlertSchedules, useSubscription, useGSCStatus, useBunnyStatus, useSessions, useBotFilterStats } from '@/lib/swr/dashboard'
|
||||
import { useSite, useGoals, useReportSchedules, useAlertSchedules, useSubscription, useGSCStatus, useBunnyStatus, useSessions, useBotFilterStats, usePageSpeedConfig } from '@/lib/swr/dashboard'
|
||||
import { updatePageSpeedConfig } from '@/lib/api/pagespeed'
|
||||
import { getRetentionOptionsForPlan, formatRetentionMonths } from '@/lib/plans'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { useAuth } from '@/lib/auth/context'
|
||||
@@ -130,6 +131,7 @@ export default function SiteSettingsPage() {
|
||||
const [gscConnecting, setGscConnecting] = useState(false)
|
||||
const [gscDisconnecting, setGscDisconnecting] = useState(false)
|
||||
const { data: bunnyStatus, mutate: mutateBunnyStatus } = useBunnyStatus(siteId)
|
||||
const { data: psiConfig, mutate: mutatePSIConfig } = usePageSpeedConfig(siteId)
|
||||
const [bunnyApiKey, setBunnyApiKey] = useState('')
|
||||
const [bunnyPullZones, setBunnyPullZones] = useState<BunnyPullZone[]>([])
|
||||
const [bunnySelectedZone, setBunnySelectedZone] = useState<BunnyPullZone | null>(null)
|
||||
@@ -736,9 +738,9 @@ export default function SiteSettingsPage() {
|
||||
|
||||
<div className="space-y-8">
|
||||
<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">
|
||||
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>
|
||||
</div>
|
||||
|
||||
@@ -858,8 +860,8 @@ export default function SiteSettingsPage() {
|
||||
<div className="space-y-12">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-neutral-900 dark: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>
|
||||
<h2 className="text-2xl font-bold text-white mb-1">General Configuration</h2>
|
||||
<p className="text-sm text-neutral-400">Update your site details and tracking script.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
@@ -905,17 +907,17 @@ export default function SiteSettingsPage() {
|
||||
type="text"
|
||||
value={site.domain}
|
||||
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
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-4">
|
||||
<h3 className="text-lg font-semibold text-white mb-2">Tracking Script</h3>
|
||||
<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.
|
||||
</p>
|
||||
<ScriptSetupBlock
|
||||
@@ -943,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 ? 'Verified' : 'Verify Installation'}
|
||||
</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.'}
|
||||
</p>
|
||||
</div>
|
||||
@@ -962,7 +964,7 @@ export default function SiteSettingsPage() {
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<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 className="space-y-4">
|
||||
@@ -1001,8 +1003,8 @@ export default function SiteSettingsPage() {
|
||||
<div className="space-y-12">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-neutral-900 dark: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>
|
||||
<h2 className="text-2xl font-bold text-white mb-1">Visibility Settings</h2>
|
||||
<p className="text-sm text-neutral-400">Manage who can view your dashboard.</p>
|
||||
</div>
|
||||
|
||||
<div className="p-6 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800">
|
||||
@@ -1012,8 +1014,8 @@ export default function SiteSettingsPage() {
|
||||
<GlobeIcon className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-neutral-900 dark:text-white">Public Dashboard</h3>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
<h3 className="font-medium text-white">Public Dashboard</h3>
|
||||
<p className="text-sm text-neutral-400">
|
||||
Allow anyone with the link to view this dashboard
|
||||
</p>
|
||||
</div>
|
||||
@@ -1039,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"
|
||||
>
|
||||
<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
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
@@ -1052,12 +1054,12 @@ export default function SiteSettingsPage() {
|
||||
<button
|
||||
type="button"
|
||||
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'}
|
||||
</button>
|
||||
</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.
|
||||
</p>
|
||||
</div>
|
||||
@@ -1152,8 +1154,8 @@ export default function SiteSettingsPage() {
|
||||
<div className="space-y-12">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-neutral-900 dark: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>
|
||||
<h2 className="text-2xl font-bold text-white mb-1">Data & Privacy</h2>
|
||||
<p className="text-sm text-neutral-400">Control what visitor data is collected. Less data = more privacy.</p>
|
||||
</div>
|
||||
|
||||
{/* Data Collection Controls */}
|
||||
@@ -1164,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="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="font-medium text-neutral-900 dark:text-white">Page Paths</h4>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-0.5">
|
||||
<h4 className="font-medium text-white">Page Paths</h4>
|
||||
<p className="text-sm text-neutral-400 mt-0.5">
|
||||
Track which pages visitors view
|
||||
</p>
|
||||
</div>
|
||||
@@ -1185,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="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="font-medium text-neutral-900 dark:text-white">Referrers</h4>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-0.5">
|
||||
<h4 className="font-medium text-white">Referrers</h4>
|
||||
<p className="text-sm text-neutral-400 mt-0.5">
|
||||
Track where visitors come from
|
||||
</p>
|
||||
</div>
|
||||
@@ -1206,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="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="font-medium text-neutral-900 dark:text-white">Device Info</h4>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-0.5">
|
||||
<h4 className="font-medium text-white">Device Info</h4>
|
||||
<p className="text-sm text-neutral-400 mt-0.5">
|
||||
Track browser, OS, and device type
|
||||
</p>
|
||||
</div>
|
||||
@@ -1227,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="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="font-medium text-neutral-900 dark:text-white">Geographic Data</h4>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-0.5">
|
||||
<h4 className="font-medium text-white">Geographic Data</h4>
|
||||
<p className="text-sm text-neutral-400 mt-0.5">
|
||||
Control location tracking granularity
|
||||
</p>
|
||||
</div>
|
||||
@@ -1251,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="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="font-medium text-neutral-900 dark:text-white">Screen Resolution</h4>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-0.5">
|
||||
<h4 className="font-medium text-white">Screen Resolution</h4>
|
||||
<p className="text-sm text-neutral-400 mt-0.5">
|
||||
Track visitor screen sizes
|
||||
</p>
|
||||
</div>
|
||||
@@ -1275,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="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="font-medium text-neutral-900 dark:text-white">Hide unknown locations</h4>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-0.5">
|
||||
<h4 className="font-medium text-white">Hide unknown locations</h4>
|
||||
<p className="text-sm text-neutral-400 mt-0.5">
|
||||
Exclude entries where geographic data could not be resolved from location stats
|
||||
</p>
|
||||
</div>
|
||||
@@ -1313,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="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="font-medium text-neutral-900 dark:text-white">Keep raw event data for</h4>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-0.5">
|
||||
<h4 className="font-medium text-white">Keep raw event data for</h4>
|
||||
<p className="text-sm text-neutral-400 mt-0.5">
|
||||
Events older than this are automatically deleted. Aggregated daily stats are kept permanently.
|
||||
</p>
|
||||
</div>
|
||||
@@ -1345,6 +1347,47 @@ export default function SiteSettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PageSpeed Monitoring */}
|
||||
<div className="space-y-4 pt-6 border-t border-neutral-100 dark:border-neutral-800">
|
||||
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">PageSpeed Monitoring</h3>
|
||||
<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>
|
||||
<h4 className="font-medium text-white">Check frequency</h4>
|
||||
<p className="text-sm text-neutral-400 mt-0.5">
|
||||
How often PageSpeed Insights runs automated checks on your site.
|
||||
</p>
|
||||
</div>
|
||||
{psiConfig?.enabled ? (
|
||||
<Select
|
||||
value={psiConfig.frequency}
|
||||
onChange={async (v) => {
|
||||
try {
|
||||
await updatePageSpeedConfig(siteId, { enabled: true, frequency: v })
|
||||
mutatePSIConfig()
|
||||
toast.success(`PageSpeed frequency updated to ${v}`)
|
||||
} catch {
|
||||
toast.error('Failed to update frequency')
|
||||
}
|
||||
}}
|
||||
options={[
|
||||
{ value: 'daily', label: 'Daily' },
|
||||
{ value: 'weekly', label: 'Weekly' },
|
||||
{ value: 'monthly', label: 'Monthly' },
|
||||
]}
|
||||
variant="input"
|
||||
align="right"
|
||||
className="min-w-[130px]"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-sm text-neutral-400 dark:text-neutral-500">
|
||||
Not enabled
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Excluded Paths */}
|
||||
<div className="space-y-4 pt-6 border-t border-neutral-100 dark:border-neutral-800">
|
||||
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">Path Filtering</h3>
|
||||
@@ -1363,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"
|
||||
/>
|
||||
</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/*).
|
||||
</p>
|
||||
</div>
|
||||
@@ -1374,7 +1417,7 @@ export default function SiteSettingsPage() {
|
||||
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||
For your privacy policy
|
||||
</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's Privacy Policy to describe your use of Pulse.
|
||||
It updates automatically based on your saved settings above.
|
||||
</p>
|
||||
@@ -1402,7 +1445,7 @@ export default function SiteSettingsPage() {
|
||||
{snippetCopied ? (
|
||||
<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" />
|
||||
<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>
|
||||
@@ -1425,7 +1468,7 @@ export default function SiteSettingsPage() {
|
||||
{activeTab === 'bot' && (
|
||||
<div className="flex-1 space-y-6">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -1584,8 +1627,8 @@ export default function SiteSettingsPage() {
|
||||
{activeTab === 'goals' && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-1">Goals & Events</h2>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
<h2 className="text-2xl font-bold text-white mb-1">Goals & Events</h2>
|
||||
<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('event_name')</code> in your snippet.
|
||||
</p>
|
||||
</div>
|
||||
@@ -1600,7 +1643,7 @@ export default function SiteSettingsPage() {
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{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.
|
||||
</div>
|
||||
) : (
|
||||
@@ -1610,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"
|
||||
>
|
||||
<div>
|
||||
<span className="font-medium text-neutral-900 dark:text-white">{goal.name}</span>
|
||||
<span className="text-neutral-500 dark:text-neutral-400 text-sm ml-2">({goal.event_name})</span>
|
||||
<span className="font-medium text-white">{goal.name}</span>
|
||||
<span className="text-neutral-400 text-sm ml-2">({goal.event_name})</span>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -1643,16 +1686,16 @@ export default function SiteSettingsPage() {
|
||||
{activeTab === 'notifications' && (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-neutral-900 dark: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>
|
||||
<h2 className="text-2xl font-bold text-white mb-1">Notifications</h2>
|
||||
<p className="text-sm text-neutral-400">Configure how you receive reports and alerts.</p>
|
||||
</div>
|
||||
|
||||
{/* Reports subsection */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-base font-medium text-neutral-900 dark: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>
|
||||
<h3 className="text-base font-medium text-white">Reports</h3>
|
||||
<p className="text-sm text-neutral-400 mt-0.5">Automatically deliver analytics reports via email or webhooks.</p>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<Button onClick={() => { setEditingSchedule(null); resetReportForm(); setReportModalOpen(true) }}>
|
||||
@@ -1668,7 +1711,7 @@ export default function SiteSettingsPage() {
|
||||
))}
|
||||
</div>
|
||||
) : 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.
|
||||
</div>
|
||||
) : (
|
||||
@@ -1689,7 +1732,7 @@ export default function SiteSettingsPage() {
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<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)}
|
||||
</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">
|
||||
@@ -1699,7 +1742,7 @@ export default function SiteSettingsPage() {
|
||||
{getReportTypeLabel(schedule.report_type)}
|
||||
</span>
|
||||
</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_config as EmailConfig).recipients.join(', ')
|
||||
: (schedule.channel_config as WebhookConfig).url}
|
||||
@@ -1778,8 +1821,8 @@ export default function SiteSettingsPage() {
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-base font-medium text-neutral-900 dark: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>
|
||||
<h3 className="text-base font-medium text-white">Alerts</h3>
|
||||
<p className="text-sm text-neutral-400 mt-0.5">Get notified when your site goes down or recovers.</p>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<Button onClick={() => { setEditingAlert(null); resetAlertForm(); setAlertModalOpen(true) }}>
|
||||
@@ -1795,7 +1838,7 @@ export default function SiteSettingsPage() {
|
||||
))}
|
||||
</div>
|
||||
) : 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.
|
||||
</div>
|
||||
) : (
|
||||
@@ -1816,14 +1859,14 @@ export default function SiteSettingsPage() {
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<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)}
|
||||
</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">
|
||||
Uptime Alert
|
||||
</span>
|
||||
</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_config as EmailConfig).recipients.join(', ')
|
||||
: (schedule.channel_config as WebhookConfig).url}
|
||||
@@ -1897,8 +1940,8 @@ export default function SiteSettingsPage() {
|
||||
{activeTab === 'integrations' && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-neutral-900 dark: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>
|
||||
<h2 className="text-2xl font-bold text-white mb-1">Integrations</h2>
|
||||
<p className="text-sm text-neutral-400">Connect external services to enrich your analytics data.</p>
|
||||
</div>
|
||||
|
||||
{/* Google Search Console */}
|
||||
@@ -1915,7 +1958,7 @@ export default function SiteSettingsPage() {
|
||||
</svg>
|
||||
</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">
|
||||
See which search queries bring visitors to your site, with impressions, clicks, CTR, and ranking position.
|
||||
</p>
|
||||
@@ -1925,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">
|
||||
<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>
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
@@ -1962,7 +2005,7 @@ export default function SiteSettingsPage() {
|
||||
</svg>
|
||||
</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">
|
||||
<span className={`inline-flex items-center gap-1.5 text-xs font-medium ${
|
||||
gscStatus.status === 'active'
|
||||
@@ -1988,28 +2031,28 @@ export default function SiteSettingsPage() {
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{gscStatus.google_email && (
|
||||
<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-sm font-medium text-neutral-900 dark:text-white mt-0.5 truncate">{gscStatus.google_email}</p>
|
||||
<p className="text-xs text-neutral-400">Google Account</p>
|
||||
<p className="text-sm font-medium text-white mt-0.5 truncate">{gscStatus.google_email}</p>
|
||||
</div>
|
||||
)}
|
||||
{gscStatus.gsc_property && (
|
||||
<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-sm font-medium text-neutral-900 dark:text-white mt-0.5 truncate">{gscStatus.gsc_property}</p>
|
||||
<p className="text-xs text-neutral-400">Property</p>
|
||||
<p className="text-sm font-medium text-white mt-0.5 truncate">{gscStatus.gsc_property}</p>
|
||||
</div>
|
||||
)}
|
||||
{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">
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">Last Synced</p>
|
||||
<p className="text-sm font-medium text-neutral-900 dark:text-white mt-0.5">
|
||||
<p className="text-xs text-neutral-400">Last Synced</p>
|
||||
<p className="text-sm font-medium text-white mt-0.5">
|
||||
{new Date(gscStatus.last_synced_at).toLocaleString('en-GB')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{gscStatus.created_at && (
|
||||
<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-sm font-medium text-neutral-900 dark:text-white mt-0.5">
|
||||
<p className="text-xs text-neutral-400">Connected Since</p>
|
||||
<p className="text-sm font-medium text-white mt-0.5">
|
||||
{new Date(gscStatus.created_at).toLocaleString('en-GB')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -2078,7 +2121,7 @@ export default function SiteSettingsPage() {
|
||||
</svg>
|
||||
</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">
|
||||
Monitor CDN performance with bandwidth usage, cache hit rates, response times, and geographic distribution.
|
||||
</p>
|
||||
@@ -2088,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">
|
||||
<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>
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
@@ -2104,7 +2147,7 @@ export default function SiteSettingsPage() {
|
||||
setBunnySelectedZone(null)
|
||||
}}
|
||||
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
|
||||
onClick={async () => {
|
||||
@@ -2147,7 +2190,7 @@ export default function SiteSettingsPage() {
|
||||
const zone = bunnyPullZones.find(z => z.id === Number(e.target.value))
|
||||
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) => (
|
||||
<option key={zone.id} value={zone.id}>{zone.name}</option>
|
||||
@@ -2209,7 +2252,7 @@ export default function SiteSettingsPage() {
|
||||
</svg>
|
||||
</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">
|
||||
<span className={`inline-flex items-center gap-1.5 text-xs font-medium ${
|
||||
bunnyStatus.status === 'active'
|
||||
@@ -2235,22 +2278,22 @@ export default function SiteSettingsPage() {
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{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">
|
||||
<p className="text-xs text-neutral-500 dark: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-xs text-neutral-400">Pull Zone</p>
|
||||
<p className="text-sm font-medium text-white mt-0.5 truncate">{bunnyStatus.pull_zone_name}</p>
|
||||
</div>
|
||||
)}
|
||||
{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">
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">Last Synced</p>
|
||||
<p className="text-sm font-medium text-neutral-900 dark:text-white mt-0.5">
|
||||
<p className="text-xs text-neutral-400">Last Synced</p>
|
||||
<p className="text-sm font-medium text-white mt-0.5">
|
||||
{new Date(bunnyStatus.last_synced_at).toLocaleString('en-GB')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{bunnyStatus.created_at && (
|
||||
<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-sm font-medium text-neutral-900 dark:text-white mt-0.5">
|
||||
<p className="text-xs text-neutral-400">Connected Since</p>
|
||||
<p className="text-sm font-medium text-white mt-0.5">
|
||||
{new Date(bunnyStatus.created_at).toLocaleString('en-GB')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -2311,7 +2354,7 @@ export default function SiteSettingsPage() {
|
||||
onChange={(e) => setGoalForm({ ...goalForm, name: e.target.value })}
|
||||
placeholder="e.g. Signups"
|
||||
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
|
||||
/>
|
||||
</div>
|
||||
@@ -2323,11 +2366,11 @@ export default function SiteSettingsPage() {
|
||||
onChange={(e) => setGoalForm({ ...goalForm, event_name: e.target.value })}
|
||||
placeholder="e.g. signup_click (letters, numbers, underscores only)"
|
||||
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
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
{editingGoal && goalForm.event_name.trim().toLowerCase().replace(/\s+/g, '_') !== editingGoal.event_name && (
|
||||
@@ -2380,10 +2423,10 @@ export default function SiteSettingsPage() {
|
||||
value={reportForm.recipients}
|
||||
onChange={(e) => setReportForm({ ...reportForm, recipients: e.target.value })}
|
||||
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
|
||||
/>
|
||||
<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>
|
||||
@@ -2395,7 +2438,7 @@ export default function SiteSettingsPage() {
|
||||
value={reportForm.webhookUrl}
|
||||
onChange={(e) => setReportForm({ ...reportForm, webhookUrl: e.target.value })}
|
||||
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
|
||||
/>
|
||||
</div>
|
||||
@@ -2539,10 +2582,10 @@ export default function SiteSettingsPage() {
|
||||
value={alertForm.recipients}
|
||||
onChange={(e) => setAlertForm({ ...alertForm, recipients: e.target.value })}
|
||||
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
|
||||
/>
|
||||
<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>
|
||||
@@ -2554,14 +2597,14 @@ export default function SiteSettingsPage() {
|
||||
value={alertForm.webhookUrl}
|
||||
onChange={(e) => setAlertForm({ ...alertForm, webhookUrl: e.target.value })}
|
||||
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
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -100,7 +100,7 @@ function getOverallStatusTextColor(status: string): string {
|
||||
case 'down':
|
||||
return 'text-red-600 dark:text-red-400'
|
||||
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%)' }}
|
||||
>
|
||||
<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 ? (
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between gap-4">
|
||||
<span className="text-neutral-500 dark:text-neutral-400">Uptime</span>
|
||||
<span className="font-medium text-neutral-900 dark:text-white">
|
||||
<span className="text-neutral-400">Uptime</span>
|
||||
<span className="font-medium text-white">
|
||||
{formatUptime(stat.uptime_percentage)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<span className="text-neutral-500 dark:text-neutral-400">Checks</span>
|
||||
<span className="font-medium text-neutral-900 dark:text-white">{stat.total_checks}</span>
|
||||
<span className="text-neutral-400">Checks</span>
|
||||
<span className="font-medium text-white">{stat.total_checks}</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<span className="text-neutral-500 dark:text-neutral-400">Avg Response</span>
|
||||
<span className="font-medium text-neutral-900 dark:text-white">
|
||||
<span className="text-neutral-400">Avg Response</span>
|
||||
<span className="font-medium text-white">
|
||||
{formatMs(Math.round(stat.avg_response_time_ms))}
|
||||
</span>
|
||||
</div>
|
||||
@@ -275,7 +275,7 @@ function ResponseTimeChart({ checks }: { checks: UptimeCheck[] }) {
|
||||
|
||||
return (
|
||||
<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
|
||||
</h4>
|
||||
<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}`}>
|
||||
{/* Header */}
|
||||
<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
|
||||
</h1>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
<p className="text-sm text-neutral-400">
|
||||
Monitor your site's availability and response time
|
||||
</p>
|
||||
</div>
|
||||
@@ -417,14 +417,14 @@ export default function UptimePage() {
|
||||
{/* 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="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" />
|
||||
</svg>
|
||||
</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
|
||||
</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's availability and response time around the clock.
|
||||
</p>
|
||||
{canEdit && (
|
||||
@@ -446,10 +446,10 @@ export default function UptimePage() {
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<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
|
||||
</h1>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
<p className="text-sm text-neutral-400">
|
||||
Monitor your site's availability and response time
|
||||
</p>
|
||||
</div>
|
||||
@@ -471,7 +471,7 @@ export default function UptimePage() {
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-3.5 h-3.5 rounded-full ${getStatusDotColor(overallStatus)}`} />
|
||||
<div>
|
||||
<span className="font-semibold text-neutral-900 dark:text-white text-lg">
|
||||
<span className="font-semibold text-white text-lg">
|
||||
{site.name}
|
||||
</span>
|
||||
<span className={`text-sm font-medium ml-3 ${getOverallStatusTextColor(overallStatus)}`}>
|
||||
@@ -480,11 +480,11 @@ export default function UptimePage() {
|
||||
</div>
|
||||
</div>
|
||||
<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
|
||||
</span>
|
||||
{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)}
|
||||
</div>
|
||||
)}
|
||||
@@ -495,7 +495,7 @@ export default function UptimePage() {
|
||||
{/* 90-day uptime bar */}
|
||||
{monitor && (
|
||||
<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
|
||||
</h3>
|
||||
<UptimeStatusBar dailyStats={monitor.daily_stats} />
|
||||
@@ -512,39 +512,39 @@ export default function UptimePage() {
|
||||
{/* Monitor details grid */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-5">
|
||||
<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
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<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)}
|
||||
</span>
|
||||
</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
|
||||
</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)}
|
||||
</span>
|
||||
</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
|
||||
</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
|
||||
? `${Math.floor(monitor.monitor.check_interval_seconds / 60)}m`
|
||||
: `${monitor.monitor.check_interval_seconds}s`}
|
||||
</span>
|
||||
</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
|
||||
</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)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -559,7 +559,7 @@ export default function UptimePage() {
|
||||
|
||||
{/* Recent checks */}
|
||||
<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
|
||||
</h4>
|
||||
<div className="space-y-1.5 max-h-48 overflow-y-auto">
|
||||
@@ -576,7 +576,7 @@ export default function UptimePage() {
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{check.status_code && (
|
||||
<span className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
<span className="text-xs text-neutral-400">
|
||||
{check.status_code}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -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">
|
||||
<CheckCircleIcon className="h-7 w-7" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white">
|
||||
<h2 className="text-2xl font-bold text-white">
|
||||
Site created
|
||||
</h2>
|
||||
<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>
|
||||
</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.
|
||||
</p>
|
||||
</div>
|
||||
@@ -146,7 +146,7 @@ export default function NewSitePage() {
|
||||
<button
|
||||
type="button"
|
||||
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
|
||||
</button>
|
||||
@@ -174,7 +174,7 @@ export default function NewSitePage() {
|
||||
// * Step 1: Name & domain form
|
||||
return (
|
||||
<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
|
||||
</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">
|
||||
<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
|
||||
</label>
|
||||
<Input
|
||||
@@ -201,7 +201,7 @@ export default function NewSitePage() {
|
||||
</div>
|
||||
|
||||
<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
|
||||
</label>
|
||||
<Input
|
||||
|
||||
@@ -39,8 +39,6 @@ import {
|
||||
ArrowRightIcon,
|
||||
ArrowLeftIcon,
|
||||
BarChartIcon,
|
||||
GlobeIcon,
|
||||
ZapIcon,
|
||||
PlusIcon,
|
||||
} from '@ciphera-net/ui'
|
||||
import Link from 'next/link'
|
||||
@@ -380,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">
|
||||
<BarChartIcon className="h-8 w-8" />
|
||||
</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
|
||||
</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.
|
||||
</p>
|
||||
</div>
|
||||
@@ -415,7 +413,7 @@ function WelcomeContent() {
|
||||
>
|
||||
{initial}
|
||||
</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'}
|
||||
</span>
|
||||
{isCurrent && (
|
||||
@@ -440,10 +438,12 @@ function WelcomeContent() {
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center">
|
||||
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-brand-orange/10 text-brand-orange mb-6">
|
||||
<ZapIcon className="h-7 w-7" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white">
|
||||
<img
|
||||
src="/illustrations/welcome.svg"
|
||||
alt="Welcome to Pulse"
|
||||
className="w-48 h-auto mx-auto mb-6"
|
||||
/>
|
||||
<h1 className="text-2xl font-bold text-white">
|
||||
Welcome to Pulse
|
||||
</h1>
|
||||
<p className="mt-2 text-neutral-600 dark:text-neutral-400">
|
||||
@@ -475,7 +475,7 @@ function WelcomeContent() {
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
<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">
|
||||
<BarChartIcon className="h-7 w-7" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white">
|
||||
<h1 className="text-2xl font-bold text-white">
|
||||
Name your organization
|
||||
</h1>
|
||||
<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)}
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
@@ -546,7 +546,7 @@ function WelcomeContent() {
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
<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">
|
||||
<CheckCircleIcon className="h-7 w-7" />
|
||||
</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"}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
@@ -631,17 +631,19 @@ function WelcomeContent() {
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
Back
|
||||
</button>
|
||||
<div className="text-center mb-6">
|
||||
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-brand-orange/10 text-brand-orange mb-4">
|
||||
<GlobeIcon className="h-7 w-7" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white">
|
||||
<img
|
||||
src="/illustrations/website-setup.svg"
|
||||
alt="Add your first site"
|
||||
className="w-44 h-auto mx-auto mb-4"
|
||||
/>
|
||||
<h1 className="text-2xl font-bold text-white">
|
||||
Add your first site
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
@@ -723,10 +725,12 @@ function WelcomeContent() {
|
||||
className={cardClass}
|
||||
>
|
||||
<div className="text-center">
|
||||
<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" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white">
|
||||
<img
|
||||
src="/illustrations/confirmed.svg"
|
||||
alt="All set"
|
||||
className="w-44 h-auto mx-auto mb-6"
|
||||
/>
|
||||
<h1 className="text-2xl font-bold text-white">
|
||||
You're all set
|
||||
</h1>
|
||||
<p className="mt-2 text-neutral-600 dark:text-neutral-400">
|
||||
@@ -754,7 +758,7 @@ function WelcomeContent() {
|
||||
>
|
||||
<span className="text-brand-orange">Verify installation</span>
|
||||
</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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -30,13 +30,13 @@ export default function ErrorDisplay({
|
||||
</div>
|
||||
|
||||
<div className="text-center px-4 z-10">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-red-100 dark:bg-red-900/30 mb-6">
|
||||
<svg className="w-8 h-8 text-red-500" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<img
|
||||
src="/illustrations/server-down.svg"
|
||||
alt="Something went wrong"
|
||||
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}
|
||||
</h2>
|
||||
<p className="text-lg text-neutral-600 dark:text-neutral-400 max-w-md mx-auto mb-10 leading-relaxed">
|
||||
|
||||
@@ -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">
|
||||
<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="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
<div className="text-sm text-neutral-400">
|
||||
© 2024-{year} Ciphera. All rights reserved.
|
||||
</div>
|
||||
<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"
|
||||
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
|
||||
</span>
|
||||
</Link>
|
||||
@@ -125,7 +125,7 @@ export function Footer({ LinkComponent = Link, appName = 'Pulse', isAuthenticate
|
||||
|
||||
{/* * Products */}
|
||||
<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">
|
||||
{footerLinks.products.map((link) => (
|
||||
<li key={link.name}>
|
||||
@@ -153,7 +153,7 @@ export function Footer({ LinkComponent = Link, appName = 'Pulse', isAuthenticate
|
||||
|
||||
{/* * Company */}
|
||||
<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">
|
||||
{footerLinks.company.map((link) => (
|
||||
<li key={link.name}>
|
||||
@@ -181,7 +181,7 @@ export function Footer({ LinkComponent = Link, appName = 'Pulse', isAuthenticate
|
||||
|
||||
{/* * Resources */}
|
||||
<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">
|
||||
{footerLinks.resources.map((link) => (
|
||||
<li key={link.name}>
|
||||
@@ -209,7 +209,7 @@ export function Footer({ LinkComponent = Link, appName = 'Pulse', isAuthenticate
|
||||
|
||||
{/* * Legal */}
|
||||
<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">
|
||||
{footerLinks.legal.map((link) => (
|
||||
<li key={link.name}>
|
||||
@@ -232,10 +232,10 @@ export function Footer({ LinkComponent = Link, appName = 'Pulse', isAuthenticate
|
||||
|
||||
{/* * Bottom bar */}
|
||||
<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.
|
||||
</p>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
<p className="text-sm text-neutral-400">
|
||||
Where Privacy Still Exists
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -109,7 +109,7 @@ export default function PricingSection() {
|
||||
const [loadingPlan, setLoadingPlan] = useState<string | null>(null)
|
||||
const { user } = useAuth()
|
||||
|
||||
// * Show toast when redirected from Stripe Checkout with canceled=true
|
||||
// * Show toast when redirected from Polar Checkout with canceled=true
|
||||
useEffect(() => {
|
||||
if (searchParams.get('canceled') === 'true') {
|
||||
toast.info('Checkout was canceled. You can try again whenever you’re ready.')
|
||||
@@ -196,7 +196,7 @@ export default function PricingSection() {
|
||||
limit,
|
||||
})
|
||||
|
||||
// 3. Redirect to Stripe Checkout
|
||||
// 3. Redirect to Polar Checkout
|
||||
if (url) {
|
||||
window.location.href = url
|
||||
} else {
|
||||
|
||||
@@ -34,11 +34,11 @@ export default function FrustrationByPageTable({ pages, loading }: FrustrationBy
|
||||
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="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
|
||||
</h3>
|
||||
</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
|
||||
</p>
|
||||
|
||||
@@ -72,7 +72,7 @@ export default function FrustrationByPageTable({ pages, loading }: FrustrationBy
|
||||
style={{ width: `${barWidth}%` }}
|
||||
/>
|
||||
<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}
|
||||
>
|
||||
{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">
|
||||
{formatNumber(page.dead_clicks)}
|
||||
</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)}
|
||||
</span>
|
||||
<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="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>
|
||||
<h4 className="font-semibold text-neutral-900 dark:text-white">
|
||||
<h4 className="font-semibold text-white">
|
||||
No frustration signals detected
|
||||
</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.
|
||||
</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>
|
||||
|
||||
@@ -31,7 +31,7 @@ function ChangeIndicator({ change }: { change: ReturnType<typeof pctChange> }) {
|
||||
? 'text-red-600 dark:text-red-400'
|
||||
: isDown
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-neutral-500 dark:text-neutral-400'
|
||||
: 'text-neutral-400'
|
||||
}`}
|
||||
>
|
||||
{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">
|
||||
{/* Rage Clicks */}
|
||||
<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
|
||||
</p>
|
||||
<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()}
|
||||
</span>
|
||||
<ChangeIndicator change={rageChange} />
|
||||
@@ -87,11 +87,11 @@ export default function FrustrationSummaryCards({ data, loading }: FrustrationSu
|
||||
|
||||
{/* Dead Clicks */}
|
||||
<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
|
||||
</p>
|
||||
<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()}
|
||||
</span>
|
||||
<ChangeIndicator change={deadChange} />
|
||||
@@ -103,10 +103,10 @@ export default function FrustrationSummaryCards({ data, loading }: FrustrationSu
|
||||
|
||||
{/* Total Frustration Signals */}
|
||||
<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
|
||||
</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()}
|
||||
</span>
|
||||
{topPage ? (
|
||||
|
||||
@@ -53,7 +53,7 @@ function SelectorCell({ selector }: { selector: string }) {
|
||||
className="flex items-center gap-1 min-w-0 group/copy cursor-pointer"
|
||||
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}
|
||||
</span>
|
||||
<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="flex items-center justify-between mb-1">
|
||||
<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}
|
||||
</h3>
|
||||
{showViewAll && (
|
||||
@@ -159,7 +159,7 @@ export default function FrustrationTable({
|
||||
)}
|
||||
</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}
|
||||
</p>
|
||||
|
||||
@@ -177,18 +177,23 @@ export default function FrustrationTable({
|
||||
</>
|
||||
) : (
|
||||
<div className="h-full 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">
|
||||
<CursorClick className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
|
||||
</div>
|
||||
<h4 className="font-semibold text-neutral-900 dark:text-white">
|
||||
<img
|
||||
src="/illustrations/blank-canvas.svg"
|
||||
alt="No frustration signals"
|
||||
className="w-44 h-auto mb-1"
|
||||
/>
|
||||
<h4 className="font-semibold text-white">
|
||||
No {title.toLowerCase()} detected
|
||||
</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:
|
||||
</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">
|
||||
{'<script defer src="https://pulse.ciphera.net/script.frustration.js"></script>'}
|
||||
</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>
|
||||
@@ -212,7 +217,7 @@ export default function FrustrationTable({
|
||||
))}
|
||||
</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
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -59,7 +59,7 @@ function CustomTooltip({ active, payload }: { active?: boolean; payload?: Array<
|
||||
className="h-2.5 w-2.5 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: item.fill }}
|
||||
/>
|
||||
<span className="text-neutral-500 dark:text-neutral-400">
|
||||
<span className="text-neutral-400">
|
||||
{LABELS[item.type] ?? item.type}
|
||||
</span>
|
||||
<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 (
|
||||
<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">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
|
||||
<h3 className="text-lg font-semibold text-white">
|
||||
Frustration Trend
|
||||
</h3>
|
||||
</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
|
||||
</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="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>
|
||||
<h4 className="font-semibold text-neutral-900 dark:text-white">
|
||||
<h4 className="font-semibold text-white">
|
||||
No trend data yet
|
||||
</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.
|
||||
</p>
|
||||
</div>
|
||||
@@ -118,11 +118,11 @@ export default function FrustrationTrend({ summary, loading }: FrustrationTrendP
|
||||
return (
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
|
||||
<h3 className="text-lg font-semibold text-white">
|
||||
Frustration Trend
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-4">
|
||||
<p className="text-sm text-neutral-400 mb-4">
|
||||
{hasPrevious
|
||||
? 'Rage and dead clicks split across current and previous period'
|
||||
: 'Rage vs. dead click breakdown'}
|
||||
|
||||
@@ -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="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 && (
|
||||
<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" />}
|
||||
@@ -357,7 +357,7 @@ export default function Chart({
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between gap-3 mb-4 px-2">
|
||||
<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}
|
||||
</span>
|
||||
</div>
|
||||
@@ -414,7 +414,12 @@ export default function Chart({
|
||||
</div>
|
||||
|
||||
{!hasData || !hasAnyNonZero ? (
|
||||
<div className="flex h-96 flex-col items-center justify-center gap-2">
|
||||
<div className="flex h-96 flex-col items-center justify-center gap-3">
|
||||
<img
|
||||
src="/illustrations/no-data.svg"
|
||||
alt="No data available"
|
||||
className="w-48 h-auto mb-2"
|
||||
/>
|
||||
<p className="text-sm text-neutral-400 dark:text-neutral-500">
|
||||
{!hasData ? 'No data for this period' : `No ${METRIC_CONFIGS.find((m) => m.key === metric)?.label.toLowerCase()} recorded`}
|
||||
</p>
|
||||
@@ -521,7 +526,7 @@ export default function Chart({
|
||||
<span className="font-medium text-neutral-400 dark:text-neutral-500">
|
||||
{ANNOTATION_LABELS[a.category] || 'Note'} · {formatEU(a.date)}{a.time ? ` at ${a.time}` : ''}
|
||||
</span>
|
||||
<p className="text-neutral-900 dark:text-white">{a.text}</p>
|
||||
<p className="text-white">{a.text}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -588,16 +593,16 @@ export default function Chart({
|
||||
{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="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'}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<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
|
||||
type="button"
|
||||
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>
|
||||
<svg className="w-4 h-4 text-neutral-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
@@ -606,7 +611,7 @@ export default function Chart({
|
||||
</button>
|
||||
</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>
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -614,7 +619,7 @@ export default function Chart({
|
||||
type="time"
|
||||
value={annotationForm.time}
|
||||
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 && (
|
||||
<button
|
||||
@@ -629,20 +634,20 @@ export default function Chart({
|
||||
</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
|
||||
type="text"
|
||||
value={annotationForm.text}
|
||||
onChange={(e) => setAnnotationForm((f) => ({ ...f, text: e.target.value.slice(0, 200) }))}
|
||||
placeholder="e.g. Launched new homepage"
|
||||
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
|
||||
/>
|
||||
<span className="text-[10px] text-neutral-400 mt-0.5 block text-right">{annotationForm.text.length}/200</span>
|
||||
</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
|
||||
value={annotationForm.category}
|
||||
onChange={(v) => setAnnotationForm((f) => ({ ...f, category: v }))}
|
||||
@@ -670,7 +675,7 @@ export default function Chart({
|
||||
<button
|
||||
type="button"
|
||||
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
|
||||
</button>
|
||||
|
||||
@@ -11,9 +11,11 @@ const Sidebar = dynamic(() => import('./Sidebar'), {
|
||||
// so page content never occupies the sidebar zone
|
||||
loading: () => (
|
||||
<div
|
||||
className="hidden md:block shrink-0 border-r border-neutral-800/60 bg-neutral-900/90 backdrop-blur-xl"
|
||||
className="hidden md:block shrink-0 bg-neutral-900 overflow-hidden relative"
|
||||
style={{ width: 64 }}
|
||||
/>
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-neutral-800/10 to-transparent animate-shimmer" />
|
||||
</div>
|
||||
),
|
||||
})
|
||||
|
||||
@@ -29,14 +31,15 @@ export default function DashboardShell({
|
||||
const openMobile = useCallback(() => setMobileOpen(true), [])
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
<div className="flex h-screen overflow-hidden bg-neutral-900">
|
||||
<Sidebar
|
||||
siteId={siteId}
|
||||
mobileOpen={mobileOpen}
|
||||
onMobileClose={closeMobile}
|
||||
onMobileOpen={openMobile}
|
||||
/>
|
||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
{/* Content panel — rounded corners, inset from edges. The left border doubles as the sidebar's right edge. */}
|
||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden mt-2 mr-2 mb-2 rounded-2xl bg-neutral-950 border border-neutral-800/60">
|
||||
<ContentHeader onMobileMenuOpen={openMobile} />
|
||||
<main className="flex-1 overflow-y-auto pt-4">
|
||||
{children}
|
||||
|
||||
@@ -480,7 +480,7 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to
|
||||
{/* Progress Bar */}
|
||||
{(isExporting || exportDone) && (
|
||||
<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 ? '100%' : `${Math.round((exportProgress.step / exportProgress.total) * 100)}%`}</span>
|
||||
</div>
|
||||
|
||||
@@ -29,7 +29,7 @@ export default function FilterBar({ filters, onRemove, onClear }: FilterBarProps
|
||||
{filters.length > 1 && (
|
||||
<button
|
||||
onClick={onClear}
|
||||
className="px-2 py-1.5 text-xs font-medium text-neutral-500 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white transition-colors cursor-pointer"
|
||||
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
|
||||
</button>
|
||||
|
||||
@@ -9,7 +9,8 @@ import { useSettingsModal } from '@/lib/settings-modal-context'
|
||||
import { getUserOrganizations, switchContext, type OrganizationMember } from '@/lib/api/organization'
|
||||
import { setSessionAction } from '@/app/actions/auth'
|
||||
import { logger } from '@/lib/utils/logger'
|
||||
import { FAVICON_SERVICE_URL } from '@/lib/utils/icons'
|
||||
import { FAVICON_SERVICE_URL } from '@/lib/utils/favicon'
|
||||
import { Gauge as GaugeIcon } from '@phosphor-icons/react'
|
||||
import {
|
||||
LayoutDashboardIcon,
|
||||
PathIcon,
|
||||
@@ -88,6 +89,7 @@ const NAV_GROUPS: NavGroup[] = [
|
||||
items: [
|
||||
{ label: 'CDN', href: (id) => `/sites/${id}/cdn`, icon: CloudUploadIcon, matchPrefix: true },
|
||||
{ label: 'Uptime', href: (id) => `/sites/${id}/uptime`, icon: HeartbeatIcon, matchPrefix: true },
|
||||
{ label: 'PageSpeed', href: (id) => `/sites/${id}/pagespeed`, icon: GaugeIcon, matchPrefix: true },
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -110,9 +112,10 @@ function Label({ children, collapsed }: { children: React.ReactNode; collapsed:
|
||||
|
||||
// ─── Site Picker ────────────────────────────────────────────
|
||||
|
||||
function SitePicker({ sites, siteId, collapsed, onExpand, onCollapse, wasCollapsed }: {
|
||||
function SitePicker({ sites, siteId, collapsed, onExpand, onCollapse, wasCollapsed, pickerOpenCallback }: {
|
||||
sites: Site[]; siteId: string; collapsed: boolean
|
||||
onExpand: () => void; onCollapse: () => void; wasCollapsed: React.MutableRefObject<boolean>
|
||||
pickerOpenCallback: React.MutableRefObject<(() => void) | null>
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
@@ -155,9 +158,8 @@ function SitePicker({ sites, siteId, collapsed, onExpand, onCollapse, wasCollaps
|
||||
onClick={() => {
|
||||
if (collapsed) {
|
||||
wasCollapsed.current = true
|
||||
pickerOpenCallback.current = () => setOpen(true)
|
||||
onExpand()
|
||||
// Open picker after sidebar expands
|
||||
setTimeout(() => setOpen(true), 220)
|
||||
} else {
|
||||
setOpen(!open)
|
||||
}
|
||||
@@ -176,7 +178,11 @@ function SitePicker({ sites, siteId, collapsed, onExpand, onCollapse, wasCollaps
|
||||
onError={() => setFaviconFailed(true)}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
) : (
|
||||
<span className="text-xs font-bold text-brand-orange">
|
||||
{currentSite?.name?.charAt(0).toUpperCase() || '?'}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<Label collapsed={collapsed}>
|
||||
<span className="flex items-center gap-1">
|
||||
@@ -187,13 +193,20 @@ function SitePicker({ sites, siteId, collapsed, onExpand, onCollapse, wasCollaps
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute left-3 top-full mt-1 z-50 w-[240px] bg-neutral-900 border border-neutral-700 rounded-xl shadow-xl overflow-hidden">
|
||||
<div className="absolute left-3 top-full mt-1 z-50 w-[240px] bg-neutral-900 border border-neutral-700 rounded-xl shadow-xl overflow-hidden animate-in fade-in zoom-in-95 duration-150">
|
||||
<div className="p-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search sites..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
setOpen(false)
|
||||
setSearch('')
|
||||
if (wasCollapsed.current) { onCollapse(); wasCollapsed.current = false }
|
||||
}
|
||||
}}
|
||||
className="w-full px-3 py-1.5 text-sm bg-neutral-800 border border-neutral-700 rounded-lg outline-none focus:ring-2 focus:ring-brand-orange/40 text-white placeholder:text-neutral-400"
|
||||
autoFocus
|
||||
/>
|
||||
@@ -249,21 +262,171 @@ function NavLink({
|
||||
const active = matchesPathname || matchesPending
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
onClick={() => { onNavigate(href); onClick?.() }}
|
||||
title={collapsed ? item.label : undefined}
|
||||
className={`flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium overflow-hidden ${
|
||||
active
|
||||
? 'bg-brand-orange/10 text-brand-orange'
|
||||
: 'text-neutral-400 hover:text-white hover:bg-neutral-800'
|
||||
}`}
|
||||
>
|
||||
<span className="w-7 h-7 flex items-center justify-center shrink-0">
|
||||
<item.icon className="w-[18px] h-[18px]" weight={active ? 'fill' : 'regular'} />
|
||||
</span>
|
||||
<Label collapsed={collapsed}>{item.label}</Label>
|
||||
</Link>
|
||||
<div className="relative group/nav">
|
||||
<Link
|
||||
href={href}
|
||||
onClick={() => { onNavigate(href); onClick?.() }}
|
||||
className={`flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium overflow-hidden transition-all duration-150 ${
|
||||
active
|
||||
? 'bg-brand-orange/10 text-brand-orange'
|
||||
: 'text-neutral-400 hover:text-white hover:bg-neutral-800 hover:translate-x-0.5'
|
||||
}`}
|
||||
>
|
||||
<span className="w-7 h-7 flex items-center justify-center shrink-0">
|
||||
<item.icon className="w-[18px] h-[18px]" weight={active ? 'fill' : 'regular'} />
|
||||
</span>
|
||||
<Label collapsed={collapsed}>{item.label}</Label>
|
||||
</Link>
|
||||
{collapsed && (
|
||||
<span className="pointer-events-none absolute left-full top-1/2 -translate-y-1/2 ml-2 px-2 py-1 rounded-md bg-neutral-800 text-white text-xs whitespace-nowrap opacity-0 group-hover/nav:opacity-100 transition-opacity duration-150 delay-150 z-50">
|
||||
{item.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Sidebar Content ────────────────────────────────────────
|
||||
|
||||
interface SidebarContentProps {
|
||||
isMobile: boolean
|
||||
collapsed: boolean
|
||||
siteId: string
|
||||
sites: Site[]
|
||||
canEdit: boolean
|
||||
pendingHref: string | null
|
||||
onNavigate: (href: string) => void
|
||||
onMobileClose: () => void
|
||||
onExpand: () => void
|
||||
onCollapse: () => void
|
||||
onToggle: () => void
|
||||
wasCollapsed: React.MutableRefObject<boolean>
|
||||
pickerOpenCallbackRef: React.MutableRefObject<(() => void) | null>
|
||||
auth: ReturnType<typeof useAuth>
|
||||
orgs: OrganizationMember[]
|
||||
onSwitchOrganization: (orgId: string | null) => Promise<void>
|
||||
openSettings: () => void
|
||||
}
|
||||
|
||||
function SidebarContent({
|
||||
isMobile, collapsed, siteId, sites, canEdit, pendingHref,
|
||||
onNavigate, onMobileClose, onExpand, onCollapse, onToggle,
|
||||
wasCollapsed, pickerOpenCallbackRef, auth, orgs, onSwitchOrganization, openSettings,
|
||||
}: SidebarContentProps) {
|
||||
const router = useRouter()
|
||||
const c = isMobile ? false : collapsed
|
||||
const { user } = auth
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
{/* App Switcher — top of sidebar (scope-level switch) */}
|
||||
<div className="flex items-center gap-2.5 px-[14px] pt-3 pb-1 shrink-0 overflow-hidden">
|
||||
<span className="w-9 h-9 flex items-center justify-center shrink-0">
|
||||
<AppLauncher apps={CIPHERA_APPS} currentAppId="pulse" anchor="right" />
|
||||
</span>
|
||||
<Label collapsed={c}>
|
||||
<span className="text-xs font-medium text-neutral-400 dark:text-neutral-500 uppercase tracking-wider">Ciphera</span>
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{/* Logo — fixed layout, text fades */}
|
||||
<Link href="/" className="flex items-center gap-3 px-[14px] py-4 shrink-0 group overflow-hidden">
|
||||
<span className="w-9 h-9 flex items-center justify-center shrink-0">
|
||||
<img src="/pulse_icon_no_margins.png" alt="Pulse" className="w-9 h-9 shrink-0 object-contain group-hover:scale-105 transition-transform duration-200" />
|
||||
</span>
|
||||
<span className={`text-xl font-bold text-white tracking-tight group-hover:text-brand-orange whitespace-nowrap transition-opacity duration-150 ${c ? 'opacity-0' : 'opacity-100'}`}>
|
||||
Pulse
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Site Picker */}
|
||||
<SitePicker sites={sites} siteId={siteId} collapsed={c} onExpand={onExpand} onCollapse={onCollapse} wasCollapsed={wasCollapsed} pickerOpenCallback={pickerOpenCallbackRef} />
|
||||
|
||||
{/* Nav Groups */}
|
||||
<nav className="flex-1 overflow-y-auto overflow-x-hidden px-2 space-y-4">
|
||||
{NAV_GROUPS.map((group) => (
|
||||
<div key={group.label}>
|
||||
{c ? (
|
||||
<div className="mx-3 my-2 border-t border-neutral-800/40" />
|
||||
) : (
|
||||
<div className="h-5 flex items-center overflow-hidden">
|
||||
<p className="px-2.5 text-[11px] font-semibold text-neutral-400 dark:text-neutral-500 uppercase tracking-wider whitespace-nowrap">
|
||||
{group.label}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-0.5">
|
||||
{group.items.map((item) => (
|
||||
<NavLink key={item.label} item={item} siteId={siteId} collapsed={c} onClick={isMobile ? onMobileClose : undefined} pendingHref={pendingHref} onNavigate={onNavigate} />
|
||||
))}
|
||||
{group.label === 'Infrastructure' && canEdit && (
|
||||
<NavLink item={SETTINGS_ITEM} siteId={siteId} collapsed={c} onClick={isMobile ? onMobileClose : undefined} pendingHref={pendingHref} onNavigate={onNavigate} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Bottom — utility items */}
|
||||
<div className="border-t border-neutral-800/60 px-2 py-3 shrink-0">
|
||||
{/* Notifications, Profile — same layout as nav items */}
|
||||
<div className="space-y-0.5 mb-1">
|
||||
<div className="relative group/notif">
|
||||
<NotificationCenter anchor="right" variant="sidebar">
|
||||
<Label collapsed={c}>Notifications</Label>
|
||||
</NotificationCenter>
|
||||
{c && (
|
||||
<span className="pointer-events-none absolute left-full top-1/2 -translate-y-1/2 ml-2 px-2 py-1 rounded-md bg-neutral-800 text-white text-xs whitespace-nowrap opacity-0 group-hover/notif:opacity-100 transition-opacity duration-150 delay-150 z-50">
|
||||
Notifications
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative group/user">
|
||||
<UserMenu
|
||||
auth={auth}
|
||||
LinkComponent={Link}
|
||||
orgs={orgs}
|
||||
activeOrgId={auth.user?.org_id}
|
||||
onSwitchOrganization={onSwitchOrganization}
|
||||
onCreateOrganization={() => router.push('/onboarding')}
|
||||
allowPersonalOrganization={false}
|
||||
onOpenSettings={openSettings}
|
||||
compact
|
||||
anchor="right"
|
||||
>
|
||||
<Label collapsed={c}>{user?.display_name?.trim() || 'Profile'}</Label>
|
||||
</UserMenu>
|
||||
{c && (
|
||||
<span className="pointer-events-none absolute left-full top-1/2 -translate-y-1/2 ml-2 px-2 py-1 rounded-md bg-neutral-800 text-white text-xs whitespace-nowrap opacity-0 group-hover/user:opacity-100 transition-opacity duration-150 delay-150 z-50">
|
||||
{user?.display_name?.trim() || 'Profile'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Settings + Collapse */}
|
||||
<div className="space-y-0.5">
|
||||
{!isMobile && (
|
||||
<div className="relative group/collapse">
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-neutral-400 dark:text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800 w-full overflow-hidden"
|
||||
>
|
||||
<span className="w-7 h-7 flex items-center justify-center shrink-0">
|
||||
<CollapseLeftIcon className={`w-[18px] h-[18px] transition-transform duration-200 ${c ? 'rotate-180' : ''}`} />
|
||||
</span>
|
||||
<Label collapsed={c}>{c ? 'Expand' : 'Collapse'}</Label>
|
||||
</button>
|
||||
{c && (
|
||||
<span className="pointer-events-none absolute left-full top-1/2 -translate-y-1/2 ml-2 px-2 py-1 rounded-md bg-neutral-800 text-white text-xs whitespace-nowrap opacity-0 group-hover/collapse:opacity-100 transition-opacity duration-150 delay-150 z-50">
|
||||
Expand (press [)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -283,7 +446,9 @@ export default function Sidebar({
|
||||
const [sites, setSites] = useState<Site[]>([])
|
||||
const [orgs, setOrgs] = useState<OrganizationMember[]>([])
|
||||
const [pendingHref, setPendingHref] = useState<string | null>(null)
|
||||
const [mobileClosing, setMobileClosing] = useState(false)
|
||||
const wasCollapsedRef = useRef(false)
|
||||
const pickerOpenCallbackRef = useRef<(() => void) | null>(null)
|
||||
// Safe to read localStorage directly — this component is loaded with ssr:false
|
||||
const [collapsed, setCollapsed] = useState(() => {
|
||||
return localStorage.getItem(SIDEBAR_KEY) !== 'false'
|
||||
@@ -335,126 +500,91 @@ export default function Sidebar({
|
||||
setCollapsed(true); localStorage.setItem(SIDEBAR_KEY, 'true')
|
||||
}, [])
|
||||
|
||||
const handleMobileClose = useCallback(() => {
|
||||
setMobileClosing(true)
|
||||
setTimeout(() => {
|
||||
setMobileClosing(false)
|
||||
onMobileClose()
|
||||
}, 200)
|
||||
}, [onMobileClose])
|
||||
|
||||
const handleNavigate = useCallback((href: string) => { setPendingHref(href) }, [])
|
||||
|
||||
const sidebarContent = (isMobile: boolean) => {
|
||||
const c = isMobile ? false : collapsed
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
{/* App Switcher — top of sidebar (scope-level switch) */}
|
||||
<div className="flex items-center gap-2.5 px-[14px] pt-3 pb-1 shrink-0 overflow-hidden">
|
||||
<span className="w-9 h-9 flex items-center justify-center shrink-0">
|
||||
<AppLauncher apps={CIPHERA_APPS} currentAppId="pulse" anchor="right" />
|
||||
</span>
|
||||
<Label collapsed={c}>
|
||||
<span className="text-xs font-medium text-neutral-400 dark:text-neutral-500 uppercase tracking-wider">Ciphera</span>
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{/* Logo — fixed layout, text fades */}
|
||||
<Link href="/" className="flex items-center gap-3 px-[14px] py-4 shrink-0 group overflow-hidden">
|
||||
<span className="w-9 h-9 flex items-center justify-center shrink-0">
|
||||
<img src="/pulse_icon_no_margins.png" alt="Pulse" className="w-9 h-9 shrink-0 object-contain group-hover:scale-105 transition-transform duration-200" />
|
||||
</span>
|
||||
<span className={`text-xl font-bold text-white tracking-tight group-hover:text-brand-orange whitespace-nowrap transition-opacity duration-150 ${c ? 'opacity-0' : 'opacity-100'}`}>
|
||||
Pulse
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Site Picker */}
|
||||
<SitePicker sites={sites} siteId={siteId} collapsed={c} onExpand={expand} onCollapse={collapse} wasCollapsed={wasCollapsedRef} />
|
||||
|
||||
{/* Nav Groups */}
|
||||
<nav className="flex-1 overflow-y-auto overflow-x-hidden px-2 space-y-4">
|
||||
{NAV_GROUPS.map((group) => (
|
||||
<div key={group.label}>
|
||||
<div className="h-5 flex items-center overflow-hidden">
|
||||
<p className={`px-2.5 text-[11px] font-semibold text-neutral-400 dark:text-neutral-500 uppercase tracking-wider whitespace-nowrap transition-opacity duration-150 ${c ? 'opacity-0' : 'opacity-100'}`}>
|
||||
{group.label}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{group.items.map((item) => (
|
||||
<NavLink key={item.label} item={item} siteId={siteId} collapsed={c} onClick={isMobile ? onMobileClose : undefined} pendingHref={pendingHref} onNavigate={handleNavigate} />
|
||||
))}
|
||||
{group.label === 'Infrastructure' && canEdit && (
|
||||
<NavLink item={SETTINGS_ITEM} siteId={siteId} collapsed={c} onClick={isMobile ? onMobileClose : undefined} pendingHref={pendingHref} onNavigate={handleNavigate} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Bottom — utility items */}
|
||||
<div className="border-t border-neutral-800/60 px-2 py-3 shrink-0">
|
||||
{/* Notifications, Profile — same layout as nav items */}
|
||||
<div className="space-y-0.5 mb-1">
|
||||
<span title={c ? 'Notifications' : undefined}>
|
||||
<NotificationCenter anchor="right" variant="sidebar">
|
||||
<Label collapsed={c}>Notifications</Label>
|
||||
</NotificationCenter>
|
||||
</span>
|
||||
<span title={c ? (user?.display_name?.trim() || 'Profile') : undefined}>
|
||||
<UserMenu
|
||||
auth={auth}
|
||||
LinkComponent={Link}
|
||||
orgs={orgs}
|
||||
activeOrgId={auth.user?.org_id}
|
||||
onSwitchOrganization={handleSwitchOrganization}
|
||||
onCreateOrganization={() => router.push('/onboarding')}
|
||||
allowPersonalOrganization={false}
|
||||
onOpenSettings={openSettings}
|
||||
compact
|
||||
anchor="right"
|
||||
>
|
||||
<Label collapsed={c}>{user?.display_name?.trim() || 'Profile'}</Label>
|
||||
</UserMenu>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Settings + Collapse */}
|
||||
<div className="space-y-0.5">
|
||||
{!isMobile && (
|
||||
<button
|
||||
onClick={toggle}
|
||||
className="flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-neutral-400 dark:text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800 w-full overflow-hidden"
|
||||
title={collapsed ? 'Expand sidebar (press [)' : 'Collapse sidebar (press [)'}
|
||||
>
|
||||
<span className="w-7 h-7 flex items-center justify-center shrink-0">
|
||||
<CollapseLeftIcon className={`w-[18px] h-[18px] transition-transform duration-200 ${c ? 'rotate-180' : ''}`} />
|
||||
</span>
|
||||
<Label collapsed={c}>Collapse</Label>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop — ssr:false means this only renders on client, no hydration flash */}
|
||||
<aside
|
||||
className="hidden md:flex flex-col shrink-0 border-r border-neutral-800/60 bg-neutral-900/90 backdrop-blur-xl overflow-hidden relative z-10"
|
||||
className="hidden md:flex flex-col shrink-0 bg-neutral-900 overflow-hidden relative z-10"
|
||||
style={{ width: collapsed ? COLLAPSED : EXPANDED, transition: 'width 200ms cubic-bezier(0.4, 0, 0.2, 1)' }}
|
||||
onTransitionEnd={(e) => {
|
||||
if (e.propertyName === 'width' && pickerOpenCallbackRef.current) {
|
||||
pickerOpenCallbackRef.current()
|
||||
pickerOpenCallbackRef.current = null
|
||||
}
|
||||
}}
|
||||
>
|
||||
{sidebarContent(false)}
|
||||
<SidebarContent
|
||||
isMobile={false}
|
||||
collapsed={collapsed}
|
||||
siteId={siteId}
|
||||
sites={sites}
|
||||
canEdit={canEdit}
|
||||
pendingHref={pendingHref}
|
||||
onNavigate={handleNavigate}
|
||||
onMobileClose={onMobileClose}
|
||||
onExpand={expand}
|
||||
onCollapse={collapse}
|
||||
onToggle={toggle}
|
||||
wasCollapsed={wasCollapsedRef}
|
||||
pickerOpenCallbackRef={pickerOpenCallbackRef}
|
||||
auth={auth}
|
||||
orgs={orgs}
|
||||
onSwitchOrganization={handleSwitchOrganization}
|
||||
openSettings={openSettings}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
{/* Mobile overlay */}
|
||||
{mobileOpen && (
|
||||
{(mobileOpen || mobileClosing) && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40 bg-black/30 md:hidden" onClick={onMobileClose} />
|
||||
<aside className="fixed inset-y-0 left-0 z-50 w-72 bg-neutral-900 border-r border-neutral-800 shadow-xl md:hidden animate-in slide-in-from-left duration-200">
|
||||
<div
|
||||
className={`fixed inset-0 z-40 bg-black/30 md:hidden transition-opacity duration-200 ${
|
||||
mobileClosing ? 'opacity-0' : 'opacity-100'
|
||||
}`}
|
||||
onClick={handleMobileClose}
|
||||
/>
|
||||
<aside
|
||||
className={`fixed inset-y-0 left-0 z-50 w-72 bg-neutral-900 border-r border-neutral-800 shadow-xl md:hidden ${
|
||||
mobileClosing
|
||||
? 'animate-out slide-out-to-left duration-200 fill-mode-forwards'
|
||||
: 'animate-in slide-in-from-left duration-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-neutral-800">
|
||||
<span className="text-sm font-semibold text-white">Navigation</span>
|
||||
<button onClick={onMobileClose} className="p-1.5 text-neutral-400 hover:text-neutral-300">
|
||||
<button onClick={handleMobileClose} className="p-1.5 text-neutral-400 hover:text-neutral-300">
|
||||
<XIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
{sidebarContent(true)}
|
||||
<SidebarContent
|
||||
isMobile={true}
|
||||
collapsed={collapsed}
|
||||
siteId={siteId}
|
||||
sites={sites}
|
||||
canEdit={canEdit}
|
||||
pendingHref={pendingHref}
|
||||
onNavigate={handleNavigate}
|
||||
onMobileClose={handleMobileClose}
|
||||
onExpand={expand}
|
||||
onCollapse={collapse}
|
||||
onToggle={toggle}
|
||||
wasCollapsed={wasCollapsedRef}
|
||||
pickerOpenCallbackRef={pickerOpenCallbackRef}
|
||||
auth={auth}
|
||||
orgs={orgs}
|
||||
onSwitchOrganization={handleSwitchOrganization}
|
||||
openSettings={openSettings}
|
||||
/>
|
||||
</aside>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -46,7 +46,7 @@ export default function SiteNav({ siteId }: SiteNavProps) {
|
||||
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 ${
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { logger } from '@/lib/utils/logger'
|
||||
import Image from 'next/image'
|
||||
import { formatNumber } from '@ciphera-net/ui'
|
||||
import { getReferrerDisplayName, getReferrerFavicon, getReferrerIcon, mergeReferrersByDisplayName } from '@/lib/utils/icons'
|
||||
import Link from 'next/link'
|
||||
@@ -48,14 +47,21 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
|
||||
const useFavicon = faviconUrl && !faviconFailed.has(referrer)
|
||||
if (useFavicon) {
|
||||
return (
|
||||
<Image
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={faviconUrl}
|
||||
alt=""
|
||||
width={20}
|
||||
height={20}
|
||||
className="w-5 h-5 flex-shrink-0 rounded object-contain"
|
||||
onError={() => setFaviconFailed((prev) => new Set(prev).add(referrer))}
|
||||
unoptimized
|
||||
onLoad={(e) => {
|
||||
// Google's favicon service returns a 16x16 default globe when no real favicon exists
|
||||
const img = e.currentTarget
|
||||
if (img.naturalWidth <= 16) {
|
||||
setFaviconFailed((prev) => new Set(prev).add(referrer))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -91,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 gap-2">
|
||||
<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
|
||||
</h3>
|
||||
{showViewAll && (
|
||||
@@ -109,7 +115,7 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
|
||||
<div className="space-y-2 flex-1 min-h-[270px]">
|
||||
{!collectReferrers ? (
|
||||
<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>
|
||||
) : hasData ? (
|
||||
<>
|
||||
@@ -126,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"
|
||||
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)}
|
||||
<span className="truncate" title={ref.referrer}>{getReferrerDisplayName(ref.referrer)}</span>
|
||||
</div>
|
||||
@@ -148,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="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>
|
||||
<h4 className="font-semibold text-neutral-900 dark:text-white">
|
||||
<h4 className="font-semibold text-white">
|
||||
No referrers yet
|
||||
</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.
|
||||
</p>
|
||||
<Link
|
||||
@@ -180,7 +186,7 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
|
||||
value={modalSearch}
|
||||
onChange={(e) => setModalSearch(e.target.value)}
|
||||
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 className="max-h-[80vh]">
|
||||
@@ -202,7 +208,7 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
|
||||
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' : ''}`}
|
||||
>
|
||||
<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)}
|
||||
<span className="truncate" title={ref.referrer}>{getReferrerDisplayName(ref.referrer)}</span>
|
||||
</div>
|
||||
|
||||
@@ -50,7 +50,7 @@ export default function BreakdownDrawer({ siteId, funnelId, stepIndex, stepName,
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-neutral-200 dark:border-neutral-800">
|
||||
<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>
|
||||
</div>
|
||||
<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">
|
||||
{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">
|
||||
<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)'}
|
||||
</span>
|
||||
<div className="flex items-center gap-4 text-sm shrink-0">
|
||||
|
||||
@@ -206,7 +206,7 @@ export default function FunnelForm({ siteId, initialData, onSubmit, submitLabel,
|
||||
Back to Funnels
|
||||
</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'}
|
||||
</h1>
|
||||
<p className="text-neutral-600 dark:text-neutral-400">
|
||||
@@ -252,7 +252,7 @@ export default function FunnelForm({ siteId, initialData, onSubmit, submitLabel,
|
||||
{/* Steps */}
|
||||
<div className="space-y-4 mb-6">
|
||||
<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
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
@@ -166,7 +166,7 @@ function ColumnHeader({
|
||||
{column.index === 0 ? 'Entry' : `Step ${column.index}`}
|
||||
</span>
|
||||
<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
|
||||
</span>
|
||||
{column.dropOffPercent !== 0 && (
|
||||
@@ -235,10 +235,10 @@ function PageRow({
|
||||
<span
|
||||
className={`relative flex-1 truncate text-sm ${
|
||||
isSelected
|
||||
? 'text-neutral-900 dark:text-white font-medium'
|
||||
? 'text-white font-medium'
|
||||
: isOther
|
||||
? 'italic text-neutral-400 dark:text-neutral-500'
|
||||
: 'text-neutral-900 dark:text-white'
|
||||
: 'text-white'
|
||||
}`}
|
||||
>
|
||||
{isOther ? page.path : smartLabel(page.path)}
|
||||
@@ -556,15 +556,20 @@ export default function ColumnJourney({
|
||||
if (!transitions.length) {
|
||||
return (
|
||||
<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">
|
||||
<TreeStructure className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
|
||||
</div>
|
||||
<h4 className="font-semibold text-neutral-900 dark:text-white">
|
||||
<img
|
||||
src="/illustrations/journey.svg"
|
||||
alt="No journey data"
|
||||
className="w-52 h-auto mb-2"
|
||||
/>
|
||||
<h4 className="font-semibold text-white">
|
||||
No journey data yet
|
||||
</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.
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -510,14 +510,17 @@ export default function SankeyJourney({
|
||||
return (
|
||||
<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">
|
||||
<TreeStructure className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
|
||||
<TreeStructure className="w-8 h-8 text-neutral-400" />
|
||||
</div>
|
||||
<h4 className="font-semibold text-neutral-900 dark:text-white">
|
||||
<h4 className="font-semibold text-white">
|
||||
No journey data yet
|
||||
</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.
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
<span className="text-neutral-700 dark:text-neutral-300">
|
||||
Showing flows through{' '}
|
||||
<span className="font-medium text-neutral-900 dark:text-white">
|
||||
<span className="font-medium text-white">
|
||||
{filterPath}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -38,11 +38,11 @@ export default function TopPathsTable({ paths, loading }: TopPathsTableProps) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
|
||||
<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
|
||||
</h3>
|
||||
</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
|
||||
</p>
|
||||
|
||||
@@ -83,7 +83,7 @@ export default function TopPathsTable({ paths, loading }: TopPathsTableProps) {
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className="text-sm text-neutral-900 dark:text-white truncate"
|
||||
className="text-sm text-white truncate"
|
||||
title={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="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>
|
||||
<h4 className="font-semibold text-neutral-900 dark:text-white">
|
||||
<h4 className="font-semibold text-white">
|
||||
No path data yet
|
||||
</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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { motion, useSpring, useTransform } from "motion/react";
|
||||
import { motion, useSpring, useTransform } from "framer-motion";
|
||||
import {
|
||||
type CSSProperties,
|
||||
type ReactNode,
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
<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 && (
|
||||
<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>
|
||||
)}
|
||||
{!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
|
||||
</div>
|
||||
)}
|
||||
@@ -260,11 +260,11 @@ export default function NotificationCenter({ anchor = 'bottom', variant = 'defau
|
||||
<div className="flex gap-3">
|
||||
{getTypeIcon(n.type)}
|
||||
<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}
|
||||
</p>
|
||||
{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}
|
||||
</p>
|
||||
)}
|
||||
@@ -283,11 +283,11 @@ export default function NotificationCenter({ anchor = 'bottom', variant = 'defau
|
||||
<div className="flex gap-3">
|
||||
{getTypeIcon(n.type)}
|
||||
<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}
|
||||
</p>
|
||||
{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}
|
||||
</p>
|
||||
)}
|
||||
@@ -315,7 +315,7 @@ export default function NotificationCenter({ anchor = 'bottom', variant = 'defau
|
||||
<Link
|
||||
href="/org-settings?tab=notifications"
|
||||
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" />
|
||||
Manage settings
|
||||
|
||||
77
components/pagespeed/ScoreGauge.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
'use client'
|
||||
|
||||
interface ScoreGaugeProps {
|
||||
score: number | null
|
||||
label: string
|
||||
size?: number
|
||||
}
|
||||
|
||||
const RADIUS = 44
|
||||
const CIRCUMFERENCE = 2 * Math.PI * RADIUS
|
||||
|
||||
function getColor(score: number): string {
|
||||
if (score >= 90) return '#0cce6b'
|
||||
if (score >= 50) return '#ffa400'
|
||||
return '#ff4e42'
|
||||
}
|
||||
|
||||
export default function ScoreGauge({ score, label, size = 120 }: ScoreGaugeProps) {
|
||||
const hasScore = score !== null && score !== undefined
|
||||
const displayScore = hasScore ? Math.round(score) : null
|
||||
const offset = hasScore ? CIRCUMFERENCE * (1 - score / 100) : CIRCUMFERENCE
|
||||
const color = hasScore ? getColor(score) : '#6b7280'
|
||||
|
||||
const fontSize = size >= 160 ? 'text-4xl' : size >= 100 ? 'text-2xl' : size >= 80 ? 'text-lg' : 'text-xs'
|
||||
const labelSize = size >= 100 ? 'text-sm' : 'text-[10px]'
|
||||
const strokeWidth = size >= 100 ? 8 : 6
|
||||
const gap = size >= 100 ? 'gap-2' : 'gap-1'
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col items-center ${gap}`}>
|
||||
<div className="relative" style={{ width: size, height: size }}>
|
||||
<svg
|
||||
className="w-full h-full -rotate-90"
|
||||
viewBox="0 0 100 100"
|
||||
>
|
||||
{/* Track */}
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r={RADIUS}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
className="text-neutral-200 dark:text-neutral-700"
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
{/* Filled arc */}
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r={RADIUS}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={CIRCUMFERENCE}
|
||||
strokeDashoffset={offset}
|
||||
style={{ transition: 'stroke-dashoffset 0.6s ease' }}
|
||||
/>
|
||||
</svg>
|
||||
{/* Score text */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span
|
||||
className={`${fontSize} font-bold`}
|
||||
style={{ color: hasScore ? color : undefined }}
|
||||
>
|
||||
{displayScore !== null ? displayScore : (
|
||||
<span className="text-neutral-400 dark:text-neutral-500">--</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`${labelSize} font-medium text-neutral-600 dark:text-neutral-400 text-center`}>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -31,19 +31,19 @@ function CustomTooltip({ active, payload, label }: TooltipProps) {
|
||||
|
||||
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="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 && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<div className="size-1.5 rounded-full" style={{ backgroundColor: '#FD5E0F' }} />
|
||||
<span className="text-neutral-500 dark:text-neutral-400">Clicks:</span>
|
||||
<span className="font-semibold text-neutral-900 dark:text-white">{clicks.value.toLocaleString()}</span>
|
||||
<span className="text-neutral-400">Clicks:</span>
|
||||
<span className="font-semibold text-white">{clicks.value.toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
{impressions && (
|
||||
<div className="flex items-center gap-2 text-sm mt-1">
|
||||
<div className="size-1.5 rounded-full" style={{ backgroundColor: '#9CA3AF' }} />
|
||||
<span className="text-neutral-500 dark:text-neutral-400">Impressions:</span>
|
||||
<span className="font-semibold text-neutral-900 dark:text-white">{impressions.value.toLocaleString()}</span>
|
||||
<span className="text-neutral-400">Impressions:</span>
|
||||
<span className="font-semibold text-white">{impressions.value.toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
OrganizationInvitation,
|
||||
Organization
|
||||
} from '@/lib/api/organization'
|
||||
import { getSubscription, createPortalSession, getInvoices, cancelSubscription, resumeSubscription, changePlan, previewInvoice, createCheckoutSession, SubscriptionDetails, Invoice, PreviewInvoiceResult } from '@/lib/api/billing'
|
||||
import { getSubscription, createPortalSession, getOrders, cancelSubscription, resumeSubscription, changePlan, createCheckoutSession, SubscriptionDetails, Order } from '@/lib/api/billing'
|
||||
import { TRAFFIC_TIERS, PLAN_ID_SOLO, PLAN_ID_TEAM, PLAN_ID_BUSINESS, getTierIndexForLimit, getLimitForTierIndex, getSitesLimitForPlan } from '@/lib/plans'
|
||||
import { getAuditLog, AuditLogEntry, GetAuditLogParams } from '@/lib/api/audit'
|
||||
import { getNotificationSettings, updateNotificationSettings } from '@/lib/api/notification-settings'
|
||||
@@ -36,7 +36,6 @@ import {
|
||||
XIcon,
|
||||
Captcha,
|
||||
BookOpenIcon,
|
||||
DownloadIcon,
|
||||
ExternalLinkIcon,
|
||||
LayoutDashboardIcon,
|
||||
Spinner,
|
||||
@@ -93,10 +92,8 @@ export default function OrganizationSettings() {
|
||||
const [changePlanId, setChangePlanId] = useState<string>(PLAN_ID_SOLO)
|
||||
const [changePlanTierIndex, setChangePlanTierIndex] = useState(2)
|
||||
const [changePlanYearly, setChangePlanYearly] = useState(false)
|
||||
const [invoicePreview, setInvoicePreview] = useState<PreviewInvoiceResult | null>(null)
|
||||
const [isLoadingPreview, setIsLoadingPreview] = useState(false)
|
||||
const [isChangingPlan, setIsChangingPlan] = useState(false)
|
||||
const [invoices, setInvoices] = useState<Invoice[]>([])
|
||||
const [orders, setOrders] = useState<Order[]>([])
|
||||
const [isLoadingInvoices, setIsLoadingInvoices] = useState(false)
|
||||
|
||||
// Invite State
|
||||
@@ -195,14 +192,14 @@ export default function OrganizationSettings() {
|
||||
}
|
||||
}, [currentOrgId])
|
||||
|
||||
const loadInvoices = useCallback(async () => {
|
||||
const loadOrders = useCallback(async () => {
|
||||
if (!currentOrgId) return
|
||||
setIsLoadingInvoices(true)
|
||||
try {
|
||||
const invs = await getInvoices()
|
||||
setInvoices(invs)
|
||||
const ords = await getOrders()
|
||||
setOrders(ords)
|
||||
} catch (error) {
|
||||
logger.error('Failed to load invoices:', error)
|
||||
logger.error('Failed to load orders:', error)
|
||||
} finally {
|
||||
setIsLoadingInvoices(false)
|
||||
}
|
||||
@@ -231,9 +228,9 @@ export default function OrganizationSettings() {
|
||||
useEffect(() => {
|
||||
if (activeTab === 'billing' && currentOrgId) {
|
||||
loadSubscription()
|
||||
loadInvoices()
|
||||
loadOrders()
|
||||
}
|
||||
}, [activeTab, currentOrgId, loadSubscription, loadInvoices])
|
||||
}, [activeTab, currentOrgId, loadSubscription, loadOrders])
|
||||
|
||||
const loadAudit = useCallback(async () => {
|
||||
if (!currentOrgId) return
|
||||
@@ -307,25 +304,14 @@ export default function OrganizationSettings() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!showChangePlanModal || !hasActiveSubscription) {
|
||||
setInvoicePreview(null)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
setIsLoadingPreview(true)
|
||||
setInvoicePreview(null)
|
||||
const interval = changePlanYearly ? 'year' : 'month'
|
||||
const limit = getLimitForTierIndex(changePlanTierIndex)
|
||||
previewInvoice({ plan_id: changePlanId, interval, limit })
|
||||
.then((res) => { if (!cancelled) setInvoicePreview(res ?? null) })
|
||||
.catch(() => { if (!cancelled) { setInvoicePreview(null) } })
|
||||
.finally(() => { if (!cancelled) setIsLoadingPreview(false) })
|
||||
return () => { cancelled = true }
|
||||
}, [showChangePlanModal, hasActiveSubscription, changePlanId, changePlanTierIndex, changePlanYearly])
|
||||
|
||||
// If no org ID, we are in personal organization context, so don't show org settings
|
||||
if (!currentOrgId) {
|
||||
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>
|
||||
</div>
|
||||
)
|
||||
@@ -382,7 +368,6 @@ export default function OrganizationSettings() {
|
||||
setChangePlanTierIndex(2)
|
||||
}
|
||||
setChangePlanYearly(subscription?.billing_interval === 'year')
|
||||
setInvoicePreview(null)
|
||||
setShowChangePlanModal(true)
|
||||
}
|
||||
|
||||
@@ -505,7 +490,7 @@ export default function OrganizationSettings() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<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">
|
||||
Manage your organization workspace and members.
|
||||
</p>
|
||||
@@ -595,8 +580,8 @@ export default function OrganizationSettings() {
|
||||
{activeTab === 'general' && (
|
||||
<div className="space-y-12">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-1">General Information</h2>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">Basic details about your organization.</p>
|
||||
<h2 className="text-2xl font-bold text-white mb-1">General Information</h2>
|
||||
<p className="text-sm text-neutral-400">Basic details about your organization.</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleUpdateOrg} className="space-y-4">
|
||||
@@ -612,7 +597,7 @@ export default function OrganizationSettings() {
|
||||
minLength={2}
|
||||
maxLength={50}
|
||||
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>
|
||||
|
||||
@@ -621,7 +606,7 @@ export default function OrganizationSettings() {
|
||||
Organization Slug
|
||||
</label>
|
||||
<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/
|
||||
</span>
|
||||
<Input
|
||||
@@ -632,10 +617,10 @@ export default function OrganizationSettings() {
|
||||
minLength={3}
|
||||
maxLength={30}
|
||||
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>
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
@@ -673,7 +658,7 @@ export default function OrganizationSettings() {
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<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 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">
|
||||
@@ -711,11 +696,11 @@ export default function OrganizationSettings() {
|
||||
<div className="space-y-12">
|
||||
{/* Invite Section */}
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-neutral-900 dark: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>
|
||||
<h2 className="text-2xl font-bold text-white mb-1">Organization Members</h2>
|
||||
<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">
|
||||
<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">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
@@ -759,12 +744,12 @@ export default function OrganizationSettings() {
|
||||
|
||||
{/* Members List */}
|
||||
<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">
|
||||
{isLoadingMembers ? (
|
||||
<MembersListSkeleton />
|
||||
) : 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) => (
|
||||
<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">
|
||||
@@ -773,10 +758,10 @@ export default function OrganizationSettings() {
|
||||
{member.user_email?.[0].toUpperCase() || '?'}
|
||||
</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'}
|
||||
</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))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -801,7 +786,7 @@ export default function OrganizationSettings() {
|
||||
{/* Pending Invitations */}
|
||||
{invitations.length > 0 && (
|
||||
<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">
|
||||
{invitations.map((invite) => (
|
||||
<div key={invite.id} className="p-4 flex items-center justify-between">
|
||||
@@ -810,10 +795,10 @@ export default function OrganizationSettings() {
|
||||
<div className="w-2 h-2 rounded-full bg-neutral-400 animate-pulse"></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}
|
||||
</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))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -836,8 +821,8 @@ export default function OrganizationSettings() {
|
||||
{activeTab === 'billing' && (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-neutral-900 dark: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>
|
||||
<h2 className="text-2xl font-bold text-white mb-1">Billing & Subscription</h2>
|
||||
<p className="text-sm text-neutral-400">Manage your plan, usage, and payment details.</p>
|
||||
</div>
|
||||
|
||||
{isLoadingSubscription ? (
|
||||
@@ -847,7 +832,7 @@ export default function OrganizationSettings() {
|
||||
</div>
|
||||
) : !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">
|
||||
<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>
|
||||
</div>
|
||||
) : (
|
||||
@@ -930,7 +915,7 @@ export default function OrganizationSettings() {
|
||||
{/* Plan header */}
|
||||
<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">
|
||||
<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
|
||||
</span>
|
||||
<span className={`px-2.5 py-0.5 rounded-full text-xs font-medium capitalize ${
|
||||
@@ -954,19 +939,15 @@ export default function OrganizationSettings() {
|
||||
Change plan
|
||||
</Button>
|
||||
</div>
|
||||
{(subscription.business_name || (subscription.tax_ids && subscription.tax_ids.length > 0)) && (
|
||||
<div className="px-6 pb-2 -mt-2 space-y-1 text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{(subscription.business_name || subscription.tax_id) && (
|
||||
<div className="px-6 pb-2 -mt-2 space-y-1 text-sm text-neutral-400">
|
||||
{subscription.business_name && (
|
||||
<div>Billing for: {subscription.business_name}</div>
|
||||
)}
|
||||
{subscription.tax_ids && subscription.tax_ids.length > 0 && (
|
||||
<div>
|
||||
Tax ID{subscription.tax_ids.length > 1 ? 's' : ''}:{' '}
|
||||
{subscription.tax_ids.map((t) => {
|
||||
const label = t.type === 'eu_vat' ? 'VAT' : t.type === 'us_ein' ? 'EIN' : t.type.replace(/_/g, ' ').toUpperCase()
|
||||
return `${label} ${t.value}${t.country ? ` (${t.country})` : ''}`
|
||||
}).join(', ')}
|
||||
</div>
|
||||
{subscription.tax_id && (
|
||||
<span>
|
||||
Tax ID: {subscription.tax_id.value} ({subscription.tax_id.type})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -975,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>
|
||||
<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'
|
||||
? (() => {
|
||||
const limit = getSitesLimitForPlan(subscription.plan_id)
|
||||
@@ -986,7 +967,7 @@ export default function OrganizationSettings() {
|
||||
</div>
|
||||
<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_usage.toLocaleString()} / ${subscription.pageview_limit.toLocaleString()}`
|
||||
: '—'}
|
||||
@@ -1012,26 +993,19 @@ export default function OrganizationSettings() {
|
||||
<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')}
|
||||
</div>
|
||||
<div className="text-lg font-semibold text-neutral-900 dark:text-white">
|
||||
<div className="text-lg font-semibold text-white">
|
||||
{(() => {
|
||||
const ts = subscription.next_invoice_period_end ?? subscription.current_period_end
|
||||
const d = ts ? new Date(typeof ts === 'number' ? ts * 1000 : ts) : null
|
||||
const dateStr = d && !Number.isNaN(d.getTime()) && d.getTime() !== 0
|
||||
const ts = subscription.current_period_end
|
||||
const d = ts ? new Date(ts) : null
|
||||
return d && !Number.isNaN(d.getTime()) && d.getTime() !== 0
|
||||
? formatDate(d)
|
||||
: '—'
|
||||
const amount = subscription.next_invoice_amount_due != null && subscription.next_invoice_currency
|
||||
? (subscription.next_invoice_amount_due / 100).toLocaleString('en-US', {
|
||||
style: 'currency',
|
||||
currency: subscription.next_invoice_currency.toUpperCase(),
|
||||
})
|
||||
: null
|
||||
return amount && dateStr !== '—' ? `${dateStr} for ${amount}` : dateStr
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
<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'}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1062,57 +1036,38 @@ export default function OrganizationSettings() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Invoice History */}
|
||||
{/* Order History */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white mb-3">Recent invoices</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">
|
||||
{isLoadingInvoices ? (
|
||||
<InvoicesListSkeleton />
|
||||
) : invoices.length === 0 ? (
|
||||
<div className="p-8 text-center text-neutral-500 dark:text-neutral-400">No invoices found.</div>
|
||||
) : orders.length === 0 ? (
|
||||
<div className="p-8 text-center text-neutral-400">No orders found.</div>
|
||||
) : (
|
||||
<>
|
||||
{invoices.map((invoice) => (
|
||||
<div key={invoice.id} className="px-4 py-3 flex items-center justify-between hover:bg-neutral-50 dark:hover:bg-neutral-800/50 transition-colors">
|
||||
{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 className="flex items-center gap-3">
|
||||
<div>
|
||||
<span className="font-medium text-sm text-neutral-900 dark:text-white">
|
||||
{(invoice.amount_paid / 100).toLocaleString('en-US', { style: 'currency', currency: invoice.currency.toUpperCase() })}
|
||||
<span className="font-medium text-sm text-white">
|
||||
{(order.total_amount / 100).toLocaleString('en-US', { style: 'currency', currency: order.currency.toUpperCase() })}
|
||||
</span>
|
||||
<span className="text-xs text-neutral-500 ml-2">
|
||||
{formatDate(new Date(invoice.created * 1000))}
|
||||
{formatDate(new Date(order.created_at))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium capitalize ${
|
||||
invoice.status === 'paid'
|
||||
order.status === 'paid'
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300'
|
||||
: invoice.status === 'open'
|
||||
: order.status === 'open'
|
||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'
|
||||
: 'bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300'
|
||||
}`}>
|
||||
{invoice.status}
|
||||
{order.status}
|
||||
</span>
|
||||
{invoice.invoice_pdf && (
|
||||
<a href={invoice.invoice_pdf} target="_blank" rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-2.5 py-1.5 text-xs font-medium text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange" title="Download PDF">
|
||||
<DownloadIcon className="w-3.5 h-3.5" />
|
||||
<span className="hidden sm:inline">Download</span> PDF
|
||||
</a>
|
||||
)}
|
||||
{invoice.hosted_invoice_url && (
|
||||
<a href={invoice.hosted_invoice_url} target="_blank" rel="noopener noreferrer"
|
||||
className={`inline-flex items-center gap-2 px-2.5 py-1.5 text-xs font-medium rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange ${
|
||||
invoice.status === 'open'
|
||||
? 'bg-brand-orange text-white hover:bg-brand-orange-hover'
|
||||
: 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800'
|
||||
}`}
|
||||
title={invoice.status === 'open' ? 'Pay now' : 'View invoice'}>
|
||||
<ExternalLinkIcon className="w-3.5 h-3.5" />
|
||||
{invoice.status === 'open' ? 'Pay now' : <><span className="hidden sm:inline">View </span>Invoice</>}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -1129,8 +1084,8 @@ export default function OrganizationSettings() {
|
||||
{activeTab === 'notifications' && (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-1">Notification Settings</h2>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-6">
|
||||
<h2 className="text-2xl font-bold text-white mb-1">Notification Settings</h2>
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
@@ -1139,7 +1094,7 @@ export default function OrganizationSettings() {
|
||||
<SettingsFormSkeleton />
|
||||
) : (
|
||||
<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">
|
||||
{notificationCategories.map((cat) => (
|
||||
<div
|
||||
@@ -1147,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"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-neutral-900 dark: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 font-medium text-white">{cat.label}</p>
|
||||
<p className="text-sm text-neutral-400 mt-0.5">{cat.description}</p>
|
||||
</div>
|
||||
<div className="flex items-center shrink-0">
|
||||
<button
|
||||
@@ -1194,8 +1149,8 @@ export default function OrganizationSettings() {
|
||||
{activeTab === 'audit' && (
|
||||
<div className="space-y-12">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-neutral-900 dark: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>
|
||||
<h2 className="text-2xl font-bold text-white mb-1">Audit log</h2>
|
||||
<p className="text-sm text-neutral-400">Who did what and when for this organization.</p>
|
||||
</div>
|
||||
|
||||
{/* Advanced Filters */}
|
||||
@@ -1208,7 +1163,7 @@ export default function OrganizationSettings() {
|
||||
placeholder="e.g. 8a2b3c"
|
||||
value={auditLogIdFilter}
|
||||
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 className="space-y-1">
|
||||
@@ -1218,7 +1173,7 @@ export default function OrganizationSettings() {
|
||||
placeholder="e.g. site_created"
|
||||
value={auditActionFilter}
|
||||
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 className="space-y-1">
|
||||
@@ -1227,7 +1182,7 @@ export default function OrganizationSettings() {
|
||||
type="date"
|
||||
value={auditStartDate}
|
||||
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 className="space-y-1">
|
||||
@@ -1236,7 +1191,7 @@ export default function OrganizationSettings() {
|
||||
type="date"
|
||||
value={auditEndDate}
|
||||
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>
|
||||
@@ -1285,10 +1240,10 @@ export default function OrganizationSettings() {
|
||||
<td className="px-4 py-3 text-neutral-600 dark:text-neutral-400 whitespace-nowrap">
|
||||
{formatDateTime(new Date(entry.occurred_at))}
|
||||
</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'}
|
||||
</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>
|
||||
</tr>
|
||||
))}
|
||||
@@ -1300,7 +1255,7 @@ export default function OrganizationSettings() {
|
||||
{/* Pagination */}
|
||||
{auditTotal > auditPageSize && (
|
||||
<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}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
@@ -1406,7 +1361,7 @@ export default function OrganizationSettings() {
|
||||
value={deleteConfirm}
|
||||
onChange={(e) => setDeleteConfirm(e.target.value)}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
@@ -1455,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"
|
||||
>
|
||||
<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
|
||||
onClick={() => setShowCancelPrompt(false)}
|
||||
className="text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-400"
|
||||
@@ -1509,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"
|
||||
>
|
||||
<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
|
||||
type="button"
|
||||
onClick={() => setShowChangePlanModal(false)}
|
||||
@@ -1545,7 +1500,7 @@ export default function OrganizationSettings() {
|
||||
: '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}
|
||||
</span>
|
||||
<span className="block text-xs text-neutral-500 mt-0.5">{plan.sites}</span>
|
||||
@@ -1564,7 +1519,7 @@ export default function OrganizationSettings() {
|
||||
<select
|
||||
value={changePlanTierIndex}
|
||||
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) => (
|
||||
<option key={tier.value} value={idx}>
|
||||
@@ -1595,26 +1550,9 @@ export default function OrganizationSettings() {
|
||||
</div>
|
||||
{hasActiveSubscription && (
|
||||
<div className="mt-4 p-4 rounded-lg bg-neutral-100 dark:bg-neutral-800/50 border border-neutral-200 dark:border-neutral-700">
|
||||
{isLoadingPreview ? (
|
||||
<div className="flex items-center gap-2 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
<Spinner className="w-4 h-4" />
|
||||
Calculating next invoice…
|
||||
</div>
|
||||
) : invoicePreview ? (
|
||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
Next invoice:{' '}
|
||||
{(invoicePreview.amount_due / 100).toLocaleString('en-US', {
|
||||
style: 'currency',
|
||||
currency: invoicePreview.currency.toUpperCase(),
|
||||
})}{' '}
|
||||
on {formatDate(new Date(invoicePreview.period_end * 1000))}{' '}
|
||||
<span className="text-neutral-500">(prorated)</span>
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
Unable to calculate preview. Your next invoice will reflect prorations.
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
Your plan will be updated. Any prorations will be reflected on your next invoice.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2 mt-6">
|
||||
|
||||
@@ -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 === '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'
|
||||
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 {
|
||||
@@ -120,8 +120,8 @@ export default function SecurityActivityCard() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-neutral-900 dark:text-white mb-1">Security Activity</h2>
|
||||
<p className="text-neutral-500 dark:text-neutral-400 text-sm mb-6">
|
||||
<h2 className="text-xl font-semibold text-white mb-1">Security Activity</h2>
|
||||
<p className="text-neutral-400 text-sm mb-6">
|
||||
Recent security events on your account{totalCount > 0 ? ` (${totalCount})` : ''}
|
||||
</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">
|
||||
<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>
|
||||
<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 className="space-y-2">
|
||||
@@ -165,11 +165,11 @@ export default function SecurityActivityCard() {
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<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}
|
||||
</span>
|
||||
{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}
|
||||
</span>
|
||||
)}
|
||||
@@ -179,7 +179,7 @@ export default function SecurityActivityCard() {
|
||||
</span>
|
||||
)}
|
||||
</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 && (deviceStr || entry.ip_address) && <span>·</span>}
|
||||
{deviceStr && <span>{deviceStr}</span>}
|
||||
@@ -189,7 +189,7 @@ export default function SecurityActivityCard() {
|
||||
</div>
|
||||
|
||||
<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)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -72,7 +72,7 @@ function NotificationCenterPlaceholder() {
|
||||
return (
|
||||
<div className="text-center max-w-md mx-auto py-8">
|
||||
<BellIcon className="w-12 h-12 text-neutral-300 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-neutral-900 dark:text-white mb-2">Notification Center</h3>
|
||||
<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>
|
||||
<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
|
||||
|
||||
@@ -56,8 +56,8 @@ export default function TrustedDevicesCard() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-neutral-900 dark:text-white mb-1">Trusted Devices</h2>
|
||||
<p className="text-neutral-500 dark:text-neutral-400 text-sm mb-6">
|
||||
<h2 className="text-xl font-semibold text-white mb-1">Trusted Devices</h2>
|
||||
<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.
|
||||
</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">
|
||||
<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>
|
||||
<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 className="space-y-2">
|
||||
@@ -83,7 +83,7 @@ export default function TrustedDevicesCard() {
|
||||
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"
|
||||
>
|
||||
<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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d={getDeviceIcon(device.display_hint)} />
|
||||
</svg>
|
||||
@@ -91,7 +91,7 @@ export default function TrustedDevicesCard() {
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<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'}
|
||||
</span>
|
||||
{device.is_current && (
|
||||
@@ -100,7 +100,7 @@ export default function TrustedDevicesCard() {
|
||||
</span>
|
||||
)}
|
||||
</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))}>
|
||||
First seen {formatRelativeTime(device.first_seen_at)}
|
||||
</span>
|
||||
|
||||
@@ -124,7 +124,7 @@ export default function DeleteSiteModal({ open, onClose, onDeleted, siteName, si
|
||||
value={deleteConfirm}
|
||||
onChange={(e) => setDeleteConfirm(e.target.value)}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
@@ -187,7 +187,7 @@ export default function DeleteSiteModal({ open, onClose, onDeleted, siteName, si
|
||||
value={permanentConfirm}
|
||||
onChange={(e) => setPermanentConfirm(e.target.value)}
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -167,7 +167,7 @@ export default function ScriptSetupBlock({
|
||||
|
||||
{/* ── Feature toggles ─────────────────────────────────────────────── */}
|
||||
<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
|
||||
</h4>
|
||||
<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"
|
||||
>
|
||||
<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}
|
||||
</span>
|
||||
<span className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
<span className="text-xs text-neutral-400">
|
||||
{f.description}
|
||||
</span>
|
||||
</div>
|
||||
@@ -191,10 +191,10 @@ export default function ScriptSetupBlock({
|
||||
{/* * 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="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
|
||||
</span>
|
||||
<span className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
<span className="text-xs text-neutral-400">
|
||||
Rage clicks & dead clicks · Loads separate add-on script
|
||||
</span>
|
||||
</div>
|
||||
@@ -204,15 +204,15 @@ export default function ScriptSetupBlock({
|
||||
|
||||
{/* ── Storage + TTL ───────────────────────────────────────────────── */}
|
||||
<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
|
||||
</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.
|
||||
</p>
|
||||
<div className="flex items-end gap-3">
|
||||
<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
|
||||
</label>
|
||||
<Select
|
||||
@@ -224,7 +224,7 @@ export default function ScriptSetupBlock({
|
||||
</div>
|
||||
{storage === 'local' && (
|
||||
<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
|
||||
</label>
|
||||
<Select
|
||||
@@ -242,14 +242,14 @@ export default function ScriptSetupBlock({
|
||||
{showFrameworkPicker && (
|
||||
<div className="mt-6">
|
||||
<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
|
||||
</h4>
|
||||
<Link
|
||||
href="/integrations"
|
||||
target="_blank"
|
||||
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 →
|
||||
</Link>
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { Stats } from '@/lib/api/stats'
|
||||
import { formatNumber } from '@ciphera-net/ui'
|
||||
import { BarChartIcon, SettingsIcon, BookOpenIcon, ExternalLinkIcon, Button } from '@ciphera-net/ui'
|
||||
import { useAuth } from '@/lib/auth/context'
|
||||
import { FAVICON_SERVICE_URL } from '@/lib/utils/icons'
|
||||
import { FAVICON_SERVICE_URL } from '@/lib/utils/favicon'
|
||||
|
||||
export type SiteStatsMap = Record<string, { stats: Stats }>
|
||||
|
||||
@@ -46,8 +46,8 @@ function SiteCard({ site, stats, statsLoading, onDelete, canDelete }: SiteCardPr
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-neutral-900 dark:text-white">{site.name}</h3>
|
||||
<div className="flex items-center gap-1 text-sm text-neutral-500 dark:text-neutral-400">
|
||||
<h3 className="font-semibold text-white">{site.name}</h3>
|
||||
<div className="flex items-center gap-1 text-sm text-neutral-400">
|
||||
{site.domain}
|
||||
<a
|
||||
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>
|
||||
<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)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<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)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -135,9 +135,17 @@ export default function SiteList({ sites, siteStats, loading, onDelete }: SiteLi
|
||||
|
||||
if (sites.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed border-neutral-300 dark:border-neutral-700 p-12 text-center">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark: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>
|
||||
<div className="rounded-2xl border border-dashed border-neutral-300 dark:border-neutral-700 p-12 text-center flex flex-col items-center">
|
||||
<Image
|
||||
src="/illustrations/setup-analytics.svg"
|
||||
alt="Set up your first site"
|
||||
width={280}
|
||||
height={210}
|
||||
className="mb-6"
|
||||
unoptimized
|
||||
/>
|
||||
<h3 className="text-lg font-semibold text-white">No sites yet</h3>
|
||||
<p className="mt-2 text-sm text-neutral-400 mb-4">Create your first site to get started.</p>
|
||||
<Link href="/sites/new">
|
||||
<Button variant="primary" className="text-sm">
|
||||
Add your first site
|
||||
@@ -168,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">
|
||||
<BookOpenIcon className="h-6 w-6 text-neutral-500" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-neutral-900 dark: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>
|
||||
<h3 className="font-semibold text-white">Need help setup?</h3>
|
||||
<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">
|
||||
Read Documentation →
|
||||
</Link>
|
||||
|
||||
@@ -105,7 +105,7 @@ export default function VerificationModal({ isOpen, onClose, site, onVerified }:
|
||||
>
|
||||
{/* Header */}
|
||||
<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
|
||||
</h3>
|
||||
<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>
|
||||
<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...
|
||||
</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}
|
||||
</p>
|
||||
</div>
|
||||
@@ -164,10 +164,10 @@ export default function VerificationModal({ isOpen, onClose, site, onVerified }:
|
||||
<CheckCircleIcon className="w-8 h-8" />
|
||||
</div>
|
||||
<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!
|
||||
</h4>
|
||||
<p className="text-neutral-500 dark:text-neutral-400">
|
||||
<p className="text-neutral-400">
|
||||
We are successfully receiving data from your website.
|
||||
</p>
|
||||
</div>
|
||||
@@ -189,7 +189,7 @@ export default function VerificationModal({ isOpen, onClose, site, onVerified }:
|
||||
</div>
|
||||
|
||||
<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:
|
||||
</p>
|
||||
<ul className="text-sm text-neutral-600 dark:text-neutral-400 space-y-1 list-disc list-inside">
|
||||
|
||||
@@ -125,7 +125,7 @@ export default function UtmBuilder({ initialSiteId }: UtmBuilderProps) {
|
||||
{/* Site Selector */}
|
||||
{sites.length > 0 && (
|
||||
<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
|
||||
value={selectedSiteId}
|
||||
onChange={handleSiteChange}
|
||||
@@ -138,7 +138,7 @@ export default function UtmBuilder({ initialSiteId }: UtmBuilderProps) {
|
||||
)}
|
||||
|
||||
<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 ? (
|
||||
<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">
|
||||
@@ -146,7 +146,7 @@ export default function UtmBuilder({ initialSiteId }: UtmBuilderProps) {
|
||||
</span>
|
||||
<input
|
||||
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"
|
||||
value={getCurrentPath()}
|
||||
onChange={handlePathChange}
|
||||
@@ -167,7 +167,7 @@ export default function UtmBuilder({ initialSiteId }: UtmBuilderProps) {
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<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
|
||||
name="source"
|
||||
placeholder="google, newsletter"
|
||||
@@ -176,7 +176,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">Medium *</label>
|
||||
<label className="block text-sm font-medium mb-1.5 text-white">Medium *</label>
|
||||
<Input
|
||||
name="medium"
|
||||
placeholder="cpc, email"
|
||||
@@ -186,7 +186,7 @@ export default function UtmBuilder({ initialSiteId }: UtmBuilderProps) {
|
||||
</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
|
||||
name="campaign"
|
||||
placeholder="spring_sale"
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
motion,
|
||||
useMotionTemplate,
|
||||
useSpring,
|
||||
} from "motion/react";
|
||||
} from "framer-motion";
|
||||
import {
|
||||
Children,
|
||||
createContext,
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
AnimatePresence,
|
||||
motion,
|
||||
useSpring,
|
||||
} from "motion/react";
|
||||
} from "framer-motion";
|
||||
import {
|
||||
Children,
|
||||
createContext,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { motion, useSpring, useTransform } from "motion/react";
|
||||
import { motion, useSpring, useTransform } from "framer-motion";
|
||||
import {
|
||||
type CSSProperties,
|
||||
type ReactNode,
|
||||
|
||||
@@ -2,8 +2,8 @@ import { authFetch } from './client'
|
||||
|
||||
export interface AdminOrgSummary {
|
||||
organization_id: string
|
||||
stripe_customer_id: string
|
||||
stripe_subscription_id: string
|
||||
billing_customer_id: string
|
||||
billing_subscription_id: string
|
||||
plan_id: string
|
||||
billing_interval: string
|
||||
pageview_limit: number
|
||||
|
||||
@@ -19,16 +19,10 @@ export interface SubscriptionDetails {
|
||||
sites_count?: number
|
||||
/** Pageviews in current billing period (when pageview_limit > 0). Present when backend supports usage API. */
|
||||
pageview_usage?: number
|
||||
/** Business name from Stripe Tax ID collection / business purchase flow (optional). */
|
||||
/** Business name from billing (optional). */
|
||||
business_name?: string
|
||||
/** Tax IDs collected on the Stripe customer (VAT, EIN, etc.) for invoice verification. */
|
||||
tax_ids?: TaxID[]
|
||||
/** Next invoice amount in cents (for "Renews on X for €Y" display). */
|
||||
next_invoice_amount_due?: number
|
||||
/** Currency for next invoice (e.g. eur). */
|
||||
next_invoice_currency?: string
|
||||
/** Unix timestamp when next invoice period ends. */
|
||||
next_invoice_period_end?: number
|
||||
/** Tax ID collected on the billing customer (VAT, EIN, etc.). */
|
||||
tax_id?: TaxID | null
|
||||
}
|
||||
|
||||
export async function getSubscription(): Promise<SubscriptionDetails> {
|
||||
@@ -66,22 +60,6 @@ export interface ChangePlanParams {
|
||||
limit: number
|
||||
}
|
||||
|
||||
export interface PreviewInvoiceResult {
|
||||
amount_due: number
|
||||
currency: string
|
||||
period_end: number
|
||||
}
|
||||
|
||||
export async function previewInvoice(params: ChangePlanParams): Promise<PreviewInvoiceResult | null> {
|
||||
const res = await apiRequest<PreviewInvoiceResult | Record<string, never>>('/api/billing/preview-invoice', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(params),
|
||||
})
|
||||
if (res && typeof res === 'object' && 'amount_due' in res && typeof (res as PreviewInvoiceResult).amount_due === 'number') {
|
||||
return res as PreviewInvoiceResult
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export async function changePlan(params: ChangePlanParams): Promise<{ ok: boolean }> {
|
||||
return apiRequest<{ ok: boolean }>('/api/billing/change-plan', {
|
||||
@@ -103,17 +81,18 @@ export async function createCheckoutSession(params: CreateCheckoutParams): Promi
|
||||
})
|
||||
}
|
||||
|
||||
export interface Invoice {
|
||||
export interface Order {
|
||||
id: string
|
||||
amount_paid: number
|
||||
amount_due: number
|
||||
total_amount: number
|
||||
subtotal_amount: number
|
||||
tax_amount: number
|
||||
currency: string
|
||||
status: string
|
||||
created: number
|
||||
hosted_invoice_url: string
|
||||
invoice_pdf: string
|
||||
created_at: string
|
||||
paid: boolean
|
||||
invoice_number: string
|
||||
}
|
||||
|
||||
export async function getInvoices(): Promise<Invoice[]> {
|
||||
return apiRequest<Invoice[]>('/api/billing/invoices')
|
||||
export async function getOrders(): Promise<Order[]> {
|
||||
return apiRequest<Order[]>('/api/billing/invoices')
|
||||
}
|
||||
|
||||
96
lib/api/pagespeed.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import apiRequest from './client'
|
||||
|
||||
// * Types for PageSpeed Insights monitoring
|
||||
|
||||
export interface PageSpeedConfig {
|
||||
site_id: string
|
||||
enabled: boolean
|
||||
frequency: 'daily' | 'weekly' | 'monthly'
|
||||
url: string | null
|
||||
next_check_at: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface AuditSummary {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
score: number | null
|
||||
display_value?: string
|
||||
savings_ms?: number
|
||||
category: 'opportunity' | 'diagnostic' | 'passed' | 'manual'
|
||||
group?: string // "performance", "accessibility", "best-practices", "seo"
|
||||
sub_group?: string // "a11y-names-labels", "a11y-contrast", etc.
|
||||
sub_group_title?: string // "Names and Labels", "Contrast", etc.
|
||||
details?: AuditDetailItem[]
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type AuditDetailItem = Record<string, any>
|
||||
|
||||
export interface FilmstripFrame {
|
||||
timing: number
|
||||
data: string
|
||||
}
|
||||
|
||||
export interface PageSpeedCheck {
|
||||
id: string
|
||||
site_id: string
|
||||
strategy: 'mobile' | 'desktop'
|
||||
performance_score: number | null
|
||||
accessibility_score: number | null
|
||||
best_practices_score: number | null
|
||||
seo_score: number | null
|
||||
lcp_ms: number | null
|
||||
cls: number | null
|
||||
tbt_ms: number | null
|
||||
fcp_ms: number | null
|
||||
si_ms: number | null
|
||||
tti_ms: number | null
|
||||
audits: AuditSummary[] | null
|
||||
screenshot?: string | null
|
||||
filmstrip?: FilmstripFrame[] | null
|
||||
triggered_by: 'scheduled' | 'manual'
|
||||
checked_at: string
|
||||
}
|
||||
|
||||
export async function getPageSpeedConfig(siteId: string): Promise<PageSpeedConfig> {
|
||||
return apiRequest<PageSpeedConfig>(`/sites/${siteId}/pagespeed/config`)
|
||||
}
|
||||
|
||||
export async function updatePageSpeedConfig(
|
||||
siteId: string,
|
||||
config: { enabled: boolean; frequency: string; url?: string }
|
||||
): Promise<PageSpeedConfig> {
|
||||
return apiRequest<PageSpeedConfig>(`/sites/${siteId}/pagespeed/config`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(config),
|
||||
})
|
||||
}
|
||||
|
||||
export async function getPageSpeedLatest(siteId: string): Promise<PageSpeedCheck[]> {
|
||||
const res = await apiRequest<{ checks: PageSpeedCheck[] }>(`/sites/${siteId}/pagespeed/latest`)
|
||||
return res?.checks ?? []
|
||||
}
|
||||
|
||||
export async function getPageSpeedHistory(
|
||||
siteId: string,
|
||||
strategy: 'mobile' | 'desktop' = 'mobile',
|
||||
days = 90
|
||||
): Promise<PageSpeedCheck[]> {
|
||||
const res = await apiRequest<{ checks: PageSpeedCheck[] }>(
|
||||
`/sites/${siteId}/pagespeed/history?strategy=${strategy}&days=${days}`
|
||||
)
|
||||
return res?.checks ?? []
|
||||
}
|
||||
|
||||
export async function getPageSpeedCheck(siteId: string, checkId: string): Promise<PageSpeedCheck> {
|
||||
return apiRequest<PageSpeedCheck>(`/sites/${siteId}/pagespeed/checks/${checkId}`)
|
||||
}
|
||||
|
||||
// * Triggers an async PageSpeed check. Returns immediately (202).
|
||||
// * Caller should poll getPageSpeedLatest() for results.
|
||||
export async function triggerPageSpeedCheck(siteId: string): Promise<void> {
|
||||
await apiRequest(`/sites/${siteId}/pagespeed/check`, { method: 'POST' })
|
||||
}
|
||||
@@ -2310,7 +2310,7 @@ export default defineConfig({
|
||||
</p>
|
||||
|
||||
<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
|
||||
</summary>
|
||||
<p className="mt-3 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
|
||||
@@ -31,6 +31,7 @@ import { getSite } from '@/lib/api/sites'
|
||||
import type { Site } from '@/lib/api/sites'
|
||||
import { listFunnels, type Funnel } from '@/lib/api/funnels'
|
||||
import { getUptimeStatus, type UptimeStatusResponse } from '@/lib/api/uptime'
|
||||
import { getPageSpeedConfig, getPageSpeedLatest, getPageSpeedHistory, type PageSpeedConfig, type PageSpeedCheck } from '@/lib/api/pagespeed'
|
||||
import { listGoals, type Goal } from '@/lib/api/goals'
|
||||
import { listReportSchedules, listAlertSchedules, type ReportSchedule } from '@/lib/api/report-schedules'
|
||||
import { listSessions, getBotFilterStats, type SessionSummary, type BotFilterStats } from '@/lib/api/bot-filter'
|
||||
@@ -79,6 +80,9 @@ const fetchers = {
|
||||
getJourneyEntryPoints(siteId, start, end),
|
||||
funnels: (siteId: string) => listFunnels(siteId),
|
||||
uptimeStatus: (siteId: string) => getUptimeStatus(siteId),
|
||||
pageSpeedConfig: (siteId: string) => getPageSpeedConfig(siteId),
|
||||
pageSpeedLatest: (siteId: string) => getPageSpeedLatest(siteId),
|
||||
pageSpeedHistory: (siteId: string, strategy: 'mobile' | 'desktop', days: number) => getPageSpeedHistory(siteId, strategy, days),
|
||||
goals: (siteId: string) => listGoals(siteId),
|
||||
reportSchedules: (siteId: string) => listReportSchedules(siteId),
|
||||
alertSchedules: (siteId: string) => listAlertSchedules(siteId),
|
||||
@@ -550,5 +554,32 @@ export function useBotFilterStats(siteId: string) {
|
||||
)
|
||||
}
|
||||
|
||||
// * Hook for PageSpeed config
|
||||
export function usePageSpeedConfig(siteId: string) {
|
||||
return useSWR<PageSpeedConfig>(
|
||||
siteId ? ['pageSpeedConfig', siteId] : null,
|
||||
() => fetchers.pageSpeedConfig(siteId),
|
||||
{ ...dashboardSWRConfig, refreshInterval: 0, dedupingInterval: 10 * 1000 }
|
||||
)
|
||||
}
|
||||
|
||||
// * Hook for latest PageSpeed checks (mobile + desktop)
|
||||
export function usePageSpeedLatest(siteId: string) {
|
||||
return useSWR<PageSpeedCheck[]>(
|
||||
siteId ? ['pageSpeedLatest', siteId] : null,
|
||||
() => fetchers.pageSpeedLatest(siteId),
|
||||
{ ...dashboardSWRConfig, refreshInterval: 60 * 1000, dedupingInterval: 10 * 1000, keepPreviousData: true }
|
||||
)
|
||||
}
|
||||
|
||||
// * Hook for PageSpeed score history (trend chart)
|
||||
export function usePageSpeedHistory(siteId: string, strategy: 'mobile' | 'desktop', days = 90) {
|
||||
return useSWR<PageSpeedCheck[]>(
|
||||
siteId ? ['pageSpeedHistory', siteId, strategy, days] : null,
|
||||
() => fetchers.pageSpeedHistory(siteId, strategy, days),
|
||||
{ ...dashboardSWRConfig, refreshInterval: 60 * 1000, dedupingInterval: 10 * 1000, keepPreviousData: true }
|
||||
)
|
||||
}
|
||||
|
||||
// * Re-export for convenience
|
||||
export { fetchers }
|
||||
|
||||
20
lib/utils/dateRanges.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { getDateRange, formatDate } from '@ciphera-net/ui'
|
||||
|
||||
/** Monday–today 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 month–today 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 }
|
||||
8
lib/utils/favicon.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Google's public favicon service base URL.
|
||||
* Append `?domain=<host>&sz=<px>` to get a favicon.
|
||||
*
|
||||
* Kept in a separate module so server components can import it
|
||||
* without pulling in the React-dependent icon registry.
|
||||
*/
|
||||
export const FAVICON_SERVICE_URL = 'https://www.google.com/s2/favicons'
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react'
|
||||
import React, { type ReactNode } from 'react'
|
||||
import {
|
||||
Globe,
|
||||
Question,
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
DeviceTablet,
|
||||
Desktop,
|
||||
Link,
|
||||
CursorClick,
|
||||
} from '@phosphor-icons/react'
|
||||
import {
|
||||
SiGoogle,
|
||||
@@ -42,11 +43,10 @@ function BingIcon({ size = 16, color = '#258FFA' }: { size?: number; color?: str
|
||||
return <svg width={size} height={size} viewBox="0 0 24 24" fill={color}><path d="M5.71 0v18.39l4.44 2.46 8.14-4.69v-4.71l-8.14-2.84V4.09L5.71 0zm4.44 11.19l4.39 1.53v2.78l-4.39 2.53v-6.84z"/></svg>
|
||||
}
|
||||
|
||||
/**
|
||||
* Google's public favicon service base URL.
|
||||
* Append `?domain=<host>&sz=<px>` to get a favicon.
|
||||
*/
|
||||
export const FAVICON_SERVICE_URL = 'https://www.google.com/s2/favicons'
|
||||
import { FAVICON_SERVICE_URL } from './favicon'
|
||||
export { FAVICON_SERVICE_URL }
|
||||
|
||||
// ─── Browser, OS, Device icons (unchanged) ───────────────────────────────────
|
||||
|
||||
const BROWSER_ICON_MAP: Record<string, { file: string; ext: 'svg' | 'png' }> = {
|
||||
'chrome': { file: 'chrome', ext: 'svg' },
|
||||
@@ -117,45 +117,94 @@ export function getDeviceIcon(deviceName: string) {
|
||||
return <Question className="text-neutral-400" />
|
||||
}
|
||||
|
||||
// ─── Referrer Registry ───────────────────────────────────────────────────────
|
||||
|
||||
const SI = { size: 16 } as const
|
||||
|
||||
export function getReferrerIcon(referrerName: string) {
|
||||
if (!referrerName) return <Globe className="text-neutral-400" />
|
||||
const lower = referrerName.toLowerCase()
|
||||
// Social / platforms
|
||||
if (lower.includes('google') && !lower.includes('gemini')) return <SiGoogle size={SI.size} color="#4285F4" />
|
||||
if (lower.includes('facebook')) return <SiFacebook size={SI.size} color="#0866FF" />
|
||||
if (lower.includes('twitter') || lower.includes('t.co') || lower.includes('x.com')) return <XIcon />
|
||||
if (lower.includes('linkedin')) return <LinkedInIcon />
|
||||
if (lower.includes('instagram')) return <SiInstagram size={SI.size} color="#E4405F" />
|
||||
if (lower.includes('github')) return <SiGithub size={SI.size} color="#fff" />
|
||||
if (lower.includes('youtube')) return <SiYoutube size={SI.size} color="#FF0000" />
|
||||
if (lower.includes('reddit')) return <SiReddit size={SI.size} color="#FF4500" />
|
||||
if (lower.includes('whatsapp')) return <SiWhatsapp size={SI.size} color="#25D366" />
|
||||
if (lower.includes('telegram')) return <SiTelegram size={SI.size} color="#26A5E4" />
|
||||
if (lower.includes('snapchat')) return <SiSnapchat size={SI.size} color="#FFFC00" />
|
||||
if (lower.includes('pinterest')) return <SiPinterest size={SI.size} color="#BD081C" />
|
||||
if (lower.includes('threads')) return <SiThreads size={SI.size} color="#fff" />
|
||||
if (lower.includes('discord')) return <SiDiscord size={SI.size} color="#5865F2" />
|
||||
// Search engines
|
||||
if (lower.includes('bing')) return <BingIcon />
|
||||
if (lower.includes('duckduckgo')) return <SiDuckduckgo size={SI.size} color="#DE5833" />
|
||||
if (lower.includes('brave')) return <SiBrave size={SI.size} color="#FB542B" />
|
||||
// AI assistants
|
||||
if (lower.includes('chatgpt') || lower.includes('openai')) return <OpenAIIcon />
|
||||
if (lower.includes('perplexity')) return <SiPerplexity size={SI.size} color="#1FB8CD" />
|
||||
if (lower.includes('claude') || lower.includes('anthropic')) return <SiAnthropic size={SI.size} color="#D97757" />
|
||||
if (lower.includes('gemini')) return <SiGooglegemini size={SI.size} color="#8E75B2" />
|
||||
if (lower.includes('copilot')) return <SiGithubcopilot size={SI.size} color="#fff" />
|
||||
if (lower.includes('deepseek')) return <OpenAIIcon color="#4D6BFE" />
|
||||
if (lower.includes('grok') || lower.includes('x.ai')) return <XIcon />
|
||||
// Shared Link
|
||||
if (lower === 'shared link') return <Link className="text-neutral-500" />
|
||||
|
||||
return <Globe className="text-neutral-400" />
|
||||
interface ReferrerEntry {
|
||||
display: string
|
||||
icon: () => ReactNode
|
||||
hostnames?: string[]
|
||||
aliases?: string[]
|
||||
}
|
||||
|
||||
const REFERRER_NO_FAVICON = ['direct', 'shared link', 'unknown', '']
|
||||
/**
|
||||
* Single source of truth for all known referrer brands.
|
||||
* Key = canonical label (what getReferrerLabel extracts from hostnames).
|
||||
* Adding a new brand = adding one entry here. Nothing else.
|
||||
*/
|
||||
const REFERRER_REGISTRY: Record<string, ReferrerEntry> = {
|
||||
// ── Special ──
|
||||
direct: { display: 'Direct', icon: () => <CursorClick className="text-neutral-500" /> },
|
||||
'shared link': { display: 'Shared Link', icon: () => <Link className="text-neutral-500" /> },
|
||||
|
||||
// ── Social / platforms ──
|
||||
google: { display: 'Google', icon: () => <SiGoogle size={SI.size} color="#4285F4" /> },
|
||||
facebook: { display: 'Facebook', icon: () => <SiFacebook size={SI.size} color="#0866FF" />, aliases: ['fb'] },
|
||||
x: { display: 'X', icon: () => <XIcon />, hostnames: ['t.co', 'x.com', 'twitter.com'] },
|
||||
linkedin: { display: 'LinkedIn', icon: () => <LinkedInIcon /> },
|
||||
instagram: { display: 'Instagram', icon: () => <SiInstagram size={SI.size} color="#E4405F" />, aliases: ['ig'] },
|
||||
github: { display: 'GitHub', icon: () => <SiGithub size={SI.size} color="#fff" /> },
|
||||
youtube: { display: 'YouTube', icon: () => <SiYoutube size={SI.size} color="#FF0000" />, aliases: ['yt'] },
|
||||
reddit: { display: 'Reddit', icon: () => <SiReddit size={SI.size} color="#FF4500" /> },
|
||||
whatsapp: { display: 'WhatsApp', icon: () => <SiWhatsapp size={SI.size} color="#25D366" /> },
|
||||
telegram: { display: 'Telegram', icon: () => <SiTelegram size={SI.size} color="#26A5E4" />, hostnames: ['t.me'] },
|
||||
snapchat: { display: 'Snapchat', icon: () => <SiSnapchat size={SI.size} color="#FFFC00" /> },
|
||||
pinterest: { display: 'Pinterest', icon: () => <SiPinterest size={SI.size} color="#BD081C" /> },
|
||||
threads: { display: 'Threads', icon: () => <SiThreads size={SI.size} color="#fff" /> },
|
||||
discord: { display: 'Discord', icon: () => <SiDiscord size={SI.size} color="#5865F2" /> },
|
||||
tumblr: { display: 'Tumblr', icon: () => <Globe className="text-neutral-400" /> },
|
||||
quora: { display: 'Quora', icon: () => <Globe className="text-neutral-400" /> },
|
||||
|
||||
// ── Search engines ──
|
||||
bing: { display: 'Bing', icon: () => <BingIcon /> },
|
||||
duckduckgo: { display: 'DuckDuckGo', icon: () => <SiDuckduckgo size={SI.size} color="#DE5833" /> },
|
||||
brave: { display: 'Brave', icon: () => <SiBrave size={SI.size} color="#FB542B" /> },
|
||||
|
||||
// ── AI assistants ──
|
||||
chatgpt: { display: 'ChatGPT', icon: () => <OpenAIIcon />, hostnames: ['chat.openai.com', 'openai.com'] },
|
||||
perplexity: { display: 'Perplexity', icon: () => <SiPerplexity size={SI.size} color="#1FB8CD" /> },
|
||||
claude: { display: 'Claude', icon: () => <SiAnthropic size={SI.size} color="#D97757" />, hostnames: ['anthropic.com'] },
|
||||
gemini: { display: 'Gemini', icon: () => <SiGooglegemini size={SI.size} color="#8E75B2" />, hostnames: ['gemini.google.com'] },
|
||||
copilot: { display: 'Copilot', icon: () => <SiGithubcopilot size={SI.size} color="#fff" />, hostnames: ['copilot.microsoft.com'] },
|
||||
deepseek: { display: 'DeepSeek', icon: () => <OpenAIIcon color="#4D6BFE" />, hostnames: ['chat.deepseek.com'] },
|
||||
grok: { display: 'Grok', icon: () => <XIcon />, hostnames: ['grok.x.ai', 'x.ai'] },
|
||||
you: { display: 'You.com', icon: () => <Globe className="text-neutral-400" /> },
|
||||
phind: { display: 'Phind', icon: () => <Globe className="text-neutral-400" /> },
|
||||
|
||||
// ── Browsers as referrers ──
|
||||
googlechrome: { display: 'Google Chrome', icon: () => <img src="/icons/browsers/chrome.svg" alt="Chrome" width={16} height={16} className="inline-block" />, hostnames: ['googlechrome.github.io'] },
|
||||
}
|
||||
|
||||
// ── Derived lookup maps (built once at module load) ──
|
||||
|
||||
/** alias → registry key (e.g. "ig" → "instagram", "fb" → "facebook") */
|
||||
const ALIAS_TO_KEY: Record<string, string> = {}
|
||||
|
||||
/** exact hostname → registry key (e.g. "t.co" → "x", "t.me" → "telegram") */
|
||||
const HOSTNAME_TO_KEY: Record<string, string> = {}
|
||||
|
||||
/** All known hostnames — union of auto-derived (key + ".com") and explicit hostnames */
|
||||
const ALL_KNOWN_HOSTNAMES = new Set<string>()
|
||||
|
||||
for (const [key, entry] of Object.entries(REFERRER_REGISTRY)) {
|
||||
if (entry.aliases) {
|
||||
for (const alias of entry.aliases) {
|
||||
ALIAS_TO_KEY[alias] = key
|
||||
}
|
||||
}
|
||||
if (entry.hostnames) {
|
||||
for (const hostname of entry.hostnames) {
|
||||
HOSTNAME_TO_KEY[hostname] = key
|
||||
ALL_KNOWN_HOSTNAMES.add(hostname)
|
||||
}
|
||||
}
|
||||
// Auto-derive common hostnames from the key itself
|
||||
ALL_KNOWN_HOSTNAMES.add(`${key}.com`)
|
||||
ALL_KNOWN_HOSTNAMES.add(`www.${key}.com`)
|
||||
}
|
||||
|
||||
// ── Referrer resolution ──
|
||||
|
||||
/** Common subdomains to skip when deriving the main label (e.g. l.instagram.com → instagram). */
|
||||
const REFERRER_SUBDOMAIN_SKIP = new Set([
|
||||
@@ -163,55 +212,7 @@ const REFERRER_SUBDOMAIN_SKIP = new Set([
|
||||
'sub', 'api', 'static', 'cdn', 'blog', 'shop', 'support', 'help', 'link',
|
||||
])
|
||||
|
||||
/**
|
||||
* Override map for display names when the heuristic would be wrong (casing or brand alias).
|
||||
* Keys: lowercase label or hostname. Values: exact display name.
|
||||
*/
|
||||
const REFERRER_DISPLAY_OVERRIDES: Record<string, string> = {
|
||||
chatgpt: 'ChatGPT',
|
||||
linkedin: 'LinkedIn',
|
||||
youtube: 'YouTube',
|
||||
reddit: 'Reddit',
|
||||
github: 'GitHub',
|
||||
bing: 'Bing',
|
||||
brave: 'Brave',
|
||||
duckduckgo: 'DuckDuckGo',
|
||||
whatsapp: 'WhatsApp',
|
||||
telegram: 'Telegram',
|
||||
pinterest: 'Pinterest',
|
||||
snapchat: 'Snapchat',
|
||||
threads: 'Threads',
|
||||
tumblr: 'Tumblr',
|
||||
quora: 'Quora',
|
||||
't.co': 'X',
|
||||
'x.com': 'X',
|
||||
// AI assistants and search tools
|
||||
openai: 'ChatGPT',
|
||||
perplexity: 'Perplexity',
|
||||
claude: 'Claude',
|
||||
anthropic: 'Claude',
|
||||
gemini: 'Gemini',
|
||||
copilot: 'Copilot',
|
||||
deepseek: 'DeepSeek',
|
||||
grok: 'Grok',
|
||||
'you': 'You.com',
|
||||
phind: 'Phind',
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the hostname for a referrer string (URL or plain hostname), or null if invalid.
|
||||
*/
|
||||
function getReferrerHostname(referrer: string): string | null {
|
||||
if (!referrer || typeof referrer !== 'string') return null
|
||||
const trimmed = referrer.trim()
|
||||
if (REFERRER_NO_FAVICON.includes(trimmed.toLowerCase())) return null
|
||||
try {
|
||||
const url = new URL(trimmed.startsWith('http') ? trimmed : `https://${trimmed}`)
|
||||
return url.hostname.toLowerCase()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
const REFERRER_NO_FAVICON = new Set(['direct', 'shared link', 'unknown', ''])
|
||||
|
||||
/**
|
||||
* Derives the main label from a hostname (e.g. "l.instagram.com" → "instagram", "google.com" → "google").
|
||||
@@ -225,27 +226,91 @@ function getReferrerLabel(hostname: string): string {
|
||||
return parts[0] ?? withoutWww
|
||||
}
|
||||
|
||||
function getReferrerHostname(referrer: string): string | null {
|
||||
if (!referrer || typeof referrer !== 'string') return null
|
||||
const trimmed = referrer.trim()
|
||||
if (REFERRER_NO_FAVICON.has(trimmed.toLowerCase())) return null
|
||||
try {
|
||||
const url = new URL(trimmed.startsWith('http') ? trimmed : `https://${trimmed}`)
|
||||
return url.hostname.toLowerCase()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a raw referrer string to a registry entry.
|
||||
* Returns null if no known brand matches (unknown domain → use favicon service).
|
||||
*/
|
||||
function resolveReferrer(referrer: string): ReferrerEntry | null {
|
||||
if (!referrer || typeof referrer !== 'string') return null
|
||||
const lower = referrer.trim().toLowerCase()
|
||||
|
||||
// 1. Exact registry key match (e.g. "Direct", "Reddit", "Google")
|
||||
if (REFERRER_REGISTRY[lower]) return REFERRER_REGISTRY[lower]
|
||||
|
||||
// 2. Alias match (e.g. "ig" → instagram, "fb" → facebook)
|
||||
const aliasKey = ALIAS_TO_KEY[lower]
|
||||
if (aliasKey) return REFERRER_REGISTRY[aliasKey]
|
||||
|
||||
// 3. Hostname-based matching
|
||||
const hostname = getReferrerHostname(referrer)
|
||||
if (!hostname) return null
|
||||
|
||||
// 3a. Exact hostname match (e.g. "t.co" → x, "t.me" → telegram)
|
||||
const hostnameKey = HOSTNAME_TO_KEY[hostname]
|
||||
if (hostnameKey) return REFERRER_REGISTRY[hostnameKey]
|
||||
|
||||
// 3b. Label-based lookup (e.g. "old.reddit.com" → label "reddit" → registry hit)
|
||||
const label = getReferrerLabel(hostname)
|
||||
if (REFERRER_REGISTRY[label]) return REFERRER_REGISTRY[label]
|
||||
|
||||
// 3c. Check alias from label (e.g. hostname "ig.something.com" → label "ig" → alias → instagram)
|
||||
const labelAliasKey = ALIAS_TO_KEY[label]
|
||||
if (labelAliasKey) return REFERRER_REGISTRY[labelAliasKey]
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// ── Public API (same signatures as before) ──
|
||||
|
||||
export function getReferrerIcon(referrerName: string): ReactNode {
|
||||
if (!referrerName) return <Globe className="text-neutral-400" />
|
||||
const entry = resolveReferrer(referrerName)
|
||||
if (entry) return entry.icon()
|
||||
return <Globe className="text-neutral-400" />
|
||||
}
|
||||
|
||||
function capitalizeLabel(label: string): string {
|
||||
if (!label) return label
|
||||
return label.charAt(0).toUpperCase() + label.slice(1).toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a friendly display name for the referrer (e.g. "Google" instead of "google.com").
|
||||
* Uses a heuristic (hostname → main label → capitalize) plus a small override map for famous brands.
|
||||
*/
|
||||
export function getReferrerDisplayName(referrer: string): string {
|
||||
if (!referrer || typeof referrer !== 'string') return referrer || ''
|
||||
const trimmed = referrer.trim()
|
||||
if (trimmed === '') return ''
|
||||
const entry = resolveReferrer(trimmed)
|
||||
if (entry) return entry.display
|
||||
// Unknown referrer — derive display name from hostname
|
||||
const hostname = getReferrerHostname(trimmed)
|
||||
if (!hostname) return trimmed
|
||||
const overrideByHostname = REFERRER_DISPLAY_OVERRIDES[hostname]
|
||||
if (overrideByHostname) return overrideByHostname
|
||||
const label = getReferrerLabel(hostname)
|
||||
const overrideByLabel = REFERRER_DISPLAY_OVERRIDES[label]
|
||||
if (overrideByLabel) return overrideByLabel
|
||||
return capitalizeLabel(label)
|
||||
return capitalizeLabel(getReferrerLabel(hostname))
|
||||
}
|
||||
|
||||
export function getReferrerFavicon(referrer: string): string | null {
|
||||
if (!referrer || typeof referrer !== 'string') return null
|
||||
const normalized = referrer.trim().toLowerCase()
|
||||
if (REFERRER_NO_FAVICON.has(normalized)) return null
|
||||
if (!normalized.includes('.')) return null
|
||||
// Known brand → skip favicon service, use registry icon
|
||||
if (resolveReferrer(referrer)) return null
|
||||
try {
|
||||
const url = new URL(referrer.startsWith('http') ? referrer : `https://${referrer}`)
|
||||
return `${FAVICON_SERVICE_URL}?domain=${url.hostname.toLowerCase()}&sz=32`
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -273,62 +338,3 @@ export function mergeReferrersByDisplayName(
|
||||
.map(({ referrer, pageviews }) => ({ referrer, pageviews }))
|
||||
.sort((a, b) => b.pageviews - a.pageviews)
|
||||
}
|
||||
|
||||
/**
|
||||
* Domains/labels where the Phosphor icon is better than Google's favicon service.
|
||||
* For these, getReferrerFavicon returns null so the caller falls back to getReferrerIcon.
|
||||
*/
|
||||
const REFERRER_PREFER_ICON = new Set([
|
||||
// Social / platforms
|
||||
't.co', 'x.com', 'twitter.com', 'www.twitter.com',
|
||||
'google.com', 'www.google.com',
|
||||
'facebook.com', 'www.facebook.com', 'm.facebook.com', 'l.facebook.com',
|
||||
'instagram.com', 'www.instagram.com', 'l.instagram.com',
|
||||
'linkedin.com', 'www.linkedin.com',
|
||||
'github.com', 'www.github.com',
|
||||
'youtube.com', 'www.youtube.com', 'm.youtube.com',
|
||||
'reddit.com', 'www.reddit.com', 'old.reddit.com',
|
||||
'whatsapp.com', 'www.whatsapp.com', 'web.whatsapp.com',
|
||||
'telegram.org', 'web.telegram.org', 't.me',
|
||||
'snapchat.com', 'www.snapchat.com',
|
||||
'pinterest.com', 'www.pinterest.com',
|
||||
'threads.net', 'www.threads.net',
|
||||
// Search engines
|
||||
'bing.com', 'www.bing.com',
|
||||
'duckduckgo.com', 'www.duckduckgo.com',
|
||||
'search.brave.com', 'brave.com',
|
||||
// AI assistants
|
||||
'chatgpt.com', 'chat.openai.com', 'openai.com',
|
||||
'perplexity.ai', 'www.perplexity.ai',
|
||||
'claude.ai', 'www.claude.ai', 'anthropic.com',
|
||||
'gemini.google.com',
|
||||
'copilot.microsoft.com',
|
||||
'deepseek.com', 'chat.deepseek.com',
|
||||
'grok.x.ai', 'x.ai',
|
||||
'phind.com', 'www.phind.com',
|
||||
'you.com', 'www.you.com',
|
||||
])
|
||||
|
||||
/**
|
||||
* Returns a favicon URL for the referrer's domain, or null for non-URL referrers
|
||||
* (e.g. "Direct", "Unknown") or known services where the Phosphor icon is better.
|
||||
*/
|
||||
export function getReferrerFavicon(referrer: string): string | null {
|
||||
if (!referrer || typeof referrer !== 'string') return null
|
||||
const normalized = referrer.trim().toLowerCase()
|
||||
if (REFERRER_NO_FAVICON.includes(normalized)) return null
|
||||
// Plain names without a dot (e.g. "Instagram", "WhatsApp") are not real domains
|
||||
if (!normalized.includes('.')) return null
|
||||
try {
|
||||
const url = new URL(referrer.startsWith('http') ? referrer : `https://${referrer}`)
|
||||
const hostname = url.hostname.toLowerCase()
|
||||
// Use Phosphor icon for known services — Google favicons are unreliable for these
|
||||
if (REFERRER_PREFER_ICON.has(hostname)) return null
|
||||
// Also check if the label matches a known referrer (catches subdomains like search.google.com)
|
||||
const label = getReferrerLabel(hostname)
|
||||
if (REFERRER_DISPLAY_OVERRIDES[label]) return null
|
||||
return `${FAVICON_SERVICE_URL}?domain=${hostname}&sz=32`
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,8 +20,6 @@
|
||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@simplewebauthn/browser": "^13.2.2",
|
||||
"@stripe/react-stripe-js": "^5.6.0",
|
||||
"@stripe/stripe-js": "^8.7.0",
|
||||
"@tanstack/react-virtual": "^3.13.21",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@visx/curve": "^3.12.0",
|
||||
@@ -44,7 +42,6 @@
|
||||
"jspdf": "^4.0.0",
|
||||
"jspdf-autotable": "^5.0.7",
|
||||
"lucide-react": "^0.577.0",
|
||||
"motion": "^12.35.2",
|
||||
"next": "^16.1.1",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.3",
|
||||
|
||||
30
public/illustrations/blank-canvas.svg
Normal file
@@ -0,0 +1,30 @@
|
||||
<svg data-name="Layer 1" viewBox="0 0 550.71039 567.98584" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<path d="M711.24677,733.99292H560.52137a6.616,6.616,0,0,1-6.60858-6.60839V622.00475a6.616,6.616,0,0,1,6.60858-6.60858h150.7254a6.616,6.616,0,0,1,6.60839,6.60858V727.38453A6.6161,6.6161,0,0,1,711.24677,733.99292Z" fill="#2a2a2a" transform="translate(-324.64481 -166.00708)"/>
|
||||
<path d="M464.93162,459.293a11.22645,11.22645,0,0,0-3.595-16.83486l-.94985-100.813-18.84219,8.64691,4.1444,97.50067A11.2873,11.2873,0,0,0,464.93162,459.293Z" fill="#9e616a" transform="translate(-324.64481 -166.00708)"/>
|
||||
<path d="M464.42409,316.12137s-18.32929-8.89112-20.86418,7.88369-12.96145,60.43336-12.96145,60.43336l31.63926-2.23768Z" fill="#9e616a" transform="translate(-324.64481 -166.00708)"/>
|
||||
<path d="M464.93162,459.293a11.22645,11.22645,0,0,0-3.595-16.83486l-.94985-100.813-18.84219,8.64691,4.1444,97.50067A11.2873,11.2873,0,0,0,464.93162,459.293Z" fill="#9e616a" transform="translate(-324.64481 -166.00708)"/>
|
||||
<path d="M464.42409,316.12137s-18.32929-8.89112-20.86418,7.88369-12.96145,60.43336-12.96145,60.43336l31.63926-2.23768Z" fill="#FD5E0F" transform="translate(-324.64481 -166.00708)"/>
|
||||
<path d="M411.35263,704.46626a3.61323,3.61323,0,0,1-2.61865-6.26262c.09111-.36213.15647-.62217.24758-.9843q-.0489-.11821-.09837-.23628a9.70311,9.70311,0,0,0-17.89849.06652c-2.92739,7.05051-6.65447,14.11307-7.57216,21.5678a28.7054,28.7054,0,0,0,.5039,9.87234,115.08614,115.08614,0,0,1-10.46893-47.79893,111.07991,111.07991,0,0,1,.689-12.392q.5708-5.05966,1.58377-10.0473a116.4192,116.4192,0,0,1,23.087-49.34152,30.9826,30.9826,0,0,0,12.88556-13.36893,23.6336,23.6336,0,0,0,2.14933-6.45821c-.62729.08228-1.26489.13369-1.89217.17479-.19543.01023-.40108.02055-.59651.03087l-.07369.0033a3.57989,3.57989,0,0,1-2.9401-5.83225q.40627-.5.81305-.99948c.4114-.51423.833-1.01814,1.24434-1.53228a1.7836,1.7836,0,0,0,.13369-.15432c.47313-.58619.94609-1.16206,1.41922-1.74825a10.35176,10.35176,0,0,0-3.39367-3.28044c-4.74083-2.77661-11.28133-.85358-14.70586,3.43476-3.43476,4.28826-4.0826,10.30438-2.88976,15.66218a41.48513,41.48513,0,0,0,5.73842,12.793c-.25715.32912-.52454.64792-.78161.977a117.17121,117.17121,0,0,0-12.22973,19.37481,48.70929,48.70929,0,0,0-2.908-22.62447c-2.78346-6.71479-8.00064-12.37-12.595-18.17495-5.51857-6.97261-16.83488-3.9296-17.80713,4.90927q-.01412.12837-.02757.25665,1.02363.57749,2.004,1.22586a4.9011,4.9011,0,0,1-1.976,8.91908l-.09994.01543a48.7668,48.7668,0,0,0,1.28544,7.29124A50.20988,50.20988,0,0,0,376.56347,641.273c.40108.20565.79193.41131,1.193.60673a119.598,119.598,0,0,0-6.43767,30.296,113.43525,113.43525,0,0,0,.08228,18.31542l-.03086-.216a29.97408,29.97408,0,0,0-10.23241-17.3076c-7.87438-6.46853-18.9994-8.8505-27.49446-14.04994a5.62528,5.62528,0,0,0-8.61571,5.47252q.01709.11352.03474.227a32.92633,32.92633,0,0,1,3.69184,1.779q1.02362.57761,2.004,1.22585a4.90116,4.90116,0,0,1-1.976,8.91917l-.1.01535c-.072.01031-.13369.02063-.20557.03094a48.80758,48.80758,0,0,0,8.97767,14.05786A50.25446,50.25446,0,0,0,373.9,706.63672h.01032a119.56344,119.56344,0,0,0,8.03167,23.447h28.69167c.10291-.3188.19542-.64792.288-.96672a32.59925,32.59925,0,0,1-7.93916-.473c2.12878-2.61214,4.25747-5.24483,6.38625-7.85688a1.78139,1.78139,0,0,0,.1337-.15424c1.07978-1.33685,2.16987-2.66347,3.24965-4.00032l.00058-.00165a47.75027,47.75027,0,0,0-1.39916-12.16412Z" fill="#2a2a2a" transform="translate(-324.64481 -166.00708)"/>
|
||||
<path d="M834.35263,704.46626a3.61323,3.61323,0,0,1-2.61865-6.26262c.09111-.36213.15647-.62217.24758-.9843q-.0489-.11821-.09837-.23628a9.70311,9.70311,0,0,0-17.89849.06652c-2.92739,7.05051-6.65447,14.11307-7.57216,21.5678a28.7054,28.7054,0,0,0,.5039,9.87234,115.08614,115.08614,0,0,1-10.46893-47.79893,111.07991,111.07991,0,0,1,.689-12.392q.5708-5.05966,1.58377-10.0473a116.4192,116.4192,0,0,1,23.087-49.34152,30.9826,30.9826,0,0,0,12.88556-13.36893,23.6336,23.6336,0,0,0,2.14933-6.45821c-.62729.08228-1.26489.13369-1.89217.17479-.19543.01023-.40108.02055-.59651.03087l-.07369.0033a3.57989,3.57989,0,0,1-2.9401-5.83225q.40627-.5.813-.99948c.4114-.51423.833-1.01814,1.24434-1.53228a1.7836,1.7836,0,0,0,.13369-.15432c.47313-.58619.94609-1.16206,1.41922-1.74825a10.35176,10.35176,0,0,0-3.39367-3.28044c-4.74083-2.77661-11.28133-.85358-14.70586,3.43476-3.43476,4.28826-4.0826,10.30438-2.88976,15.66218a41.48513,41.48513,0,0,0,5.73842,12.793c-.25715.32912-.52454.64792-.78161.977a117.17121,117.17121,0,0,0-12.22973,19.37481,48.70929,48.70929,0,0,0-2.908-22.62447c-2.78346-6.71479-8.00064-12.37-12.595-18.17495-5.51857-6.97261-16.83488-3.9296-17.80713,4.90927q-.01412.12837-.02757.25665,1.02363.57749,2.004,1.22586a4.9011,4.9011,0,0,1-1.976,8.91908l-.09994.01543a48.7668,48.7668,0,0,0,1.28544,7.29124A50.20988,50.20988,0,0,0,799.56347,641.273c.40108.20565.79193.41131,1.193.60673a119.598,119.598,0,0,0-6.43767,30.296,113.43525,113.43525,0,0,0,.08228,18.31542l-.03086-.216a29.97408,29.97408,0,0,0-10.23241-17.3076c-7.87438-6.46853-18.9994-8.8505-27.49446-14.04994a5.62528,5.62528,0,0,0-8.61571,5.47252q.01708.11352.03474.227a32.92633,32.92633,0,0,1,3.69184,1.779q1.02362.57761,2.004,1.22585a4.90116,4.90116,0,0,1-1.976,8.91917l-.1.01535c-.072.01031-.13369.02063-.20557.03094a48.80758,48.80758,0,0,0,8.97767,14.05786A50.25446,50.25446,0,0,0,796.9,706.63672h.01032a119.56344,119.56344,0,0,0,8.03167,23.447h28.69167c.10291-.3188.19542-.64792.288-.96672a32.59925,32.59925,0,0,1-7.93916-.473c2.12878-2.61214,4.25747-5.24483,6.38625-7.85688a1.78139,1.78139,0,0,0,.1337-.15424c1.07978-1.33685,2.16987-2.66347,3.24965-4.00032l.00058-.00165a47.75027,47.75027,0,0,0-1.39916-12.16412Z" fill="#2a2a2a" transform="translate(-324.64481 -166.00708)"/>
|
||||
<path d="M723.86447,729.89617c-.08252,0-.165-.00195-.24756-.0039a8.169,8.169,0,0,1-7.70215-8.25488c0-12.79493-9.13965-20.25879-18.19726-21.9795-9.05762-1.71972-20.29785,1.8711-24.98926,13.77637l-4.26514,10.707a19.67152,19.67152,0,0,1-27.78662,0l-.25244-.252.15674-.32129,75.334-154.13379v-3.77734a19.488,19.488,0,0,1,.002-3.5l-.002-388.20313a7.94627,7.94627,0,0,1,8.19043-7.94287,8.16941,8.16941,0,0,1,7.70264,8.25488v387.936a19.489,19.489,0,0,1-.00195,3.5l.00195,3.73144,75.49072,154.45606-.25244.252a19.67152,19.67152,0,0,1-27.78662,0l-.11182-.16992-4.15332-10.53711c-4.6914-11.9043-15.93017-15.49316-24.98926-13.77637-9.05761,1.72071-18.19726,9.18457-18.19726,21.9795v.3125a7.94652,7.94652,0,0,1-7.94336,7.94628Z" fill="#404040" transform="translate(-324.64481 -166.00708)"/>
|
||||
<path d="M861.76287,446.99292H585.95965A12.10626,12.10626,0,0,1,573.867,434.90063v-192.828A12.10611,12.10611,0,0,1,585.95965,229.98H861.76287a12.10626,12.10626,0,0,1,12.09229,12.09264v192.828A12.10641,12.10641,0,0,1,861.76287,446.99292Z" fill="#262626" transform="translate(-324.64481 -166.00708)"/>
|
||||
<path d="M861.76291,448.49285H585.95968a13.60791,13.60791,0,0,1-13.59277-13.59179V242.07244a13.608,13.608,0,0,1,13.59277-13.59228H861.76291a13.60791,13.60791,0,0,1,13.59228,13.59228V434.90106A13.6078,13.6078,0,0,1,861.76291,448.49285ZM585.95968,231.48016a10.60442,10.60442,0,0,0-10.59277,10.59228V434.90106a10.60463,10.60463,0,0,0,10.59277,10.59179H861.76291a10.60421,10.60421,0,0,0,10.59228-10.59179V242.07244a10.604,10.604,0,0,0-10.59228-10.59228Z" fill="#2a2a2a" transform="translate(-324.64481 -166.00708)"/>
|
||||
<path d="M764.63979,242.0363H683.08263a19.52487,19.52487,0,0,1-19.5028-19.5028v-.35459H784.14258v.35459A19.52486,19.52486,0,0,1,764.63979,242.0363Z" fill="#404040" transform="translate(-324.64481 -166.00708)"/>
|
||||
<path d="M764.99438,459.04925H683.43723a19.52509,19.52509,0,0,1-19.5028-19.5028v-.35459H784.49718v.35459A19.52509,19.52509,0,0,1,764.99438,459.04925Z" fill="#404040" transform="translate(-324.64481 -166.00708)"/>
|
||||
<path d="M874.31485,730.88939a1.18647,1.18647,0,0,1-1.19006,1.19h-547.29a1.19,1.19,0,0,1,0-2.38h547.29A1.18651,1.18651,0,0,1,874.31485,730.88939Z" fill="#525252" transform="translate(-324.64481 -166.00708)"/>
|
||||
<polygon fill="#9e616a" points="184.306 537.181 198.722 537.181 205.58 481.575 184.303 481.576 184.306 537.181"/>
|
||||
<path d="M504.06863,696.07669l22.78907-1.3602v9.76383l21.6662,14.96345a6.09886,6.09886,0,0,1-3.46558,11.11765H517.92709l-4.67648-9.65792-1.82593,9.65792H501.19512Z" fill="#a3a3a3" transform="translate(-324.64481 -166.00708)"/>
|
||||
<polygon fill="#9e616a" points="135.277 227.892 133.865 234.622 132.664 259.447 180.908 252.915 184.613 224.169 179.492 215.755 135.277 227.892"/>
|
||||
<polygon fill="#9e616a" points="111.347 537.181 125.763 537.181 132.621 481.575 111.344 481.576 111.347 537.181"/>
|
||||
<path d="M428.1707,604.94848l-.00054-.14891,5.39635-25.192,3.32751-12.54128-9.24939-73.42936c-2.17782-53.34848,28.7723-78.9931,29.01588-79.24519l.67116-.69775,49.63579-1.2147c26.70628,16.00123,23.683,103.51941,28.57341,163.039.34872,4.24414,4.73124,10.50859,3.654,14.54583-8.54582,32.02677-6.04255,66.6612-12.553,67.685l-19.96125-.27744-10.30939-46.78874,9.51636-35.68634c-4.22057-.77752-7.13727-21.41179-7.13727-21.41179-1.83905-2.18368-8.1881-20.75593-8.1881-20.75593l-8.46552-51.40977-13.601,88.42417,3.27551,20.74438-2.71071,7.13285-11.60823,58.88563-.12339.94878L435.44348,655.37Z" fill="#a3a3a3" transform="translate(-324.64481 -166.00708)"/>
|
||||
<path d="M431.10991,696.07669l22.78906-1.3602v9.76383l21.6662,14.96345a6.09886,6.09886,0,0,1-3.46557,11.11765H444.96836l-4.67648-9.65792-1.82593,9.65792H428.23639Z" fill="#a3a3a3" transform="translate(-324.64481 -166.00708)"/>
|
||||
<path d="M499.72323,296.81336l12.16793,13.51992,14.61521,27.54745-7.17925,33.96819,3.13895,15.93971,4.635,17.52209c-17.8595,9.87926-39.074-3.76443-58.5635-9.82419l-19.176,8.4722,1.014-23.32186.52-58.76042a16.46939,16.46939,0,0,1,17.73187-16.27514h0l12.84392-9.464Z" fill="#FD5E0F" transform="translate(-324.64481 -166.00708)"/>
|
||||
<circle cx="171.67023" cy="98.07194" fill="#9e616a" r="25.65727"/>
|
||||
<path d="M526.50637,254.81959c-.452,2.51394-11.2531,3.72725-10.928-.74545-2.10149,4.211-5.24175,10.322-1.24175,17.322a10.98281,10.98281,0,0,0-1.48157,12.81309,33.15262,33.15262,0,0,1,2.847,5.84463,8.87535,8.87535,0,0,1,.32512,1.51466,2.89763,2.89763,0,0,1-4.12372,2.97386c-10.88039-5.32913-29.85434-4.42589-41.75772-1.17444q.119-3.47344-.09516-6.93107a17.13363,17.13363,0,0,1-3.37832,7.91442c-2.03809.60271-4.03649,1.22919-5.97148,1.84777a1.71539,1.71539,0,0,1-2.18084-2.09361,9.64658,9.64658,0,0,0,.2062-4.203c-.49172-2.90252-1.95087-5.52745-2.9818-8.27924s-1.61781-5.916-.35687-8.57264c1.95876-4.12377,7.47826-5.38471,9.91287-9.24676,2.736-4.33784.61065-9.99215,1.03886-15.1072.60271-7.23243,6.52662-13.10875,13.204-15.94781,11.56233-4.9168,26.02719-.935,34.695,8.15314a7.93768,7.93768,0,0,1,8.303-.35687c2.56152,1.37192,4.4013,2.08567,5.5512,4.75817C529.09959,247.65062,526.96634,252.29776,526.50637,254.81959Z" fill="#a3a3a3" transform="translate(-324.64481 -166.00708)"/>
|
||||
<path d="M515.48591,556.53986l-60.36453-60.36452a3.7472,3.7472,0,0,1-.00008-5.29331l42.20391-42.20391a3.74715,3.74715,0,0,1,5.29338,0l60.36453,60.36453a3.7472,3.7472,0,0,1-.00008,5.29331l-42.2039,42.2039A3.74724,3.74724,0,0,1,515.48591,556.53986Z" fill="#262626" transform="translate(-324.64481 -166.00708)"/>
|
||||
<path d="M518.13254,558.63348a4.71009,4.71009,0,0,1-3.35352-1.38672L454.41427,496.8825a4.74593,4.74593,0,0,1,0-6.707l42.20411-42.2041a4.74649,4.74649,0,0,1,6.70752,0l60.36425,60.36426a4.7459,4.7459,0,0,1,0,6.707l-42.2041,42.2041A4.71009,4.71009,0,0,1,518.13254,558.63348ZM499.97189,448.58367a2.71951,2.71951,0,0,0-1.939.80176l-42.20459,42.2041a2.74665,2.74665,0,0,0,0,3.87891l60.36474,60.36426a2.81453,2.81453,0,0,0,3.87891,0l42.2041-42.2041a2.74665,2.74665,0,0,0,0-3.87891l-60.36475-60.36426A2.72144,2.72144,0,0,0,499.97189,448.58367Z" fill="#404040" transform="translate(-324.64481 -166.00708)"/>
|
||||
<path d="M492.74166,454.293a11.22642,11.22642,0,0,1,3.595-16.83486l.94985-100.813,18.84219,8.64691-4.1444,97.50067A11.2873,11.2873,0,0,1,492.74166,454.293Z" fill="#9e616a" transform="translate(-324.64481 -166.00708)"/>
|
||||
<path d="M493.24918,311.12137s18.3293-8.89112,20.86419,7.88369,12.96145,60.43336,12.96145,60.43336l-31.63926-2.23768Z" fill="#9e616a" transform="translate(-324.64481 -166.00708)"/>
|
||||
<path d="M492.74166,454.293a11.22642,11.22642,0,0,1,3.595-16.83486l.94985-100.813,18.84219,8.64691-4.1444,97.50067A11.2873,11.2873,0,0,1,492.74166,454.293Z" fill="#9e616a" transform="translate(-324.64481 -166.00708)"/>
|
||||
<path d="M493.24918,311.12137s18.3293-8.89112,20.86419,7.88369,12.96145,60.43336,12.96145,60.43336l-31.63926-2.23768Z" fill="#FD5E0F" transform="translate(-324.64481 -166.00708)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 12 KiB |
21
public/illustrations/confirmed.svg
Normal file
@@ -0,0 +1,21 @@
|
||||
<svg data-name="Layer 1" viewBox="0 0 505.46625 596.94537" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<path d="M706.73312,652.47268l5,96S500.239,547.178,473.89063,488.28153,497.13921,283.694,497.13921,283.694l110.04332,60.44633Z" fill="#9f616a" transform="translate(-347.26688 -151.52732)"/>
|
||||
<path d="M382.44618,243.39641l-32.548,3.09981s-17.049,37.19774,35.64783,40.29756Z" fill="#9f616a" transform="translate(-347.26688 -151.52732)"/>
|
||||
<path d="M382.44618,300.74293l-32.548,3.09981s-17.049,37.19774,35.64783,40.29755Z" fill="#9f616a" transform="translate(-347.26688 -151.52732)"/>
|
||||
<path d="M382.44618,362.73916l-32.548,3.09981s-17.049,37.19774,35.64783,40.29756Z" fill="#9f616a" transform="translate(-347.26688 -151.52732)"/>
|
||||
<path d="M391.74562,424.7354l-32.548,3.09981s-17.049,37.19774,35.64784,40.29755Z" fill="#9f616a" transform="translate(-347.26688 -151.52732)"/>
|
||||
<path d="M366.60044,258.3401h2.46966V190.68455a39.15718,39.15718,0,0,1,39.15715-39.15723H551.56477a39.15718,39.15718,0,0,1,39.15726,39.1571V561.84816a39.15719,39.15719,0,0,1-39.15715,39.15723H408.22744a39.1572,39.1572,0,0,1-39.15731-39.15708V306.49848h-2.46969Z" fill="#d4d4d4" transform="translate(-347.26688 -151.52732)"/>
|
||||
<path d="M406.64739,161.71467h18.71028a13.8929,13.8929,0,0,0,12.86292,19.13985h82.1162a13.89286,13.89286,0,0,0,12.86291-19.13987h17.47545a29.24215,29.24215,0,0,1,29.24218,29.24211V561.57589a29.24216,29.24216,0,0,1-29.24214,29.24218H406.64739a29.24216,29.24216,0,0,1-29.24218-29.24214h0V190.95679A29.24214,29.24214,0,0,1,406.64739,161.71467Z" fill="#262626" transform="translate(-347.26688 -151.52732)"/>
|
||||
<rect fill="#FD5E0F" height="14.89247" style="isolation:isolate" width="61.71533" x="100.53673" y="114.01642"/>
|
||||
<rect fill="#e5e5e5" height="14.89247" width="67.59164" x="97.59857" y="178.00873"/>
|
||||
<rect fill="#e5e5e5" height="14.89247" width="150.85812" x="55.96534" y="209.9996"/>
|
||||
<rect fill="#e5e5e5" height="14.89247" width="150.85812" x="55.96534" y="241.99047"/>
|
||||
<path d="M852.73312,702.47268l-173.48-197.91713-6.19962-127.09228-65.096-108.49341-18.59887-46.49718s-43.39736,4.64972-7.74953,92.99435l14.72411,47.27213,0,0a216.67408,216.67408,0,0,0-20.14876,91.24394v108.3237c0,25.50678,121.55,164.44287,135.69866,185.6658l0,0Z" fill="#9f616a" transform="translate(-347.26688 -151.52732)"/>
|
||||
<polygon opacity="0.2" points="247.195 126.636 260.136 120.276 259.695 119.378 247.738 125.255 229.688 77.932 228.754 78.289 247.195 126.636"/>
|
||||
<rect height="20.43016" opacity="0.2" transform="translate(-346.65612 349.39987) rotate(-69.7779)" width="1.00012" x="358.98404" y="239.81058"/>
|
||||
<rect height="20.43016" opacity="0.2" transform="translate(-401.08099 387.35158) rotate(-69.7779)" width="1.00012" x="358.98404" y="297.81058"/>
|
||||
<rect height="20.43016" opacity="0.2" transform="translate(-459.25929 427.92066) rotate(-69.7779)" width="1.00012" x="358.98404" y="359.81058"/>
|
||||
<rect height="10.35283" opacity="0.2" transform="translate(-528.7721 421.49849) rotate(-64.36101)" width="0.99981" x="364.06574" y="425.55799"/>
|
||||
<circle cx="131.47081" cy="342.17535" fill="#FD5E0F" r="43.22999"/>
|
||||
<polygon fill="#262626" points="127.351 360.424 114.413 343.787 121.937 337.935 128.063 345.812 148.76 323.964 155.681 330.521 127.351 360.424"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
56
public/illustrations/data-trends.svg
Normal file
@@ -0,0 +1,56 @@
|
||||
<svg data-name="Layer 1" viewBox="0 0 942.54724 631.43903" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<path d="M879.59026,413.72015l2.79791-22.42655a30.28454,30.28454,0,0,1,60.10315,7.4984l-2.79791,22.42654a4.07267,4.07267,0,0,1-4.5404,3.53316l-52.02959-6.49115A4.07266,4.07266,0,0,1,879.59026,413.72015Z" fill="#a3a3a3" transform="translate(-128.72638 -134.28048)"/>
|
||||
<circle cx="775.78648" cy="263.92678" fill="#ffb8b8" r="22.20356"/>
|
||||
<path d="M875.05486,392.20076a23.98352,23.98352,0,0,1,26.73792-20.80636l4.48553.55961a23.98334,23.98334,0,0,1,20.80614,26.73789l-.056.44853-9.47894-1.18258-2.10358-9.45631L913.67,397.47385l-4.8988-.61117-1.06132-4.77116-.89618,4.52695-31.81482-3.96918Z" fill="#a3a3a3" transform="translate(-128.72638 -134.28048)"/>
|
||||
<path d="M795.91611,477.41149a10.22784,10.22784,0,0,0,15.28426,3.51477L831.3673,492.74l10.16823-10.50441-28.71392-16.28326a10.28328,10.28328,0,0,0-16.9055,11.45917Z" fill="#ffb8b8" transform="translate(-128.72638 -134.28048)"/>
|
||||
<path d="M862.01251,498.63944c-14.87549,0-41.052-6.65918-42.50146-7.03125l-.45777-.11719,4.11988-20.85889,39.98193,7.897,21.7207-30.58692,24.93726-2.53515-.69605.916c-.32422.42676-32.46972,42.74317-37.47241,49.8418C870.38214,497.95585,866.77618,498.63944,862.01251,498.63944Z" fill="#d4d4d4" transform="translate(-128.72638 -134.28048)"/>
|
||||
<path d="M866.00055,576.92752l-.59522-.28417c-.12671-.06055-12.76513-6.208-19.31128-18.209-6.51489-11.94434,23.98316-62.6123,26.1167-66.12793l.031-16.09082L883.213,446.59452l13.9917-7.90821L885.19171,466.7161Z" fill="#d4d4d4" transform="translate(-128.72638 -134.28048)"/>
|
||||
<polygon fill="#ffb8b8" points="723.257 615.591 709.885 615.59 703.523 564.011 723.259 564.012 723.257 615.591"/>
|
||||
<path d="M855.39347,762.83333l-43.11785-.00159v-.54537A16.78359,16.78359,0,0,1,829.05829,745.504h.00107l26.33491.00107Z" fill="#a3a3a3" transform="translate(-128.72638 -134.28048)"/>
|
||||
<polygon fill="#ffb8b8" points="866.891 603.872 854.184 608.035 832.077 561.002 850.832 554.857 866.891 603.872"/>
|
||||
<path d="M1002.89464,749.40842,961.92,762.83333l-.16982-.51825a16.78358,16.78358,0,0,1,10.72241-21.174l.001-.00033,25.02593-8.1994Z" fill="#a3a3a3" transform="translate(-128.72638 -134.28048)"/>
|
||||
<path d="M969.72638,729.71952l-38.1205-63.64087L899.4256,598.08573l-28.95826,60.06054-11.50366,77.78809-40.343.56055.15893-.63086L880.51715,490.5535l48.352,7.22363-2.20093,31.916,1.31079,1.86426c10.92261,15.5166,22.21607,31.55957,15.73877,48.85351l18.36231,54.52246,40.64624,90.78614Z" fill="#a3a3a3" transform="translate(-128.72638 -134.28048)"/>
|
||||
<path d="M882.72638,543.71952c-7.00635,0-15-8-10.853-21.65821l-.26855-.19433,9.96069-44.00781c-8.69263-12.43165,2.76807-22.69141,3.842-23.60791l5.4707-9.84717,24.1377-15.30811L927.22882,530.5701l-.18066.17285C918.2276,539.18241,888.60358,543.71952,882.72638,543.71952Z" fill="#FD5E0F" transform="translate(-128.72638 -134.28048)"/>
|
||||
<path d="M932.15948,566.617a11.88134,11.88134,0,0,1-2.85131-.30176c-.93873-.23535-2.4585-1.43066-4.80689-9.07715-9.33618-30.39746-20.35913-121.86035-12.35083-130.81055l.18335-.20507,13.14893,2.1914c1.093-.80664,6.82861-4.81054,12.12133-4.11425a8.033,8.033,0,0,1,5.49659,3.249l.09057.11865,4.189,59.59424,15.34619,70.13086-.36792.167C961.46564,557.96561,942.303,566.617,932.15948,566.617Z" fill="#d4d4d4" transform="translate(-128.72638 -134.28048)"/>
|
||||
<path d="M923.31655,543.32446a10.22788,10.22788,0,0,0,9.66238-12.35317l19.24856-13.25752-5.20361-13.66228-26.91975,19.104a10.28328,10.28328,0,0,0,3.21242,20.169Z" fill="#ffb8b8" transform="translate(-128.72638 -134.28048)"/>
|
||||
<path d="M945.876,528.3367l-17.14551-12.57324,24.10083-32.86426L934.33624,450.261l8.27466-23.66113.53467,1.019c.249.47461,24.94824,47.52686,29.25683,55.06738,4.48267,7.84375-24.97,43.76075-26.22583,45.28516Z" fill="#d4d4d4" transform="translate(-128.72638 -134.28048)"/>
|
||||
<path d="M560.20614,676.255l-14.5923-6.1443-10.01026-73.15138H402.299L391.4486,669.81186l-13.05511,6.52746a3.10016,3.10016,0,0,0,1.38657,5.873H559.00349A3.1,3.1,0,0,0,560.20614,676.255Z" fill="#404040" transform="translate(-128.72638 -134.28048)"/>
|
||||
<path d="M797.09757,606.69181H142.26837a12.97344,12.97344,0,0,1-12.9443-12.97332V501.379h680.7178v92.33952A12.97357,12.97357,0,0,1,797.09757,606.69181Z" fill="#525252" transform="translate(-128.72638 -134.28048)"/>
|
||||
<path d="M810.72638,545.02084h-682V149.91957A15.65719,15.65719,0,0,1,144.366,134.28048H795.08655a15.65736,15.65736,0,0,1,15.63983,15.63909Z" fill="#d4d4d4" transform="translate(-128.72638 -134.28048)"/>
|
||||
<path d="M769.99322,516.34435H169.45954a12.07024,12.07024,0,0,1-12.057-12.05667v-329.274a12.07088,12.07088,0,0,1,12.057-12.05741H769.99322a12.07088,12.07088,0,0,1,12.057,12.05741v329.274A12.07024,12.07024,0,0,1,769.99322,516.34435Z" fill="#262626" transform="translate(-128.72638 -134.28048)"/>
|
||||
<path d="M638.14638,682.97542l-337.44822,0a1.56681,1.56681,0,0,1-1.53908-1.13363,1.52911,1.52911,0,0,1,1.47725-1.91893l337.385,0a1.61535,1.61535,0,0,1,1.61617,1.19368A1.52819,1.52819,0,0,1,638.14638,682.97542Z" fill="#525252" transform="translate(-128.72638 -134.28048)"/>
|
||||
<rect fill="#2a2a2a" height="110.7733" width="144.99594" x="489.85973" y="81.90943"/>
|
||||
<rect fill="#262626" height="99.76477" width="130.58641" x="497.0645" y="87.41369"/>
|
||||
<path d="M693.37738,295.69477q.02592.00028.05185-.00109a34.81675,34.81675,0,0,0,.779-69.48892.79424.79424,0,0,0-.60112.20836.78592.78592,0,0,0-.25678.57553l-.7612,67.902a.79707.79707,0,0,0,.78826.80412Z" fill="#d4d4d4" transform="translate(-128.72638 -134.28048)"/>
|
||||
<path d="M666.072,237.43291a1.02524,1.02524,0,0,1,.71962.30735l23.37584,23.90588a1.01418,1.01418,0,0,1,.29094.72511l-.36424,32.49143a1.01059,1.01059,0,0,1-.33018.73985,1.026,1.026,0,0,1-.77126.26782,35.04446,35.04446,0,0,1-23.69951-58.09062,1.02636,1.02636,0,0,1,.74091-.34658Q666.05306,237.4327,666.072,237.43291Z" fill="#404040" transform="translate(-128.72638 -134.28048)"/>
|
||||
<path d="M689.84193,225.9272a1.02931,1.02931,0,0,1,.69948.28585,1.01135,1.01135,0,0,1,.31394.74786l-.32864,29.31578a1.02077,1.02077,0,0,1-1.75041.70223L668.378,236.11808a1.02291,1.02291,0,0,1,.05493-1.48163,35.11806,35.11806,0,0,1,21.34564-8.708C689.79961,225.92739,689.82088,225.927,689.84193,225.9272Z" fill="#FD5E0F" transform="translate(-128.72638 -134.28048)"/>
|
||||
<path d="M679.33353,309.77539a6.25343,6.25343,0,1,1-6.18294-6.32313A6.26054,6.26054,0,0,1,679.33353,309.77539Z" fill="#FD5E0F" transform="translate(-128.72638 -134.28048)"/>
|
||||
<path d="M697.19935,309.97567a6.25343,6.25343,0,1,1-6.18294-6.32313A6.26056,6.26056,0,0,1,697.19935,309.97567Z" fill="#d4d4d4" transform="translate(-128.72638 -134.28048)"/>
|
||||
<path d="M715.06516,310.176a6.25343,6.25343,0,1,1-6.18294-6.32314A6.26054,6.26054,0,0,1,715.06516,310.176Z" fill="#404040" transform="translate(-128.72638 -134.28048)"/>
|
||||
<rect fill="#2a2a2a" height="110.7733" width="144.99594" x="488.33346" y="210.11637"/>
|
||||
<rect fill="#262626" height="99.76477" width="130.58641" x="495.53823" y="215.62063"/>
|
||||
<circle cx="517.69473" cy="242.98812" fill="#d4d4d4" r="5.42203"/>
|
||||
<path d="M733.53881,373.0493a3.67713,3.67713,0,1,1,0,7.35425H676.51323a3.67712,3.67712,0,1,1,0-7.35425h57.02558m0-.9006H676.51323a4.57773,4.57773,0,1,0,0,9.15545h57.02558a4.57773,4.57773,0,1,0,0-9.15545Z" fill="#d4d4d4" transform="translate(-128.72638 -134.28048)"/>
|
||||
<path d="M711.03754,381.30414h-42.9283a4.57772,4.57772,0,1,1,0-9.15544h42.9283a4.57772,4.57772,0,0,1,0,9.15544Z" fill="#FD5E0F" transform="translate(-128.72638 -134.28048)"/>
|
||||
<circle cx="517.69473" cy="265.50302" fill="#d4d4d4" r="5.42203"/>
|
||||
<path d="M733.53881,395.5642a3.67713,3.67713,0,1,1,0,7.35425H676.51323a3.67712,3.67712,0,1,1,0-7.35425h57.02558m0-.9006H676.51323a4.57772,4.57772,0,1,0,0,9.15545h57.02558a4.57772,4.57772,0,1,0,0-9.15545Z" fill="#d4d4d4" transform="translate(-128.72638 -134.28048)"/>
|
||||
<path d="M727.24826,403.819h-59.139a4.57772,4.57772,0,1,1,0-9.15544h59.139a4.57772,4.57772,0,0,1,0,9.15544Z" fill="#FD5E0F" transform="translate(-128.72638 -134.28048)"/>
|
||||
<circle cx="517.69473" cy="288.01792" fill="#d4d4d4" r="5.42203"/>
|
||||
<path d="M733.53881,418.0791a3.67713,3.67713,0,1,1,0,7.35425H676.51323a3.67712,3.67712,0,1,1,0-7.35425h57.02558m0-.9006H676.51323a4.57772,4.57772,0,1,0,0,9.15545h57.02558a4.57772,4.57772,0,1,0,0-9.15545Z" fill="#d4d4d4" transform="translate(-128.72638 -134.28048)"/>
|
||||
<path d="M692.125,426.33393H668.10924a4.57772,4.57772,0,1,1,0-9.15543H692.125a4.57772,4.57772,0,0,1,0,9.15543Z" fill="#FD5E0F" transform="translate(-128.72638 -134.28048)"/>
|
||||
<rect fill="#2a2a2a" height="207.61032" width="405.09331" x="51.18842" y="101.50782"/>
|
||||
<rect fill="#262626" height="186.97824" width="378.08709" x="64.69153" y="111.82386"/>
|
||||
<path d="M524.61425,404.55724H248.996a.8626.8626,0,0,1-.86256-.86256V271.59086a.86256.86256,0,1,1,1.72512,0V402.83212H524.61425a.86256.86256,0,0,1,0,1.72512Z" fill="#d4d4d4" transform="translate(-128.72638 -134.28048)"/>
|
||||
<path d="M307.88363,395.06909H282.81508a2.56336,2.56336,0,0,1-2.56051-2.5603V357.95166a2.56337,2.56337,0,0,1,2.56051-2.5603h25.06855a2.56337,2.56337,0,0,1,2.56051,2.5603v34.55713A2.56336,2.56336,0,0,1,307.88363,395.06909Z" fill="#FD5E0F" transform="translate(-128.72638 -134.28048)"/>
|
||||
<path d="M353.59927,395.06909H328.53072a2.56336,2.56336,0,0,1-2.56051-2.5603V325.17441a2.56336,2.56336,0,0,1,2.56051-2.5603h25.06855a2.56336,2.56336,0,0,1,2.56051,2.5603v67.33438A2.56336,2.56336,0,0,1,353.59927,395.06909Z" fill="#FD5E0F" transform="translate(-128.72638 -134.28048)"/>
|
||||
<path d="M399.31491,395.06909H374.24636a2.56337,2.56337,0,0,1-2.56051-2.5603V357.95166a2.56337,2.56337,0,0,1,2.56051-2.5603h25.06855a2.56337,2.56337,0,0,1,2.56052,2.5603v34.55713A2.56337,2.56337,0,0,1,399.31491,395.06909Z" fill="#FD5E0F" transform="translate(-128.72638 -134.28048)"/>
|
||||
<path d="M445.03056,395.06909H419.962a2.50734,2.50734,0,0,1-2.56052-2.44431V312.12a2.50734,2.50734,0,0,1,2.56052-2.44431h25.06855a2.50734,2.50734,0,0,1,2.56051,2.44431v80.50475A2.50734,2.50734,0,0,1,445.03056,395.06909Z" fill="#FD5E0F" transform="translate(-128.72638 -134.28048)"/>
|
||||
<path d="M490.7462,395.06909H465.67765a2.56336,2.56336,0,0,1-2.56051-2.5603V288.94692a2.56336,2.56336,0,0,1,2.56051-2.5603H490.7462a2.56336,2.56336,0,0,1,2.56051,2.5603V392.50879A2.56336,2.56336,0,0,1,490.7462,395.06909Z" fill="#FD5E0F" transform="translate(-128.72638 -134.28048)"/>
|
||||
<circle cx="166.62298" cy="205.58481" fill="#d4d4d4" r="5.17536"/>
|
||||
<circle cx="212.33862" cy="171.945" fill="#d4d4d4" r="5.17536"/>
|
||||
<circle cx="258.05426" cy="205.58481" fill="#d4d4d4" r="5.17536"/>
|
||||
<circle cx="303.7699" cy="155.55637" fill="#d4d4d4" r="5.17536"/>
|
||||
<circle cx="349.48555" cy="136.58007" fill="#d4d4d4" r="5.17536"/>
|
||||
<polygon fill="#d4d4d4" points="258.163 206.744 212.339 172.421 167.14 206.275 166.106 204.895 212.339 170.265 257.945 204.425 303.266 154.83 303.447 154.756 349.163 136.337 349.808 137.937 304.274 156.283 258.163 206.744"/>
|
||||
<path d="M1070.27362,765.71952h-381a1,1,0,0,1,0-2h381a1,1,0,0,1,0,2Z" fill="#525252" transform="translate(-128.72638 -134.28048)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 11 KiB |
41
public/illustrations/journey.svg
Normal file
@@ -0,0 +1,41 @@
|
||||
<svg viewBox="0 0 606.78384 466.94693" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<path d="M243.1138,465.75693c0,.66003,.53003,1.19,1.19006,1.19h361.28998c.65997,0,1.19-.52997,1.19-1.19,0-.65997-.53003-1.19-1.19-1.19H244.30386c-.66003,0-1.19006,.53003-1.19006,1.19Z" fill="#525252"/>
|
||||
<g>
|
||||
<path d="M478.73106,208.21561l-12.81104-88.46045,11.30322-1.22412c11.81543-1.27734,22.81394,5.6587,26.74851,16.87208,7.11395,9.12006,11.27283,19.82416,11.45898,32.65771,4.6001,13.10986-2.28025,27.63817-15.33689,32.38622l-21.36279,7.76855Z" fill="#d4d4d4"/>
|
||||
<g>
|
||||
<polygon fill="#a0616a" points="480.18534 444.73761 471.57779 444.94074 466.7798 406.27023 479.48238 405.96963 480.18534 444.73761"/>
|
||||
<path d="M450.65393,462.69556h0c0,1.45409,1.03191,2.63289,2.30485,2.63289h17.08527s1.68133-6.7585,8.53637-9.66706l.47312,9.66706h8.81369l-1.06787-15.5442s2.35771-8.3162-2.53875-12.5672c-4.89651-4.251-.93052-3.65923-.93052-3.65923l-1.9262-9.62068-13.31844,1.56616-.09793,15.10222-6.46334,14.99036-9.48016,4.68304c-.84378,.4168-1.39,1.3665-1.39,2.41661l-.00011,.00003Z" fill="#a3a3a3"/>
|
||||
</g>
|
||||
<g>
|
||||
<polygon fill="#a0616a" points="448.73769 444.44459 440.13013 444.64772 435.33214 405.97721 448.03472 405.67661 448.73769 444.44459"/>
|
||||
<path d="M419.20627,462.40254h0c0,1.45409,1.03191,2.63289,2.30485,2.63289h17.08527s1.68133-6.7585,8.53637-9.66706l.47312,9.66706h8.81369l-1.06787-15.5442s2.35771-8.3162-2.53875-12.5672c-4.89651-4.251-.93052-3.65923-.93052-3.65923l-1.9262-9.62068-13.31844,1.56616-.09793,15.10222-6.46334,14.99036-9.48016,4.68304c-.84378,.4168-1.39,1.3665-1.39,2.41661l-.00011,.00003Z" fill="#a3a3a3"/>
|
||||
</g>
|
||||
<path d="M473.51973,114.47654l-30.99834,5.39101c-2.13559-5.46579-12.47307-20.54879-13.35857-25.90032-2.30216-13.91303,8.07112-26.73999,22.13422-27.78947h.00005c13.48814-1.00658,21.70534,1.51938,22.85326,14.367,.40236,4.50321,6.26672,8.56659,7.44772,12.93076,6.51556,24.07697-6.52003,15.87037-8.07834,21.00102Z" fill="#a3a3a3"/>
|
||||
<path d="M426.97896,215.32013l-2.02163,30.99834,8.68046,172.71696,16-3,10.79624-140.74026,3.9451,143.19884,18.86855,.67388,13.26058-168.16821c1.01319-12.84915-2.35442-25.66276-9.55424-36.3534v-.00002l-59.97505,.67388Z" fill="#a3a3a3"/>
|
||||
<path d="M466.06382,115.58636h-24.73181l-5.59265,8.7604-19.54243,8.08652,10.78203,76.82196-2.69551,33.01997c15.23536,5.16951,50.59214,13.171,76.16536-7.94676l-22.92909-41.24626,5.39101-57.95341-10.44509-9.50326-6.40183-10.03917Z" fill="#FD5E0F"/>
|
||||
<circle cx="452.38118" cy="92.54439" fill="#a0616a" r="18.68574"/>
|
||||
<path d="M498.50889,260.19966c-.40161-2.02836-.3118-3.97925,.16337-5.58086l-5.04638-19.08113,9.14981-2.13566,4.13391,19.58585c1.04964,1.2997,1.87607,3.06917,2.27768,5.09753,.91748,4.63378-.72921,8.86346-3.67797,9.4473-2.94876,.58384-6.08294-2.69925-7.00042-7.33302h0Z" fill="#a0616a"/>
|
||||
<path d="M470.44402,130.74859s11.45591-4.71714,16.84692,4.04326c5.39101,8.7604,19.54243,111.86356,19.54243,111.86356l-11.79285,1.68469-23.24875-83.22379-1.34775-34.36772Z" fill="#FD5E0F"/>
|
||||
<polygon fill="#d4d4d4" points="478.6378 190.03543 469.07173 122.96288 477.22047 129.48033 478.6378 190.03543"/>
|
||||
<g>
|
||||
<path d="M473.60477,147.41331c-1.53019,1.57383-3.28897,2.67156-4.97489,3.222l-13.63005,15.91357-7.38258-6.70724,14.62119-15.42057c.5028-1.70074,1.55066-3.48969,3.08085-5.06352,3.49571-3.59541,8.18429-4.7067,10.47226-2.48215,2.28798,2.22454,1.30893,6.9425-2.18677,10.53791h0Z" fill="#a0616a"/>
|
||||
<path d="M428.687,127.80491s-12.87212,1.47288-13.84915,6.83274,5.99069,55.13488,5.99069,55.13488l9.68255,10.2629,37.82838-45.06214-11.37771-7.14613-17.48543,13.73961-10.78935-33.76186Z" fill="#FD5E0F"/>
|
||||
</g>
|
||||
<path d="M450.09075,69.15171c-7.60062,1.33743-14.96605,5.45258-18.90924,12.08654-3.94319,6.63396-11.16814,15.87716-5.93007,21.54468,1.81043,1.95886,4.11695,3.44175,5.74553,5.55422,2.77133,3.59476,3.17157,8.64549,1.78962,12.969s-4.34565,7.9896-7.73629,11.00723c7.25776-.74874,13.88133-5.90489,16.38777-12.75715,1.15985-3.17087,1.39592-6.97442-.58875-9.70586-1.04378-1.43653-2.58113-2.41857-3.90693-3.59981-3.41944-3.0466-1.22336-7.64853-1.20302-12.22827s.59689-7.76592,4.04326-10.78203c2.37347,1.8969,10.24363,1.43894,13.24276,.9523,2.99913-.48664,11.38654,11.03204,13.03844,8.48198,3.23374,4.15672,1.02121-4.4089,.41411,.82243-.43433,3.74256-2.89171,6.97085-3.63262,10.66496-.64474,3.21461,.05988,6.5598,1.19239,9.63662,2.55466,6.9406,7.34433,13.03873,13.48203,17.1651-3.54377-5.01207-7.23832-10.48941-7.12173-16.62664,.08648-4.55209,9.78516-21.94748,11.14336-26.29309,2.59893-8.31535-4.55362-9.53005-10.08652-16.25957-4.85898-5.90985-14.25182-4.08367-21.69996-2.33402" fill="#a3a3a3"/>
|
||||
</g>
|
||||
<path d="M304.97998,0H19.02002C8.53003,0,0,8.52997,0,19.01996V313.97998c0,10.48999,8.53003,19.02002,19.02002,19.02002H304.97998c10.48999,0,19.02002-8.53003,19.02002-19.02002V19.01996c0-10.48999-8.53003-19.01996-19.02002-19.01996Zm17.02002,313.97998c0,9.39001-7.63,17.02002-17.02002,17.02002H19.02002c-9.39001,0-17.02002-7.63-17.02002-17.02002V19.01996C2,9.63,9.63,2,19.02002,2H304.97998c9.39001,0,17.02002,7.63,17.02002,17.01996V313.97998Z" fill="#d4d4d4"/>
|
||||
<g>
|
||||
<path d="M129.03003,19.34998h-28.06006c-1.40997,0-2.56,1.15002-2.56,2.56,0,1.41998,1.15002,2.57001,2.56,2.57001h28.06006c1.40997,0,2.56-1.15002,2.56-2.57001,0-1.40997-1.15002-2.56-2.56-2.56Z" fill="#404040"/>
|
||||
<path d="M176.03003,19.34998h-28.06006c-1.40997,0-2.56,1.15002-2.56,2.56,0,1.41998,1.15002,2.57001,2.56,2.57001h28.06006c1.40997,0,2.56-1.15002,2.56-2.57001,0-1.40997-1.15002-2.56-2.56-2.56Z" fill="#404040"/>
|
||||
<path d="M223.03003,19.34998h-28.06006c-1.40997,0-2.56,1.15002-2.56,2.56,0,1.41998,1.15002,2.57001,2.56,2.57001h28.06006c1.40997,0,2.56-1.15002,2.56-2.57001,0-1.40997-1.15002-2.56-2.56-2.56Z" fill="#d4d4d4"/>
|
||||
</g>
|
||||
<path d="M85.49814,87.41216H50.29819c-4.36005,0-7.90002,3.53998-7.90002,7.89996v20.20001c0,4.35999,3.53998,7.90002,7.90002,7.90002h35.19995c4.36005,0,7.90002-3.54004,7.90002-7.90002v-20.20001c0-4.35999-3.53998-7.89996-7.90002-7.89996Z" fill="#FD5E0F"/>
|
||||
<path d="M122.73777,140.88811h-35.19995c-4.36005,0-7.90002,3.53998-7.90002,7.89996v20.20001c0,4.35999,3.53998,7.90002,7.90002,7.90002h35.19995c4.36005,0,7.90002-3.54004,7.90002-7.90002v-20.20001c0-4.35999-3.53998-7.89996-7.90002-7.89996Z" fill="#404040"/>
|
||||
<path d="M129.43365,122.27012c-.66503,0-1.20414-.53911-1.20414-1.20414v-31.30765c0-.66503,.53911-1.20414,1.20414-1.20414s1.20414,.53911,1.20414,1.20414v31.30765c0,.66503-.53911,1.20414-1.20414,1.20414Zm-6.0207-16.85797c0,.66503-.53911,1.20414-1.20414,1.20414h-13.95117l5.17058,5.16817c.47084,.47084,.47084,1.23422,0,1.70506s-1.23422,.47084-1.70506,0l-7.22484-7.22484c-.47084-.46965-.4718-1.23207-.00215-1.70291l.00215-.00215,7.22484-7.22484c.47084-.47084,1.23422-.47084,1.70506,0s.47084,1.23422,0,1.70506l-5.17058,5.16817h13.95117c.66503,0,1.20414,.53911,1.20414,1.20414Z" fill="#d4d4d4"/>
|
||||
<path d="M49.62301,158.88811c0,.66503,.53911,1.20414,1.20414,1.20414h13.95117l-5.17058,5.16817c-.47084,.47084-.47084,1.23422,0,1.70506s1.23422,.47084,1.70506,0l7.22484-7.22484c.47084-.46965,.47181-1.23207,.00215-1.70291l-.00215-.00215-7.22484-7.22484c-.47084-.47084-1.23422-.47084-1.70506,0s-.47084,1.23422,0,1.70506l5.17058,5.16817h-13.95117c-.66503,0-1.20414,.53911-1.20414,1.20414Zm-6.0207,16.85797c-.66503,0-1.20414-.53911-1.20414-1.20414v-31.30765c0-.66503,.53911-1.20414,1.20414-1.20414s1.20414,.53911,1.20414,1.20414v31.30765c0,.66503-.53911,1.20414-1.20414,1.20414Z" fill="#d4d4d4"/>
|
||||
<path d="M231.73777,115.03543h-35.19995c-4.36005,0-7.90002,3.53998-7.90002,7.89996v20.20001c0,4.35999,3.53998,7.90002,7.90002,7.90002h35.19995c4.36005,0,7.90002-3.54004,7.90002-7.90002v-20.20001c0-4.35999-3.53998-7.89996-7.90002-7.89996Z" fill="#404040"/>
|
||||
<path d="M231.73777,239.03543h-35.19995c-4.36005,0-7.90002,3.53998-7.90002,7.89996v20.20001c0,4.35999,3.53998,7.90002,7.90002,7.90002h35.19995c4.36005,0,7.90002-3.54004,7.90002-7.90002v-20.20001c0-4.35999-3.53998-7.89996-7.90002-7.89996Z" fill="#404040"/>
|
||||
<path d="M197.27983,204.60386c0-.66503,.53911-1.20414,1.20414-1.20414h31.30765c.66503,0,1.20414,.53911,1.20414,1.20414s-.53911,1.20414-1.20414,1.20414h-31.30765c-.66503,0-1.20414-.53911-1.20414-1.20414Zm16.85797,6.0207c.66503,0,1.20414,.53911,1.20414,1.20414v13.95117l5.16817-5.17058c.47084-.47084,1.23422-.47084,1.70506,0s.47084,1.23422,0,1.70506l-7.22484,7.22484c-.46965,.47084-1.23207,.47181-1.70291,.00215l-.00215-.00215-7.22484-7.22484c-.47084-.47084-.47084-1.23422,0-1.70506s1.23422-.47084,1.70506,0l5.16817,5.17058v-13.95117c0-.66503,.53911-1.20414,1.20414-1.20414Z" fill="#d4d4d4"/>
|
||||
<path d="M214.1378,180.81059c.66503,0,1.20414-.53911,1.20414-1.20414v-13.95117l5.16817,5.17058c.47084,.47084,1.23422,.47084,1.70506,0s.47084-1.23422,0-1.70506l-7.22484-7.22484c-.46965-.47084-1.23207-.4718-1.70291-.00215l-.00215,.00215-7.22484,7.22484c-.47084,.47084-.47084,1.23422,0,1.70506s1.23422,.47084,1.70506,0l5.16817-5.17058v13.95117c0,.66503,.53911,1.20414,1.20414,1.20414Zm-16.85797,6.0207c0-.66503,.53911-1.20414,1.20414-1.20414h31.30765c.66503,0,1.20414,.53911,1.20414,1.20414s-.53911,1.20414-1.20414,1.20414h-31.30765c-.66503,0-1.20414-.53911-1.20414-1.20414Z" fill="#d4d4d4"/>
|
||||
<circle cx="208.6378" cy="22.03543" fill="#FD5E0F" r="9"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 9.0 KiB |
12
public/illustrations/no-data.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<svg data-name="Layer 1" viewBox="0 0 647.63626 632.17383" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<path d="M687.3279,276.08691H512.81813a15.01828,15.01828,0,0,0-15,15v387.85l-2,.61005-42.81006,13.11a8.00676,8.00676,0,0,1-9.98974-5.31L315.678,271.39691a8.00313,8.00313,0,0,1,5.31006-9.99l65.97022-20.2,191.25-58.54,65.96972-20.2a7.98927,7.98927,0,0,1,9.99024,5.3l32.5498,106.32Z" fill="#2a2a2a" transform="translate(-276.18187 -133.91309)"/>
|
||||
<path d="M725.408,274.08691l-39.23-128.14a16.99368,16.99368,0,0,0-21.23-11.28l-92.75,28.39L380.95827,221.60693l-92.75,28.4a17.0152,17.0152,0,0,0-11.28028,21.23l134.08008,437.93a17.02661,17.02661,0,0,0,16.26026,12.03,16.78926,16.78926,0,0,0,4.96972-.75l63.58008-19.46,2-.62v-2.09l-2,.61-64.16992,19.65a15.01489,15.01489,0,0,1-18.73-9.95l-134.06983-437.94a14.97935,14.97935,0,0,1,9.94971-18.73l92.75-28.4,191.24024-58.54,92.75-28.4a15.15551,15.15551,0,0,1,4.40966-.66,15.01461,15.01461,0,0,1,14.32032,10.61l39.0498,127.56.62012,2h2.08008Z" fill="#d4d4d4" transform="translate(-276.18187 -133.91309)"/>
|
||||
<path d="M398.86279,261.73389a9.0157,9.0157,0,0,1-8.61133-6.3667l-12.88037-42.07178a8.99884,8.99884,0,0,1,5.9712-11.24023l175.939-53.86377a9.00867,9.00867,0,0,1,11.24072,5.9707l12.88037,42.07227a9.01029,9.01029,0,0,1-5.9707,11.24072L401.49219,261.33887A8.976,8.976,0,0,1,398.86279,261.73389Z" fill="#FD5E0F" transform="translate(-276.18187 -133.91309)"/>
|
||||
<circle cx="190.15351" cy="24.95465" fill="#FD5E0F" r="20"/>
|
||||
<circle cx="190.15351" cy="24.95465" fill="#262626" r="12.66462"/>
|
||||
<path d="M878.81836,716.08691h-338a8.50981,8.50981,0,0,1-8.5-8.5v-405a8.50951,8.50951,0,0,1,8.5-8.5h338a8.50982,8.50982,0,0,1,8.5,8.5v405A8.51013,8.51013,0,0,1,878.81836,716.08691Z" fill="#404040" transform="translate(-276.18187 -133.91309)"/>
|
||||
<path d="M723.31813,274.08691h-210.5a17.02411,17.02411,0,0,0-17,17v407.8l2-.61v-407.19a15.01828,15.01828,0,0,1,15-15H723.93825Zm183.5,0h-394a17.02411,17.02411,0,0,0-17,17v458a17.0241,17.0241,0,0,0,17,17h394a17.0241,17.0241,0,0,0,17-17v-458A17.02411,17.02411,0,0,0,906.81813,274.08691Zm15,475a15.01828,15.01828,0,0,1-15,15h-394a15.01828,15.01828,0,0,1-15-15v-458a15.01828,15.01828,0,0,1,15-15h394a15.01828,15.01828,0,0,1,15,15Z" fill="#d4d4d4" transform="translate(-276.18187 -133.91309)"/>
|
||||
<path d="M801.81836,318.08691h-184a9.01015,9.01015,0,0,1-9-9v-44a9.01016,9.01016,0,0,1,9-9h184a9.01016,9.01016,0,0,1,9,9v44A9.01015,9.01015,0,0,1,801.81836,318.08691Z" fill="#FD5E0F" transform="translate(-276.18187 -133.91309)"/>
|
||||
<circle cx="433.63626" cy="105.17383" fill="#FD5E0F" r="20"/>
|
||||
<circle cx="433.63626" cy="105.17383" fill="#262626" r="12.18187"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
38
public/illustrations/page-not-found.svg
Normal file
@@ -0,0 +1,38 @@
|
||||
<svg data-name="Layer 1" viewBox="0 0 860.13137 571.14799" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<path d="M605.66974,324.95306c-7.66934-12.68446-16.7572-26.22768-30.98954-30.36953-16.482-4.7965-33.4132,4.73193-47.77473,14.13453a1392.15692,1392.15692,0,0,0-123.89338,91.28311l.04331.49238q46.22556-3.1878,92.451-6.37554c22.26532-1.53546,45.29557-3.2827,64.97195-13.8156,7.46652-3.99683,14.74475-9.33579,23.20555-9.70782,10.51175-.46217,19.67733,6.87923,26.8802,14.54931,42.60731,45.371,54.937,114.75409,102.73817,154.61591A1516.99453,1516.99453,0,0,0,605.66974,324.95306Z" fill="#2a2a2a" transform="translate(-169.93432 -164.42601)"/>
|
||||
<path d="M867.57068,709.78146c-4.71167-5.94958-6.6369-7.343-11.28457-13.34761q-56.7644-73.41638-106.70791-151.79237-33.92354-53.23-64.48275-108.50439-14.54864-26.2781-28.29961-52.96872-10.67044-20.6952-20.8646-41.63793c-1.94358-3.98782-3.8321-7.99393-5.71122-12.00922-4.42788-9.44232-8.77341-18.93047-13.43943-28.24449-5.31686-10.61572-11.789-21.74485-21.55259-28.877a29.40493,29.40493,0,0,0-15.31855-5.89458c-7.948-.51336-15.28184,2.76855-22.17568,6.35295-50.43859,26.301-97.65922,59.27589-140.3696,96.79771A730.77816,730.77816,0,0,0,303.32241,496.24719c-1.008,1.43927-3.39164.06417-2.37419-1.38422q6.00933-8.49818,12.25681-16.81288A734.817,734.817,0,0,1,500.80465,303.06436q18.24824-11.82581,37.18269-22.54245c6.36206-3.60275,12.75188-7.15967,19.25136-10.49653,6.37146-3.27274,13.13683-6.21547,20.41563-6.32547,24.7701-.385,37.59539,27.66695,46.40506,46.54248q4.15283,8.9106,8.40636,17.76626,16.0748,33.62106,33.38729,66.628,10.68453,20.379,21.83683,40.51955,34.7071,62.71816,73.77854,122.897c34.5059,53.1429,68.73651,100.08874,108.04585,149.78472C870.59617,709.21309,868.662,711.17491,867.57068,709.78146Z" fill="#404040" transform="translate(-169.93432 -164.42601)"/>
|
||||
<path d="M414.91613,355.804c-1.43911-1.60428-2.86927-3.20856-4.31777-4.81284-11.42244-12.63259-23.6788-25.11847-39.3644-32.36067a57.11025,57.11025,0,0,0-23.92679-5.54622c-8.56213.02753-16.93178,2.27348-24.84306,5.41792-3.74034,1.49427-7.39831,3.1902-11.00078,4.99614-4.11634,2.07182-8.15927,4.28118-12.1834,6.50883q-11.33112,6.27044-22.36816,13.09089-21.9606,13.57221-42.54566,29.21623-10.67111,8.11311-20.90174,16.75788-9.51557,8.03054-18.64618,16.492c-1.30169,1.20091-3.24527-.74255-1.94358-1.94347,1.60428-1.49428,3.22691-2.97938,4.84955-4.44613q6.87547-6.21546,13.9712-12.19257,12.93921-10.91827,26.54851-20.99312,21.16293-15.67614,43.78288-29.22541,11.30361-6.76545,22.91829-12.96259c2.33794-1.24675,4.70318-2.466,7.09572-3.6211a113.11578,113.11578,0,0,1,16.86777-6.86632,60.0063,60.0063,0,0,1,25.476-2.50265,66.32706,66.32706,0,0,1,23.50512,8.1314c15.40091,8.60812,27.34573,21.919,38.97,34.90915C418.03337,355.17141,416.09875,357.12405,414.91613,355.804Z" fill="#404040" transform="translate(-169.93432 -164.42601)"/>
|
||||
<path d="M730.47659,486.71092l36.90462-13.498,18.32327-6.70183c5.96758-2.18267,11.92082-4.66747,18.08988-6.23036a28.53871,28.53871,0,0,1,16.37356.20862,37.73753,37.73753,0,0,1,12.771,7.91666,103.63965,103.63965,0,0,1,10.47487,11.18643c3.98932,4.79426,7.91971,9.63877,11.86772,14.46706q24.44136,29.89094,48.56307,60.04134,24.12117,30.14991,47.91981,60.556,23.85681,30.48041,47.38548,61.21573,2.88229,3.76518,5.75966,7.53415c1.0598,1.38809,3.44949.01962,2.37472-1.38808Q983.582,650.9742,959.54931,620.184q-24.09177-30.86383-48.51647-61.46586-24.42421-30.60141-49.17853-60.93743-6.16706-7.55761-12.35445-15.09858c-3.47953-4.24073-6.91983-8.52718-10.73628-12.47427-7.00539-7.24516-15.75772-13.64794-26.23437-13.82166-6.15972-.10214-12.121,1.85248-17.844,3.92287-6.16968,2.232-12.32455,4.50571-18.48633,6.75941l-37.16269,13.59243-9.29067,3.3981c-1.64875.603-.93651,3.2619.73111,2.652Z" fill="#404040" transform="translate(-169.93432 -164.42601)"/>
|
||||
<path d="M366.37741,334.52609c-18.75411-9.63866-42.77137-7.75087-60.00508,4.29119a855.84708,855.84708,0,0,1,97.37056,22.72581C390.4603,353.75916,380.07013,341.5635,366.37741,334.52609Z" fill="#2a2a2a" transform="translate(-169.93432 -164.42601)"/>
|
||||
<path d="M306.18775,338.7841l-3.61042,2.93462c1.22123-1.02713,2.4908-1.99013,3.795-2.90144C306.31073,338.80665,306.24935,338.79473,306.18775,338.7841Z" fill="#2a2a2a" transform="translate(-169.93432 -164.42601)"/>
|
||||
<path d="M831.54929,486.84576c-3.6328-4.42207-7.56046-9.05222-12.99421-10.84836l-5.07308.20008A575.436,575.436,0,0,0,966.74929,651.418Q899.14929,569.13192,831.54929,486.84576Z" fill="#2a2a2a" transform="translate(-169.93432 -164.42601)"/>
|
||||
<path d="M516.08388,450.36652A37.4811,37.4811,0,0,0,531.015,471.32518c2.82017,1.92011,6.15681,3.76209,7.12158,7.03463a8.37858,8.37858,0,0,1-.87362,6.1499,24.88351,24.88351,0,0,1-3.86126,5.04137l-.13667.512c-6.99843-4.14731-13.65641-9.3934-17.52227-16.55115s-4.40553-16.53895.34116-23.14544" fill="#2a2a2a" transform="translate(-169.93432 -164.42601)"/>
|
||||
<path d="M749.08388,653.36652A37.4811,37.4811,0,0,0,764.015,674.32518c2.82017,1.92011,6.15681,3.76209,7.12158,7.03463a8.37858,8.37858,0,0,1-.87362,6.1499,24.88351,24.88351,0,0,1-3.86126,5.04137l-.13667.512c-6.99843-4.14731-13.65641-9.3934-17.52227-16.55115s-4.40553-16.53895.34116-23.14544" fill="#2a2a2a" transform="translate(-169.93432 -164.42601)"/>
|
||||
<path d="M284.08388,639.36652A37.4811,37.4811,0,0,0,299.015,660.32518c2.82017,1.92011,6.15681,3.76209,7.12158,7.03463a8.37858,8.37858,0,0,1-.87362,6.1499,24.88351,24.88351,0,0,1-3.86126,5.04137l-.13667.512c-6.99843-4.14731-13.65641-9.3934-17.52227-16.55115s-4.40553-16.53895.34116-23.14544" fill="#2a2a2a" transform="translate(-169.93432 -164.42601)"/>
|
||||
<circle cx="649.24878" cy="51" fill="#FD5E0F" r="51"/>
|
||||
<path d="M911.21851,176.29639c-24.7168-3.34094-52.93512,10.01868-59.34131,34.12353a21.59653,21.59653,0,0,0-41.09351,2.10871l2.82972,2.02667a372.27461,372.27461,0,0,0,160.65881-.72638C957.07935,195.76,935.93537,179.63727,911.21851,176.29639Z" fill="#2a2a2a" transform="translate(-169.93432 -164.42601)"/>
|
||||
<path d="M805.21851,244.29639c-24.7168-3.34094-52.93512,10.01868-59.34131,34.12353a21.59653,21.59653,0,0,0-41.09351,2.10871l2.82972,2.02667a372.27461,372.27461,0,0,0,160.65881-.72638C851.07935,263.76,829.93537,247.63727,805.21851,244.29639Z" fill="#2a2a2a" transform="translate(-169.93432 -164.42601)"/>
|
||||
<path d="M1020.94552,257.15423a.98189.98189,0,0,1-.30176-.04688C756.237,173.48919,523.19942,184.42376,374.26388,208.32122c-20.26856,3.251-40.59131,7.00586-60.40381,11.16113-5.05811,1.05957-10.30567,2.19532-15.59668,3.37793-6.31885,1.40723-12.55371,2.85645-18.53223,4.30567q-3.873.917-7.59472,1.84863c-3.75831.92773-7.57178,1.89453-11.65967,2.957-4.56787,1.17774-9.209,2.41309-13.79737,3.67188a.44239.44239,0,0,1-.05127.01465l.00049.001c-5.18261,1.415-10.33789,2.8711-15.32324,4.3252-2.69824.77929-5.30371,1.54785-7.79932,2.30664-.2788.07715-.52587.15136-.77636.22754l-.53614.16308c-.31054.09473-.61718.1875-.92382.27539l-.01953.00586.00048.001-.81152.252c-.96777.293-1.91211.5791-2.84082.86426-24.54492,7.56641-38.03809,12.94922-38.17139,13.00195a1,1,0,1,1-.74414-1.85644c.13428-.05274,13.69336-5.46289,38.32764-13.05762.93213-.28613,1.87891-.57226,2.84961-.86621l.7539-.23438c.02588-.00976.05176-.01757.07813-.02539.30518-.08691.60986-.17968.91943-.27343l.53711-.16309c.26758-.08105.53125-.16113.80127-.23535,2.47852-.75391,5.09278-1.52441,7.79785-2.30664,4.98731-1.45508,10.14746-2.91113,15.334-4.32813.01611-.00586.03271-.00976.04883-.01464v-.001c4.60449-1.2627,9.26269-2.50293,13.84521-3.68457,4.09424-1.06348,7.915-2.03223,11.67969-2.96192q3.73755-.93017,7.60937-1.85253c5.98536-1.45118,12.23291-2.90235,18.563-4.3125,5.29932-1.1836,10.55567-2.32227,15.62207-3.38282,19.84326-4.16211,40.19776-7.92285,60.49707-11.17871C523.09591,182.415,756.46749,171.46282,1021.2463,255.2011a.99974.99974,0,0,1-.30078,1.95313Z" fill="#525252" transform="translate(-169.93432 -164.42601)"/>
|
||||
<path d="M432.92309,584.266a6.72948,6.72948,0,0,0-1.7-2.67,6.42983,6.42983,0,0,0-.92-.71c-2.61-1.74-6.51-2.13-8.99,0a5.81012,5.81012,0,0,0-.69.71q-1.11,1.365-2.28,2.67c-1.28,1.46-2.59,2.87-3.96,4.24-.39.38-.78.77-1.18,1.15-.23.23-.46.45-.69.67-.88.84-1.78,1.65-2.69,2.45-.48.43-.96.85-1.45,1.26-.73.61-1.46,1.22-2.2,1.81-.07.05-.14.1-.21.16-.02.01-.03.03-.05.04-.01,0-.02,0-.03.02a.17861.17861,0,0,0-.07.05c-.22.15-.37.25-.48.34.04-.01995.08-.05.12-.07-.18.14-.37.28-.55.42-1.75,1.29-3.54,2.53-5.37,3.69a99.21022,99.21022,0,0,1-14.22,7.55c-.33.13-.67.27-1.01.4a85.96993,85.96993,0,0,1-40.85,6.02q-2.13008-.165-4.26-.45c-1.64-.24-3.27-.53-4.89-.86a97.93186,97.93186,0,0,1-18.02-5.44,118.65185,118.65185,0,0,1-20.66-12.12c-1-.71-2.01-1.42-3.02-2.11,1.15-2.82,2.28-5.64,3.38-8.48.55-1.37,1.08-2.74,1.6-4.12,4.09-10.63,7.93-21.36,11.61-32.13q5.58-16.365,10.53-32.92.51-1.68.99-3.36,2.595-8.745,4.98-17.53c.15-.56994.31-1.12994.45-1.7q.68994-2.52,1.35-5.04c1-3.79-1.26-8.32-5.24-9.23a7.63441,7.63441,0,0,0-9.22,5.24c-.43,1.62-.86,3.23-1.3,4.85q-3.165,11.74494-6.66,23.41-.51,1.68-1.02,3.36-7.71,25.41-16.93,50.31-1.11,3.015-2.25,6.01c-.37.98-.74,1.96-1.12,2.94-.73,1.93-1.48,3.86-2.23,5.79-.43006,1.13-.87006,2.26-1.31,3.38-.29.71-.57,1.42-.85,2.12a41.80941,41.80941,0,0,0-8.81-2.12l-.48-.06a27.397,27.397,0,0,0-7.01.06,23.91419,23.91419,0,0,0-17.24,10.66c-4.77,7.51-4.71,18.25,1.98,24.63,6.89,6.57,17.32,6.52,25.43,2.41a28.35124,28.35124,0,0,0,10.52-9.86,50.56939,50.56939,0,0,0,2.74-4.65c.21.14.42.28.63.43.8.56,1.6,1.13,2.39,1.69a111.73777,111.73777,0,0,0,14.51,8.91,108.35887,108.35887,0,0,0,34.62,10.47c.27.03.53.07.8.1,1.33.17,2.67.3,4.01.41a103.78229,103.78229,0,0,0,55.58-11.36q2.175-1.125,4.31-2.36,3.315-1.92,6.48-4.08c1.15-.78,2.27-1.57,3.38-2.4a101.04244,101.04244,0,0,0,13.51-11.95q2.35491-2.475,4.51-5.11005a8.0612,8.0612,0,0,0,2.2-5.3A7.5644,7.5644,0,0,0,432.92309,584.266Zm-165.59,23.82c.21-.15.42-.31.62-.47C267.89312,607.766,267.60308,607.936,267.33312,608.086Zm3.21-3.23c-.23.26-.44.52-.67.78a23.36609,23.36609,0,0,1-2.25,2.2c-.11.1-.23.2-.35.29a.00976.00976,0,0,0-.01.01,3.80417,3.80417,0,0,0-.42005.22q-.645.39-1.31994.72a17.00459,17.00459,0,0,1-2.71.75,16.79925,16.79925,0,0,1-2.13.02h-.02a14.82252,14.82252,0,0,1-1.45-.4c-.24-.12-.47-.25994-.7-.4-.09-.08-.17005-.16-.22-.21a2.44015,2.44015,0,0,1-.26995-.29.0098.0098,0,0,0-.01-.01c-.11005-.2-.23005-.4-.34-.6a.031.031,0,0,1-.01-.02c-.08-.25-.15-.51-.21-.77a12.51066,12.51066,0,0,1,.01-1.37,13.4675,13.4675,0,0,1,.54-1.88,11.06776,11.06776,0,0,1,.69-1.26c.02-.04.12-.2.23-.38.01-.01.01-.01.01-.02.15-.17.3-.35.46-.51.27-.3.56-.56.85-.83a18.02212,18.02212,0,0,1,1.75-1.01,19.48061,19.48061,0,0,1,2.93-.79,24.98945,24.98945,0,0,1,4.41.04,30.30134,30.30134,0,0,1,4.1,1.01,36.94452,36.94452,0,0,1-2.77,4.54C270.6231,604.746,270.58312,604.806,270.54308,604.856Zm-11.12-3.29a2.18029,2.18029,0,0,1-.31.38995A1.40868,1.40868,0,0,1,259.42309,601.566Z" fill="#d4d4d4" transform="translate(-169.93432 -164.42601)"/>
|
||||
<path d="M402.86309,482.136q-.13494,4.71-.27,9.42-.285,10.455-.59,20.92-.315,11.775-.66,23.54-.165,6.07507-.34,12.15-.465,16.365-.92,32.72c-.03,1.13-.07,2.25-.1,3.38q-.225,8.11506-.45,16.23-.255,8.805-.5,17.61-.18,6.59994-.37,13.21-1.34994,47.895-2.7,95.79a7.64844,7.64844,0,0,1-7.5,7.5,7.56114,7.56114,0,0,1-7.5-7.5q.75-26.94,1.52-53.88.675-24.36,1.37-48.72.225-8.025.45-16.06.345-12.09.68-24.18c.03-1.13.07-2.25.1-3.38.02-.99.05-1.97.08-2.96q.66-23.475,1.32-46.96.27-9.24.52-18.49.3-10.545.6-21.08c.09-3.09.17005-6.17.26-9.26a7.64844,7.64844,0,0,1,7.5-7.5A7.56116,7.56116,0,0,1,402.86309,482.136Z" fill="#d4d4d4" transform="translate(-169.93432 -164.42601)"/>
|
||||
<path d="M814.29118,484.2172a893.23753,893.23753,0,0,1-28.16112,87.94127c-3.007,7.94641-6.08319,15.877-9.3715,23.71185l.75606-1.7916a54.58274,54.58274,0,0,1-5.58953,10.61184q-.22935.32119-.46685.63642,1.16559-1.49043.4428-.589c-.25405.30065-.5049.60219-.7676.89546a23.66436,23.66436,0,0,1-2.2489,2.20318q-.30139.25767-.61188.5043l.93783-.729c-.10884.25668-.87275.59747-1.11067.74287a18.25362,18.25362,0,0,1-2.40479,1.21853l1.7916-.75606a19.0859,19.0859,0,0,1-4.23122,1.16069l1.9938-.26791a17.02055,17.02055,0,0,1-4.29785.046l1.99379.2679a14.0022,14.0022,0,0,1-3.40493-.917l1.79159.75606a12.01175,12.01175,0,0,1-1.67882-.89614c-.27135-.17688-1.10526-.80852-.01487.02461,1.13336.86595.14562.07434-.08763-.15584-.19427-.19171-.36962-.4-.55974-.595-.88208-.90454.99637,1.55662.39689.49858a18.18179,18.18179,0,0,1-.87827-1.63672l.75606,1.7916a11.92493,11.92493,0,0,1-.728-2.65143l.26791,1.9938a13.65147,13.65147,0,0,1-.00316-3.40491l-.2679,1.9938a15.96371,15.96371,0,0,1,.99486-3.68011l-.75606,1.7916a16.72914,16.72914,0,0,1,1.17794-2.29848,6.72934,6.72934,0,0,1,.72851-1.0714c.04915.01594-1.26865,1.51278-.56937.757.1829-.19767.354-.40592.539-.602.29617-.31382.61354-.60082.92561-.89791,1.04458-.99442-1.46188.966-.25652.17907a19.0489,19.0489,0,0,1,2.74925-1.49923l-1.79159.75606a20.31136,20.31136,0,0,1,4.99523-1.33984l-1.9938.2679a25.62828,25.62828,0,0,1,6.46062.07647l-1.9938-.2679a33.21056,33.21056,0,0,1,7.89178,2.2199l-1.7916-.75606c5.38965,2.31383,10.16308,5.74926,14.928,9.118a111.94962,111.94962,0,0,0,14.50615,8.9065,108.38849,108.38849,0,0,0,34.62226,10.47371,103.93268,103.93268,0,0,0,92.58557-36.75192,8.07773,8.07773,0,0,0,2.1967-5.3033,7.63232,7.63232,0,0,0-2.1967-5.3033c-2.75154-2.52586-7.94926-3.239-10.6066,0a95.63575,95.63575,0,0,1-8.10664,8.72692q-2.01736,1.914-4.14232,3.70983-1.21364,1.02588-2.46086,2.01121c-.3934.31081-1.61863,1.13807.26309-.19744-.43135.30614-.845.64036-1.27058.95478a99.26881,99.26881,0,0,1-20.33215,11.56478l1.79159-.75606a96.8364,96.8364,0,0,1-24.17119,6.62249l1.99379-.2679a97.64308,97.64308,0,0,1-25.75362-.03807l1.99379.2679a99.79982,99.79982,0,0,1-24.857-6.77027l1.7916.75607a116.02515,116.02515,0,0,1-21.7364-12.59112,86.87725,86.87725,0,0,0-11.113-6.99417,42.8238,42.8238,0,0,0-14.43784-4.38851c-9.43884-1.11076-19.0571,2.56562-24.24624,10.72035-4.77557,7.50482-4.71394,18.24362,1.97369,24.62519,6.8877,6.5725,17.31846,6.51693,25.43556,2.40567,7.81741-3.95946,12.51288-12.18539,15.815-19.94186,7.43109-17.45514,14.01023-35.31364,20.1399-53.263q9.09651-26.63712,16.49855-53.81332.91661-3.36581,1.80683-6.73869c1.001-3.78869-1.26094-8.32-5.23829-9.22589a7.63317,7.63317,0,0,0-9.22589,5.23829Z" fill="#d4d4d4" transform="translate(-169.93432 -164.42601)"/>
|
||||
<path d="M889.12382,482.13557l-2.69954,95.79311-2.68548,95.29418-1.5185,53.88362a7.56465,7.56465,0,0,0,7.5,7.5,7.64923,7.64923,0,0,0,7.5-7.5l2.69955-95.79311,2.68548-95.29418,1.51849-53.88362a7.56465,7.56465,0,0,0-7.5-7.5,7.64923,7.64923,0,0,0-7.5,7.5Z" fill="#d4d4d4" transform="translate(-169.93432 -164.42601)"/>
|
||||
<path d="M629.52566,700.36106h2.32885V594.31942h54.32863v-2.32291H631.85451V547.25214H673.8102q-.92256-1.17339-1.89893-2.31694H631.85451V515.38231c-.7703-.32846-1.54659-.64493-2.32885-.9435V544.9352h-45.652V507.07c-.78227.03583-1.55258.08959-2.3289.15527v37.71h-36.4201V516.68409c-.78227.34636-1.55258.71061-2.31694,1.0928V544.9352h-30.6158v2.31694h30.6158v44.74437h-30.6158v2.32291h30.6158V700.36106h2.31694V594.31942a36.41283,36.41283,0,0,1,36.4201,36.42007v69.62157h2.3289V594.31942h45.652Zm-84.401-108.36455V547.25214h36.4201v44.74437Zm38.749,0V547.25214h.91362a44.74135,44.74135,0,0,1,44.73842,44.74437Z" opacity="0.2" transform="translate(-169.93432 -164.42601)"/>
|
||||
<path d="M615.30309,668.566a63.05854,63.05854,0,0,1-20.05,33.7c-.74.64-1.48,1.26-2.25,1.87q-2.805.25506-5.57.52c-1.53.14-3.04.29-4.54.43l-.27.03-.19-1.64-.76-6.64a37.623,37.623,0,0,1-3.3-32.44c2.64-7.12,7.42-13.41,12.12-19.65,6.49-8.62,12.8-17.14,13.03-27.65a60.54415,60.54415,0,0,1,7.9,13.33,16.432,16.432,0,0,0-5.12,3.76995c-.41.45-.82,1.08-.54,1.62006.24.46.84.57,1.36.62994,1.25.13,2.51.26,3.76.39,1,.11,2,.21,3,.32a63.99025,63.99025,0,0,1,2.45,12.18A61.18851,61.18851,0,0,1,615.30309,668.566Z" fill="#d4d4d4" transform="translate(-169.93432 -164.42601)"/>
|
||||
<path d="M648.50311,642.356c-5.9,4.29-9.35,10.46-12.03,17.26a16.62776,16.62776,0,0,0-7.17,4.58c-.41.45-.82,1.08-.54,1.62006.24.46.84.57,1.36.62994,1.25.13,2.51.26,3.76.39-2.68,8.04-5.14,16.36-9.88,23.15a36.98942,36.98942,0,0,1-12.03,10.91,38.49166,38.49166,0,0,1-4.02,1.99q-7.62.585-14.95,1.25-2.805.25506-5.57.52c-1.53.14-3.04.29-4.54.43q-.015-.825,0-1.65a63.30382,63.30382,0,0,1,15.25-39.86c.45-.52.91-1.03,1.38-1.54a61.7925,61.7925,0,0,1,16.81-12.7A62.65425,62.65425,0,0,1,648.50311,642.356Z" fill="#FD5E0F" transform="translate(-169.93432 -164.42601)"/>
|
||||
<path d="M589.16308,699.526l-1.15,3.4-.58,1.73c-1.53.14-3.04.29-4.54.43l-.27.03c-1.66.17-3.31.34-4.96.51-.43-.5-.86-1.01-1.28-1.53a62.03045,62.03045,0,0,1,8.07-87.11c-1.32,6.91.22,13.53,2.75,20.1-.27.11-.53.22-.78.34a16.432,16.432,0,0,0-5.12,3.76995c-.41.45-.82,1.08-.54,1.62006.24.46.84.57,1.36.62994,1.25.13,2.51.26,3.76.39,1,.11,2,.21,3,.32q.705.075,1.41.15c.07.15.13.29.2.44,2.85,6.18,5.92,12.39,7.65,18.83a43.66591,43.66591,0,0,1,1.02,4.91A37.604,37.604,0,0,1,589.16308,699.526Z" fill="#FD5E0F" transform="translate(-169.93432 -164.42601)"/>
|
||||
<path d="M689.82123,554.48655c-8.60876-16.79219-21.94605-30.92088-37.63219-41.30357a114.2374,114.2374,0,0,0-52.5626-18.37992q-3.69043-.33535-7.399-.39281c-2.92141-.04371-46.866,12.63176-61.58712,22.98214a114.29462,114.29462,0,0,0-35.333,39.527,102.49972,102.49972,0,0,0-12.12557,51.6334,113.56387,113.56387,0,0,0,14.70268,51.47577,110.47507,110.47507,0,0,0,36.44425,38.74592C549.66655,708.561,565.07375,734.51,583.1831,735.426c18.24576.923,39.05418-23.55495,55.6951-30.98707a104.42533,104.42533,0,0,0,41.72554-34.005,110.24964,110.24964,0,0,0,19.599-48.94777c2.57368-18.08313,1.37415-36.73271-4.80123-54.01627a111.85969,111.85969,0,0,0-5.58024-12.9833c-1.77961-3.50519-6.996-4.7959-10.26142-2.69063a7.67979,7.67979,0,0,0-2.69064,10.26142q1.56766,3.08773,2.91536,6.27758l-.75606-1.7916a101.15088,101.15088,0,0,1,6.87641,25.53816l-.26791-1.99379a109.2286,109.2286,0,0,1-.06613,28.68252l.26791-1.9938a109.73379,109.73379,0,0,1-7.55462,27.67419l.75606-1.79159a104.212,104.212,0,0,1-6.67151,13.09835q-1.92308,3.18563-4.08062,6.22159c-.63172.8881-1.28287,1.761-1.939,2.63114-.85625,1.13555,1.16691-1.48321.28228-.36941-.15068.18972-.30049.3801-.45182.5693q-.68121.85165-1.3818,1.68765a93.61337,93.61337,0,0,1-10.17647,10.38359q-1.36615,1.19232-2.77786,2.33115c-.46871.37832-.932.77269-1.42079,1.12472.01861-.0134,1.57956-1.19945.65556-.511-.2905.21644-.57851.43619-.86961.65184q-2.90994,2.1558-5.97433,4.092a103.48509,103.48509,0,0,1-14.75565,7.7131l1.7916-.75606a109.21493,109.21493,0,0,1-27.59663,7.55154l1.9938-.26791a108.15361,108.15361,0,0,1-28.58907.0506l1.99379.2679a99.835,99.835,0,0,1-25.09531-6.78448l1.79159.75607a93.64314,93.64314,0,0,1-13.41605-6.99094q-3.17437-2-6.18358-4.24743c-.2862-.21359-.56992-.43038-.855-.64549-.9155-.69088.65765.50965.67021.51787a19.16864,19.16864,0,0,1-1.535-1.22469q-1.45353-1.18358-2.86136-2.4218a101.98931,101.98931,0,0,1-10.49319-10.70945q-1.21308-1.43379-2.37407-2.91054c-.33524-.4263-.9465-1.29026.40424.5289-.17775-.23939-.36206-.47414-.54159-.71223q-.64657-.85751-1.27568-1.72793-2.203-3.048-4.18787-6.24586a109.29037,109.29037,0,0,1-7.8054-15.10831l.75606,1.7916a106.58753,106.58753,0,0,1-7.34039-26.837l.26791,1.9938a97.86589,97.86589,0,0,1-.04843-25.63587l-.2679,1.9938A94.673,94.673,0,0,1,505.27587,570.55l-.75606,1.7916a101.55725,101.55725,0,0,1,7.19519-13.85624q2.0655-3.32328,4.37767-6.4847.52528-.71832,1.06244-1.42786c.324-.4279,1.215-1.49333-.30537.38842.14906-.18449.29252-.37428.43942-.56041q1.26882-1.60756,2.59959-3.1649A107.40164,107.40164,0,0,1,530.772,536.21508q1.47408-1.29171,2.99464-2.52906.6909-.56218,1.39108-1.11284c.18664-.14673.37574-.29073.56152-.43858-1.99743,1.58953-.555.43261-.10157.09288q3.13393-2.34833,6.43534-4.46134a103.64393,103.64393,0,0,1,15.38655-8.10791l-1.7916.75606c7.76008-3.25839,42.14086-10.9492,48.394-10.10973l-1.99379-.26791A106.22471,106.22471,0,0,1,628.768,517.419l-1.7916-.75606a110.31334,110.31334,0,0,1,12.6002,6.32922q3.04344,1.78405,5.96742,3.76252,1.38351.93658,2.73809,1.915.677.48917,1.34626.98885c.24789.185.49386.37253.74135.558,1.03924.779-1.43148-1.1281-.34209-.26655a110.84261,110.84261,0,0,1,10.36783,9.2532q2.401,2.445,4.63686,5.04515,1.14659,1.33419,2.24643,2.70757c.36436.45495,1.60506,2.101.08448.08457.37165.49285.74744.98239,1.11436,1.47884a97.97718,97.97718,0,0,1,8.39161,13.53807c1.79317,3.49775,6.98675,4.80186,10.26142,2.69064A7.67666,7.67666,0,0,0,689.82123,554.48655Z" fill="#d4d4d4" transform="translate(-169.93432 -164.42601)"/>
|
||||
<path d="M602.43116,676.88167a3.77983,3.77983,0,0,1-2.73939-6.55137c.09531-.37882.16368-.65085.259-1.02968q-.05115-.12366-.1029-.24717c-3.47987-8.29769-25.685,14.83336-26.645,22.63179a30.029,30.029,0,0,0,.52714,10.32752A120.39223,120.39223,0,0,1,562.77838,652.01a116.20247,116.20247,0,0,1,.72078-12.96332q.59712-5.293,1.65679-10.51055a121.78667,121.78667,0,0,1,24.1515-51.61646c6.87378.38364,12.898-.66348,13.47967-13.98532.10346-2.36972,1.86113-4.42156,2.24841-6.756-.65621.08607-1.32321.13985-1.97941.18285-.20444.0107-.41958.02149-.624.03228l-.07709.00346a3.745,3.745,0,0,1-3.07566-6.10115q.425-.52305.85054-1.04557c.43036-.53793.87143-1.06507,1.30171-1.60292a1.865,1.865,0,0,0,.13986-.16144c.49494-.61322.98971-1.21564,1.48465-1.82885a10.82911,10.82911,0,0,0-3.55014-3.43169c-4.95941-2.90463-11.80146-.89293-15.38389,3.59313-3.59313,4.486-4.27083,10.77947-3.023,16.3843a43.39764,43.39764,0,0,0,6.003,13.3828c-.269.34429-.54872.67779-.81765,1.02209a122.57366,122.57366,0,0,0-12.79359,20.2681c1.0163-7.93863-11.41159-36.60795-16.21776-42.68052-5.773-7.29409-17.61108-4.11077-18.62815,5.13562q-.01476.13428-.02884.26849,1.07082.60411,2.0964,1.28237a5.12707,5.12707,0,0,1-2.06713,9.33031l-.10452.01613c-9.55573,13.64367,21.07745,49.1547,28.74518,41.18139a125.11045,125.11045,0,0,0-6.73449,31.69282,118.66429,118.66429,0,0,0,.08607,19.15986l-.03231-.22593C558.90163,648.154,529.674,627.51374,521.139,629.233c-4.91675.99041-9.75952.76525-9.01293,5.72484q.01788.11874.03635.2375a34.4418,34.4418,0,0,1,3.862,1.86105q1.07082.60423,2.09639,1.28237a5.12712,5.12712,0,0,1-2.06712,9.33039l-.10464.01606c-.07528.01079-.13987.02157-.21507.03237-4.34967,14.96631,27.90735,39.12,47.5177,31.43461h.01081a125.07484,125.07484,0,0,0,8.402,24.52806H601.679c.10765-.3335.20443-.67779.3013-1.01129a34.102,34.102,0,0,1-8.30521-.49477c2.22693-2.73257,4.45377-5.48664,6.6807-8.21913a1.86122,1.86122,0,0,0,.13986-.16135c1.12956-1.39849,2.26992-2.78627,3.39948-4.18476l.00061-.00173a49.95232,49.95232,0,0,0-1.46367-12.72495Zm-34.37066-67.613.0158-.02133-.0158.04282Zm-6.64832,59.93237-.25822-.58084c.01079-.41957.01079-.83914,0-1.26942,0-.11845-.0215-.23672-.0215-.35508.09678.74228.18285,1.48464.29042,2.22692Z" fill="#d4d4d4" transform="translate(-169.93432 -164.42601)"/>
|
||||
<circle cx="95.24878" cy="439" fill="#d4d4d4" r="11"/>
|
||||
<circle cx="227.24878" cy="559" fill="#d4d4d4" r="11"/>
|
||||
<circle cx="728.24878" cy="559" fill="#d4d4d4" r="11"/>
|
||||
<circle cx="755.24878" cy="419" fill="#d4d4d4" r="11"/>
|
||||
<circle cx="723.24878" cy="317" fill="#d4d4d4" r="11"/>
|
||||
<path d="M434.1831,583.426a10.949,10.949,0,1,1-.21-2.16A10.9921,10.9921,0,0,1,434.1831,583.426Z" fill="#d4d4d4" transform="translate(-169.93432 -164.42601)"/>
|
||||
<circle cx="484.24878" cy="349" fill="#d4d4d4" r="11"/>
|
||||
<path d="M545.1831,513.426a10.949,10.949,0,1,1-.21-2.16A10.9921,10.9921,0,0,1,545.1831,513.426Z" fill="#d4d4d4" transform="translate(-169.93432 -164.42601)"/>
|
||||
<path d="M403.1831,481.426a10.949,10.949,0,1,1-.21-2.16A10.9921,10.9921,0,0,1,403.1831,481.426Z" fill="#d4d4d4" transform="translate(-169.93432 -164.42601)"/>
|
||||
<circle cx="599.24878" cy="443" fill="#d4d4d4" r="11"/>
|
||||
<circle cx="426.24878" cy="338" fill="#d4d4d4" r="16"/>
|
||||
<path d="M1028.875,735.26666l-857.75.30733a1.19068,1.19068,0,1,1,0-2.38136l857.75-.30734a1.19069,1.19069,0,0,1,0,2.38137Z" fill="#525252" transform="translate(-169.93432 -164.42601)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 23 KiB |
55
public/illustrations/server-down.svg
Normal file
@@ -0,0 +1,55 @@
|
||||
<svg data-name="Layer 1" viewBox="0 0 1119.60911 699" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>
|
||||
server down
|
||||
</title>
|
||||
<circle cx="292.60911" cy="213" fill="#2a2a2a" r="213"/>
|
||||
<path d="M31.39089,151.64237c0,77.49789,48.6181,140.20819,108.70073,140.20819" fill="#a3a3a3" transform="translate(-31.39089 -100.5)"/>
|
||||
<path d="M140.09162,291.85056c0-78.36865,54.255-141.78356,121.30372-141.78356" fill="#FD5E0F" transform="translate(-31.39089 -100.5)"/>
|
||||
<path d="M70.77521,158.66768c0,73.61476,31.00285,133.18288,69.31641,133.18288" fill="#FD5E0F" transform="translate(-31.39089 -100.5)"/>
|
||||
<path d="M140.09162,291.85056c0-100.13772,62.7103-181.16788,140.20819-181.16788" fill="#a3a3a3" transform="translate(-31.39089 -100.5)"/>
|
||||
<path d="M117.22379,292.83905s15.41555-.47479,20.06141-3.783,23.713-7.2585,24.86553-1.95278,23.16671,26.38821,5.76263,26.5286-40.43935-2.711-45.07627-5.53549S117.22379,292.83905,117.22379,292.83905Z" fill="#a8a8a8" transform="translate(-31.39089 -100.5)"/>
|
||||
<path d="M168.224,311.78489c-17.40408.14042-40.43933-2.71094-45.07626-5.53548-3.53126-2.151-4.93843-9.86945-5.40926-13.43043-.32607.014-.51463.02-.51463.02s.97638,12.43276,5.61331,15.2573,27.67217,5.67589,45.07626,5.53547c5.02386-.04052,6.7592-1.82793,6.66391-4.47526C173.87935,310.756,171.96329,311.75474,168.224,311.78489Z" opacity="0.2" transform="translate(-31.39089 -100.5)"/>
|
||||
<ellipse cx="198.60911" cy="424.5" fill="#d4d4d4" rx="187" ry="25.43993"/>
|
||||
<ellipse cx="198.60911" cy="424.5" opacity="0.1" rx="157" ry="21.35866"/>
|
||||
<ellipse cx="836.60911" cy="660.5" fill="#d4d4d4" rx="283" ry="38.5"/>
|
||||
<ellipse cx="310.60911" cy="645.5" fill="#d4d4d4" rx="170" ry="23.12721"/>
|
||||
<path d="M494,726.5c90,23,263-30,282-90" fill="none" stroke="#a3a3a3" stroke-miterlimit="10" stroke-width="2" transform="translate(-31.39089 -100.5)"/>
|
||||
<path d="M341,359.5s130-36,138,80-107,149-17,172" fill="none" stroke="#a3a3a3" stroke-miterlimit="10" stroke-width="2" transform="translate(-31.39089 -100.5)"/>
|
||||
<path d="M215.40233,637.78332s39.0723-10.82,41.47675,24.04449-32.15951,44.78287-5.10946,51.69566" fill="none" stroke="#a3a3a3" stroke-miterlimit="10" stroke-width="2" transform="translate(-31.39089 -100.5)"/>
|
||||
<path d="M810.09554,663.73988,802.218,714.03505s-38.78182,20.60284-11.51335,21.20881,155.73324,0,155.73324,0,24.84461,0-14.54318-21.81478l-7.87756-52.719Z" fill="#a3a3a3" transform="translate(-31.39089 -100.5)"/>
|
||||
<path d="M785.21906,734.69812c6.193-5.51039,16.9989-11.252,16.9989-11.252l7.87756-50.2952,113.9216.10717,7.87756,49.582c9.185,5.08711,14.8749,8.987,18.20362,11.97818,5.05882-1.15422,10.58716-5.44353-18.20362-21.38921l-7.87756-52.719-113.9216,3.02983L802.218,714.03506S769.62985,731.34968,785.21906,734.69812Z" opacity="0.1" transform="translate(-31.39089 -100.5)"/>
|
||||
<rect fill="#a3a3a3" height="357.51989" rx="18.04568" width="513.25314" x="578.43291" y="212.68859"/>
|
||||
<rect fill="#d4d4d4" height="267.83694" width="478.71308" x="595.70294" y="231.77652"/>
|
||||
<circle cx="835.05948" cy="223.29299" fill="#2a2a2a" r="3.02983"/>
|
||||
<path d="M1123.07694,621.32226V652.6628a18.04341,18.04341,0,0,1-18.04568,18.04568H627.86949A18.04341,18.04341,0,0,1,609.8238,652.6628V621.32226Z" fill="#a3a3a3" transform="translate(-31.39089 -100.5)"/>
|
||||
<polygon fill="#a3a3a3" points="968.978 667.466 968.978 673.526 642.968 673.526 642.968 668.678 643.417 667.466 651.452 645.651 962.312 645.651 968.978 667.466"/>
|
||||
<path d="M1125.828,762.03359c-.59383,2.539-2.83591,5.21743-7.90178,7.75032-18.179,9.08949-55.1429-2.42386-55.1429-2.42386s-28.4804-4.84773-28.4804-17.573a22.72457,22.72457,0,0,1,2.49658-1.48459c7.64294-4.04351,32.98449-14.02122,77.9177.42248a18.73921,18.73921,0,0,1,8.54106,5.59715C1125.07908,756.45353,1126.50669,759.15715,1125.828,762.03359Z" fill="#a3a3a3" transform="translate(-31.39089 -100.5)"/>
|
||||
<path d="M1125.828,762.03359c-22.251,8.526-42.0843,9.1622-62.43871-4.975-10.26507-7.12617-19.59089-8.88955-26.58979-8.75618,7.64294-4.04351,32.98449-14.02122,77.9177.42248a18.73921,18.73921,0,0,1,8.54106,5.59715C1125.07908,756.45353,1126.50669,759.15715,1125.828,762.03359Z" opacity="0.1" transform="translate(-31.39089 -100.5)"/>
|
||||
<ellipse cx="1066.53846" cy="654.13477" fill="#2a2a2a" rx="7.87756" ry="2.42386"/>
|
||||
<circle cx="835.05948" cy="545.66686" fill="#2a2a2a" r="11.51335"/>
|
||||
<polygon opacity="0.1" points="968.978 667.466 968.978 673.526 642.968 673.526 642.968 668.678 643.417 667.466 968.978 667.466"/>
|
||||
<rect fill="#a3a3a3" height="242" width="208" x="108.60911" y="159"/>
|
||||
<rect fill="#d4d4d4" height="86" width="250" x="87.60911" y="135"/>
|
||||
<rect fill="#d4d4d4" height="86" width="250" x="87.60911" y="237"/>
|
||||
<rect fill="#d4d4d4" height="86" width="250" x="87.60911" y="339"/>
|
||||
<rect fill="#FD5E0F" height="16" opacity="0.4" width="16" x="271.60911" y="150"/>
|
||||
<rect fill="#FD5E0F" height="16" opacity="0.8" width="16" x="294.60911" y="150"/>
|
||||
<rect fill="#FD5E0F" height="16" width="16" x="317.60911" y="150"/>
|
||||
<rect fill="#FD5E0F" height="16" opacity="0.4" width="16" x="271.60911" y="251"/>
|
||||
<rect fill="#FD5E0F" height="16" opacity="0.8" width="16" x="294.60911" y="251"/>
|
||||
<rect fill="#FD5E0F" height="16" width="16" x="317.60911" y="251"/>
|
||||
<rect fill="#FD5E0F" height="16" opacity="0.4" width="16" x="271.60911" y="352"/>
|
||||
<rect fill="#FD5E0F" height="16" opacity="0.8" width="16" x="294.60911" y="352"/>
|
||||
<rect fill="#FD5E0F" height="16" width="16" x="317.60911" y="352"/>
|
||||
<circle cx="316.60911" cy="538" fill="#a3a3a3" r="79"/>
|
||||
<rect fill="#a3a3a3" height="43" width="24" x="280.60911" y="600"/>
|
||||
<rect fill="#a3a3a3" height="43" width="24" x="328.60911" y="600"/>
|
||||
<ellipse cx="300.60911" cy="643.5" fill="#a3a3a3" rx="20" ry="7.5"/>
|
||||
<ellipse cx="348.60911" cy="642.5" fill="#a3a3a3" rx="20" ry="7.5"/>
|
||||
<circle cx="318.60911" cy="518" fill="#262626" r="27"/>
|
||||
<circle cx="318.60911" cy="518" fill="#d4d4d4" r="9"/>
|
||||
<path d="M271.36733,565.03228c-6.37889-28.56758,14.01185-57.43392,45.544-64.47477s62.2651,10.41,68.644,38.9776-14.51861,39.10379-46.05075,46.14464S277.74622,593.59986,271.36733,565.03228Z" fill="#FD5E0F" transform="translate(-31.39089 -100.5)"/>
|
||||
<ellipse cx="417.21511" cy="611.34365" fill="#a3a3a3" rx="39.5" ry="12.40027" transform="translate(-238.28665 112.98044) rotate(-23.17116)"/>
|
||||
<ellipse cx="269.21511" cy="664.34365" fill="#a3a3a3" rx="39.5" ry="12.40027" transform="translate(-271.07969 59.02084) rotate(-23.17116)"/>
|
||||
<path d="M394,661.5c0,7.732-19.90861,23-42,23s-43-14.268-43-22,20.90861-6,43-6S394,653.768,394,661.5Z" fill="#262626" transform="translate(-31.39089 -100.5)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.4 KiB |
35
public/illustrations/setup-analytics.svg
Normal file
@@ -0,0 +1,35 @@
|
||||
<svg data-name="Layer 1" viewBox="0 0 686.30979 507.37542" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<path d="M839.55171,703.68771h-29.02c-.43018-.78-.83008-1.58-1.2002-2.39-3.33984-7.15-4.75-15.13-6.1001-22.95l-2.18994-12.7q10.5,7.635,20.99024,15.26c2.27978,1.66,4.60986,3.39,6.7998,5.26,4.61035,3.91,8.59033,8.43,10.1499,14.17.08008.32.16016.64.22022.96A19.46641,19.46641,0,0,1,839.55171,703.68771Z" fill="#2a2a2a" transform="translate(-256.84511 -196.31229)"/>
|
||||
<path d="M843.62153,701.29769a.77484.77484,0,0,1-.00976.15,10.48655,10.48655,0,0,1-.54,2.24H830.04145a16.31218,16.31218,0,0,1-1.03955-2.39,17.49851,17.49851,0,0,1-.8501-3.39,35.21814,35.21814,0,0,1,.67969-11.74c.25-1.25.52-2.51.79-3.75l1.54-7.08,7.71,12.39C841.45161,691.87771,844.13179,696.44766,843.62153,701.29769Z" fill="#2a2a2a" transform="translate(-256.84511 -196.31229)"/>
|
||||
<path d="M942.16168,232.86478H257.83832a1.0156,1.0156,0,0,1,0-2.0307H942.16168a1.0156,1.0156,0,0,1,0,2.0307Z" fill="#525252" transform="translate(-256.84511 -196.31229)"/>
|
||||
<ellipse cx="23.34831" cy="11.16881" fill="#d4d4d4" rx="10.92534" ry="11.16881"/>
|
||||
<ellipse cx="61.09038" cy="11.16881" fill="#d4d4d4" rx="10.92534" ry="11.16881"/>
|
||||
<ellipse cx="98.83246" cy="11.16881" fill="#d4d4d4" rx="10.92534" ry="11.16881"/>
|
||||
<path d="M919.5485,203.1439H892.73177a2.03119,2.03119,0,0,1,0-4.06139H919.5485a2.03119,2.03119,0,0,1,0,4.06139Z" fill="#d4d4d4" transform="translate(-256.84511 -196.31229)"/>
|
||||
<path d="M919.5485,210.759H892.73177a2.03119,2.03119,0,0,1,0-4.06139H919.5485a2.03119,2.03119,0,0,1,0,4.06139Z" fill="#d4d4d4" transform="translate(-256.84511 -196.31229)"/>
|
||||
<path d="M919.5485,218.3741H892.73177a2.03119,2.03119,0,0,1,0-4.06139H919.5485a2.03119,2.03119,0,0,1,0,4.06139Z" fill="#d4d4d4" transform="translate(-256.84511 -196.31229)"/>
|
||||
<path d="M841.26582,578.28769H321.67744a20.7293,20.7293,0,0,1-20.70581-20.70606V298.99325a20.72918,20.72918,0,0,1,20.70581-20.70556H841.26582a20.72919,20.72919,0,0,1,20.70581,20.70556V557.58163A20.7293,20.7293,0,0,1,841.26582,578.28769Z" fill="#2a2a2a" transform="translate(-256.84511 -196.31229)"/>
|
||||
<path d="M898.30024,648.84188H378.71186a20.7293,20.7293,0,0,1-20.70581-20.706V369.54745a20.7292,20.7292,0,0,1,20.70581-20.70557H898.30024a20.72919,20.72919,0,0,1,20.70581,20.70557V628.13583A20.72929,20.72929,0,0,1,898.30024,648.84188Z" fill="#262626" transform="translate(-256.84511 -196.31229)"/>
|
||||
<path d="M898.30024,648.84188H378.71186a20.7293,20.7293,0,0,1-20.70581-20.706V369.54745a20.7292,20.7292,0,0,1,20.70581-20.70557H898.30024a20.72919,20.72919,0,0,1,20.70581,20.70557V628.13583A20.72929,20.72929,0,0,1,898.30024,648.84188Zm-519.58838-297a17.72584,17.72584,0,0,0-17.70581,17.70557V628.13583a17.72594,17.72594,0,0,0,17.70581,17.706H898.30024a17.726,17.726,0,0,0,17.70581-17.706V369.54745a17.72592,17.72592,0,0,0-17.70581-17.70557Z" fill="#404040" transform="translate(-256.84511 -196.31229)"/>
|
||||
<path d="M418.61225,603.56884H834.09264V532.35693a56.38609,56.38609,0,0,0-17.29473-40.16373l-.11295-.11031c-7.42075-7.44571-32.22061-26.34266-57.88-24.74044-13.55448.84379-25.159,7.3761-34.49064,19.4157-29.04672,37.48987-67.20243,24.835-87.36145,13.955-17.76588-9.59031-35.90932-14.09161-53.93-13.37521-25.23817.98693-60.76106,10.28438-86.6677,48.9746-9.89467,14.76533-31.41327,34.80813-77.74291,46.587Z" fill="#FD5E0F" style="isolation:isolate" transform="translate(-256.84511 -196.31229)"/>
|
||||
<path d="M857.6003,605.225H419.41186a1.3714,1.3714,0,0,1-1.37133-1.37133V393.8299a1.37133,1.37133,0,0,1,2.74266,0V602.4823H857.6003a1.37133,1.37133,0,1,1,0,2.74266Z" fill="#d4d4d4" transform="translate(-256.84511 -196.31229)"/>
|
||||
<circle cx="325.26146" cy="293.18288" fill="#d4d4d4" r="8.22798"/>
|
||||
<circle cx="486.63869" cy="271.66591" fill="#d4d4d4" r="8.22798"/>
|
||||
<circle cx="405.95007" cy="316.04465" fill="#d4d4d4" r="8.22798"/>
|
||||
<circle cx="244.57284" cy="328.14794" fill="#d4d4d4" r="8.22798"/>
|
||||
<circle cx="567.32731" cy="301.25174" fill="#d4d4d4" r="8.22798"/>
|
||||
<path d="M919.49205,523.44529a8.62866,8.62866,0,0,1,.92064-13.199L911.5418,480.8942l15.27123,4.54873,6.0827,27.17593a8.67542,8.67542,0,0,1-13.40368,10.82643Z" fill="#9e616a" transform="translate(-256.84511 -196.31229)"/>
|
||||
<path d="M775.49791,348.90049a8.62865,8.62865,0,0,0,8.96991,9.72628l11.42578,28.4551,9.0756-13.09714-12.24151-25.01351a8.67542,8.67542,0,0,0-17.22978-.07073Z" fill="#9e616a" transform="translate(-256.84511 -196.31229)"/>
|
||||
<polygon fill="#9e616a" points="647.472 494.812 636.952 494.812 631.948 454.235 647.474 454.236 647.472 494.812"/>
|
||||
<path d="M907.00008,701.32191l-33.92056-.00126v-.429A13.20353,13.20353,0,0,1,886.28235,687.689h.00084l20.71751.00084Z" fill="#a3a3a3" transform="translate(-256.84511 -196.31229)"/>
|
||||
<polygon fill="#9e616a" points="613.047 494.478 603.018 491.303 610.493 451.107 625.295 455.794 613.047 494.478"/>
|
||||
<path d="M869.37275,701.32191l-32.33854-10.23828.12948-.409a13.20355,13.20355,0,0,1,16.57171-8.60245l.0008.00025,19.75126,6.25326Z" fill="#a3a3a3" transform="translate(-256.84511 -196.31229)"/>
|
||||
<path d="M857.43293,492.80844s-15.60875,24.50868-10.09315,46.383,11.52216,63.92019,11.52216,63.92019l29.90593,71.17205,22.2929-.90945L888.92232,589.46l.87536-56.10627s15.38706-26.81444,12.39054-35.16157S857.43293,492.80844,857.43293,492.80844Z" fill="#a3a3a3" transform="translate(-256.84511 -196.31229)"/>
|
||||
<path d="M908.42646,501.571s1.92027,24.31339-4.08629,34.61035-12.406,49.48841-12.406,49.48841l-10.20948,78.38239-21.14661-6.88153,18.03212-87.81806,14.1729-69.07881Z" fill="#a3a3a3" transform="translate(-256.84511 -196.31229)"/>
|
||||
<path d="M915.92425,383.87218l-16.499-11.34081-25.10769-.72927-8.74352,8.57208-13.34314,6.81363-1.23569,111.00437s39.95671,19.15179,61.45273,9.90725l10.341-86.47174C927.07929,404.4661,915.92425,383.87218,915.92425,383.87218Z" fill="#d4d4d4" transform="translate(-256.84511 -196.31229)"/>
|
||||
<path d="M855.58211,416.85183H811.57262l-28.9141-49.77031,18.80108-7.447,15.92224,28.32334,23.04561-2.08571,14.38938-.69593a15.84711,15.84711,0,0,1,16.49437,17.75949h0A15.86568,15.86568,0,0,1,855.58211,416.85183Z" fill="#d4d4d4" transform="translate(-256.84511 -196.31229)"/>
|
||||
<circle cx="632.07342" cy="144.5792" fill="#9e616a" r="21.76948"/>
|
||||
<path d="M915.1489,335.93858c-.66849-4.77931-3.90535-9.31727-8.50873-10.76518-1.82866-5.31031-6.67233-9.21985-12.0238-10.92407a23.48728,23.48728,0,0,0-27.00124,9.84786c-.40956.64393-1.66882,2.22585-1.63362,2.99427.04462.97592,1.53767,1.98358,2.28765,2.59886a33.53927,33.53927,0,0,0,5.98121,3.69167c7.8281,4.05838,5.22223,10.43637,5.116,17.66385-.05153,3.51224,1.121,6.51112,4.1159,8.51727,4.32224,2.89534,10.50791,1.74345,15.2108.58145,5.3364-1.31865,9.773-6.00656,12.81631-10.52662C914.20472,345.61491,915.8176,340.71788,915.1489,335.93858Z" fill="#a3a3a3" transform="translate(-256.84511 -196.31229)"/>
|
||||
<polygon fill="#d4d4d4" points="658.087 215.476 676.547 306.933 649.333 308.422 658.087 215.476"/>
|
||||
<path d="M933.99165,702.4877a1.19488,1.19488,0,0,1-1.18994,1.2H791.5019a1.195,1.195,0,0,1,0-2.39H932.80171A1.19269,1.19269,0,0,1,933.99165,702.4877Z" fill="#525252" transform="translate(-256.84511 -196.31229)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.0 KiB |
36
public/illustrations/website-setup.svg
Normal file
@@ -0,0 +1,36 @@
|
||||
<svg data-name="Layer 1" viewBox="0 0 842.16979 568.85227" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<path d="M716.621,734.1684v-72.34S744.81264,713.11437,716.621,734.1684Z" fill="#f1f1f1" transform="translate(-178.45119 -165.31613)"/>
|
||||
<path d="M718.36244,734.15568l-53.28962-48.92125S721.918,699.15,718.36244,734.15568Z" fill="#f1f1f1" transform="translate(-178.45119 -165.31613)"/>
|
||||
<rect data-name="Rectangle 62" fill="#e5e5e5" height="399.45384" id="f54375ad-557d-4249-95e2-413e25c77bd8" width="841.81084" x="0.35895" y="23.0788"/>
|
||||
<rect data-name="Rectangle 75" fill="#262626" height="333.27683" id="eb84611c-d49d-4ce2-b538-4fee0a7e188d" width="793.66829" x="24.43081" y="57.33267"/>
|
||||
<rect data-name="Rectangle 80" fill="#FD5E0F" height="35.76263" id="a33b5263-04c3-449f-a8ad-1420252c1e48" width="841.81084"/>
|
||||
<circle cx="26.57607" cy="17.81511" data-name="Ellipse 90" fill="#262626" id="a01a8c4b-bf8b-4728-9381-d6cbaed15ee5" r="6.62847"/>
|
||||
<circle cx="51.73556" cy="17.81511" data-name="Ellipse 91" fill="#262626" id="a70f271b-ee75-4447-9507-72af025f0784" r="6.62847"/>
|
||||
<circle cx="76.89622" cy="17.81511" data-name="Ellipse 92" fill="#262626" id="b6ae4294-4107-4abd-ba05-1d458fcbc8d7" r="6.62847"/>
|
||||
<path d="M332.91148,298.51922a3.268,3.268,0,0,0,0,6.536h89.293a3.268,3.268,0,1,0,0-6.536Z" data-name="Path 680" fill="#d4d4d4" id="a98f5d70-28fc-4129-b4f3-463d07786f2f-1376" transform="translate(-178.45119 -165.31613)"/>
|
||||
<path d="M282.41148,343.51922a3.268,3.268,0,0,0,0,6.536h190.293a3.268,3.268,0,1,0,0-6.536Z" data-name="Path 680" fill="#525252" id="a5717240-4d8d-4c71-83bc-6deeb889f166-1377" transform="translate(-178.45119 -165.31613)"/>
|
||||
<path d="M282.41148,369.51922a3.268,3.268,0,0,0,0,6.536h190.293a3.268,3.268,0,1,0,0-6.536Z" data-name="Path 680" fill="#525252" id="f9c6f13d-6e3a-4758-9844-a0f5d5c969ce-1378" transform="translate(-178.45119 -165.31613)"/>
|
||||
<path d="M282.41148,395.51922a3.268,3.268,0,0,0,0,6.536h190.293a3.268,3.268,0,1,0,0-6.536Z" data-name="Path 680" fill="#525252" id="bf808dfd-2bb2-4793-b7c6-d8671bf7e9b4-1379" transform="translate(-178.45119 -165.31613)"/>
|
||||
<path d="M282.41148,421.51922a3.268,3.268,0,0,0,0,6.536h190.293a3.268,3.268,0,1,0,0-6.536Z" data-name="Path 680" fill="#525252" id="e27373f7-fd0b-426a-8828-0829b084b689-1380" transform="translate(-178.45119 -165.31613)"/>
|
||||
<path d="M282.41148,447.51922a3.268,3.268,0,0,0,0,6.536h190.293a3.268,3.268,0,1,0,0-6.536Z" data-name="Path 680" fill="#525252" id="b4c6c7b3-e22e-4fba-a4f6-80d19d07dbd3-1381" transform="translate(-178.45119 -165.31613)"/>
|
||||
<path d="M282.41148,473.51922a3.268,3.268,0,0,0,0,6.536h190.293a3.268,3.268,0,1,0,0-6.536Z" data-name="Path 680" fill="#525252" id="ebf0dc46-a6ad-47cb-a3de-ddb71e9e94e3-1382" transform="translate(-178.45119 -165.31613)"/>
|
||||
<path d="M910.16054,481.4543H622.05779a7.271,7.271,0,0,1-7.2631-7.26309V304.719a7.271,7.271,0,0,1,7.2631-7.26309H910.16054a7.271,7.271,0,0,1,7.2631,7.26309V474.19121A7.271,7.271,0,0,1,910.16054,481.4543Z" fill="#f1f1f1" transform="translate(-178.45119 -165.31613)"/>
|
||||
<path d="M948.06072,520.21623H659.958a7.271,7.271,0,0,1-7.26309-7.2631V343.48092a7.271,7.271,0,0,1,7.26309-7.26309H948.06072a7.271,7.271,0,0,1,7.26309,7.26309V512.95313A7.271,7.271,0,0,1,948.06072,520.21623Z" fill="#262626" transform="translate(-178.45119 -165.31613)"/>
|
||||
<path d="M948.0605,522.68784H659.958a9.74579,9.74579,0,0,1-9.73465-9.73485V343.4813a9.7458,9.7458,0,0,1,9.73465-9.73486H948.0605a9.74585,9.74585,0,0,1,9.73486,9.73486V512.953A9.74584,9.74584,0,0,1,948.0605,522.68784ZM659.958,338.6895a4.79721,4.79721,0,0,0-4.7916,4.7918V512.953a4.79721,4.79721,0,0,0,4.7916,4.7918H948.0605a4.79738,4.79738,0,0,0,4.7918-4.7918V343.4813a4.79738,4.79738,0,0,0-4.7918-4.7918Z" fill="#d4d4d4" transform="translate(-178.45119 -165.31613)"/>
|
||||
<circle cx="625.16979" cy="285.43031" fill="#ff6583" r="25"/>
|
||||
<path d="M851.849,426.695a16.09868,16.09868,0,0,0-23.02463-.41135l-81.07973,81.08013c-10.386,10.43459-21.22811-5.52482-28.15628-11.9606-16.66045-16.47735-34.65555,14.419-45.85423,23.26678H934.2846v-3.3168Z" fill="#FD5E0F" transform="translate(-178.45119 -165.31613)"/>
|
||||
<path d="M530.68063,524.9767l-4.38425-60.75355,7.8739-56.93181-22.3624-1.50994-4.41411,62.88884L519.869,527.80992a8.79767,8.79767,0,1,0,10.81159-2.83322Z" fill="#9f616a" transform="translate(-178.45119 -165.31613)"/>
|
||||
<path d="M508.95477,413.96379l5.251-22.72576a17.49724,17.49724,0,0,1,34.43542,5.897l-2.8917,25.67924Z" fill="#cbcbcb" transform="translate(-178.45119 -165.31613)"/>
|
||||
<polygon fill="#9f616a" points="371.903 557.597 382.445 557.597 387.46 516.933 371.901 516.934 371.903 557.597"/>
|
||||
<path d="M547.66455,719.47144l20.7617-.00084h.00084a13.23168,13.23168,0,0,1,13.231,13.23076v.43l-33.99289.00126Z" fill="#a3a3a3" transform="translate(-178.45119 -165.31613)"/>
|
||||
<polygon fill="#9f616a" points="424.424 557.252 434.559 554.349 428.184 513.876 413.226 518.161 424.424 557.252"/>
|
||||
<path d="M599.34235,719.99967l19.95893-5.71742.00081-.00024a13.2317,13.2317,0,0,1,16.36256,9.07627l.11839.41333-32.67857,9.361Z" fill="#a3a3a3" transform="translate(-178.45119 -165.31613)"/>
|
||||
<path d="M544.65572,708.15422l4.90409-123.1785-12.73593-55.74418c-17.13859-28.7351,1.35092-74.57013,1.53985-75.02995l.06876-.16759.17972-.02456,44.832-6.074.62973,1.44515a214.69124,214.69124,0,0,1,13.94748,127.51609l-4.66605,23.86585,23.06132,93.11029-21.82566,10.91291-16.74555-43.4245L566.52636,705.79Z" fill="#a3a3a3" transform="translate(-178.45119 -165.31613)"/>
|
||||
<path d="M532.459,465.6262l-14.62586-42.85509c-9.09235-16.01207-3.3251-30.22752,3.11438-39.33307,6.95394-9.83243,7.3714-15.34659,7.46364-15.40768l.07444-.04911,19.21593-.58229,17.78443,18.07143c1.1187-.4371,10.568,22.84952,9.767,24.02026l12.421,40.152Z" fill="#cbcbcb" transform="translate(-178.45119 -165.31613)"/>
|
||||
<path d="M650.88561,473.03769l-48.17993-37.26808L565.551,391.92034,549.50639,407.5705,593.40674,452.817l52.376,30.1644a8.79767,8.79767,0,1,0,5.1029-9.94375Z" fill="#9f616a" transform="translate(-178.45119 -165.31613)"/>
|
||||
<path d="M544.94672,423.42644l-13.42486-19.07371a17.49724,17.49724,0,0,1,27.36746-21.71625l17.199,19.28682Z" fill="#cbcbcb" transform="translate(-178.45119 -165.31613)"/>
|
||||
<circle cx="357.38226" cy="174.00948" fill="#9f616a" r="22.81899"/>
|
||||
<path d="M559.03585,327.74915H522.802V311.95491c7.95292-3.15955,15.73526-5.84657,20.4396,0a15.79433,15.79433,0,0,1,15.79424,15.79423Z" fill="#a3a3a3" transform="translate(-178.45119 -165.31613)"/>
|
||||
<path d="M527.796,308.16769c-33.19817-.90658-26.9117,53.78567-26.9117,53.78567s6.26341.82834,9.12141,1.315l3.45152-1.94369,3.71386,2.43573c1.70527.00851,3.49673-.0245,5.354-.059l1.70255-3.50569,3.79659,3.4428c6.91068.01028,25.66641.20072,25.66641.20072S564.99338,309.18349,527.796,308.16769Z" fill="#a3a3a3" transform="translate(-178.45119 -165.31613)"/>
|
||||
<path d="M796.63279,733.74644h-381a1,1,0,0,1,0-2h381a1,1,0,0,1,0,2Z" fill="#cbcbcb" transform="translate(-178.45119 -165.31613)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.8 KiB |
59
public/illustrations/welcome.svg
Normal file
|
After Width: | Height: | Size: 26 KiB |