diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3b66357..06558e7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,47 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
## [Unreleased]
+## [0.11.0-alpha] - 2026-02-22
+
+### Added
+
+- **Better page titles.** Browser tabs now show which site and page you're on (e.g. "Uptime · example.com | Pulse") instead of the same generic title everywhere.
+- **Link previews for public dashboards.** Sharing a public dashboard link on social media now shows a proper preview with the site name and description.
+- **Faster login redirects.** If you're not signed in and try to open a dashboard or settings page, you're redirected to login immediately instead of seeing a blank page first. Already-signed-in users who visit the login page are sent straight to the dashboard.
+- **Graceful error recovery.** If a page crashes, you now see a friendly error screen with a "Try again" button instead of a blank white page. Each section of the app has its own error message so you know exactly what went wrong.
+- **Security headers.** All pages now include clickjacking protection, MIME-sniffing prevention, a strict referrer policy, and HSTS. Browser APIs like camera and microphone are explicitly disabled.
+- **Better form experience.** Forms now auto-focus the first field when they open, text inputs enforce character limits with a visible counter when you're close, and the settings page warns you before navigating away with unsaved changes.
+- **Accessibility improvements.** The notification bell, workspace switcher, and all dashboard tabs are now fully keyboard-navigable. Screen readers announce unread counts, active organizations, and tab changes correctly. Decorative icons are hidden from assistive technology.
+- **Smooth organization switching.** Switching between organizations now shows a branded loading screen instead of a blank flash while the page reloads.
+- **Graceful server shutdown.** Deployments no longer kill in-flight requests or interrupt background tasks. The server finishes ongoing work before shutting down.
+- **Database connection pooling.** The backend now limits and recycles database connections, preventing exhaustion under load and reducing query latency.
+- **Date range validation.** Analytics, funnel, and uptime queries now reject invalid date ranges (end before start, or spans longer than a year) instead of silently returning empty or oversized results.
+- **Excluded paths limit.** Sites can now have up to 50 excluded paths. Previously there was no cap, which could slow down event processing.
+
+### Changed
+
+- **Smoother loading experience.** Pages now show a subtle preview of the layout while data loads instead of a blank screen or spinner. This applies everywhere — dashboards, settings, uptime, funnels, notifications, billing, and detail modals.
+- **Clearer error messages.** When something goes wrong, the error message now tells you what failed (e.g. "Failed to load uptime monitors") instead of a generic "Failed to load data".
+- **Faster favicon loading.** Site icons in the dashboard, referrers, and campaigns now use Next.js image optimization for better caching and lazy loading.
+- **Tighter name limits.** Site, funnel, and monitor names are now capped at 100 characters instead of 255 — long enough for any real name, short enough to not break the UI.
+- **Stricter type safety.** Eliminated all `any` types and `@ts-ignore` suppressions across the codebase, so the TypeScript compiler catches more bugs at build time.
+- **Smaller page downloads.** Icon imports are now tree-shaken so only the icons actually used are included in the bundle.
+- **Removed debug logs.** Auth and organization-switching details no longer leak into the browser console in production. Error logs are now also suppressed in production and only appear during development.
+
+### Fixed
+
+- **Landing page dashboard preview.** The homepage now shows a realistic preview of the Pulse dashboard instead of an empty placeholder.
+- **Logout redirect loop.** Signing out no longer bounces you straight to Ciphera Auth. You now land on the Pulse homepage where you can choose to sign back in.
+- **No more loading flicker.** Fast-loading pages no longer flash a loading state for a split second before showing content.
+- **Organization context switch.** Switching away from a deleted organization now stores the session correctly instead of using an insecure fallback.
+- **Dark mode uptime chart.** The response time chart on the uptime page now correctly follows your dark mode preference instead of always showing a white tooltip background.
+- **Onboarding form limits.** The welcome page now enforces the same character limits as the rest of the app.
+- **Audit log reliability.** Failed audit log writes are now logged to the server instead of being silently ignored, so gaps in the audit trail are detectable.
+- **Safer error messages.** Server errors no longer expose internal details (database errors, stack traces) to the browser. You see a clear message like "Failed to create site" while the full error is logged server-side for debugging.
+- **Content Security Policy.** The backend CSP header was being overwritten by a duplicate, and the captcha service was incorrectly whitelisted under image sources instead of connection sources. Both are now fixed.
+- **Logout redirect loop.** Signing out no longer bounces you straight to Ciphera Auth. You now land on the Pulse homepage where you can choose to sign back in.
+- **Date range edge case.** The maximum date range check could be off by a day due to an internal time adjustment. It now compares calendar days accurately.
+
## [0.10.0-alpha] - 2026-02-21
### Changed
@@ -127,7 +168,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
---
-[Unreleased]: https://github.com/ciphera-net/pulse/compare/v0.10.0-alpha...HEAD
+[Unreleased]: https://github.com/ciphera-net/pulse/compare/v0.11.0-alpha...HEAD
+[0.11.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.10.0-alpha...v0.11.0-alpha
[0.10.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.9.0-alpha...v0.10.0-alpha
[0.9.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.8.0-alpha...v0.9.0-alpha
[0.8.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.7.0-alpha...v0.8.0-alpha
diff --git a/app/about/layout.tsx b/app/about/layout.tsx
new file mode 100644
index 0000000..cb91b21
--- /dev/null
+++ b/app/about/layout.tsx
@@ -0,0 +1,19 @@
+import type { Metadata } from 'next'
+
+export const metadata: Metadata = {
+ title: 'About | Pulse',
+ description: 'Pulse is built by Ciphera — privacy-first web analytics made in Switzerland.',
+ openGraph: {
+ title: 'About | Pulse',
+ description: 'Pulse is built by Ciphera — privacy-first web analytics made in Switzerland.',
+ siteName: 'Pulse by Ciphera',
+ },
+}
+
+export default function AboutLayout({
+ children,
+}: {
+ children: React.ReactNode
+}) {
+ return children
+}
diff --git a/app/actions/auth.ts b/app/actions/auth.ts
index 79d0e15..869cfff 100644
--- a/app/actions/auth.ts
+++ b/app/actions/auth.ts
@@ -1,6 +1,7 @@
'use server'
import { cookies } from 'next/headers'
+import { logger } from '@/lib/utils/logger'
const AUTH_API_URL = process.env.NEXT_PUBLIC_AUTH_API_URL || process.env.NEXT_PUBLIC_AUTH_URL || 'http://localhost:8081'
@@ -102,7 +103,7 @@ export async function exchangeAuthCode(code: string, codeVerifier: string, redir
}
} catch (error: unknown) {
- console.error('Auth Exchange Error:', error)
+ logger.error('Auth Exchange Error:', error)
const isNetwork =
error instanceof TypeError ||
(error instanceof Error && (error.name === 'AbortError' || /fetch|network|ECONNREFUSED|ETIMEDOUT/i.test(error.message)))
@@ -112,18 +113,13 @@ export async function exchangeAuthCode(code: string, codeVerifier: string, redir
export async function setSessionAction(accessToken: string, refreshToken?: string) {
try {
- console.log('[setSessionAction] Decoding token...')
if (!accessToken) throw new Error('Access token is missing')
const payloadPart = accessToken.split('.')[1]
const payload: UserPayload = JSON.parse(Buffer.from(payloadPart, 'base64').toString())
- console.log('[setSessionAction] Token Payload:', { sub: payload.sub, org_id: payload.org_id })
-
const cookieStore = await cookies()
const cookieDomain = getCookieDomain()
-
- console.log('[setSessionAction] Setting cookies with domain:', cookieDomain)
cookieStore.set('access_token', accessToken, {
httpOnly: true,
@@ -146,8 +142,6 @@ export async function setSessionAction(accessToken: string, refreshToken?: strin
})
}
- console.log('[setSessionAction] Cookies set successfully')
-
return {
success: true,
user: {
@@ -159,7 +153,7 @@ export async function setSessionAction(accessToken: string, refreshToken?: strin
}
}
} catch (e) {
- console.error('[setSessionAction] Error:', e)
+ logger.error('[setSessionAction] Error:', e)
return { success: false as const, error: 'invalid' }
}
}
diff --git a/app/auth/callback/page.tsx b/app/auth/callback/page.tsx
index 30396c4..b2a8c34 100644
--- a/app/auth/callback/page.tsx
+++ b/app/auth/callback/page.tsx
@@ -1,6 +1,7 @@
'use client'
import { useEffect, useState, Suspense, useRef, useCallback } from 'react'
+import { logger } from '@/lib/utils/logger'
import { useRouter, useSearchParams } from 'next/navigation'
import { useAuth } from '@/lib/auth/context'
import { AUTH_URL, default as apiRequest } from '@/lib/api/client'
@@ -96,7 +97,7 @@ function AuthCallbackContent() {
return
}
if (state !== storedState) {
- console.error('State mismatch', { received: state, stored: storedState })
+ logger.error('State mismatch', { received: state, stored: storedState })
setError('Invalid state')
return
}
diff --git a/app/error.tsx b/app/error.tsx
new file mode 100644
index 0000000..4b1380b
--- /dev/null
+++ b/app/error.tsx
@@ -0,0 +1,13 @@
+'use client'
+
+import ErrorDisplay from '@/components/ErrorDisplay'
+
+export default function GlobalError({ reset }: { error: Error; reset: () => void }) {
+ return (
+
+ )
+}
diff --git a/app/faq/layout.tsx b/app/faq/layout.tsx
new file mode 100644
index 0000000..696202e
--- /dev/null
+++ b/app/faq/layout.tsx
@@ -0,0 +1,19 @@
+import type { Metadata } from 'next'
+
+export const metadata: Metadata = {
+ title: 'FAQ | Pulse',
+ description: 'Frequently asked questions about Pulse, privacy, GDPR compliance, and how it works.',
+ openGraph: {
+ title: 'FAQ | Pulse',
+ description: 'Frequently asked questions about Pulse, privacy, GDPR compliance, and how it works.',
+ siteName: 'Pulse by Ciphera',
+ },
+}
+
+export default function FaqLayout({
+ children,
+}: {
+ children: React.ReactNode
+}) {
+ return children
+}
diff --git a/app/features/layout.tsx b/app/features/layout.tsx
new file mode 100644
index 0000000..9ec58ac
--- /dev/null
+++ b/app/features/layout.tsx
@@ -0,0 +1,19 @@
+import type { Metadata } from 'next'
+
+export const metadata: Metadata = {
+ title: 'Features | Pulse',
+ description: 'Dashboards, funnels, uptime monitoring, realtime visitors, and more — all without cookies.',
+ openGraph: {
+ title: 'Features | Pulse',
+ description: 'Dashboards, funnels, uptime monitoring, realtime visitors, and more — all without cookies.',
+ siteName: 'Pulse by Ciphera',
+ },
+}
+
+export default function FeaturesLayout({
+ children,
+}: {
+ children: React.ReactNode
+}) {
+ return children
+}
diff --git a/app/integrations/layout.tsx b/app/integrations/layout.tsx
new file mode 100644
index 0000000..52a43b0
--- /dev/null
+++ b/app/integrations/layout.tsx
@@ -0,0 +1,19 @@
+import type { Metadata } from 'next'
+
+export const metadata: Metadata = {
+ title: 'Integrations | Pulse',
+ description: 'Add Pulse analytics to Next.js, React, Vue, WordPress, and more in under a minute.',
+ openGraph: {
+ title: 'Integrations | Pulse',
+ description: 'Add Pulse analytics to Next.js, React, Vue, WordPress, and more in under a minute.',
+ siteName: 'Pulse by Ciphera',
+ },
+}
+
+export default function IntegrationsLayout({
+ children,
+}: {
+ children: React.ReactNode
+}) {
+ return children
+}
diff --git a/app/layout-content.tsx b/app/layout-content.tsx
index 1f50e03..727254f 100644
--- a/app/layout-content.tsx
+++ b/app/layout-content.tsx
@@ -8,22 +8,39 @@ import { useAuth } from '@/lib/auth/context'
import { useOnlineStatus } from '@/lib/hooks/useOnlineStatus'
import Link from 'next/link'
import { useEffect, useState } from 'react'
+import { logger } from '@/lib/utils/logger'
import { getUserOrganizations, switchContext } from '@/lib/api/organization'
import { setSessionAction } from '@/app/actions/auth'
+import { LoadingOverlay } from '@ciphera-net/ui'
import { useRouter } from 'next/navigation'
+const ORG_SWITCH_KEY = 'pulse_switching_org'
+
export default function LayoutContent({ children }: { children: React.ReactNode }) {
const auth = useAuth()
const router = useRouter()
const isOnline = useOnlineStatus()
const [orgs, setOrgs] = useState([])
-
+ const [isSwitchingOrg, setIsSwitchingOrg] = useState(() => {
+ if (typeof window === 'undefined') return false
+ return sessionStorage.getItem(ORG_SWITCH_KEY) === 'true'
+ })
+
+ // * Clear the switching flag once the page has settled after reload
+ useEffect(() => {
+ if (isSwitchingOrg) {
+ sessionStorage.removeItem(ORG_SWITCH_KEY)
+ const timer = setTimeout(() => setIsSwitchingOrg(false), 600)
+ return () => clearTimeout(timer)
+ }
+ }, [isSwitchingOrg])
+
// * Fetch organizations for the header organization switcher
useEffect(() => {
if (auth.user) {
getUserOrganizations()
.then((organizations) => setOrgs(Array.isArray(organizations) ? organizations : []))
- .catch(err => console.error('Failed to fetch orgs for header', err))
+ .catch(err => logger.error('Failed to fetch orgs for header', err))
}
}, [auth.user])
@@ -32,9 +49,10 @@ export default function LayoutContent({ children }: { children: React.ReactNode
try {
const { access_token } = await switchContext(orgId)
await setSessionAction(access_token)
+ sessionStorage.setItem(ORG_SWITCH_KEY, 'true')
window.location.reload()
} catch (err) {
- console.error('Failed to switch organization', err)
+ logger.error('Failed to switch organization', err)
}
}
@@ -47,6 +65,10 @@ export default function LayoutContent({ children }: { children: React.ReactNode
const headerHeightRem = 6;
const mainTopPaddingRem = barHeightRem + headerHeightRem;
+ if (isSwitchingOrg) {
+ return
+ }
+
return (
<>
{auth.user && }
diff --git a/app/notifications/error.tsx b/app/notifications/error.tsx
new file mode 100644
index 0000000..8a14fd3
--- /dev/null
+++ b/app/notifications/error.tsx
@@ -0,0 +1,13 @@
+'use client'
+
+import ErrorDisplay from '@/components/ErrorDisplay'
+
+export default function NotificationsError({ reset }: { error: Error; reset: () => void }) {
+ return (
+
+ )
+}
diff --git a/app/notifications/layout.tsx b/app/notifications/layout.tsx
new file mode 100644
index 0000000..40928a2
--- /dev/null
+++ b/app/notifications/layout.tsx
@@ -0,0 +1,15 @@
+import type { Metadata } from 'next'
+
+export const metadata: Metadata = {
+ title: 'Notifications | Pulse',
+ description: 'View your alerts and activity updates.',
+ robots: { index: false, follow: false },
+}
+
+export default function NotificationsLayout({
+ children,
+}: {
+ children: React.ReactNode
+}) {
+ return children
+}
diff --git a/app/notifications/page.tsx b/app/notifications/page.tsx
index 0635cf5..2b4fe85 100644
--- a/app/notifications/page.tsx
+++ b/app/notifications/page.tsx
@@ -15,7 +15,8 @@ import {
} from '@/lib/api/notifications'
import { getAuthErrorMessage } from '@ciphera-net/ui'
import { formatTimeAgo, getTypeIcon } from '@/lib/utils/notifications'
-import { Button, ArrowLeftIcon, Spinner } from '@ciphera-net/ui'
+import { Button, ArrowLeftIcon } from '@ciphera-net/ui'
+import { NotificationsListSkeleton, useMinimumLoading } from '@/components/skeletons'
import { toast } from '@ciphera-net/ui'
const PAGE_SIZE = 50
@@ -29,6 +30,7 @@ export default function NotificationsPage() {
const [offset, setOffset] = useState(0)
const [hasMore, setHasMore] = useState(true)
const [loadingMore, setLoadingMore] = useState(false)
+ const showSkeleton = useMinimumLoading(loading)
const fetchPage = async (pageOffset: number, append: boolean) => {
if (append) setLoadingMore(true)
@@ -127,10 +129,8 @@ export default function NotificationsPage() {
- {loading ? (
-
-
-
+ {showSkeleton ? (
+
) : error ? (
{error}
diff --git a/app/org-settings/error.tsx b/app/org-settings/error.tsx
new file mode 100644
index 0000000..f9889a3
--- /dev/null
+++ b/app/org-settings/error.tsx
@@ -0,0 +1,13 @@
+'use client'
+
+import ErrorDisplay from '@/components/ErrorDisplay'
+
+export default function OrgSettingsError({ reset }: { error: Error; reset: () => void }) {
+ return (
+
+ )
+}
diff --git a/app/org-settings/page.tsx b/app/org-settings/page.tsx
index 0ed5837..2d7fdab 100644
--- a/app/org-settings/page.tsx
+++ b/app/org-settings/page.tsx
@@ -1,5 +1,6 @@
import { Suspense } from 'react'
import OrganizationSettings from '@/components/settings/OrganizationSettings'
+import { SettingsFormSkeleton } from '@/components/skeletons'
export const metadata = {
title: 'Organization Settings - Pulse',
@@ -10,7 +11,17 @@ export default function OrgSettingsPage() {
return (
- Loading...
}>
+
+
+
+
+
+
+ }>
diff --git a/app/page.tsx b/app/page.tsx
index 02d85b4..8ea1c77 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -6,10 +6,13 @@ import { motion } from 'framer-motion'
import { useAuth } from '@/lib/auth/context'
import { initiateOAuthFlow, initiateSignupFlow } from '@/lib/api/oauth'
import { listSites, deleteSite, type Site } from '@/lib/api/sites'
+import { getStats } from '@/lib/api/stats'
+import type { Stats } from '@/lib/api/stats'
import { getSubscription, type SubscriptionDetails } from '@/lib/api/billing'
import { LoadingOverlay } from '@ciphera-net/ui'
import SiteList from '@/components/sites/SiteList'
import { Button } from '@ciphera-net/ui'
+import Image from 'next/image'
import { BarChartIcon, LockIcon, ZapIcon, CheckCircleIcon, XIcon, GlobeIcon } from '@ciphera-net/ui'
import { toast } from '@ciphera-net/ui'
import { getAuthErrorMessage } from '@ciphera-net/ui'
@@ -17,29 +20,36 @@ import { getSitesLimitForPlan } from '@/lib/plans'
function DashboardPreview() {
return (
-
- {/* * Glow behind the image */}
+
-
- {/* * Static Container */}
-
- {/* * Header of the fake browser window */}
-
-
-
-
+ {/* * Browser chrome */}
+
-
- {/* * Placeholder for actual dashboard screenshot - replace src with real image later */}
-
-
+
+ {/* * Screenshot with bottom fade */}
+
-
+
)
}
@@ -97,10 +107,13 @@ function ComparisonSection() {
}
+type SiteStatsMap = Record
+
export default function HomePage() {
const { user, loading: authLoading } = useAuth()
const [sites, setSites] = useState([])
const [sitesLoading, setSitesLoading] = useState(true)
+ const [siteStats, setSiteStats] = useState({})
const [subscription, setSubscription] = useState(null)
const [subscriptionLoading, setSubscriptionLoading] = useState(false)
const [showFinishSetupBanner, setShowFinishSetupBanner] = useState(true)
@@ -112,6 +125,37 @@ export default function HomePage() {
}
}, [user])
+ useEffect(() => {
+ if (sites.length === 0) {
+ setSiteStats({})
+ return
+ }
+ let cancelled = false
+ const today = new Date().toISOString().split('T')[0]
+ const emptyStats: Stats = { pageviews: 0, visitors: 0, bounce_rate: 0, avg_duration: 0 }
+ const load = async () => {
+ const results = await Promise.allSettled(
+ sites.map(async (site) => {
+ const statsRes = await getStats(site.id, today, today)
+ return { siteId: site.id, stats: statsRes }
+ })
+ )
+ if (cancelled) return
+ const map: SiteStatsMap = {}
+ results.forEach((r, i) => {
+ const site = sites[i]
+ if (r.status === 'fulfilled') {
+ map[site.id] = { stats: r.value.stats }
+ } else {
+ map[site.id] = { stats: emptyStats }
+ }
+ })
+ setSiteStats(map)
+ }
+ load()
+ return () => { cancelled = true }
+ }, [sites])
+
useEffect(() => {
if (typeof window === 'undefined') return
if (localStorage.getItem('pulse_welcome_completed') === 'true') setShowFinishSetupBanner(false)
@@ -133,8 +177,8 @@ export default function HomePage() {
setSitesLoading(true)
const data = await listSites()
setSites(Array.isArray(data) ? data : [])
- } catch (error: any) {
- toast.error(getAuthErrorMessage(error) || 'Failed to load sites: ' + ((error as Error)?.message || 'Unknown error'))
+ } catch (error: unknown) {
+ toast.error(getAuthErrorMessage(error) || 'Failed to load your sites')
setSites([])
} finally {
setSitesLoading(false)
@@ -162,8 +206,8 @@ export default function HomePage() {
await deleteSite(id)
toast.success('Site deleted successfully')
loadSites()
- } catch (error: any) {
- toast.error(getAuthErrorMessage(error) || 'Failed to delete site: ' + ((error as Error)?.message || 'Unknown error'))
+ } catch (error: unknown) {
+ toast.error(getAuthErrorMessage(error) || 'Failed to delete site')
}
}
@@ -362,20 +406,29 @@ export default function HomePage() {
)}
- {/* * Global Overview */}
+ {/* * Global Overview - min-h ensures no layout shift when Plan & usage loads */}
-
+
Total Sites
{sites.length}
-
+
Total Visitors (24h)
-
--
+
+ {sites.length === 0 || Object.keys(siteStats).length < sites.length
+ ? '--'
+ : Object.values(siteStats).reduce((sum, { stats }) => sum + (stats?.visitors ?? 0), 0).toLocaleString()}
+
-
+
Plan & usage
{subscriptionLoading ? (
-
...
+
) : subscription ? (
<>
@@ -456,7 +509,7 @@ export default function HomePage() {
)}
{(sitesLoading || sites.length > 0) && (
-
+
)}
)
diff --git a/app/pricing/page.tsx b/app/pricing/page.tsx
index 6381381..f31aa18 100644
--- a/app/pricing/page.tsx
+++ b/app/pricing/page.tsx
@@ -1,10 +1,30 @@
import { Suspense } from 'react'
+import type { Metadata } from 'next'
import PricingSection from '@/components/PricingSection'
+import { PricingCardsSkeleton } from '@/components/skeletons'
+
+export const metadata: Metadata = {
+ title: 'Pricing | Pulse',
+ description: 'Simple, transparent pricing for privacy-first web analytics. Free tier included.',
+ openGraph: {
+ title: 'Pricing | Pulse',
+ description: 'Simple, transparent pricing for privacy-first web analytics. Free tier included.',
+ siteName: 'Pulse by Ciphera',
+ },
+}
export default function PricingPage() {
return (
- Loading...
}>
+
+
+
+
+ }>
diff --git a/app/share/[id]/error.tsx b/app/share/[id]/error.tsx
new file mode 100644
index 0000000..9756b5f
--- /dev/null
+++ b/app/share/[id]/error.tsx
@@ -0,0 +1,13 @@
+'use client'
+
+import ErrorDisplay from '@/components/ErrorDisplay'
+
+export default function ShareError({ reset }: { error: Error; reset: () => void }) {
+ return (
+
+ )
+}
diff --git a/app/share/[id]/layout.tsx b/app/share/[id]/layout.tsx
new file mode 100644
index 0000000..60de8ee
--- /dev/null
+++ b/app/share/[id]/layout.tsx
@@ -0,0 +1,73 @@
+import type { Metadata } from 'next'
+import { FAVICON_SERVICE_URL } from '@/lib/utils/icons'
+
+const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8082'
+
+interface SharePageParams {
+ params: Promise<{ id: string }>
+}
+
+export async function generateMetadata({ params }: SharePageParams): Promise
{
+ const { id } = await params
+ const fallback: Metadata = {
+ title: 'Public Dashboard | Pulse',
+ description: 'Privacy-first web analytics — view this site\'s public stats.',
+ openGraph: {
+ title: 'Public Dashboard | Pulse',
+ description: 'Privacy-first web analytics — view this site\'s public stats.',
+ siteName: 'Pulse by Ciphera',
+ type: 'website',
+ },
+ twitter: {
+ card: 'summary',
+ title: 'Public Dashboard | Pulse',
+ description: 'Privacy-first web analytics — view this site\'s public stats.',
+ },
+ }
+
+ try {
+ const res = await fetch(`${API_URL}/public/sites/${id}/dashboard?limit=1`, {
+ next: { revalidate: 3600 },
+ })
+ if (!res.ok) return fallback
+
+ const data = await res.json()
+ const domain = data?.site?.domain
+ if (!domain) return fallback
+
+ const title = `${domain} analytics | Pulse`
+ const description = `Live, privacy-first analytics for ${domain} — powered by Pulse.`
+
+ return {
+ title,
+ description,
+ openGraph: {
+ title,
+ description,
+ siteName: 'Pulse by Ciphera',
+ type: 'website',
+ images: [{
+ url: `${FAVICON_SERVICE_URL}?domain=${domain}&sz=128`,
+ width: 128,
+ height: 128,
+ alt: `${domain} favicon`,
+ }],
+ },
+ twitter: {
+ card: 'summary',
+ title,
+ description,
+ },
+ }
+ } catch {
+ return fallback
+ }
+}
+
+export default function ShareLayout({
+ children,
+}: {
+ children: React.ReactNode
+}) {
+ return children
+}
diff --git a/app/share/[id]/page.tsx b/app/share/[id]/page.tsx
index 5ed4452..cc28021 100644
--- a/app/share/[id]/page.tsx
+++ b/app/share/[id]/page.tsx
@@ -1,10 +1,12 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
+import Image from 'next/image'
import { useParams, useSearchParams, useRouter } from 'next/navigation'
import { getPublicDashboard, getPublicStats, getPublicDailyStats, getPublicRealtime, getPublicPerformanceByPage, type DashboardData, type Stats, type DailyStat, type PerformanceByPageStat } from '@/lib/api/stats'
import { toast } from '@ciphera-net/ui'
import { getAuthErrorMessage } from '@ciphera-net/ui'
+import { ApiError } from '@/lib/api/client'
import { LoadingOverlay, Button } from '@ciphera-net/ui'
import Chart from '@/components/dashboard/Chart'
import TopPages from '@/components/dashboard/ContentStats'
@@ -13,7 +15,9 @@ import Locations from '@/components/dashboard/Locations'
import TechSpecs from '@/components/dashboard/TechSpecs'
import PerformanceStats from '@/components/dashboard/PerformanceStats'
import { Select, DatePicker as DatePickerModal, Captcha, DownloadIcon, ZapIcon } from '@ciphera-net/ui'
+import { DashboardSkeleton, useMinimumLoading } from '@/components/skeletons'
import ExportModal from '@/components/dashboard/ExportModal'
+import { FAVICON_SERVICE_URL } from '@/lib/utils/icons'
// Helper to get date ranges
const getDateRange = (days: number) => {
@@ -152,8 +156,9 @@ export default function PublicDashboardPage() {
setCaptchaId('')
setCaptchaSolution('')
setCaptchaToken('')
- } catch (error: any) {
- if ((error.status === 401 || error.response?.status === 401) && (error.data?.is_protected || error.response?.data?.is_protected)) {
+ } catch (error: unknown) {
+ const apiErr = error instanceof ApiError ? error : null
+ if (apiErr?.status === 401 && (apiErr.data as Record)?.is_protected) {
setIsPasswordProtected(true)
if (password) {
toast.error('Invalid password or captcha')
@@ -162,10 +167,10 @@ export default function PublicDashboardPage() {
setCaptchaSolution('')
setCaptchaToken('')
}
- } else if (error.status === 404 || error.response?.status === 404) {
+ } else if (apiErr?.status === 404) {
toast.error('Site not found')
} else if (!silent) {
- toast.error(getAuthErrorMessage(error) || 'Failed to load dashboard: ' + ((error as Error)?.message || 'Unknown error'))
+ toast.error(getAuthErrorMessage(error) || 'Failed to load public dashboard')
}
} finally {
if (!silent) setLoading(false)
@@ -192,8 +197,10 @@ export default function PublicDashboardPage() {
loadDashboard()
}
- if (loading && !data && !isPasswordProtected) {
- return
+ const showSkeleton = useMinimumLoading(loading && !data && !isPasswordProtected)
+
+ if (showSkeleton) {
+ return
}
if (isPasswordProtected && !data) {
@@ -279,13 +286,16 @@ export default function PublicDashboardPage() {
Public Dashboard
- {
(e.target as HTMLImageElement).src = '/globe.svg'
}}
+ unoptimized
/>
{site.domain}
diff --git a/app/sites/[id]/error.tsx b/app/sites/[id]/error.tsx
new file mode 100644
index 0000000..e1ea50a
--- /dev/null
+++ b/app/sites/[id]/error.tsx
@@ -0,0 +1,13 @@
+'use client'
+
+import ErrorDisplay from '@/components/ErrorDisplay'
+
+export default function DashboardError({ reset }: { error: Error; reset: () => void }) {
+ return (
+
+ )
+}
diff --git a/app/sites/[id]/funnels/[funnelId]/page.tsx b/app/sites/[id]/funnels/[funnelId]/page.tsx
index 3695f52..4b7c0bc 100644
--- a/app/sites/[id]/funnels/[funnelId]/page.tsx
+++ b/app/sites/[id]/funnels/[funnelId]/page.tsx
@@ -4,7 +4,8 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { ApiError } from '@/lib/api/client'
import { getFunnel, getFunnelStats, deleteFunnel, type Funnel, type FunnelStats } from '@/lib/api/funnels'
-import { toast, LoadingOverlay, Select, DatePicker, ChevronLeftIcon, ArrowRightIcon, TrashIcon, useTheme, Button } from '@ciphera-net/ui'
+import { toast, Select, DatePicker, ChevronLeftIcon, ArrowRightIcon, TrashIcon, useTheme, Button } from '@ciphera-net/ui'
+import { FunnelDetailSkeleton, useMinimumLoading } from '@/components/skeletons'
import Link from 'next/link'
import {
BarChart,
@@ -63,7 +64,7 @@ export default function FunnelReportPage() {
if (status === 404) setLoadError('not_found')
else if (status === 403) setLoadError('forbidden')
else setLoadError('error')
- if (status !== 404 && status !== 403) toast.error('Failed to load funnel data')
+ if (status !== 404 && status !== 403) toast.error('Failed to load funnel details')
} finally {
setLoading(false)
}
@@ -91,8 +92,10 @@ export default function FunnelReportPage() {
}
}
- if (loading && !funnel) {
- return
+ const showSkeleton = useMinimumLoading(loading && !funnel)
+
+ if (showSkeleton) {
+ return
}
if (loadError === 'not_found' || (!funnel && !stats && !loadError)) {
diff --git a/app/sites/[id]/funnels/error.tsx b/app/sites/[id]/funnels/error.tsx
new file mode 100644
index 0000000..2be69ce
--- /dev/null
+++ b/app/sites/[id]/funnels/error.tsx
@@ -0,0 +1,13 @@
+'use client'
+
+import ErrorDisplay from '@/components/ErrorDisplay'
+
+export default function FunnelsError({ reset }: { error: Error; reset: () => void }) {
+ return (
+
+ )
+}
diff --git a/app/sites/[id]/funnels/layout.tsx b/app/sites/[id]/funnels/layout.tsx
new file mode 100644
index 0000000..fe54962
--- /dev/null
+++ b/app/sites/[id]/funnels/layout.tsx
@@ -0,0 +1,15 @@
+import type { Metadata } from 'next'
+
+export const metadata: Metadata = {
+ title: 'Funnels | Pulse',
+ description: 'Track conversion funnels and user journeys.',
+ robots: { index: false, follow: false },
+}
+
+export default function FunnelsLayout({
+ children,
+}: {
+ children: React.ReactNode
+}) {
+ return children
+}
diff --git a/app/sites/[id]/funnels/new/page.tsx b/app/sites/[id]/funnels/new/page.tsx
index 187ad5a..74f2fa0 100644
--- a/app/sites/[id]/funnels/new/page.tsx
+++ b/app/sites/[id]/funnels/new/page.tsx
@@ -84,7 +84,7 @@ export default function CreateFunnelPage() {
toast.success('Funnel created')
router.push(`/sites/${siteId}/funnels`)
} catch (error) {
- toast.error('Failed to create funnel')
+ toast.error('Failed to create funnel. Please try again.')
} finally {
setSaving(false)
}
@@ -120,8 +120,13 @@ export default function CreateFunnelPage() {
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Signup Flow"
+ autoFocus
required
+ maxLength={100}
/>
+ {name.length > 80 && (
+
90 ? 'text-amber-500' : 'text-neutral-400'}`}>{name.length}/100
+ )}
diff --git a/app/sites/[id]/funnels/page.tsx b/app/sites/[id]/funnels/page.tsx
index 8f78661..a7b5726 100644
--- a/app/sites/[id]/funnels/page.tsx
+++ b/app/sites/[id]/funnels/page.tsx
@@ -3,7 +3,8 @@
import { useCallback, useEffect, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { listFunnels, deleteFunnel, type Funnel } from '@/lib/api/funnels'
-import { toast, LoadingOverlay, PlusIcon, ArrowRightIcon, ChevronLeftIcon, TrashIcon, Button } from '@ciphera-net/ui'
+import { toast, PlusIcon, ArrowRightIcon, ChevronLeftIcon, TrashIcon, Button } from '@ciphera-net/ui'
+import { FunnelsListSkeleton, useMinimumLoading } from '@/components/skeletons'
import Link from 'next/link'
export default function FunnelsPage() {
@@ -20,7 +21,7 @@ export default function FunnelsPage() {
const data = await listFunnels(siteId)
setFunnels(data)
} catch (error) {
- toast.error('Failed to load funnels')
+ toast.error('Failed to load your funnels')
} finally {
setLoading(false)
}
@@ -43,8 +44,10 @@ export default function FunnelsPage() {
}
}
- if (loading) {
- return
+ const showSkeleton = useMinimumLoading(loading)
+
+ if (showSkeleton) {
+ return
}
return (
diff --git a/app/sites/[id]/layout.tsx b/app/sites/[id]/layout.tsx
new file mode 100644
index 0000000..6b377d3
--- /dev/null
+++ b/app/sites/[id]/layout.tsx
@@ -0,0 +1,15 @@
+import type { Metadata } from 'next'
+
+export const metadata: Metadata = {
+ title: 'Dashboard | Pulse',
+ description: 'View your site analytics, traffic, and performance.',
+ robots: { index: false, follow: false },
+}
+
+export default function SiteLayout({
+ children,
+}: {
+ children: React.ReactNode
+}) {
+ return children
+}
diff --git a/app/sites/[id]/page.tsx b/app/sites/[id]/page.tsx
index eb5665d..8622551 100644
--- a/app/sites/[id]/page.tsx
+++ b/app/sites/[id]/page.tsx
@@ -1,6 +1,7 @@
'use client'
import { useAuth } from '@/lib/auth/context'
+import { logger } from '@/lib/utils/logger'
import { useCallback, useEffect, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { motion } from 'framer-motion'
@@ -11,6 +12,7 @@ import { toast } from '@ciphera-net/ui'
import { getAuthErrorMessage } from '@ciphera-net/ui'
import { LoadingOverlay, Button } from '@ciphera-net/ui'
import { Select, DatePicker, DownloadIcon } from '@ciphera-net/ui'
+import { DashboardSkeleton, useMinimumLoading } from '@/components/skeletons'
import ExportModal from '@/components/dashboard/ExportModal'
import ContentStats from '@/components/dashboard/ContentStats'
import TopReferrers from '@/components/dashboard/TopReferrers'
@@ -84,7 +86,7 @@ export default function SiteDashboardPage() {
if (settings.multiDayInterval) setMultiDayInterval(settings.multiDayInterval)
}
} catch (e) {
- console.error('Failed to load dashboard settings', e)
+ logger.error('Failed to load dashboard settings', e)
} finally {
setIsSettingsLoaded(true)
}
@@ -102,7 +104,7 @@ export default function SiteDashboardPage() {
}
localStorage.setItem('pulse_dashboard_settings', JSON.stringify(settings))
} catch (e) {
- console.error('Failed to save dashboard settings', e)
+ logger.error('Failed to save dashboard settings', e)
}
}
@@ -190,7 +192,7 @@ export default function SiteDashboardPage() {
setLastUpdatedAt(Date.now())
} catch (error: unknown) {
if (!silent) {
- toast.error(getAuthErrorMessage(error) || 'Failed to load data: ' + ((error as Error)?.message || 'Unknown error'))
+ toast.error(getAuthErrorMessage(error) || 'Failed to load dashboard analytics')
}
} finally {
if (!silent) setLoading(false)
@@ -215,8 +217,14 @@ export default function SiteDashboardPage() {
return () => clearInterval(interval)
}, [siteId, dateRange, todayInterval, multiDayInterval, isSettingsLoaded, loadData, loadRealtime])
- if (loading) {
- return
+ useEffect(() => {
+ if (site?.domain) document.title = `${site.domain} | Pulse`
+ }, [site?.domain])
+
+ const showSkeleton = useMinimumLoading(loading)
+
+ if (showSkeleton) {
+ return
}
if (!site) {
diff --git a/app/sites/[id]/realtime/error.tsx b/app/sites/[id]/realtime/error.tsx
new file mode 100644
index 0000000..77bb93a
--- /dev/null
+++ b/app/sites/[id]/realtime/error.tsx
@@ -0,0 +1,13 @@
+'use client'
+
+import ErrorDisplay from '@/components/ErrorDisplay'
+
+export default function RealtimeError({ reset }: { error: Error; reset: () => void }) {
+ return (
+
+ )
+}
diff --git a/app/sites/[id]/realtime/layout.tsx b/app/sites/[id]/realtime/layout.tsx
new file mode 100644
index 0000000..64b256b
--- /dev/null
+++ b/app/sites/[id]/realtime/layout.tsx
@@ -0,0 +1,15 @@
+import type { Metadata } from 'next'
+
+export const metadata: Metadata = {
+ title: 'Realtime | Pulse',
+ description: 'See who is on your site right now.',
+ robots: { index: false, follow: false },
+}
+
+export default function RealtimeLayout({
+ children,
+}: {
+ children: React.ReactNode
+}) {
+ return children
+}
diff --git a/app/sites/[id]/realtime/page.tsx b/app/sites/[id]/realtime/page.tsx
index 78cb713..2ddced3 100644
--- a/app/sites/[id]/realtime/page.tsx
+++ b/app/sites/[id]/realtime/page.tsx
@@ -6,7 +6,8 @@ import { getSite, type Site } from '@/lib/api/sites'
import { getRealtimeVisitors, getSessionDetails, type Visitor, type SessionEvent } from '@/lib/api/realtime'
import { toast } from '@ciphera-net/ui'
import { getAuthErrorMessage } from '@ciphera-net/ui'
-import { LoadingOverlay, UserIcon } from '@ciphera-net/ui'
+import { UserIcon } from '@ciphera-net/ui'
+import { RealtimeSkeleton, SessionEventsSkeleton, useMinimumLoading } from '@/components/skeletons'
import { motion, AnimatePresence } from 'framer-motion'
function formatTimeAgo(dateString: string) {
@@ -47,7 +48,7 @@ export default function RealtimePage() {
handleSelectVisitor(visitorsData[0])
}
} catch (error: unknown) {
- toast.error(getAuthErrorMessage(error) || 'Failed to load data')
+ toast.error(getAuthErrorMessage(error) || 'Failed to load realtime visitors')
} finally {
setLoading(false)
}
@@ -84,13 +85,19 @@ export default function RealtimePage() {
const events = await getSessionDetails(siteId, visitor.session_id)
setSessionEvents(events || [])
} catch (error: unknown) {
- toast.error(getAuthErrorMessage(error) || 'Failed to load session details')
+ toast.error(getAuthErrorMessage(error) || 'Failed to load session events')
} finally {
setLoadingEvents(false)
}
}
- if (loading) return
+ useEffect(() => {
+ if (site?.domain) document.title = `Realtime · ${site.domain} | Pulse`
+ }, [site?.domain])
+
+ const showSkeleton = useMinimumLoading(loading)
+
+ if (showSkeleton) return
if (!site) return Site not found
return (
@@ -197,9 +204,7 @@ export default function RealtimePage() {
Select a visitor on the left to see their activity.
) : loadingEvents ? (
-
+
) : (
{sessionEvents.map((event, idx) => (
diff --git a/app/sites/[id]/settings/error.tsx b/app/sites/[id]/settings/error.tsx
new file mode 100644
index 0000000..5f53d0b
--- /dev/null
+++ b/app/sites/[id]/settings/error.tsx
@@ -0,0 +1,13 @@
+'use client'
+
+import ErrorDisplay from '@/components/ErrorDisplay'
+
+export default function SiteSettingsError({ reset }: { error: Error; reset: () => void }) {
+ return (
+
+ )
+}
diff --git a/app/sites/[id]/settings/layout.tsx b/app/sites/[id]/settings/layout.tsx
new file mode 100644
index 0000000..6a44c2e
--- /dev/null
+++ b/app/sites/[id]/settings/layout.tsx
@@ -0,0 +1,15 @@
+import type { Metadata } from 'next'
+
+export const metadata: Metadata = {
+ title: 'Site Settings | Pulse',
+ description: 'Configure your site tracking, privacy, and goals.',
+ robots: { index: false, follow: false },
+}
+
+export default function SiteSettingsLayout({
+ children,
+}: {
+ children: React.ReactNode
+}) {
+ return children
+}
diff --git a/app/sites/[id]/settings/page.tsx b/app/sites/[id]/settings/page.tsx
index fc30439..e5d34ea 100644
--- a/app/sites/[id]/settings/page.tsx
+++ b/app/sites/[id]/settings/page.tsx
@@ -1,18 +1,19 @@
'use client'
-import { useEffect, useState } from 'react'
+import { useEffect, useState, useRef } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { getSite, updateSite, resetSiteData, deleteSite, type Site, type GeoDataLevel } from '@/lib/api/sites'
import { listGoals, createGoal, updateGoal, deleteGoal, type Goal } from '@/lib/api/goals'
import { toast } from '@ciphera-net/ui'
import { getAuthErrorMessage } from '@ciphera-net/ui'
-import { LoadingOverlay } from '@ciphera-net/ui'
+import { SettingsFormSkeleton, GoalsListSkeleton, useMinimumLoading } from '@/components/skeletons'
import VerificationModal from '@/components/sites/VerificationModal'
import ScriptSetupBlock from '@/components/sites/ScriptSetupBlock'
import { PasswordInput } from '@ciphera-net/ui'
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 { getSubscription, type SubscriptionDetails } from '@/lib/api/billing'
import { getRetentionOptionsForPlan, formatRetentionMonths } from '@/lib/plans'
import { motion, AnimatePresence } from 'framer-motion'
@@ -86,6 +87,7 @@ export default function SiteSettingsPage() {
const [editingGoal, setEditingGoal] = useState
(null)
const [goalForm, setGoalForm] = useState({ name: '', event_name: '' })
const [goalSaving, setGoalSaving] = useState(false)
+ const initialFormRef = useRef('')
useEffect(() => {
loadSite()
@@ -146,13 +148,27 @@ export default function SiteSettingsPage() {
// Data retention (default 6 = free-tier max; avoids flash-then-clamp for existing sites)
data_retention_months: data.data_retention_months ?? 6
})
+ initialFormRef.current = JSON.stringify({
+ name: data.name,
+ timezone: data.timezone || 'UTC',
+ is_public: data.is_public || false,
+ excluded_paths: (data.excluded_paths || []).join('\n'),
+ collect_page_paths: data.collect_page_paths ?? true,
+ collect_referrers: data.collect_referrers ?? true,
+ collect_device_info: data.collect_device_info ?? true,
+ collect_geo_data: data.collect_geo_data || 'full',
+ collect_screen_resolution: data.collect_screen_resolution ?? true,
+ enable_performance_insights: data.enable_performance_insights ?? false,
+ filter_bots: data.filter_bots ?? true,
+ data_retention_months: data.data_retention_months ?? 6
+ })
if (data.has_password) {
setIsPasswordEnabled(true)
} else {
setIsPasswordEnabled(false)
}
- } catch (error: any) {
- toast.error(getAuthErrorMessage(error) || 'Failed to load site: ' + ((error as Error)?.message || 'Unknown error'))
+ } catch (error: unknown) {
+ toast.error(getAuthErrorMessage(error) || 'Failed to load site settings')
} finally {
setLoading(false)
}
@@ -264,9 +280,23 @@ export default function SiteSettingsPage() {
data_retention_months: formData.data_retention_months
})
toast.success('Site updated successfully')
+ initialFormRef.current = JSON.stringify({
+ name: formData.name,
+ timezone: formData.timezone,
+ is_public: formData.is_public,
+ excluded_paths: formData.excluded_paths,
+ collect_page_paths: formData.collect_page_paths,
+ collect_referrers: formData.collect_referrers,
+ collect_device_info: formData.collect_device_info,
+ collect_geo_data: formData.collect_geo_data,
+ collect_screen_resolution: formData.collect_screen_resolution,
+ enable_performance_insights: formData.enable_performance_insights,
+ filter_bots: formData.filter_bots,
+ data_retention_months: formData.data_retention_months
+ })
loadSite()
- } catch (error: any) {
- toast.error(getAuthErrorMessage(error) || 'Failed to update site: ' + ((error as Error)?.message || 'Unknown error'))
+ } catch (error: unknown) {
+ toast.error(getAuthErrorMessage(error) || 'Failed to save site settings')
} finally {
setSaving(false)
}
@@ -280,8 +310,8 @@ export default function SiteSettingsPage() {
try {
await resetSiteData(siteId)
toast.success('All site data has been reset')
- } catch (error: any) {
- toast.error(getAuthErrorMessage(error) || 'Failed to reset data: ' + ((error as Error)?.message || 'Unknown error'))
+ } catch (error: unknown) {
+ toast.error(getAuthErrorMessage(error) || 'Failed to reset site data')
}
}
@@ -296,8 +326,8 @@ export default function SiteSettingsPage() {
await deleteSite(siteId)
toast.success('Site deleted successfully')
router.push('/')
- } catch (error: any) {
- toast.error(getAuthErrorMessage(error) || 'Failed to delete site: ' + ((error as Error)?.message || 'Unknown error'))
+ } catch (error: unknown) {
+ toast.error(getAuthErrorMessage(error) || 'Failed to delete site')
}
}
@@ -317,8 +347,50 @@ export default function SiteSettingsPage() {
setTimeout(() => setSnippetCopied(false), 2000)
}
- if (loading) {
- return
+ const isFormDirty = initialFormRef.current !== '' && JSON.stringify({
+ name: formData.name,
+ timezone: formData.timezone,
+ is_public: formData.is_public,
+ excluded_paths: formData.excluded_paths,
+ collect_page_paths: formData.collect_page_paths,
+ collect_referrers: formData.collect_referrers,
+ collect_device_info: formData.collect_device_info,
+ collect_geo_data: formData.collect_geo_data,
+ collect_screen_resolution: formData.collect_screen_resolution,
+ enable_performance_insights: formData.enable_performance_insights,
+ filter_bots: formData.filter_bots,
+ data_retention_months: formData.data_retention_months
+ }) !== initialFormRef.current
+
+ useUnsavedChanges(isFormDirty)
+
+ useEffect(() => {
+ if (site?.domain) document.title = `Settings · ${site.domain} | Pulse`
+ }, [site?.domain])
+
+ const showSkeleton = useMinimumLoading(loading)
+
+ if (showSkeleton) {
+ return (
+
+
+
+
+
+ {Array.from({ length: 4 }).map((_, i) => (
+
+ ))}
+
+
+
+
+
+
+
+ )
}
if (!site) {
@@ -429,11 +501,15 @@ export default function SiteSettingsPage() {
type="text"
id="name"
required
+ maxLength={100}
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-lg bg-neutral-50/50 dark:bg-neutral-900/50 focus:bg-white dark:focus:bg-neutral-900
focus:border-brand-orange focus:ring-4 focus:ring-brand-orange/10 outline-none transition-all duration-200 dark:text-white"
/>
+ {formData.name.length > 80 && (
+ 90 ? 'text-amber-500' : 'text-neutral-400'}`}>{formData.name.length}/100
+ )}
@@ -970,7 +1046,7 @@ export default function SiteSettingsPage() {
{goalsLoading ? (
-
Loading goals…
+
) : (
<>
{canEdit && (
@@ -1037,6 +1113,7 @@ export default function SiteSettingsPage() {
value={goalForm.name}
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"
required
/>
@@ -1048,10 +1125,14 @@ export default function SiteSettingsPage() {
value={goalForm.event_name}
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"
required
/>
-
Only letters, numbers, and underscores; spaces become underscores. Invalid characters cannot be used. Max 64 characters after formatting.
+
+
Letters, numbers, and underscores only. Spaces become underscores.
+
56 ? 'text-amber-500' : 'text-neutral-400'}`}>{goalForm.event_name.length}/64
+
{editingGoal && goalForm.event_name.trim().toLowerCase().replace(/\s+/g, '_') !== editingGoal.event_name && (
Changing event name does not reassign events already tracked under the previous name.
)}
diff --git a/app/sites/[id]/uptime/error.tsx b/app/sites/[id]/uptime/error.tsx
new file mode 100644
index 0000000..87bd036
--- /dev/null
+++ b/app/sites/[id]/uptime/error.tsx
@@ -0,0 +1,13 @@
+'use client'
+
+import ErrorDisplay from '@/components/ErrorDisplay'
+
+export default function UptimeError({ reset }: { error: Error; reset: () => void }) {
+ return (
+
+ )
+}
diff --git a/app/sites/[id]/uptime/layout.tsx b/app/sites/[id]/uptime/layout.tsx
new file mode 100644
index 0000000..af93f93
--- /dev/null
+++ b/app/sites/[id]/uptime/layout.tsx
@@ -0,0 +1,15 @@
+import type { Metadata } from 'next'
+
+export const metadata: Metadata = {
+ title: 'Uptime | Pulse',
+ description: 'Monitor your site uptime and response times.',
+ robots: { index: false, follow: false },
+}
+
+export default function UptimeLayout({
+ children,
+}: {
+ children: React.ReactNode
+}) {
+ return children
+}
diff --git a/app/sites/[id]/uptime/page.tsx b/app/sites/[id]/uptime/page.tsx
index cf4b001..85a666b 100644
--- a/app/sites/[id]/uptime/page.tsx
+++ b/app/sites/[id]/uptime/page.tsx
@@ -20,7 +20,8 @@ import {
import { toast } from '@ciphera-net/ui'
import { useTheme } from '@ciphera-net/ui'
import { getAuthErrorMessage } from '@ciphera-net/ui'
-import { LoadingOverlay, Button, Modal } from '@ciphera-net/ui'
+import { Button, Modal } from '@ciphera-net/ui'
+import { UptimeSkeleton, ChecksSkeleton, useMinimumLoading } from '@/components/skeletons'
import {
AreaChart,
Area,
@@ -283,8 +284,8 @@ function UptimeStatusBar({
// * Component: Response time chart (Recharts area chart)
function ResponseTimeChart({ checks }: { checks: UptimeCheck[] }) {
- const { theme } = useTheme()
- const colors = theme === 'dark' ? CHART_COLORS_DARK : CHART_COLORS_LIGHT
+ const { resolvedTheme } = useTheme()
+ const colors = resolvedTheme === 'dark' ? CHART_COLORS_DARK : CHART_COLORS_LIGHT
// * Prepare data in chronological order (oldest first)
const data = [...checks]
@@ -510,9 +511,7 @@ function MonitorCard({
{/* Response time chart */}
{loadingChecks ? (
-
- Loading checks...
-
+
) : checks.length > 0 ? (
<>
@@ -616,7 +615,7 @@ export default function UptimePage() {
setSite(siteData)
setUptimeData(statusData)
} catch (error: unknown) {
- toast.error(getAuthErrorMessage(error) || 'Failed to load uptime data')
+ toast.error(getAuthErrorMessage(error) || 'Failed to load uptime monitors')
} finally {
setLoading(false)
}
@@ -704,7 +703,13 @@ export default function UptimePage() {
setShowEditModal(true)
}
- if (loading) return
+ useEffect(() => {
+ if (site?.domain) document.title = `Uptime · ${site.domain} | Pulse`
+ }, [site?.domain])
+
+ const showSkeleton = useMinimumLoading(loading)
+
+ if (showSkeleton) return
if (!site) return
Site not found
const monitors = Array.isArray(uptimeData?.monitors) ? uptimeData.monitors : []
@@ -932,8 +937,13 @@ function MonitorForm({
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="e.g. API, Website, CDN"
+ autoFocus
+ maxLength={100}
className="w-full px-3 py-2 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-white placeholder-neutral-400 focus:outline-none focus:ring-2 focus:ring-brand-orange focus:border-transparent text-sm"
/>
+ {formData.name.length > 80 && (
+
90 ? 'text-amber-500' : 'text-neutral-400'}`}>{formData.name.length}/100
+ )}
{/* URL with protocol dropdown + domain prefix */}
diff --git a/app/sites/new/page.tsx b/app/sites/new/page.tsx
index cdcd4d1..da34c13 100644
--- a/app/sites/new/page.tsx
+++ b/app/sites/new/page.tsx
@@ -1,6 +1,7 @@
'use client'
import { useState, useEffect } from 'react'
+import { logger } from '@/lib/utils/logger'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { createSite, listSites, getSite, type Site } from '@/lib/api/sites'
@@ -65,7 +66,7 @@ export default function NewSitePage() {
router.replace('/')
}
} catch (error) {
- console.error('Failed to check limits', error)
+ logger.error('Failed to check limits', error)
} finally {
setLimitsChecked(true)
}
@@ -87,7 +88,7 @@ export default function NewSitePage() {
sessionStorage.setItem(LAST_CREATED_SITE_KEY, JSON.stringify({ id: site.id }))
}
} catch (error: unknown) {
- toast.error(getAuthErrorMessage(error) || 'Failed to create site: ' + ((error as Error)?.message || 'Unknown error'))
+ toast.error(getAuthErrorMessage(error) || 'Failed to create site. Please try again.')
} finally {
setLoading(false)
}
@@ -191,6 +192,8 @@ export default function NewSitePage() {
setFormData({ ...formData, name: e.target.value })}
placeholder="My Website"
@@ -204,6 +207,7 @@ export default function NewSitePage() {
setFormData({ ...formData, domain: e.target.value.toLowerCase().trim() })}
placeholder="example.com"
diff --git a/app/welcome/page.tsx b/app/welcome/page.tsx
index 295b374..759c6fb 100644
--- a/app/welcome/page.tsx
+++ b/app/welcome/page.tsx
@@ -162,7 +162,7 @@ function WelcomeContent() {
setStep(3)
}
} catch (err) {
- toast.error(getAuthErrorMessage(err) || 'Failed to switch organization')
+ toast.error(getAuthErrorMessage(err) || 'Failed to switch workspace')
} finally {
setSwitchingOrgId(null)
}
@@ -659,6 +659,7 @@ function WelcomeContent() {
placeholder="My Website"
value={siteName}
onChange={(e) => setSiteName(e.target.value)}
+ maxLength={100}
className="w-full"
/>
@@ -672,6 +673,7 @@ function WelcomeContent() {
placeholder="example.com"
value={siteDomain}
onChange={(e) => setSiteDomain(e.target.value.toLowerCase().trim())}
+ maxLength={253}
className="w-full"
/>
diff --git a/components/ErrorDisplay.tsx b/components/ErrorDisplay.tsx
new file mode 100644
index 0000000..6bed625
--- /dev/null
+++ b/components/ErrorDisplay.tsx
@@ -0,0 +1,63 @@
+'use client'
+
+import { Button } from '@ciphera-net/ui'
+
+interface ErrorDisplayProps {
+ title?: string
+ message?: string
+ onRetry?: () => void
+ onGoHome?: boolean
+}
+
+/**
+ * Shared error UI for route-level error.tsx boundaries.
+ * Matches the visual style of the 404 page.
+ */
+export default function ErrorDisplay({
+ title = 'Something went wrong',
+ message = 'An unexpected error occurred. Please try again or go back to the dashboard.',
+ onRetry,
+ onGoHome = true,
+}: ErrorDisplayProps) {
+ return (
+
+
+
+
+
+
+
+ {title}
+
+
+ {message}
+
+
+
+
+
+ )
+}
diff --git a/components/Footer.tsx b/components/Footer.tsx
index 0adeaa1..ed6b2cd 100644
--- a/components/Footer.tsx
+++ b/components/Footer.tsx
@@ -5,7 +5,7 @@ import Image from 'next/image'
import { GithubIcon, TwitterIcon, SwissFlagIcon } from '@ciphera-net/ui'
interface FooterProps {
- LinkComponent?: any
+ LinkComponent?: React.ElementType
appName?: string
isAuthenticated?: boolean
}
diff --git a/components/PricingSection.tsx b/components/PricingSection.tsx
index 41e346f..42b7663 100644
--- a/components/PricingSection.tsx
+++ b/components/PricingSection.tsx
@@ -1,6 +1,7 @@
'use client'
import { useState, useEffect } from 'react'
+import { logger } from '@/lib/utils/logger'
import { useSearchParams } from 'next/navigation'
import { motion } from 'framer-motion'
import { Button, CheckCircleIcon } from '@ciphera-net/ui'
@@ -140,7 +141,7 @@ export default function PricingSection() {
// Clear intent
localStorage.removeItem('pulse_pending_checkout')
} catch (e) {
- console.error('Failed to parse pending checkout', e)
+ logger.error('Failed to parse pending checkout', e)
localStorage.removeItem('pulse_pending_checkout')
}
}
@@ -150,8 +151,7 @@ export default function PricingSection() {
// Helper to get all price details
const getPriceDetails = (planId: string) => {
- // @ts-ignore
- const basePrice = currentTraffic.prices[planId]
+ const basePrice = currentTraffic.prices[planId as keyof typeof currentTraffic.prices]
// Handle "Custom"
if (basePrice === null || basePrice === undefined) return null
@@ -203,9 +203,9 @@ export default function PricingSection() {
throw new Error('No checkout URL returned')
}
- } catch (error: any) {
- console.error('Checkout error:', error)
- toast.error('Failed to start checkout. Please try again.')
+ } catch (error: unknown) {
+ logger.error('Checkout error:', error)
+ toast.error('Failed to start checkout — please try again')
} finally {
setLoadingPlan(null)
}
diff --git a/components/WorkspaceSwitcher.tsx b/components/WorkspaceSwitcher.tsx
index a177337..6d8ff26 100644
--- a/components/WorkspaceSwitcher.tsx
+++ b/components/WorkspaceSwitcher.tsx
@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation'
import { PlusIcon, PersonIcon, CubeIcon, CheckIcon } from '@radix-ui/react-icons'
import { switchContext, OrganizationMember } from '@/lib/api/organization'
import { setSessionAction } from '@/app/actions/auth'
+import { logger } from '@/lib/utils/logger'
import Link from 'next/link'
export default function OrganizationSwitcher({ orgs, activeOrgId }: { orgs: OrganizationMember[], activeOrgId: string | null }) {
@@ -12,7 +13,6 @@ export default function OrganizationSwitcher({ orgs, activeOrgId }: { orgs: Orga
const [switching, setSwitching] = useState(null)
const handleSwitch = async (orgId: string | null) => {
- console.log('Switching to organization:', orgId)
setSwitching(orgId || 'personal')
try {
// * If orgId is null, we can't switch context via API in the same way if strict mode is on
@@ -34,18 +34,18 @@ export default function OrganizationSwitcher({ orgs, activeOrgId }: { orgs: Orga
// * Note: switchContext only returns access_token, we keep existing refresh token
await setSessionAction(access_token)
- // Force reload to pick up new permissions
- window.location.reload()
+ sessionStorage.setItem('pulse_switching_org', 'true')
+ window.location.reload()
} catch (err) {
- console.error('Failed to switch organization', err)
+ logger.error('Failed to switch organization', err)
setSwitching(null)
}
}
return (
-
-
+
+
Organizations
@@ -75,21 +75,28 @@ export default function OrganizationSwitcher({ orgs, activeOrgId }: { orgs: Orga
handleSwitch(org.organization_id)}
+ aria-current={activeOrgId === org.organization_id ? 'true' : undefined}
+ aria-busy={switching === org.organization_id ? 'true' : undefined}
className={`w-full flex items-center justify-between px-3 py-2 text-sm rounded-lg transition-colors mt-1 ${
activeOrgId === org.organization_id ? 'bg-neutral-100 dark:bg-neutral-800' : 'hover:bg-neutral-50 dark:hover:bg-neutral-800/50'
}`}
>
-
+
{org.organization_name}
- {switching === org.organization_id && Loading... }
- {activeOrgId === org.organization_id && !switching && }
+ {switching === org.organization_id && Switching… }
+ {activeOrgId === org.organization_id && !switching && (
+ <>
+
+ (current)
+ >
+ )}
))}
@@ -99,7 +106,7 @@ export default function OrganizationSwitcher({ orgs, activeOrgId }: { orgs: Orga
href="/onboarding"
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-neutral-500 hover:text-blue-600 dark:text-neutral-400 dark:hover:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/10 rounded-lg transition-colors mt-1"
>
-
+
Create Organization
diff --git a/components/dashboard/Campaigns.tsx b/components/dashboard/Campaigns.tsx
index bd0f6a3..31bed08 100644
--- a/components/dashboard/Campaigns.tsx
+++ b/components/dashboard/Campaigns.tsx
@@ -1,9 +1,12 @@
'use client'
import { useState, useEffect, useMemo } from 'react'
+import { logger } from '@/lib/utils/logger'
import Link from 'next/link'
+import Image from 'next/image'
import { formatNumber } from '@ciphera-net/ui'
-import { Modal, ArrowRightIcon, Button, Spinner } from '@ciphera-net/ui'
+import { Modal, ArrowRightIcon, Button } from '@ciphera-net/ui'
+import { TableSkeleton } from '@/components/skeletons'
import { ChevronDownIcon, DownloadIcon } from '@ciphera-net/ui'
import { getCampaigns, CampaignStat } from '@/lib/api/stats'
import { getReferrerFavicon, getReferrerIcon, getReferrerDisplayName } from '@/lib/utils/icons'
@@ -56,7 +59,7 @@ export default function Campaigns({ siteId, dateRange }: CampaignsProps) {
const result = await getCampaigns(siteId, dateRange.start, dateRange.end, 10)
setData(result)
} catch (e) {
- console.error(e)
+ logger.error(e)
} finally {
setIsLoading(false)
}
@@ -72,7 +75,7 @@ export default function Campaigns({ siteId, dateRange }: CampaignsProps) {
const result = await getCampaigns(siteId, dateRange.start, dateRange.end, 100)
setFullData(result)
} catch (e) {
- console.error(e)
+ logger.error(e)
} finally {
setIsLoadingFull(false)
}
@@ -110,11 +113,14 @@ export default function Campaigns({ siteId, dateRange }: CampaignsProps) {
const useFavicon = faviconUrl && !faviconFailed.has(source)
if (useFavicon) {
return (
-
setFaviconFailed((prev) => new Set(prev).add(source))}
+ unoptimized
/>
)
}
@@ -292,9 +298,8 @@ export default function Campaigns({ siteId, dateRange }: CampaignsProps) {
>
{isLoadingFull ? (
-
-
-
Loading...
+
) : (
<>
diff --git a/components/dashboard/Chart.tsx b/components/dashboard/Chart.tsx
index 355090f..792f2de 100644
--- a/components/dashboard/Chart.tsx
+++ b/components/dashboard/Chart.tsx
@@ -14,6 +14,7 @@ import {
} from 'recharts'
import type { TooltipProps } from 'recharts'
import { formatNumber, formatDuration, formatUpdatedAgo } from '@ciphera-net/ui'
+import Sparkline from './Sparkline'
import { ArrowUpRightIcon, ArrowDownRightIcon, BarChartIcon, Select, Button, DownloadIcon } from '@ciphera-net/ui'
import { Checkbox } from '@ciphera-net/ui'
@@ -208,51 +209,6 @@ function getTrendContext(dateRange: { start: string; end: string }): string {
return `vs previous ${days} days`
}
-// * Mini sparkline SVG for KPI cards
-function Sparkline({
- data,
- dataKey,
- color,
- width = 56,
- height = 20,
-}: {
- data: Array
>
- dataKey: string
- color: string
- width?: number
- height?: number
-}) {
- if (!data.length) return null
- const values = data.map((d) => Number(d[dataKey] ?? 0))
- const max = Math.max(...values, 1)
- const min = Math.min(...values, 0)
- const range = max - min || 1
- const padding = 2
- const w = width - padding * 2
- const h = height - padding * 2
-
- const points = values.map((v, i) => {
- const x = padding + (i / Math.max(values.length - 1, 1)) * w
- const y = padding + h - ((v - min) / range) * h
- return `${x},${y}`
- })
-
- const pathD = points.length > 1 ? `M ${points.join(' L ')}` : `M ${points[0]} L ${points[0]}`
-
- return (
-
-
-
- )
-}
-
export default function Chart({
data,
prevData,
diff --git a/components/dashboard/ContentStats.tsx b/components/dashboard/ContentStats.tsx
index b7d65dd..7f2e956 100644
--- a/components/dashboard/ContentStats.tsx
+++ b/components/dashboard/ContentStats.tsx
@@ -1,9 +1,12 @@
'use client'
import { useState, useEffect } from 'react'
+import { logger } from '@/lib/utils/logger'
import { formatNumber } from '@ciphera-net/ui'
+import { useTabListKeyboard } from '@/lib/hooks/useTabListKeyboard'
import { TopPage, getTopPages, getEntryPages, getExitPages } from '@/lib/api/stats'
-import { Modal, ArrowUpRightIcon, LayoutDashboardIcon, Spinner } from '@ciphera-net/ui'
+import { Modal, ArrowUpRightIcon, LayoutDashboardIcon } from '@ciphera-net/ui'
+import { ListSkeleton } from '@/components/skeletons'
interface ContentStatsProps {
topPages: TopPage[]
@@ -21,6 +24,7 @@ const LIMIT = 7
export default function ContentStats({ topPages, entryPages, exitPages, domain, collectPagePaths = true, siteId, dateRange }: ContentStatsProps) {
const [activeTab, setActiveTab] = useState('top_pages')
+ const handleTabKeyDown = useTabListKeyboard()
const [isModalOpen, setIsModalOpen] = useState(false)
const [fullData, setFullData] = useState([])
const [isLoadingFull, setIsLoadingFull] = useState(false)
@@ -47,7 +51,7 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain,
}
setFullData(filterGenericPaths(data))
} catch (e) {
- console.error(e)
+ logger.error(e)
} finally {
setIsLoadingFull(false)
}
@@ -102,7 +106,7 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain,
)}
-
+
{(['top_pages', 'entry_pages', 'exit_pages'] as Tab[]).map((tab) => (
{isLoadingFull ? (
-
-
-
Loading...
+
+
) : (
(fullData.length > 0 ? fullData : data).map((page, index) => (
diff --git a/components/dashboard/Countries.tsx b/components/dashboard/Countries.tsx
index fba76a9..21688b9 100644
--- a/components/dashboard/Countries.tsx
+++ b/components/dashboard/Countries.tsx
@@ -20,7 +20,7 @@ export default function Locations({ countries, cities }: LocationProps) {
if (!countryCode || countryCode === 'Unknown') return null
// * The API returns 2-letter country codes (e.g. US, DE)
// * We cast it to the flag component name
- const FlagComponent = (Flags as any)[countryCode]
+ const FlagComponent = (Flags as Record
>)[countryCode]
return FlagComponent ? : null
}
diff --git a/components/dashboard/ExportModal.tsx b/components/dashboard/ExportModal.tsx
index ca4ce39..8a347ac 100644
--- a/components/dashboard/ExportModal.tsx
+++ b/components/dashboard/ExportModal.tsx
@@ -67,9 +67,9 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to
// Prepare data
const exportData = data.map((item) => {
- const filteredItem: Partial = {}
+ const filteredItem: Record = {}
fields.forEach((field) => {
- (filteredItem as any)[field] = item[field]
+ filteredItem[field] = item[field]
})
return filteredItem
})
@@ -212,7 +212,7 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to
autoTable(doc, {
startY: startY,
head: [fields.map(f => f.charAt(0).toUpperCase() + f.slice(1).replace('_', ' '))],
- body: tableData as any[][],
+ body: tableData as (string | number)[][],
styles: {
font: 'helvetica',
fontSize: 9,
@@ -249,7 +249,7 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to
}
})
- let finalY = (doc as any).lastAutoTable.finalY + 10
+ let finalY = doc.lastAutoTable.finalY + 10
// Top Pages Table
if (topPages && topPages.length > 0) {
@@ -276,7 +276,7 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to
alternateRowStyles: { fillColor: [255, 250, 245] },
})
- finalY = (doc as any).lastAutoTable.finalY + 10
+ finalY = doc.lastAutoTable.finalY + 10
}
// Top Referrers Table
@@ -305,7 +305,7 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to
alternateRowStyles: { fillColor: [255, 250, 245] },
})
- finalY = (doc as any).lastAutoTable.finalY + 10
+ finalY = doc.lastAutoTable.finalY + 10
}
// Campaigns Table
diff --git a/components/dashboard/Locations.tsx b/components/dashboard/Locations.tsx
index 4d85d34..cb71101 100644
--- a/components/dashboard/Locations.tsx
+++ b/components/dashboard/Locations.tsx
@@ -1,12 +1,14 @@
'use client'
import { useState, useEffect } from 'react'
+import { logger } from '@/lib/utils/logger'
import { formatNumber } from '@ciphera-net/ui'
+import { useTabListKeyboard } from '@/lib/hooks/useTabListKeyboard'
import * as Flags from 'country-flag-icons/react/3x2'
-// @ts-ignore
import iso3166 from 'iso-3166-2'
import WorldMap from './WorldMap'
-import { Modal, GlobeIcon, Spinner } from '@ciphera-net/ui'
+import { Modal, GlobeIcon } from '@ciphera-net/ui'
+import { ListSkeleton } from '@/components/skeletons'
import { SiTorproject } from 'react-icons/si'
import { FaUserSecret, FaSatellite } from 'react-icons/fa'
import { getCountries, getCities, getRegions } from '@/lib/api/stats'
@@ -26,8 +28,10 @@ const LIMIT = 7
export default function Locations({ countries, cities, regions, geoDataLevel = 'full', siteId, dateRange }: LocationProps) {
const [activeTab, setActiveTab] = useState('map')
+ const handleTabKeyDown = useTabListKeyboard()
const [isModalOpen, setIsModalOpen] = useState(false)
- const [fullData, setFullData] = useState([])
+ type LocationItem = { country?: string; city?: string; region?: string; pageviews: number }
+ const [fullData, setFullData] = useState([])
const [isLoadingFull, setIsLoadingFull] = useState(false)
useEffect(() => {
@@ -35,7 +39,7 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
const fetchData = async () => {
setIsLoadingFull(true)
try {
- let data: any[] = []
+ let data: LocationItem[] = []
if (activeTab === 'countries') {
data = await getCountries(siteId, dateRange.start, dateRange.end, 250)
} else if (activeTab === 'regions') {
@@ -45,7 +49,7 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
}
setFullData(data)
} catch (e) {
- console.error(e)
+ logger.error(e)
} finally {
setIsLoadingFull(false)
}
@@ -72,7 +76,7 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
return
}
- const FlagComponent = (Flags as any)[countryCode]
+ const FlagComponent = (Flags as Record>)[countryCode]
return FlagComponent ? : null
}
@@ -157,7 +161,7 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
}
// Filter out "Unknown" entries that result from disabled collection
- const filterUnknown = (data: any[]) => {
+ const filterUnknown = (data: LocationItem[]) => {
return data.filter(item => {
if (activeTab === 'countries') return item.country && item.country !== 'Unknown' && item.country !== ''
if (activeTab === 'regions') return item.region && item.region !== 'Unknown' && item.region !== ''
@@ -171,7 +175,7 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
const hasData = activeTab === 'map'
? (countries && filterUnknown(countries).length > 0)
: (data && data.length > 0)
- const displayedData = (activeTab !== 'map' && hasData) ? (data as any[]).slice(0, LIMIT) : []
+ const displayedData = (activeTab !== 'map' && hasData) ? data.slice(0, LIMIT) : []
const emptySlots = Math.max(0, LIMIT - displayedData.length)
const showViewAll = activeTab !== 'map' && hasData && data.length > LIMIT
@@ -202,7 +206,7 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
)}
-
+
{(['map', 'countries', 'regions', 'cities'] as Tab[]).map((tab) => (
: (
+ hasData ?
: (
@@ -246,13 +250,13 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
{displayedData.map((item, index) => (
- {activeTab === 'countries' && {getFlagComponent(item.country)} }
- {activeTab !== 'countries' && {getFlagComponent(item.country)} }
+ {activeTab === 'countries' && {getFlagComponent(item.country ?? '')} }
+ {activeTab !== 'countries' && {getFlagComponent(item.country ?? '')} }
- {activeTab === 'countries' ? getCountryName(item.country) :
- activeTab === 'regions' ? getRegionName(item.region, item.country) :
- getCityName(item.city)}
+ {activeTab === 'countries' ? getCountryName(item.country ?? '') :
+ activeTab === 'regions' ? getRegionName(item.region ?? '', item.country ?? '') :
+ getCityName(item.city ?? '')}
@@ -288,19 +292,18 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
>
{isLoadingFull ? (
-
-
-
Loading...
+
+
) : (
- (fullData.length > 0 ? fullData : data as any[]).map((item, index) => (
+ (fullData.length > 0 ? fullData : data).map((item, index) => (
- {getFlagComponent(item.country)}
+ {getFlagComponent(item.country ?? '')}
- {activeTab === 'countries' ? getCountryName(item.country) :
- activeTab === 'regions' ? getRegionName(item.region, item.country) :
- getCityName(item.city)}
+ {activeTab === 'countries' ? getCountryName(item.country ?? '') :
+ activeTab === 'regions' ? getRegionName(item.region ?? '', item.country ?? '') :
+ getCityName(item.city ?? '')}
diff --git a/components/dashboard/PerformanceStats.tsx b/components/dashboard/PerformanceStats.tsx
index 6b9b184..58af809 100644
--- a/components/dashboard/PerformanceStats.tsx
+++ b/components/dashboard/PerformanceStats.tsx
@@ -5,6 +5,7 @@ import { motion } from 'framer-motion'
import { ChevronDownIcon } from '@ciphera-net/ui'
import { PerformanceStats as Stats, PerformanceByPageStat, getPerformanceByPage } from '@/lib/api/stats'
import { Select } from '@ciphera-net/ui'
+import { TableSkeleton } from '@/components/skeletons'
interface Props {
stats: Stats
@@ -205,7 +206,7 @@ export default function PerformanceStats({ stats, performanceByPage, siteId, sta
style={{ overflow: 'hidden' }}
>
{loadingTable ? (
-
Loading…
+
) : rows.length === 0 ? (
No per-page metrics yet. Data appears as visitors are tracked with performance insights enabled.
diff --git a/components/dashboard/Sparkline.tsx b/components/dashboard/Sparkline.tsx
new file mode 100644
index 0000000..f87a203
--- /dev/null
+++ b/components/dashboard/Sparkline.tsx
@@ -0,0 +1,50 @@
+'use client'
+
+/**
+ * Mini sparkline SVG for KPI cards.
+ * Renders a line chart from an array of data points.
+ */
+export default function Sparkline({
+ data,
+ dataKey,
+ color,
+ width = 56,
+ height = 20,
+}: {
+ /** Array of objects with numeric values (e.g. DailyStat with visitors, pageviews) */
+ data: ReadonlyArray
+ dataKey: string
+ color: string
+ width?: number
+ height?: number
+}) {
+ if (!data.length) return null
+ const values = data.map((d) => Number((d as Record)[dataKey] ?? 0))
+ const max = Math.max(...values, 1)
+ const min = Math.min(...values, 0)
+ const range = max - min || 1
+ const padding = 2
+ const w = width - padding * 2
+ const h = height - padding * 2
+
+ const points = values.map((v, i) => {
+ const x = padding + (i / Math.max(values.length - 1, 1)) * w
+ const y = padding + h - ((v - min) / range) * h
+ return `${x},${y}`
+ })
+
+ const pathD = points.length > 1 ? `M ${points.join(' L ')}` : `M ${points[0]} L ${points[0]}`
+
+ return (
+
+
+
+ )
+}
diff --git a/components/dashboard/TechSpecs.tsx b/components/dashboard/TechSpecs.tsx
index 04f3274..8f3a82f 100644
--- a/components/dashboard/TechSpecs.tsx
+++ b/components/dashboard/TechSpecs.tsx
@@ -1,10 +1,13 @@
'use client'
import { useState, useEffect } from 'react'
+import { logger } from '@/lib/utils/logger'
import { formatNumber } from '@ciphera-net/ui'
+import { useTabListKeyboard } from '@/lib/hooks/useTabListKeyboard'
import { getBrowserIcon, getOSIcon, getDeviceIcon } from '@/lib/utils/icons'
import { MdMonitor } from 'react-icons/md'
-import { Modal, GridIcon, Spinner } from '@ciphera-net/ui'
+import { Modal, GridIcon } from '@ciphera-net/ui'
+import { ListSkeleton } from '@/components/skeletons'
import { getBrowsers, getOS, getDevices, getScreenResolutions } from '@/lib/api/stats'
interface TechSpecsProps {
@@ -24,8 +27,10 @@ const LIMIT = 7
export default function TechSpecs({ browsers, os, devices, screenResolutions, collectDeviceInfo = true, collectScreenResolution = true, siteId, dateRange }: TechSpecsProps) {
const [activeTab, setActiveTab] = useState('browsers')
+ const handleTabKeyDown = useTabListKeyboard()
const [isModalOpen, setIsModalOpen] = useState(false)
- const [fullData, setFullData] = useState([])
+ type TechItem = { name: string; pageviews: number; icon: React.ReactNode }
+ const [fullData, setFullData] = useState([])
const [isLoadingFull, setIsLoadingFull] = useState(false)
// Filter out "Unknown" entries that result from disabled collection
@@ -38,7 +43,7 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
const fetchData = async () => {
setIsLoadingFull(true)
try {
- let data: any[] = []
+ let data: TechItem[] = []
if (activeTab === 'browsers') {
const res = await getBrowsers(siteId, dateRange.start, dateRange.end, 100)
data = res.map(b => ({ name: b.browser, pageviews: b.pageviews, icon: getBrowserIcon(b.browser) }))
@@ -54,7 +59,7 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
}
setFullData(filterUnknown(data))
} catch (e) {
- console.error(e)
+ logger.error(e)
} finally {
setIsLoadingFull(false)
}
@@ -125,7 +130,7 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
)}
-
+
{(['browsers', 'os', 'devices', 'screens'] as Tab[]).map((tab) => (
{isLoadingFull ? (
-
-
-
Loading...
+
+
) : (
(fullData.length > 0 ? fullData : data).map((item, index) => (
diff --git a/components/dashboard/TopReferrers.tsx b/components/dashboard/TopReferrers.tsx
index 4349118..9e9fb1c 100644
--- a/components/dashboard/TopReferrers.tsx
+++ b/components/dashboard/TopReferrers.tsx
@@ -1,9 +1,12 @@
'use client'
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 { Modal, GlobeIcon, Spinner } from '@ciphera-net/ui'
+import { Modal, GlobeIcon } from '@ciphera-net/ui'
+import { ListSkeleton } from '@/components/skeletons'
import { getTopReferrers, TopReferrer } from '@/lib/api/stats'
interface TopReferrersProps {
@@ -38,11 +41,14 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
const useFavicon = faviconUrl && !faviconFailed.has(referrer)
if (useFavicon) {
return (
-
setFaviconFailed((prev) => new Set(prev).add(referrer))}
+ unoptimized
/>
)
}
@@ -61,7 +67,7 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
)
setFullData(filtered)
} catch (e) {
- console.error(e)
+ logger.error(e)
} finally {
setIsLoadingFull(false)
}
@@ -134,9 +140,8 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
>
{isLoadingFull ? (
-
-
-
Loading...
+
+
) : (
mergeReferrersByDisplayName(fullData.length > 0 ? fullData : filteredReferrers).map((ref, index) => (
diff --git a/components/notifications/NotificationCenter.tsx b/components/notifications/NotificationCenter.tsx
index 19f9a4c..eb612d3 100644
--- a/components/notifications/NotificationCenter.tsx
+++ b/components/notifications/NotificationCenter.tsx
@@ -10,6 +10,7 @@ import { listNotifications, markNotificationRead, markAllNotificationsRead, type
import { getAuthErrorMessage } from '@ciphera-net/ui'
import { formatTimeAgo, getTypeIcon } from '@/lib/utils/notifications'
import { SettingsIcon } from '@ciphera-net/ui'
+import { SkeletonLine, SkeletonCircle } from '@/components/skeletons'
// * Bell icon (simple SVG, no extra deps)
function BellIcon({ className }: { className?: string }) {
@@ -25,6 +26,7 @@ function BellIcon({ className }: { className?: string }) {
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
+ aria-hidden="true"
>
@@ -82,16 +84,22 @@ export default function NotificationCenter() {
return () => clearInterval(id)
}, [])
- // * Close dropdown when clicking outside
+ // * Close dropdown when clicking outside or pressing Escape
useEffect(() => {
+ if (!open) return
function handleClickOutside(e: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setOpen(false)
}
}
- if (open) {
- document.addEventListener('mousedown', handleClickOutside)
- return () => document.removeEventListener('mousedown', handleClickOutside)
+ function handleKeyDown(e: KeyboardEvent) {
+ if (e.key === 'Escape') setOpen(false)
+ }
+ document.addEventListener('mousedown', handleClickOutside)
+ document.addEventListener('keydown', handleKeyDown)
+ return () => {
+ document.removeEventListener('mousedown', handleClickOutside)
+ document.removeEventListener('keydown', handleKeyDown)
}
}, [open])
@@ -127,23 +135,32 @@ export default function NotificationCenter() {
setOpen(!open)}
+ aria-expanded={open}
+ aria-haspopup="true"
+ aria-controls={open ? 'notification-dropdown' : undefined}
className="relative p-2 text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white rounded-lg hover:bg-neutral-100/50 dark:hover:bg-neutral-800/50 transition-colors"
- aria-label={unreadCount > 0 ? `${unreadCount} unread notifications` : 'Notifications'}
+ aria-label={unreadCount > 0 ? `Notifications, ${unreadCount} unread` : 'Notifications'}
>
{unreadCount > 0 && (
-
+
)}
{open && (
-
+
Notifications
{unreadCount > 0 && (
Mark all read
@@ -153,8 +170,16 @@ export default function NotificationCenter() {
{loading && (
-
- Loading…
+
+ {Array.from({ length: 4 }).map((_, i) => (
+
+ ))}
)}
{error && (
@@ -193,12 +218,10 @@ export default function NotificationCenter() {
) : (
-
handleNotificationClick(n)}
- onKeyDown={(e) => e.key === 'Enter' && handleNotificationClick(n)}
- className={`block px-4 py-3 hover:bg-neutral-50 dark:hover:bg-neutral-800/50 cursor-pointer ${!n.read ? 'bg-brand-orange/5 dark:bg-brand-orange/10' : ''}`}
+ className={`w-full text-left block px-4 py-3 hover:bg-neutral-50 dark:hover:bg-neutral-800/50 cursor-pointer ${!n.read ? 'bg-brand-orange/5 dark:bg-brand-orange/10' : ''}`}
>
{getTypeIcon(n.type)}
@@ -216,7 +239,7 @@ export default function NotificationCenter() {
-
+
)}
))}
@@ -237,7 +260,7 @@ export default function NotificationCenter() {
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"
>
-
+
Manage settings
diff --git a/components/settings/OrganizationSettings.tsx b/components/settings/OrganizationSettings.tsx
index 327c857..764eca7 100644
--- a/components/settings/OrganizationSettings.tsx
+++ b/components/settings/OrganizationSettings.tsx
@@ -2,6 +2,8 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
+import { setSessionAction } from '@/app/actions/auth'
+import { logger } from '@/lib/utils/logger'
import { useAuth } from '@/lib/auth/context'
import {
deleteOrganization,
@@ -37,6 +39,7 @@ import {
LayoutDashboardIcon,
Spinner,
} from '@ciphera-net/ui'
+import { MembersListSkeleton, InvoicesListSkeleton, AuditLogSkeleton, SettingsFormSkeleton, SkeletonCard } from '@/components/skeletons'
// * Bell icon for notifications tab
function BellIcon({ className }: { className?: string }) {
@@ -47,7 +50,6 @@ function BellIcon({ className }: { className?: string }) {
)
}
-// @ts-ignore
import { Button, Input } from '@ciphera-net/ui'
export default function OrganizationSettings() {
@@ -169,7 +171,7 @@ export default function OrganizationSettings() {
setOrgName(orgData.name)
setOrgSlug(orgData.slug)
} catch (error) {
- console.error('Failed to load data:', error)
+ logger.error('Failed to load data:', error)
// toast.error('Failed to load members')
} finally {
setIsLoadingMembers(false)
@@ -183,7 +185,7 @@ export default function OrganizationSettings() {
const sub = await getSubscription()
setSubscription(sub)
} catch (error) {
- console.error('Failed to load subscription:', error)
+ logger.error('Failed to load subscription:', error)
// toast.error('Failed to load subscription details')
} finally {
setIsLoadingSubscription(false)
@@ -197,7 +199,7 @@ export default function OrganizationSettings() {
const invs = await getInvoices()
setInvoices(invs)
} catch (error) {
- console.error('Failed to load invoices:', error)
+ logger.error('Failed to load invoices:', error)
} finally {
setIsLoadingInvoices(false)
}
@@ -246,8 +248,8 @@ export default function OrganizationSettings() {
setAuditEntries(Array.isArray(entries) ? entries : [])
setAuditTotal(typeof total === 'number' ? total : 0)
} catch (error) {
- console.error('Failed to load audit log', error)
- toast.error(getAuthErrorMessage(error as Error) || 'Failed to load audit log')
+ logger.error('Failed to load audit log', error)
+ toast.error(getAuthErrorMessage(error as Error) || 'Failed to load audit log entries')
} finally {
setIsLoadingAudit(false)
}
@@ -278,7 +280,7 @@ export default function OrganizationSettings() {
setNotificationSettings(res.settings || {})
setNotificationCategories(res.categories || [])
} catch (error) {
- console.error('Failed to load notification settings', error)
+ logger.error('Failed to load notification settings', error)
toast.error(getAuthErrorMessage(error as Error) || 'Failed to load notification settings')
} finally {
setIsLoadingNotificationSettings(false)
@@ -331,8 +333,8 @@ export default function OrganizationSettings() {
try {
const { url } = await createPortalSession()
window.location.href = url
- } catch (error: any) {
- toast.error(getAuthErrorMessage(error) || error.message || 'Failed to redirect to billing portal')
+ } catch (error: unknown) {
+ toast.error(getAuthErrorMessage(error) || (error instanceof Error ? error.message : '') || 'Failed to open billing portal')
setIsRedirectingToPortal(false)
}
}
@@ -344,8 +346,8 @@ export default function OrganizationSettings() {
toast.success(atPeriodEnd ? 'Subscription will cancel at the end of the billing period.' : 'Subscription canceled.')
setShowCancelPrompt(false)
loadSubscription()
- } catch (error: any) {
- toast.error(getAuthErrorMessage(error) || error.message || 'Failed to cancel subscription')
+ } catch (error: unknown) {
+ toast.error(getAuthErrorMessage(error) || (error instanceof Error ? error.message : '') || 'Failed to cancel subscription')
} finally {
setCancelLoadingAction(null)
}
@@ -357,8 +359,8 @@ export default function OrganizationSettings() {
await resumeSubscription()
toast.success('Subscription will continue. Cancellation has been undone.')
loadSubscription()
- } catch (error: any) {
- toast.error(getAuthErrorMessage(error) || error.message || 'Failed to resume subscription')
+ } catch (error: unknown) {
+ toast.error(getAuthErrorMessage(error) || (error instanceof Error ? error.message : '') || 'Failed to resume subscription')
} finally {
setIsResuming(false)
}
@@ -396,8 +398,8 @@ export default function OrganizationSettings() {
if (url) window.location.href = url
else throw new Error('No checkout URL')
}
- } catch (error: any) {
- toast.error(getAuthErrorMessage(error) || error.message || 'Something went wrong.')
+ } catch (error: unknown) {
+ toast.error(getAuthErrorMessage(error) || (error instanceof Error ? error.message : '') || 'Failed to update plan')
} finally {
setIsChangingPlan(false)
}
@@ -417,17 +419,18 @@ export default function OrganizationSettings() {
// * Switch to personal context explicitly
try {
const { access_token } = await switchContext(null)
- localStorage.setItem('token', access_token)
+ await setSessionAction(access_token)
+ sessionStorage.setItem('pulse_switching_org', 'true')
window.location.href = '/'
} catch (switchErr) {
- console.error('Failed to switch to personal context after delete:', switchErr)
- // Fallback: reload and let backend handle invalid token if any
+ logger.error('Failed to switch to personal context after delete:', switchErr)
+ sessionStorage.setItem('pulse_switching_org', 'true')
window.location.href = '/'
}
- } catch (err: any) {
- console.error(err)
- toast.error(getAuthErrorMessage(err) || err.message || 'Failed to delete organization')
+ } catch (err: unknown) {
+ logger.error(err)
+ toast.error(getAuthErrorMessage(err) || (err instanceof Error ? err.message : '') || 'Failed to delete organization')
setIsDeleting(false)
}
}
@@ -455,8 +458,8 @@ export default function OrganizationSettings() {
setCaptchaSolution('')
setCaptchaToken('')
loadMembers() // Refresh list
- } catch (error: any) {
- toast.error(getAuthErrorMessage(error) || error.message || 'Failed to send invitation')
+ } catch (error: unknown) {
+ toast.error(getAuthErrorMessage(error) || (error instanceof Error ? error.message : '') || 'Failed to send invitation')
} finally {
setIsInviting(false)
}
@@ -467,8 +470,8 @@ export default function OrganizationSettings() {
await revokeInvitation(currentOrgId, inviteId)
toast.success('Invitation revoked')
loadMembers() // Refresh list
- } catch (error: any) {
- toast.error(getAuthErrorMessage(error) || error.message || 'Failed to revoke invitation')
+ } catch (error: unknown) {
+ toast.error(getAuthErrorMessage(error) || (error instanceof Error ? error.message : '') || 'Failed to revoke invitation')
}
}
@@ -482,8 +485,8 @@ export default function OrganizationSettings() {
toast.success('Organization updated successfully')
setIsEditing(false)
loadMembers()
- } catch (error: any) {
- toast.error(getAuthErrorMessage(error) || error.message || 'Failed to update organization')
+ } catch (error: unknown) {
+ toast.error(getAuthErrorMessage(error) || (error instanceof Error ? error.message : '') || 'Failed to save organization settings')
} finally {
setIsSaving(false)
}
@@ -601,7 +604,7 @@ export default function OrganizationSettings() {
setOrgName(e.target.value)}
+ onChange={(e: React.ChangeEvent
) => setOrgName(e.target.value)}
required
minLength={2}
maxLength={50}
@@ -621,7 +624,7 @@ export default function OrganizationSettings() {
setOrgSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))}
+ onChange={(e: React.ChangeEvent) => setOrgSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))}
required
minLength={3}
maxLength={30}
@@ -701,7 +704,7 @@ export default function OrganizationSettings() {
type="email"
placeholder="colleague@company.com"
value={inviteEmail}
- onChange={(e: any) => setInviteEmail(e.target.value)}
+ onChange={(e: React.ChangeEvent) => setInviteEmail(e.target.value)}
required
className="bg-white dark:bg-neutral-900"
/>
@@ -740,9 +743,7 @@ export default function OrganizationSettings() {
Active Members
{isLoadingMembers ? (
-
-
-
+
) : members.length === 0 ? (
No members found.
) : (
@@ -821,8 +822,9 @@ export default function OrganizationSettings() {
{isLoadingSubscription ? (
-
-
+
+
+
) : !subscription ? (
@@ -1046,9 +1048,7 @@ export default function OrganizationSettings() {
Recent invoices
{isLoadingInvoices ? (
-
-
-
+
) : invoices.length === 0 ? (
No invoices found.
) : (
@@ -1117,9 +1117,7 @@ export default function OrganizationSettings() {
{isLoadingNotificationSettings ? (
-
-
-
+
) : (
Notification categories
@@ -1149,7 +1147,7 @@ export default function OrganizationSettings() {
toast.success('Notification settings updated')
})
.catch((err) => {
- toast.error(getAuthErrorMessage(err) || 'Failed to update settings')
+ toast.error(getAuthErrorMessage(err) || 'Failed to save notification preferences')
setNotificationSettings(prev)
})
.finally(() => setIsSavingNotificationSettings(false))
@@ -1244,9 +1242,7 @@ export default function OrganizationSettings() {
{/* Table */}
{isLoadingAudit ? (
-
-
-
+
) : (auditEntries ?? []).length === 0 ? (
No audit events found.
) : (
diff --git a/components/sites/SiteList.tsx b/components/sites/SiteList.tsx
index b88004f..0e79116 100644
--- a/components/sites/SiteList.tsx
+++ b/components/sites/SiteList.tsx
@@ -1,18 +1,118 @@
'use client'
import Link from 'next/link'
+import Image from 'next/image'
import { Site } from '@/lib/api/sites'
+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'
+
+export type SiteStatsMap = Record
interface SiteListProps {
sites: Site[]
+ siteStats: SiteStatsMap
loading: boolean
onDelete: (id: string) => void
}
-export default function SiteList({ sites, loading, onDelete }: SiteListProps) {
+interface SiteCardProps {
+ site: Site
+ stats: Stats | null
+ statsLoading: boolean
+ onDelete: (id: string) => void
+ canDelete: boolean
+}
+
+function SiteCard({ site, stats, statsLoading, onDelete, canDelete }: SiteCardProps) {
+ const visitors24h = stats?.visitors ?? 0
+ const pageviews = stats?.pageviews ?? 0
+
+ return (
+
+ {/* Header: Icon + Name + Live Status */}
+
+
+
+
+
+
+
+
+ Active
+
+
+
+ {/* Mini Stats Grid */}
+
+
+
Visitors (24h)
+
+ {statsLoading ? '--' : formatNumber(visitors24h)}
+
+
+
+
Pageviews
+
+ {statsLoading ? '--' : formatNumber(pageviews)}
+
+
+
+
+ {/* Actions */}
+
+
+
+
+ View Dashboard
+
+
+ {canDelete && (
+ onDelete(site.id)}
+ className="flex items-center justify-center rounded-lg border border-neutral-200 px-3 hover:bg-neutral-50 dark:border-neutral-700 dark:hover:bg-neutral-800 text-neutral-500 hover:text-red-600 dark:hover:text-red-400 transition-colors focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
+ title="Delete Site"
+ >
+
+
+ )}
+
+
+ )
+}
+
+export default function SiteList({ sites, siteStats, loading, onDelete }: SiteListProps) {
const { user } = useAuth()
+ const canDelete = user?.role === 'owner' || user?.role === 'admin'
if (loading) {
return (
@@ -40,85 +140,19 @@ export default function SiteList({ sites, loading, onDelete }: SiteListProps) {
return (
- {sites.map((site) => (
-
- {/* Header: Icon + Name + Live Status */}
-
-
- {/* Auto-fetch favicon */}
-
-
-
-
-
-
- {/* "Live" Indicator */}
-
-
-
-
-
- Active
-
-
-
- {/* Mini Stats Grid */}
-
-
- {/* Actions */}
-
-
-
-
- View Dashboard
-
-
- {(user?.role === 'owner' || user?.role === 'admin') && (
- onDelete(site.id)}
- className="flex items-center justify-center rounded-lg border border-neutral-200 px-3 hover:bg-neutral-50 dark:border-neutral-700 dark:hover:bg-neutral-800 text-neutral-500 hover:text-red-600 dark:hover:text-red-400 transition-colors focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
- title="Delete Site"
- >
-
-
- )}
-
-
- ))}
+ {sites.map((site) => {
+ const data = siteStats[site.id]
+ return (
+
+ )
+ })}
{/* Resources Card */}
diff --git a/components/skeletons.tsx b/components/skeletons.tsx
new file mode 100644
index 0000000..bf33111
--- /dev/null
+++ b/components/skeletons.tsx
@@ -0,0 +1,463 @@
+/**
+ * Reusable skeleton loading primitives and composites for Pulse.
+ * All skeletons follow the design-system pattern:
+ * animate-pulse + bg-neutral-100 dark:bg-neutral-800 + rounded
+ */
+
+const SK = 'animate-pulse bg-neutral-100 dark:bg-neutral-800'
+
+export { useMinimumLoading } from './useMinimumLoading'
+
+// ─── Primitives ──────────────────────────────────────────────
+
+export function SkeletonLine({ className = '' }: { className?: string }) {
+ return
+}
+
+export function SkeletonCircle({ className = '' }: { className?: string }) {
+ return
+}
+
+export function SkeletonCard({ className = '' }: { className?: string }) {
+ return
+}
+
+// ─── List skeleton (icon + two text lines per row) ───────────
+
+export function ListRowSkeleton() {
+ return (
+
+ )
+}
+
+export function ListSkeleton({ rows = 7 }: { rows?: number }) {
+ return (
+
+ {Array.from({ length: rows }).map((_, i) => (
+
+ ))}
+
+ )
+}
+
+// ─── Table skeleton (header row + data rows) ─────────────────
+
+export function TableSkeleton({ rows = 7, cols = 5 }: { rows?: number; cols?: number }) {
+ return (
+
+
+ {Array.from({ length: cols }).map((_, i) => (
+
+ ))}
+
+ {Array.from({ length: rows }).map((_, i) => (
+
+ {Array.from({ length: cols }).map((_, j) => (
+
+ ))}
+
+ ))}
+
+ )
+}
+
+// ─── Widget panel skeleton (used inside dashboard grid) ──────
+
+export function WidgetSkeleton() {
+ return (
+
+ )
+}
+
+// ─── Stat card skeleton ──────────────────────────────────────
+
+export function StatCardSkeleton() {
+ return (
+
+
+
+
+ )
+}
+
+// ─── Chart area skeleton ─────────────────────────────────────
+
+export function ChartSkeleton() {
+ return (
+
+
+
+ {Array.from({ length: 4 }).map((_, i) => (
+
+
+
+
+ ))}
+
+
+
+
+
+ )
+}
+
+// ─── Full dashboard skeleton ─────────────────────────────────
+
+export function DashboardSkeleton() {
+ return (
+
+ {/* Header */}
+
+
+ {/* Chart */}
+
+
+
+
+ {/* Widget grid (2 cols) */}
+
+
+
+
+
+
+
+
+
+ {/* Campaigns table */}
+
+
+ )
+}
+
+// ─── Realtime page skeleton ──────────────────────────────────
+
+export function RealtimeSkeleton() {
+ return (
+
+
+
+
+
+
+ {/* Visitors list */}
+
+
+
+
+
+ {Array.from({ length: 6 }).map((_, i) => (
+
+ ))}
+
+
+ {/* Session details */}
+
+
+
+
+
+ {Array.from({ length: 5 }).map((_, i) => (
+
+ ))}
+
+
+
+
+ )
+}
+
+// ─── Session events skeleton (for loading events panel) ──────
+
+export function SessionEventsSkeleton() {
+ return (
+
+ {Array.from({ length: 5 }).map((_, i) => (
+
+ ))}
+
+ )
+}
+
+// ─── Uptime page skeleton ────────────────────────────────────
+
+export function UptimeSkeleton() {
+ return (
+
+
+
+
+
+
+ {/* Overall status */}
+
+ {/* Monitor cards */}
+
+ {Array.from({ length: 3 }).map((_, i) => (
+
+ ))}
+
+
+ )
+}
+
+// ─── Checks / Response time skeleton ─────────────────────────
+
+export function ChecksSkeleton() {
+ return (
+
+
+
+ {Array.from({ length: 5 }).map((_, i) => (
+
+ ))}
+
+
+ )
+}
+
+// ─── Funnels list skeleton ───────────────────────────────────
+
+export function FunnelsListSkeleton() {
+ return (
+
+
+
+
+ {Array.from({ length: 3 }).map((_, i) => (
+
+
+
+
+ {Array.from({ length: 3 }).map((_, j) => (
+
+
+ {j < 2 && }
+
+ ))}
+
+
+ ))}
+
+
+
+ )
+}
+
+// ─── Funnel detail skeleton ──────────────────────────────────
+
+export function FunnelDetailSkeleton() {
+ return (
+
+
+
+
+
+
+
+
+ {Array.from({ length: 3 }).map((_, i) => (
+
+ ))}
+
+
+ )
+}
+
+// ─── Notifications list skeleton ─────────────────────────────
+
+export function NotificationsListSkeleton() {
+ return (
+
+ {Array.from({ length: 6 }).map((_, i) => (
+
+ ))}
+
+ )
+}
+
+// ─── Settings form skeleton ──────────────────────────────────
+
+export function SettingsFormSkeleton() {
+ return (
+
+ {Array.from({ length: 4 }).map((_, i) => (
+
+
+
+
+ ))}
+
+
+ )
+}
+
+// ─── Goals list skeleton ─────────────────────────────────────
+
+export function GoalsListSkeleton() {
+ return (
+
+ {Array.from({ length: 3 }).map((_, i) => (
+
+ ))}
+
+ )
+}
+
+// ─── Pricing cards skeleton ──────────────────────────────────
+
+export function PricingCardsSkeleton() {
+ return (
+
+ {Array.from({ length: 3 }).map((_, i) => (
+
+ ))}
+
+ )
+}
+
+// ─── Organization settings skeleton (members, billing, etc) ─
+
+export function MembersListSkeleton() {
+ return (
+
+ {Array.from({ length: 4 }).map((_, i) => (
+
+ ))}
+
+ )
+}
+
+export function InvoicesListSkeleton() {
+ return (
+
+ {Array.from({ length: 4 }).map((_, i) => (
+
+ ))}
+
+ )
+}
+
+export function AuditLogSkeleton() {
+ return (
+
+ {Array.from({ length: 5 }).map((_, i) => (
+
+
+
+
+
+ ))}
+
+ )
+}
diff --git a/components/tools/UtmBuilder.tsx b/components/tools/UtmBuilder.tsx
index 3e7dd7f..7f5ad34 100644
--- a/components/tools/UtmBuilder.tsx
+++ b/components/tools/UtmBuilder.tsx
@@ -1,6 +1,7 @@
'use client'
import { useState, useEffect } from 'react'
+import { logger } from '@/lib/utils/logger'
import { CopyIcon, CheckIcon } from '@radix-ui/react-icons'
import { listSites, Site } from '@/lib/api/sites'
import { Select, Input, Button } from '@ciphera-net/ui'
@@ -30,7 +31,7 @@ export default function UtmBuilder({ initialSiteId }: UtmBuilderProps) {
const data = await listSites()
setSites(data)
} catch (e) {
- console.error('Failed to load sites for UTM builder', e)
+ logger.error('Failed to load sites for UTM builder', e)
}
}
fetchSites()
diff --git a/components/useMinimumLoading.ts b/components/useMinimumLoading.ts
new file mode 100644
index 0000000..a5a86c3
--- /dev/null
+++ b/components/useMinimumLoading.ts
@@ -0,0 +1,34 @@
+'use client'
+
+import { useState, useEffect, useRef } from 'react'
+
+/**
+ * Prevents skeleton flicker on fast loads by keeping it visible
+ * for at least `minMs` once it appears.
+ *
+ * @param loading - The raw loading state from data fetching
+ * @param minMs - Minimum milliseconds the skeleton stays visible (default 300)
+ * @returns Whether the skeleton should be shown
+ */
+export function useMinimumLoading(loading: boolean, minMs = 300): boolean {
+ const [show, setShow] = useState(loading)
+ const startRef = useRef
(loading ? Date.now() : 0)
+
+ useEffect(() => {
+ if (loading) {
+ startRef.current = Date.now()
+ setShow(true)
+ } else {
+ const elapsed = Date.now() - startRef.current
+ const remaining = minMs - elapsed
+ if (remaining > 0) {
+ const timer = setTimeout(() => setShow(false), remaining)
+ return () => clearTimeout(timer)
+ } else {
+ setShow(false)
+ }
+ }
+ }, [loading, minMs])
+
+ return show
+}
diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md
index b6cfa30..91f7bd2 100644
--- a/docs/DESIGN_SYSTEM.md
+++ b/docs/DESIGN_SYSTEM.md
@@ -405,8 +405,8 @@ toast.success('Site created successfully')
**Error Toast:**
```tsx
-toast.error('Failed to load data')
-// Red toast with X icon
+toast.error('Failed to load uptime monitors')
+// Red toast with X icon — always mention what failed
```
**Error Display:**
diff --git a/lib/api/client.ts b/lib/api/client.ts
index e012341..f7cccce 100644
--- a/lib/api/client.ts
+++ b/lib/api/client.ts
@@ -24,9 +24,9 @@ export function getSignupUrl(redirectPath = '/auth/callback') {
export class ApiError extends Error {
status: number
- data?: any
+ data?: Record
- constructor(message: string, status: number, data?: any) {
+ constructor(message: string, status: number, data?: Record) {
super(message)
this.status = status
this.data = data
@@ -184,46 +184,5 @@ async function apiRequest(
return response.json()
}
-// * Legacy axios-style client for compatibility
-export function getClient() {
- return {
- post: async (endpoint: string, body: any) => {
- // Handle the case where endpoint might start with /api (remove it if our base client adds it, OR adjust usage)
- // Our apiRequest adds /api/v1 prefix.
- // If we pass /api/billing/checkout, apiRequest makes it /api/v1/api/billing/checkout -> Wrong.
- // We should probably just expose apiRequest directly or wrap it properly.
-
- // Let's adapt the endpoint:
- // If endpoint starts with /api/, strip it because apiRequest adds /api/v1
- // BUT WAIT: The backend billing endpoint is likely at /api/billing/checkout (not /api/v1/billing/checkout) if I registered it at root group?
- // Let's check backend routing.
- // In main.go: billingGroup := router.Group("/api/billing") -> so it is at /api/billing/... NOT /api/v1/billing...
-
- // So we need a raw fetch for this, or modify apiRequest to support non-v1 routes.
- // For now, let's just implement a simple fetch wrapper that mimics axios
-
- const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null
- const headers: any = { 'Content-Type': 'application/json' }
- // Although we use cookies, sometimes we might fallback to token if cookies fail?
- // Pulse uses cookies primarily now.
-
- const url = `${API_URL}${endpoint}`
- const res = await fetch(url, {
- method: 'POST',
- headers,
- body: JSON.stringify(body),
- credentials: 'include'
- })
-
- if (!res.ok) {
- const err = await res.json().catch(() => ({}))
- throw new Error(err.error || 'Request failed')
- }
-
- return { data: await res.json() }
- }
- }
-}
-
export const authFetch = apiRequest
export default apiRequest
diff --git a/lib/api/organization.ts b/lib/api/organization.ts
index 3cc5003..b48ab07 100644
--- a/lib/api/organization.ts
+++ b/lib/api/organization.ts
@@ -47,7 +47,6 @@ export async function getUserOrganizations(): Promise {
// Switch Context (Get token for specific org)
export async function switchContext(organizationId: string | null): Promise<{ access_token: string; expires_in: number }> {
const payload = { organization_id: organizationId || '' }
- console.log('Sending switch context request:', payload)
return await authFetch<{ access_token: string; expires_in: number }>('/auth/switch-context', {
method: 'POST',
body: JSON.stringify(payload),
@@ -87,10 +86,7 @@ export async function sendInvitation(
role: string = 'member',
captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string }
): Promise {
- const body: any = {
- email,
- role
- }
+ const body: Record = { email, role }
if (captcha?.captcha_id) body.captcha_id = captcha.captcha_id
if (captcha?.captcha_solution) body.captcha_solution = captcha.captcha_solution
diff --git a/lib/auth/context.tsx b/lib/auth/context.tsx
index 61e2b45..537732b 100644
--- a/lib/auth/context.tsx
+++ b/lib/auth/context.tsx
@@ -6,6 +6,7 @@ import apiRequest from '@/lib/api/client'
import { LoadingOverlay } from '@ciphera-net/ui'
import { logoutAction, getSessionAction, setSessionAction } from '@/app/actions/auth'
import { getUserOrganizations, switchContext } from '@/lib/api/organization'
+import { logger } from '@/lib/utils/logger'
interface User {
id: string
@@ -66,7 +67,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
return merged
})
})
- .catch((e) => console.error('Failed to fetch full profile after login', e))
+ .catch((e) => logger.error('Failed to fetch full profile after login', e))
}
const logout = useCallback(async () => {
@@ -96,7 +97,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
return merged
})
} catch (e) {
- console.error('Failed to refresh user data', e)
+ logger.error('Failed to refresh user data', e)
}
router.refresh()
}, [router])
@@ -121,7 +122,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setUser(merged)
localStorage.setItem('user', JSON.stringify(merged))
} catch (e) {
- console.error('Failed to fetch full profile', e)
+ logger.error('Failed to fetch full profile', e)
}
} else {
// * Session invalid/expired
@@ -159,7 +160,6 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
// * If user has organizations but no context (org_id), switch to the first one
if (!user.org_id && organizations.length > 0) {
const firstOrg = organizations[0]
- console.log('Auto-switching to organization:', firstOrg.organization_name)
try {
const { access_token } = await switchContext(firstOrg.organization_id)
@@ -179,11 +179,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
router.refresh()
}
} catch (e) {
- console.error('Failed to auto-switch context', e)
+ logger.error('Failed to auto-switch context', e)
}
}
} catch (e) {
- console.error("Failed to fetch organizations", e)
+ logger.error("Failed to fetch organizations", e)
}
}
}
diff --git a/lib/hooks/useTabListKeyboard.ts b/lib/hooks/useTabListKeyboard.ts
new file mode 100644
index 0000000..269407b
--- /dev/null
+++ b/lib/hooks/useTabListKeyboard.ts
@@ -0,0 +1,28 @@
+'use client'
+
+import { useCallback } from 'react'
+
+/**
+ * Provides an onKeyDown handler for WAI-ARIA tab lists.
+ * Moves focus between sibling `[role="tab"]` buttons with Left/Right arrow keys.
+ */
+export function useTabListKeyboard() {
+ return useCallback((e: React.KeyboardEvent) => {
+ const target = e.currentTarget
+ const tabs = Array.from(target.querySelectorAll('[role="tab"]'))
+ const index = tabs.indexOf(e.target as HTMLElement)
+ if (index < 0) return
+
+ let next: number | null = null
+ if (e.key === 'ArrowRight') next = (index + 1) % tabs.length
+ else if (e.key === 'ArrowLeft') next = (index - 1 + tabs.length) % tabs.length
+ else if (e.key === 'Home') next = 0
+ else if (e.key === 'End') next = tabs.length - 1
+
+ if (next !== null) {
+ e.preventDefault()
+ tabs[next].focus()
+ tabs[next].click()
+ }
+ }, [])
+}
diff --git a/lib/hooks/useUnsavedChanges.ts b/lib/hooks/useUnsavedChanges.ts
new file mode 100644
index 0000000..99016ba
--- /dev/null
+++ b/lib/hooks/useUnsavedChanges.ts
@@ -0,0 +1,24 @@
+'use client'
+
+import { useEffect, useCallback } from 'react'
+
+/**
+ * Warns users with a browser prompt when they try to navigate away
+ * or close the tab while there are unsaved form changes.
+ *
+ * @param isDirty - Whether the form has unsaved changes
+ */
+export function useUnsavedChanges(isDirty: boolean) {
+ const handleBeforeUnload = useCallback(
+ (e: BeforeUnloadEvent) => {
+ if (!isDirty) return
+ e.preventDefault()
+ },
+ [isDirty]
+ )
+
+ useEffect(() => {
+ window.addEventListener('beforeunload', handleBeforeUnload)
+ return () => window.removeEventListener('beforeunload', handleBeforeUnload)
+ }, [handleBeforeUnload])
+}
diff --git a/lib/utils/icons.tsx b/lib/utils/icons.tsx
index 7ec7742..da7f3cd 100644
--- a/lib/utils/icons.tsx
+++ b/lib/utils/icons.tsx
@@ -1,4 +1,10 @@
import React from 'react'
+
+/**
+ * Google's public favicon service base URL.
+ * Append `?domain=&sz=` to get a favicon.
+ */
+export const FAVICON_SERVICE_URL = 'https://www.google.com/s2/favicons'
import {
FaChrome,
FaFirefox,
@@ -197,7 +203,7 @@ export function getReferrerFavicon(referrer: string): string | null {
try {
const url = new URL(referrer.startsWith('http') ? referrer : `https://${referrer}`)
if (REFERRER_USE_X_ICON.has(url.hostname.toLowerCase())) return null
- return `https://www.google.com/s2/favicons?domain=${url.hostname}&sz=32`
+ return `${FAVICON_SERVICE_URL}?domain=${url.hostname}&sz=32`
} catch {
return null
}
diff --git a/lib/utils/logger.ts b/lib/utils/logger.ts
new file mode 100644
index 0000000..52f53b9
--- /dev/null
+++ b/lib/utils/logger.ts
@@ -0,0 +1,16 @@
+/**
+ * Dev-only logger that suppresses client-side output in production.
+ * Server-side logs always pass through (they go to server logs, not the browser).
+ */
+
+const isServer = typeof window === 'undefined'
+const isDev = process.env.NODE_ENV === 'development'
+
+export const logger = {
+ error(...args: unknown[]) {
+ if (isServer || isDev) console.error(...args)
+ },
+ warn(...args: unknown[]) {
+ if (isServer || isDev) console.warn(...args)
+ },
+}
diff --git a/lib/utils/notifications.tsx b/lib/utils/notifications.tsx
index 9ed6c7c..b4441f1 100644
--- a/lib/utils/notifications.tsx
+++ b/lib/utils/notifications.tsx
@@ -22,7 +22,7 @@ export function formatTimeAgo(dateStr: string): string {
*/
export function getTypeIcon(type: string) {
if (type.includes('down') || type.includes('degraded') || type.startsWith('billing_')) {
- return
+ return
}
- return
+ return
}
diff --git a/middleware.ts b/middleware.ts
new file mode 100644
index 0000000..440ccf1
--- /dev/null
+++ b/middleware.ts
@@ -0,0 +1,66 @@
+import { NextResponse } from 'next/server'
+import type { NextRequest } from 'next/server'
+
+const PUBLIC_ROUTES = new Set([
+ '/',
+ '/login',
+ '/signup',
+ '/auth/callback',
+ '/pricing',
+ '/features',
+ '/about',
+ '/faq',
+ '/changelog',
+ '/installation',
+])
+
+const PUBLIC_PREFIXES = [
+ '/share/',
+ '/integrations',
+ '/docs',
+]
+
+function isPublicRoute(pathname: string): boolean {
+ if (PUBLIC_ROUTES.has(pathname)) return true
+ return PUBLIC_PREFIXES.some((prefix) => pathname.startsWith(prefix))
+}
+
+const AUTH_ONLY_ROUTES = new Set(['/login', '/signup'])
+
+export function middleware(request: NextRequest) {
+ const { pathname } = request.nextUrl
+
+ const hasAccess = request.cookies.has('access_token')
+ const hasRefresh = request.cookies.has('refresh_token')
+ const hasSession = hasAccess || hasRefresh
+
+ // * Authenticated user hitting /login or /signup → send them home
+ if (hasSession && AUTH_ONLY_ROUTES.has(pathname)) {
+ return NextResponse.redirect(new URL('/', request.url))
+ }
+
+ // * Public route → allow through
+ if (isPublicRoute(pathname)) {
+ return NextResponse.next()
+ }
+
+ // * Protected route without a session → redirect to login
+ if (!hasSession) {
+ const loginUrl = new URL('/login', request.url)
+ return NextResponse.redirect(loginUrl)
+ }
+
+ return NextResponse.next()
+}
+
+export const config = {
+ matcher: [
+ /*
+ * Match all routes except:
+ * - _next/static, _next/image (Next.js internals)
+ * - favicon.ico, manifest.json, icons, images (static assets)
+ * - api routes (handled by their own auth)
+ */
+ '/((?!_next/static|_next/image|favicon\\.ico|manifest\\.json|.*\\.png$|.*\\.svg$|.*\\.ico$|api/).*)',
+ ],
+}
diff --git a/next.config.ts b/next.config.ts
index 2bf94f0..648d8ee 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -12,6 +12,39 @@ const nextConfig: NextConfig = {
output: 'standalone',
// * Privacy-first: Disable analytics and telemetry
productionBrowserSourceMaps: false,
+ experimental: {
+ optimizePackageImports: ['react-icons'],
+ },
+ images: {
+ remotePatterns: [
+ {
+ protocol: 'https',
+ hostname: 'www.google.com',
+ pathname: '/s2/favicons**',
+ },
+ ],
+ },
+ async headers() {
+ return [
+ {
+ source: '/(.*)',
+ headers: [
+ { key: 'X-Frame-Options', value: 'DENY' },
+ { key: 'X-Content-Type-Options', value: 'nosniff' },
+ { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
+ {
+ key: 'Permissions-Policy',
+ value: 'camera=(), microphone=(), geolocation=(), interest-cohort=()',
+ },
+ { key: 'X-XSS-Protection', value: '1; mode=block' },
+ {
+ key: 'Strict-Transport-Security',
+ value: 'max-age=63072000; includeSubDomains; preload',
+ },
+ ],
+ },
+ ]
+ },
async redirects() {
return [
{
diff --git a/package.json b/package.json
index 10fb548..022fa33 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "pulse-frontend",
- "version": "0.10.0-alpha",
+ "version": "0.11.0-alpha",
"private": true,
"scripts": {
"dev": "next dev",
diff --git a/public/dashboard-preview-v2.png b/public/dashboard-preview-v2.png
new file mode 100644
index 0000000..c506bd1
Binary files /dev/null and b/public/dashboard-preview-v2.png differ
diff --git a/types/iso-3166-2.d.ts b/types/iso-3166-2.d.ts
new file mode 100644
index 0000000..0a61a82
--- /dev/null
+++ b/types/iso-3166-2.d.ts
@@ -0,0 +1,21 @@
+declare module 'iso-3166-2' {
+ interface SubdivisionInfo {
+ name: string
+ type: string
+ parent?: string
+ }
+
+ interface CountryInfo {
+ name: string
+ sub: Record
+ }
+
+ const iso3166: {
+ data: Record
+ country(code: string): CountryInfo | undefined
+ subdivision(code: string): SubdivisionInfo | undefined
+ codes: string[]
+ }
+
+ export default iso3166
+}
diff --git a/types/jspdf-autotable.d.ts b/types/jspdf-autotable.d.ts
new file mode 100644
index 0000000..a041282
--- /dev/null
+++ b/types/jspdf-autotable.d.ts
@@ -0,0 +1,9 @@
+import 'jspdf'
+
+declare module 'jspdf' {
+ interface jsPDF {
+ lastAutoTable: {
+ finalY: number
+ }
+ }
+}