Merge pull request #68 from ciphera-net/staging

PageSpeed monitoring, Polar billing, sidebar polish, frontend consistency audit
This commit is contained in:
Usman
2026-03-23 20:07:54 +01:00
committed by GitHub
82 changed files with 2632 additions and 983 deletions

View File

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

View File

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

View File

@@ -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">Stripe Sub:</span>
<span className="font-mono text-xs">{org.stripe_subscription_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">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">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -47,7 +47,7 @@ export default function OnboardingPage() {
<div className="min-h-screen flex items-center justify-center bg-neutral-50 dark:bg-neutral-900 px-4">
<div className="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">

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
import { useCallback, useEffect, useState } from 'react'
import { 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>

View File

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

View File

@@ -177,10 +177,10 @@ export default function CDNPage() {
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-5 mb-6">
<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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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&apos;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&apos;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&ndash;49</span>
<span className="flex items-center gap-1"><span className="inline-block w-2 h-2 rounded-full bg-amber-500" />50&ndash;89</span>
<span className="flex items-center gap-1"><span className="inline-block w-2 h-2 rounded-full bg-emerald-500" />90&ndash;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>
)
}

View File

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

View File

@@ -3,7 +3,8 @@
import { useEffect, useState } from 'react'
import { 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>

View File

@@ -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&apos;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(&apos;event_name&apos;)</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>

View File

@@ -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&apos;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&apos;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&apos;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>
)}

View File

@@ -113,7 +113,7 @@ export default function NewSitePage() {
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-green-500/10 text-green-600 dark:text-green-400 mb-6">
<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

View File

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

View File

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

View File

@@ -48,7 +48,7 @@ export function Footer({ LinkComponent = Link, appName = 'Pulse', isAuthenticate
<footer className="w-full py-8 mt-auto border-t border-neutral-100 dark:border-neutral-800 bg-white/50 dark:bg-neutral-900/50 backdrop-blur-sm">
<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>

View File

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

View File

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

View File

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

View File

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

View File

@@ -59,7 +59,7 @@ function CustomTooltip({ active, payload }: { active?: boolean; payload?: Array<
className="h-2.5 w-2.5 shrink-0 rounded-full"
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'}

View File

@@ -322,7 +322,7 @@ export default function Chart({
>
<div className={cn('text-[10px] font-semibold uppercase tracking-widest mb-2', metric === m.key ? 'text-brand-orange' : 'text-neutral-400 dark:text-neutral-500')}>{m.label}</div>
<div className="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'} &middot; {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>

View File

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

View File

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

View File

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

View File

@@ -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>
</>
)}

View File

@@ -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'
}`}
>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -212,7 +212,7 @@ export default function NotificationCenter({ anchor = 'bottom', variant = 'defau
style={anchor === 'right' && fixedPos ? { left: fixedPos.left, top: fixedPos.top, bottom: fixedPos.bottom } : undefined}
>
<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

View 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>
)
}

View File

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

View File

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

View File

@@ -38,7 +38,7 @@ function getEventColor(eventType: string, outcome: string): string {
if (eventType === '2fa_disabled') return 'text-amber-500 dark:text-amber-400 bg-amber-50 dark:bg-amber-950/30'
if (eventType === '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>&middot;</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>

View File

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

View File

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

View File

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

View File

@@ -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 &amp; dead clicks &middot; 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>

View File

@@ -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 &rarr;
</Link>

View File

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

View File

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

View File

@@ -12,7 +12,7 @@ import {
motion,
useMotionTemplate,
useSpring,
} from "motion/react";
} from "framer-motion";
import {
Children,
createContext,

View File

@@ -9,7 +9,7 @@ import {
AnimatePresence,
motion,
useSpring,
} from "motion/react";
} from "framer-motion";
import {
Children,
createContext,

View File

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

View File

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

View File

@@ -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
View 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' })
}

View File

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

View File

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

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

8
lib/utils/favicon.ts Normal file
View 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'

View File

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

View File

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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 26 KiB