[PULSE-60] Frontend hardening, UX polish, and security #35

Merged
uz1mani merged 41 commits from staging into main 2026-02-22 21:43:06 +00:00
73 changed files with 1863 additions and 427 deletions

View File

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

19
app/about/layout.tsx Normal file
View File

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

View File

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

View File

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

13
app/error.tsx Normal file
View File

@@ -0,0 +1,13 @@
'use client'
import ErrorDisplay from '@/components/ErrorDisplay'
export default function GlobalError({ reset }: { error: Error; reset: () => void }) {
return (
<ErrorDisplay
title="Something went wrong"
message="An unexpected error occurred. Please try again or go back to the dashboard."
onRetry={reset}
/>
)
}

19
app/faq/layout.tsx Normal file
View File

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

19
app/features/layout.tsx Normal file
View File

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

View File

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

View File

@@ -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<any[]>([])
greptile-apps[bot] commented 2026-02-22 21:47:15 +00:00 (Migrated from github.com)
Review

Remaining any type for orgs state

Throughout this PR, any types have been systematically replaced with proper types, but orgs is still any[]. This should use the OrganizationMember type that's already imported from @/lib/api/organization:

  const [orgs, setOrgs] = useState<OrganizationMember[]>([])
Prompt To Fix With AI
This is a comment left during a code review.
Path: app/layout-content.tsx
Line: 23

Comment:
**Remaining `any` type for `orgs` state**

Throughout this PR, `any` types have been systematically replaced with proper types, but `orgs` is still `any[]`. This should use the `OrganizationMember` type that's already imported from `@/lib/api/organization`:

```suggestion
  const [orgs, setOrgs] = useState<OrganizationMember[]>([])
```

How can I resolve this? If you propose a fix, please make it concise.
**Remaining `any` type for `orgs` state** Throughout this PR, `any` types have been systematically replaced with proper types, but `orgs` is still `any[]`. This should use the `OrganizationMember` type that's already imported from `@/lib/api/organization`: ```suggestion const [orgs, setOrgs] = useState<OrganizationMember[]>([]) ``` <details><summary>Prompt To Fix With AI</summary> `````markdown This is a comment left during a code review. Path: app/layout-content.tsx Line: 23 Comment: **Remaining `any` type for `orgs` state** Throughout this PR, `any` types have been systematically replaced with proper types, but `orgs` is still `any[]`. This should use the `OrganizationMember` type that's already imported from `@/lib/api/organization`: ```suggestion const [orgs, setOrgs] = useState<OrganizationMember[]>([]) ``` How can I resolve this? If you propose a fix, please make it concise. ````` </details>
const [isSwitchingOrg, setIsSwitchingOrg] = useState(() => {
if (typeof window === 'undefined') return false
return sessionStorage.getItem(ORG_SWITCH_KEY) === 'true'
})
greptile-apps[bot] commented 2026-02-22 21:47:12 +00:00 (Migrated from github.com)
Review

SSR/hydration mismatch from sessionStorage in initial state

The lazy initializer reads from sessionStorage during the first render, but on the server typeof window === 'undefined' returns false. Since this is a 'use client' component, the server renders with false while the client may initialize with true, causing a React hydration mismatch warning. A safer pattern is to initialize as false and sync in a useEffect:

const [isSwitchingOrg, setIsSwitchingOrg] = useState(false)

useEffect(() => {
  if (sessionStorage.getItem(ORG_SWITCH_KEY) === 'true') {
    setIsSwitchingOrg(true)
  }
}, [])

This trades a brief flash for a guaranteed clean hydration. In practice, the current approach may work due to 'use client' only running on the client in many setups, but it's not guaranteed by the Next.js contract.

Prompt To Fix With AI
This is a comment left during a code review.
Path: app/layout-content.tsx
Line: 24-27

Comment:
**SSR/hydration mismatch from `sessionStorage` in initial state**

The lazy initializer reads from `sessionStorage` during the first render, but on the server `typeof window === 'undefined'` returns `false`. Since this is a `'use client'` component, the server renders with `false` while the client may initialize with `true`, causing a React hydration mismatch warning. A safer pattern is to initialize as `false` and sync in a `useEffect`:

```
const [isSwitchingOrg, setIsSwitchingOrg] = useState(false)

useEffect(() => {
  if (sessionStorage.getItem(ORG_SWITCH_KEY) === 'true') {
    setIsSwitchingOrg(true)
  }
}, [])
```

This trades a brief flash for a guaranteed clean hydration. In practice, the current approach may work due to `'use client'` only running on the client in many setups, but it's not guaranteed by the Next.js contract.

How can I resolve this? If you propose a fix, please make it concise.
**SSR/hydration mismatch from `sessionStorage` in initial state** The lazy initializer reads from `sessionStorage` during the first render, but on the server `typeof window === 'undefined'` returns `false`. Since this is a `'use client'` component, the server renders with `false` while the client may initialize with `true`, causing a React hydration mismatch warning. A safer pattern is to initialize as `false` and sync in a `useEffect`: ``` const [isSwitchingOrg, setIsSwitchingOrg] = useState(false) useEffect(() => { if (sessionStorage.getItem(ORG_SWITCH_KEY) === 'true') { setIsSwitchingOrg(true) } }, []) ``` This trades a brief flash for a guaranteed clean hydration. In practice, the current approach may work due to `'use client'` only running on the client in many setups, but it's not guaranteed by the Next.js contract. <details><summary>Prompt To Fix With AI</summary> `````markdown This is a comment left during a code review. Path: app/layout-content.tsx Line: 24-27 Comment: **SSR/hydration mismatch from `sessionStorage` in initial state** The lazy initializer reads from `sessionStorage` during the first render, but on the server `typeof window === 'undefined'` returns `false`. Since this is a `'use client'` component, the server renders with `false` while the client may initialize with `true`, causing a React hydration mismatch warning. A safer pattern is to initialize as `false` and sync in a `useEffect`: ``` const [isSwitchingOrg, setIsSwitchingOrg] = useState(false) useEffect(() => { if (sessionStorage.getItem(ORG_SWITCH_KEY) === 'true') { setIsSwitchingOrg(true) } }, []) ``` This trades a brief flash for a guaranteed clean hydration. In practice, the current approach may work due to `'use client'` only running on the client in many setups, but it's not guaranteed by the Next.js contract. How can I resolve this? If you propose a fix, please make it concise. ````` </details>
// * 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 <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Pulse" portal={false} />
}
return (
<>
{auth.user && <OfflineBanner isOnline={isOnline} />}

View File

@@ -0,0 +1,13 @@
'use client'
import ErrorDisplay from '@/components/ErrorDisplay'
export default function NotificationsError({ reset }: { error: Error; reset: () => void }) {
return (
<ErrorDisplay
title="Notifications failed to load"
message="We couldn't load your notifications. Please try again."
onRetry={reset}
/>
)
}

View File

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

View File

@@ -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() {
</Link>
</p>
{loading ? (
<div className="flex justify-center py-12">
<Spinner />
</div>
{showSkeleton ? (
<NotificationsListSkeleton />
) : error ? (
<div className="p-6 text-center text-red-500 bg-red-50 dark:bg-red-900/10 rounded-2xl border border-red-200 dark:border-red-800">
{error}

View File

@@ -0,0 +1,13 @@
'use client'
import ErrorDisplay from '@/components/ErrorDisplay'
export default function OrgSettingsError({ reset }: { error: Error; reset: () => void }) {
return (
<ErrorDisplay
title="Organization settings failed to load"
message="We couldn't load your organization settings. Please try again."
onRetry={reset}
/>
)
}

View File

@@ -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 (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
<div>
<Suspense fallback={<div className="p-8 text-center text-neutral-500">Loading...</div>}>
<Suspense fallback={
<div className="space-y-8">
<div>
<div className="h-8 w-56 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800 mb-2" />
<div className="h-4 w-80 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800" />
</div>
<div className="bg-white dark:bg-neutral-900 rounded-2xl border border-neutral-200 dark:border-neutral-800 p-6 md:p-8">
<SettingsFormSkeleton />
</div>
</div>
}>
<OrganizationSettings />
</Suspense>
</div>

View File

@@ -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 (
<div className="relative w-full max-w-7xl mx-auto mt-20 mb-32 h-[600px] flex items-center justify-center">
{/* * Glow behind the image */}
<div className="relative w-full max-w-7xl mx-auto mt-20 mb-32">
<div className="absolute inset-0 bg-brand-orange/20 blur-[100px] -z-10 rounded-full opacity-50" />
{/* * Static Container */}
<div
className="relative w-full h-full rounded-xl border border-neutral-200/50 dark:border-neutral-800/50 bg-neutral-900/50 backdrop-blur-sm shadow-2xl overflow-hidden"
<motion.div
initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, delay: 0.4 }}
className="relative rounded-xl border border-neutral-200/50 dark:border-neutral-800/50 shadow-2xl overflow-hidden"
>
{/* * Header of the fake browser window */}
<div className="h-8 bg-neutral-800/50 border-b border-white/5 flex items-center px-4 gap-2">
<div className="w-3 h-3 rounded-full bg-red-500/50" />
<div className="w-3 h-3 rounded-full bg-yellow-500/50" />
<div className="w-3 h-3 rounded-full bg-green-500/50" />
{/* * Browser chrome */}
<div className="h-8 bg-neutral-100 dark:bg-neutral-800/80 border-b border-neutral-200 dark:border-white/5 flex items-center px-4 gap-2">
<div className="w-3 h-3 rounded-full bg-red-400/60" />
<div className="w-3 h-3 rounded-full bg-yellow-400/60" />
<div className="w-3 h-3 rounded-full bg-green-400/60" />
<div className="ml-4 flex-1 max-w-xs h-5 rounded bg-neutral-200 dark:bg-neutral-700/50" />
</div>
{/* * Placeholder for actual dashboard screenshot - replace src with real image later */}
<div className="w-full h-[calc(100%-2rem)] bg-neutral-900 flex items-center justify-center text-neutral-700">
<div className="text-center">
<BarChartIcon className="w-16 h-16 mx-auto mb-4 opacity-20" />
<p>Dashboard Preview</p>
</div>
{/* * Screenshot with bottom fade */}
<div className="relative max-h-[900px] overflow-hidden">
<Image
src="/dashboard-preview-v2.png"
alt="Pulse analytics dashboard showing visitor stats, charts, top pages, referrers, locations, and technology breakdown"
width={1920}
height={3000}
className="w-full h-auto object-cover object-top"
priority
/>
<div className="absolute inset-0 pointer-events-none bg-gradient-to-b from-transparent from-60% to-white dark:to-neutral-950" />
</div>
</div>
</motion.div>
</div>
)
}
@@ -97,10 +107,13 @@ function ComparisonSection() {
}
type SiteStatsMap = Record<string, { stats: Stats }>
export default function HomePage() {
const { user, loading: authLoading } = useAuth()
const [sites, setSites] = useState<Site[]>([])
const [sitesLoading, setSitesLoading] = useState(true)
const [siteStats, setSiteStats] = useState<SiteStatsMap>({})
const [subscription, setSubscription] = useState<SubscriptionDetails | null>(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() {
)}
</div>
{/* * Global Overview */}
{/* * Global Overview - min-h ensures no layout shift when Plan & usage loads */}
<div className="mb-8 grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="rounded-2xl border border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900">
<div className="flex min-h-[160px] flex-col rounded-2xl border border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900">
<p className="text-sm text-neutral-500 dark:text-neutral-400">Total Sites</p>
<p className="text-2xl font-bold text-neutral-900 dark:text-white">{sites.length}</p>
</div>
<div className="rounded-2xl border border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900">
<div className="flex min-h-[160px] flex-col rounded-2xl border border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900">
<p className="text-sm text-neutral-500 dark:text-neutral-400">Total Visitors (24h)</p>
<p className="text-2xl font-bold text-neutral-900 dark:text-white">--</p>
<p className="text-2xl font-bold text-neutral-900 dark:text-white">
{sites.length === 0 || Object.keys(siteStats).length < sites.length
? '--'
: Object.values(siteStats).reduce((sum, { stats }) => sum + (stats?.visitors ?? 0), 0).toLocaleString()}
</p>
</div>
<div className="rounded-2xl border border-neutral-200 bg-brand-orange/10 p-4 dark:border-neutral-800">
<div className="flex min-h-[160px] flex-col rounded-2xl border border-neutral-200 bg-brand-orange/10 p-4 dark:border-neutral-800">
<p className="text-sm text-brand-orange">Plan & usage</p>
{subscriptionLoading ? (
<p className="text-lg font-bold text-brand-orange">...</p>
<div className="animate-pulse space-y-2">
<div className="h-6 w-24 rounded bg-brand-orange/25 dark:bg-brand-orange/20" />
<div className="h-4 w-full rounded bg-brand-orange/25 dark:bg-brand-orange/20" />
<div className="h-4 w-3/4 rounded bg-brand-orange/25 dark:bg-brand-orange/20" />
<div className="h-4 w-20 rounded bg-brand-orange/25 dark:bg-brand-orange/20 pt-2" />
</div>
) : subscription ? (
<>
<p className="text-lg font-bold text-brand-orange">
@@ -456,7 +509,7 @@ export default function HomePage() {
)}
{(sitesLoading || sites.length > 0) && (
<SiteList sites={sites} loading={sitesLoading} onDelete={handleDelete} />
<SiteList sites={sites} siteStats={siteStats} loading={sitesLoading} onDelete={handleDelete} />
)}
</div>
)

View File

@@ -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 (
<div className="min-h-screen pt-20">
<Suspense fallback={<div className="min-h-screen pt-20 flex items-center justify-center">Loading...</div>}>
<Suspense fallback={
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-16">
<div className="text-center mb-12">
<div className="h-10 w-64 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800 mx-auto mb-4" />
<div className="h-5 w-96 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800 mx-auto" />
</div>
<PricingCardsSkeleton />
</div>
}>
<PricingSection />
</Suspense>
</div>

13
app/share/[id]/error.tsx Normal file
View File

@@ -0,0 +1,13 @@
'use client'
import ErrorDisplay from '@/components/ErrorDisplay'
export default function ShareError({ reset }: { error: Error; reset: () => void }) {
return (
<ErrorDisplay
title="Dashboard failed to load"
message="We couldn't load this public dashboard. It may be temporarily unavailable — try again."
onRetry={reset}
/>
)
}

73
app/share/[id]/layout.tsx Normal file
View File

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

View File

@@ -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<string, unknown>)?.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 <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Pulse" />
const showSkeleton = useMinimumLoading(loading && !data && !isPasswordProtected)
if (showSkeleton) {
return <DashboardSkeleton />
}
if (isPasswordProtected && !data) {
@@ -279,13 +286,16 @@ export default function PublicDashboardPage() {
<span className="text-sm font-medium text-brand-orange uppercase tracking-wider">Public Dashboard</span>
</div>
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white flex items-center gap-3">
<img
src={`https://www.google.com/s2/favicons?domain=${site.domain}&sz=64`}
<Image
src={`${FAVICON_SERVICE_URL}?domain=${site.domain}&sz=64`}
alt={site.name}
width={32}
height={32}
className="w-8 h-8 rounded-lg"
onError={(e) => {
(e.target as HTMLImageElement).src = '/globe.svg'
}}
unoptimized
/>
{site.domain}
</h1>

13
app/sites/[id]/error.tsx Normal file
View File

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

View File

@@ -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 <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Pulse" />
const showSkeleton = useMinimumLoading(loading && !funnel)
if (showSkeleton) {
return <FunnelDetailSkeleton />
}
if (loadError === 'not_found' || (!funnel && !stats && !loadError)) {

View File

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

View File

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

View File

@@ -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 && (
<span className={`text-xs tabular-nums mt-1 ${name.length > 90 ? 'text-amber-500' : 'text-neutral-400'}`}>{name.length}/100</span>
)}
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">

View File

@@ -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 <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Pulse" />
const showSkeleton = useMinimumLoading(loading)
if (showSkeleton) {
return <FunnelsListSkeleton />
}
return (

15
app/sites/[id]/layout.tsx Normal file
View File

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

View File

@@ -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 <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Pulse" />
useEffect(() => {
if (site?.domain) document.title = `${site.domain} | Pulse`
}, [site?.domain])
const showSkeleton = useMinimumLoading(loading)
if (showSkeleton) {
return <DashboardSkeleton />
}
if (!site) {

View File

@@ -0,0 +1,13 @@
'use client'
import ErrorDisplay from '@/components/ErrorDisplay'
export default function RealtimeError({ reset }: { error: Error; reset: () => void }) {
return (
<ErrorDisplay
title="Realtime view failed to load"
message="We couldn't connect to the realtime data stream. Please try again."
onRetry={reset}
/>
)
}

View File

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

View File

@@ -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 <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Realtime" />
useEffect(() => {
if (site?.domain) document.title = `Realtime · ${site.domain} | Pulse`
}, [site?.domain])
const showSkeleton = useMinimumLoading(loading)
if (showSkeleton) return <RealtimeSkeleton />
if (!site) return <div className="p-8">Site not found</div>
return (
@@ -197,9 +204,7 @@ export default function RealtimePage() {
Select a visitor on the left to see their activity.
</div>
) : loadingEvents ? (
<div className="h-full flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-neutral-900 dark:border-white"></div>
</div>
<SessionEventsSkeleton />
) : (
<div className="relative pl-6 border-l-2 border-neutral-100 dark:border-neutral-800 space-y-8">
{sessionEvents.map((event, idx) => (

View File

@@ -0,0 +1,13 @@
'use client'
import ErrorDisplay from '@/components/ErrorDisplay'
export default function SiteSettingsError({ reset }: { error: Error; reset: () => void }) {
return (
<ErrorDisplay
title="Settings failed to load"
message="We couldn't load your site settings. Please try again."
onRetry={reset}
/>
)
}

View File

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

View File

@@ -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<Goal | null>(null)
const [goalForm, setGoalForm] = useState({ name: '', event_name: '' })
const [goalSaving, setGoalSaving] = useState(false)
const initialFormRef = useRef<string>('')
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 <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Pulse" />
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 (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
<div className="space-y-8">
<div>
<div className="h-8 w-40 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800 mb-2" />
<div className="h-4 w-64 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800" />
</div>
<div className="flex flex-col md:flex-row gap-8">
<nav className="w-full md:w-64 flex-shrink-0 space-y-1">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="h-12 animate-pulse rounded-xl bg-neutral-100 dark:bg-neutral-800" />
))}
</nav>
<div className="flex-1 bg-white dark:bg-neutral-900 rounded-2xl border border-neutral-200 dark:border-neutral-800 p-6 md:p-8">
<SettingsFormSkeleton />
</div>
</div>
</div>
</div>
)
}
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 && (
<span className={`text-xs tabular-nums ${formData.name.length > 90 ? 'text-amber-500' : 'text-neutral-400'}`}>{formData.name.length}/100</span>
)}
</div>
<div className="space-y-1.5">
@@ -970,7 +1046,7 @@ export default function SiteSettingsPage() {
</p>
</div>
{goalsLoading ? (
<div className="py-8 text-center text-neutral-500 dark:text-neutral-400">Loading goals</div>
<GoalsListSkeleton />
) : (
<>
{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
/>
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">Only letters, numbers, and underscores; spaces become underscores. Invalid characters cannot be used. Max 64 characters after formatting.</p>
<div className="flex justify-between mt-1">
<p className="text-xs text-neutral-500 dark:text-neutral-400">Letters, numbers, and underscores only. Spaces become underscores.</p>
<span className={`text-xs tabular-nums ${goalForm.event_name.length > 56 ? 'text-amber-500' : 'text-neutral-400'}`}>{goalForm.event_name.length}/64</span>
</div>
{editingGoal && goalForm.event_name.trim().toLowerCase().replace(/\s+/g, '_') !== editingGoal.event_name && (
<p className="mt-2 text-xs text-amber-600 dark:text-amber-400">Changing event name does not reassign events already tracked under the previous name.</p>
)}

View File

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

View File

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

View File

@@ -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 ? (
<div className="text-center py-4 text-neutral-500 dark:text-neutral-400 text-sm">
Loading checks...
</div>
<ChecksSkeleton />
) : checks.length > 0 ? (
<>
<ResponseTimeChart checks={checks} />
@@ -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 <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Uptime" />
useEffect(() => {
if (site?.domain) document.title = `Uptime · ${site.domain} | Pulse`
}, [site?.domain])
const showSkeleton = useMinimumLoading(loading)
if (showSkeleton) return <UptimeSkeleton />
if (!site) return <div className="p-8 text-neutral-500">Site not found</div>
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 && (
<span className={`text-xs tabular-nums mt-1 ${formData.name.length > 90 ? 'text-amber-500' : 'text-neutral-400'}`}>{formData.name.length}/100</span>
)}
</div>
{/* URL with protocol dropdown + domain prefix */}

View File

@@ -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() {
<Input
id="name"
required
autoFocus
maxLength={100}
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="My Website"
@@ -204,6 +207,7 @@ export default function NewSitePage() {
<Input
id="domain"
required
maxLength={253}
value={formData.domain}
onChange={(e) => setFormData({ ...formData, domain: e.target.value.toLowerCase().trim() })}
placeholder="example.com"

View File

@@ -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"
/>
</div>
@@ -672,6 +673,7 @@ function WelcomeContent() {
placeholder="example.com"
value={siteDomain}
onChange={(e) => setSiteDomain(e.target.value.toLowerCase().trim())}
maxLength={253}
className="w-full"
/>
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">

View File

@@ -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 (
<div className="relative min-h-[80vh] flex flex-col items-center justify-center overflow-hidden">
<div className="absolute inset-0 -z-10 pointer-events-none">
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[500px] h-[500px] bg-red-500/10 rounded-full blur-[128px] opacity-60" />
<div
className="absolute inset-0 bg-grid-pattern opacity-[0.02] dark:opacity-[0.05]"
style={{ maskImage: 'radial-gradient(ellipse at center, black 0%, transparent 70%)' }}
/>
</div>
<div className="text-center px-4 z-10">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-red-100 dark:bg-red-900/30 mb-6">
<svg className="w-8 h-8 text-red-500" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
</svg>
</div>
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-4">
{title}
</h2>
<p className="text-lg text-neutral-600 dark:text-neutral-400 max-w-md mx-auto mb-10 leading-relaxed">
{message}
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
{onRetry && (
<Button variant="primary" onClick={onRetry} className="px-8 py-3">
Try again
</Button>
)}
{onGoHome && (
<a href="/">
<Button variant="secondary" className="px-8 py-3">
Go to dashboard
</Button>
</a>
)}
</div>
</div>
</div>
)
}

View File

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

View File

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

View File

@@ -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<string | null>(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 (
<div className="border-b border-neutral-100 dark:border-neutral-800 pb-2 mb-2">
<div className="px-3 py-2 text-xs font-medium text-neutral-500 uppercase tracking-wider">
<div className="border-b border-neutral-100 dark:border-neutral-800 pb-2 mb-2" role="group" aria-label="Organizations">
<div className="px-3 py-2 text-xs font-medium text-neutral-500 uppercase tracking-wider" aria-hidden="true">
Organizations
</div>
@@ -75,21 +75,28 @@ export default function OrganizationSwitcher({ orgs, activeOrgId }: { orgs: Orga
<button
key={org.organization_id}
onClick={() => handleSwitch(org.organization_id)}
aria-current={activeOrgId === org.organization_id ? 'true' : undefined}
aria-busy={switching === org.organization_id ? 'true' : undefined}
className={`w-full flex items-center justify-between px-3 py-2 text-sm rounded-lg transition-colors mt-1 ${
activeOrgId === org.organization_id ? 'bg-neutral-100 dark:bg-neutral-800' : 'hover:bg-neutral-50 dark:hover:bg-neutral-800/50'
}`}
>
<div className="flex items-center gap-2">
<div className="h-5 w-5 rounded bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
<CubeIcon className="h-3 w-3 text-blue-600 dark:text-blue-400" />
<CubeIcon className="h-3 w-3 text-blue-600 dark:text-blue-400" aria-hidden="true" />
</div>
<span className="text-neutral-700 dark:text-neutral-300 truncate max-w-[140px]">
{org.organization_name}
</span>
</div>
<div className="flex items-center gap-2">
{switching === org.organization_id && <span className="text-xs text-neutral-400">Loading...</span>}
{activeOrgId === org.organization_id && !switching && <CheckIcon className="h-4 w-4 text-neutral-600 dark:text-neutral-400" />}
{switching === org.organization_id && <span className="text-xs text-neutral-400" aria-live="polite">Switching</span>}
{activeOrgId === org.organization_id && !switching && (
<>
<CheckIcon className="h-4 w-4 text-neutral-600 dark:text-neutral-400" aria-hidden="true" />
<span className="sr-only">(current)</span>
</>
)}
</div>
</button>
))}
@@ -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"
>
<div className="h-5 w-5 rounded border border-dashed border-neutral-300 dark:border-neutral-600 flex items-center justify-center">
<div className="h-5 w-5 rounded border border-dashed border-neutral-300 dark:border-neutral-600 flex items-center justify-center" aria-hidden="true">
<PlusIcon className="h-3 w-3" />
</div>
<span>Create Organization</span>

View File

@@ -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 (
<img
<Image
src={faviconUrl}
alt=""
width={20}
height={20}
className="w-5 h-5 flex-shrink-0 rounded object-contain"
onError={() => setFaviconFailed((prev) => new Set(prev).add(source))}
unoptimized
/>
)
}
@@ -292,9 +298,8 @@ export default function Campaigns({ siteId, dateRange }: CampaignsProps) {
>
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-2">
{isLoadingFull ? (
<div className="py-8 flex flex-col items-center gap-2">
<Spinner />
<p className="text-sm text-neutral-500 dark:text-neutral-400">Loading...</p>
<div className="py-4">
<TableSkeleton rows={10} cols={5} />
</div>
) : (
<>

View File

@@ -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<Record<string, unknown>>
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 (
<svg width={width} height={height} className="flex-shrink-0" aria-hidden>
<path
d={pathD}
fill="none"
stroke={color}
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
export default function Chart({
data,
prevData,

View File

@@ -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<Tab>('top_pages')
const handleTabKeyDown = useTabListKeyboard()
const [isModalOpen, setIsModalOpen] = useState(false)
const [fullData, setFullData] = useState<TopPage[]>([])
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,
</button>
)}
</div>
<div className="flex p-1 bg-neutral-100 dark:bg-neutral-800 rounded-lg" role="tablist" aria-label="Content view tabs">
<div className="flex p-1 bg-neutral-100 dark:bg-neutral-800 rounded-lg" role="tablist" aria-label="Content view tabs" onKeyDown={handleTabKeyDown}>
{(['top_pages', 'entry_pages', 'exit_pages'] as Tab[]).map((tab) => (
<button
key={tab}
@@ -173,9 +177,8 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain,
>
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-2">
{isLoadingFull ? (
<div className="py-8 flex flex-col items-center gap-2">
<Spinner />
<p className="text-sm text-neutral-500 dark:text-neutral-400">Loading...</p>
<div className="py-4">
<ListSkeleton rows={10} />
</div>
) : (
(fullData.length > 0 ? fullData : data).map((page, index) => (

View File

@@ -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<string, React.ComponentType<{ className?: string }>>)[countryCode]
return FlagComponent ? <FlagComponent className="w-5 h-5 rounded-sm shadow-sm" /> : null
}

View File

@@ -67,9 +67,9 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to
// Prepare data
const exportData = data.map((item) => {
const filteredItem: Partial<DailyStat> = {}
const filteredItem: Record<string, string | number> = {}
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

View File

@@ -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<Tab>('map')
const handleTabKeyDown = useTabListKeyboard()
const [isModalOpen, setIsModalOpen] = useState(false)
const [fullData, setFullData] = useState<any[]>([])
type LocationItem = { country?: string; city?: string; region?: string; pageviews: number }
const [fullData, setFullData] = useState<LocationItem[]>([])
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 <GlobeIcon className="w-5 h-5 text-neutral-500 dark:text-neutral-400" />
}
const FlagComponent = (Flags as any)[countryCode]
const FlagComponent = (Flags as Record<string, React.ComponentType<{ className?: string }>>)[countryCode]
return FlagComponent ? <FlagComponent className="w-5 h-5 rounded-sm shadow-sm" /> : 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 = '
</button>
)}
</div>
<div className="flex p-1 bg-neutral-100 dark:bg-neutral-800 rounded-lg" role="tablist" aria-label="Location view tabs">
<div className="flex p-1 bg-neutral-100 dark:bg-neutral-800 rounded-lg" role="tablist" aria-label="Location view tabs" onKeyDown={handleTabKeyDown}>
{(['map', 'countries', 'regions', 'cities'] as Tab[]).map((tab) => (
<button
key={tab}
@@ -227,7 +231,7 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
<p className="text-neutral-500 dark:text-neutral-400 text-sm">{getDisabledMessage()}</p>
</div>
) : activeTab === 'map' ? (
hasData ? <WorldMap data={filterUnknown(countries)} /> : (
hasData ? <WorldMap data={filterUnknown(countries) as { country: string; pageviews: number }[]} /> : (
<div className="h-full flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
<GlobeIcon className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
@@ -246,13 +250,13 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
{displayedData.map((item, index) => (
<div key={index} className="flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors">
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
{activeTab === 'countries' && <span className="shrink-0">{getFlagComponent(item.country)}</span>}
{activeTab !== 'countries' && <span className="shrink-0">{getFlagComponent(item.country)}</span>}
{activeTab === 'countries' && <span className="shrink-0">{getFlagComponent(item.country ?? '')}</span>}
{activeTab !== 'countries' && <span className="shrink-0">{getFlagComponent(item.country ?? '')}</span>}
<span className="truncate">
{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 ?? '')}
</span>
</div>
<div className="text-sm font-semibold text-neutral-600 dark:text-neutral-400 ml-4">
@@ -288,19 +292,18 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
>
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-2">
{isLoadingFull ? (
<div className="py-8 flex flex-col items-center gap-2">
<Spinner />
<p className="text-sm text-neutral-500 dark:text-neutral-400">Loading...</p>
<div className="py-4">
<ListSkeleton rows={10} />
</div>
) : (
(fullData.length > 0 ? fullData : data as any[]).map((item, index) => (
(fullData.length > 0 ? fullData : data).map((item, index) => (
<div key={index} className="flex items-center justify-between py-2 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors">
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
<span className="shrink-0">{getFlagComponent(item.country)}</span>
<span className="shrink-0">{getFlagComponent(item.country ?? '')}</span>
<span className="truncate">
{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 ?? '')}
</span>
</div>
<div className="text-sm font-semibold text-neutral-600 dark:text-neutral-400 ml-4">

View File

@@ -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 ? (
<div className="py-8 text-center text-neutral-500 text-sm">Loading</div>
<div className="py-4"><TableSkeleton rows={5} cols={5} /></div>
) : rows.length === 0 ? (
<div className="py-6 text-center text-neutral-500 text-sm">
No per-page metrics yet. Data appears as visitors are tracked with performance insights enabled.

View File

@@ -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<object>
dataKey: string
color: string
width?: number
height?: number
}) {
if (!data.length) return null
const values = data.map((d) => Number((d as Record<string, unknown>)[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 (
<svg width={width} height={height} className="flex-shrink-0" aria-hidden>
<path
d={pathD}
fill="none"
stroke={color}
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}

View File

@@ -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<Tab>('browsers')
const handleTabKeyDown = useTabListKeyboard()
const [isModalOpen, setIsModalOpen] = useState(false)
const [fullData, setFullData] = useState<any[]>([])
type TechItem = { name: string; pageviews: number; icon: React.ReactNode }
const [fullData, setFullData] = useState<TechItem[]>([])
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
</button>
)}
</div>
<div className="flex p-1 bg-neutral-100 dark:bg-neutral-800 rounded-lg" role="tablist" aria-label="Technology view tabs">
<div className="flex p-1 bg-neutral-100 dark:bg-neutral-800 rounded-lg" role="tablist" aria-label="Technology view tabs" onKeyDown={handleTabKeyDown}>
{(['browsers', 'os', 'devices', 'screens'] as Tab[]).map((tab) => (
<button
key={tab}
@@ -189,9 +194,8 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
>
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-2">
{isLoadingFull ? (
<div className="py-8 flex flex-col items-center gap-2">
<Spinner />
<p className="text-sm text-neutral-500 dark:text-neutral-400">Loading...</p>
<div className="py-4">
<ListSkeleton rows={10} />
</div>
) : (
(fullData.length > 0 ? fullData : data).map((item, index) => (

View File

@@ -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 (
<img
<Image
src={faviconUrl}
alt=""
width={20}
height={20}
className="w-5 h-5 flex-shrink-0 rounded object-contain"
onError={() => setFaviconFailed((prev) => new Set(prev).add(referrer))}
unoptimized
/>
)
}
@@ -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
>
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-2">
{isLoadingFull ? (
<div className="py-8 flex flex-col items-center gap-2">
<Spinner />
<p className="text-sm text-neutral-500 dark:text-neutral-400">Loading...</p>
<div className="py-4">
<ListSkeleton rows={10} />
</div>
) : (
mergeReferrersByDisplayName(fullData.length > 0 ? fullData : filteredReferrers).map((ref, index) => (

View File

@@ -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"
>
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
@@ -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() {
<button
type="button"
onClick={() => 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'}
>
<BellIcon />
{unreadCount > 0 && (
<span className="absolute top-1 right-1 w-2 h-2 bg-brand-orange rounded-full" />
<span className="absolute top-1 right-1 w-2 h-2 bg-brand-orange rounded-full" aria-hidden="true" />
)}
</button>
{open && (
<div className="absolute right-0 top-full mt-2 w-80 sm:w-96 bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 rounded-xl shadow-xl overflow-hidden z-[100]">
<div
id="notification-dropdown"
role="dialog"
aria-label="Notifications"
className="absolute right-0 top-full mt-2 w-80 sm:w-96 bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 rounded-xl shadow-xl overflow-hidden z-[100]"
>
<div className="flex items-center justify-between px-4 py-3 border-b border-neutral-200 dark:border-neutral-700">
<h3 className="font-semibold text-neutral-900 dark:text-white">Notifications</h3>
{unreadCount > 0 && (
<button
type="button"
onClick={handleMarkAllRead}
aria-label="Mark all notifications as read"
className="text-sm text-brand-orange hover:underline"
>
Mark all read
@@ -153,8 +170,16 @@ export default function NotificationCenter() {
<div className="max-h-80 overflow-y-auto">
{loading && (
<div className="p-6 text-center text-neutral-500 dark:text-neutral-400 text-sm">
Loading
<div className="p-3 space-y-1">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex gap-3 px-4 py-3">
<SkeletonCircle className="h-8 w-8 shrink-0" />
<div className="flex-1 space-y-1.5">
<SkeletonLine className="h-3.5 w-3/4" />
<SkeletonLine className="h-3 w-1/2" />
</div>
</div>
))}
</div>
)}
{error && (
@@ -193,12 +218,10 @@ export default function NotificationCenter() {
</div>
</Link>
) : (
<div
role="button"
tabIndex={0}
<button
type="button"
onClick={() => 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' : ''}`}
>
<div className="flex gap-3">
{getTypeIcon(n.type)}
@@ -216,7 +239,7 @@ export default function NotificationCenter() {
</p>
</div>
</div>
</div>
</button>
)}
</li>
))}
@@ -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"
>
<SettingsIcon className="w-4 h-4" />
<SettingsIcon className="w-4 h-4" aria-hidden="true" />
Manage settings
</Link>
</div>

View File

@@ -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 }) {
</svg>
)
}
// @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() {
<Input
type="text"
value={orgName}
onChange={(e: any) => setOrgName(e.target.value)}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setOrgName(e.target.value)}
required
minLength={2}
maxLength={50}
@@ -621,7 +624,7 @@ export default function OrganizationSettings() {
<Input
type="text"
value={orgSlug}
onChange={(e: any) => setOrgSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => 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<HTMLInputElement>) => setInviteEmail(e.target.value)}
required
className="bg-white dark:bg-neutral-900"
/>
@@ -740,9 +743,7 @@ export default function OrganizationSettings() {
<h3 className="text-sm font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Active Members</h3>
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden divide-y divide-neutral-200 dark:divide-neutral-800">
{isLoadingMembers ? (
<div className="flex items-center justify-center py-8">
<Spinner />
</div>
<MembersListSkeleton />
) : members.length === 0 ? (
<div className="p-8 text-center text-neutral-500 dark:text-neutral-400">No members found.</div>
) : (
@@ -821,8 +822,9 @@ export default function OrganizationSettings() {
</div>
{isLoadingSubscription ? (
<div className="flex items-center justify-center py-12">
<Spinner />
<div className="space-y-4">
<SkeletonCard className="h-32" />
<SkeletonCard className="h-20" />
</div>
) : !subscription ? (
<div className="p-6 text-center bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-200 dark:border-neutral-800">
@@ -1046,9 +1048,7 @@ export default function OrganizationSettings() {
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white mb-3">Recent invoices</h3>
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden divide-y divide-neutral-200 dark:divide-neutral-800">
{isLoadingInvoices ? (
<div className="flex items-center justify-center py-8">
<Spinner />
</div>
<InvoicesListSkeleton />
) : invoices.length === 0 ? (
<div className="p-8 text-center text-neutral-500 dark:text-neutral-400">No invoices found.</div>
) : (
@@ -1117,9 +1117,7 @@ export default function OrganizationSettings() {
</div>
{isLoadingNotificationSettings ? (
<div className="flex items-center justify-center py-12">
<Spinner />
</div>
<SettingsFormSkeleton />
) : (
<div className="space-y-4">
<h3 className="text-sm font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Notification categories</h3>
@@ -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 */}
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden">
{isLoadingAudit ? (
<div className="flex items-center justify-center py-12">
<Spinner />
</div>
<AuditLogSkeleton />
) : (auditEntries ?? []).length === 0 ? (
<div className="p-8 text-center text-neutral-500">No audit events found.</div>
) : (

View File

@@ -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<string, { stats: Stats }>
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 (
<div className="group relative flex flex-col rounded-2xl border border-neutral-200 bg-white p-6 shadow-sm transition-all hover:shadow-md dark:border-neutral-800 dark:bg-neutral-900">
{/* Header: Icon + Name + Live Status */}
<div className="flex items-start justify-between mb-6">
<div className="flex items-center gap-4">
<div className="h-12 w-12 overflow-hidden rounded-lg border border-neutral-100 bg-neutral-50 p-1 dark:border-neutral-800 dark:bg-neutral-800">
<Image
src={`${FAVICON_SERVICE_URL}?domain=${site.domain}&sz=64`}
alt={site.name}
width={40}
height={40}
className="h-full w-full object-contain"
unoptimized
/>
</div>
<div>
<h3 className="font-semibold text-neutral-900 dark:text-white">{site.name}</h3>
<div className="flex items-center gap-1 text-sm text-neutral-500 dark:text-neutral-400">
{site.domain}
<a
href={`https://${site.domain}`}
target="_blank"
rel="noopener noreferrer"
className="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300"
onClick={(e) => e.stopPropagation()}
>
<ExternalLinkIcon className="h-3 w-3" />
</a>
</div>
</div>
</div>
<div className="flex items-center gap-2 rounded-full bg-green-50 px-2 py-1 text-xs font-medium text-green-700 dark:bg-green-900/20 dark:text-green-400">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
</span>
Active
</div>
</div>
{/* Mini Stats Grid */}
<div className="mb-6 grid grid-cols-2 gap-4 rounded-lg bg-neutral-50 p-3 dark:bg-neutral-800/50">
<div>
<p className="text-xs text-neutral-500">Visitors (24h)</p>
<p className="font-mono text-lg font-medium text-neutral-900 dark:text-white">
{statsLoading ? '--' : formatNumber(visitors24h)}
</p>
</div>
<div>
<p className="text-xs text-neutral-500">Pageviews</p>
<p className="font-mono text-lg font-medium text-neutral-900 dark:text-white">
{statsLoading ? '--' : formatNumber(pageviews)}
</p>
</div>
</div>
{/* Actions */}
<div className="mt-auto flex gap-2">
<Link href={`/sites/${site.id}`} className="flex-1">
<Button variant="primary" className="w-full justify-center text-sm">
<BarChartIcon className="w-4 h-4" />
View Dashboard
</Button>
</Link>
{canDelete && (
<button
type="button"
onClick={() => 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"
>
<SettingsIcon className="h-4 w-4" />
</button>
)}
</div>
</div>
)
}
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 (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{sites.map((site) => (
<div
key={site.id}
className="group relative flex flex-col rounded-2xl border border-neutral-200 bg-white p-6 shadow-sm transition-all hover:shadow-md dark:border-neutral-800 dark:bg-neutral-900"
>
{/* Header: Icon + Name + Live Status */}
<div className="flex items-start justify-between mb-6">
<div className="flex items-center gap-4">
{/* Auto-fetch favicon */}
<div className="h-12 w-12 overflow-hidden rounded-lg border border-neutral-100 bg-neutral-50 p-1 dark:border-neutral-800 dark:bg-neutral-800">
<img
src={`https://www.google.com/s2/favicons?domain=${site.domain}&sz=64`}
alt={site.name}
className="h-full w-full object-contain"
/>
</div>
<div>
<h3 className="font-semibold text-neutral-900 dark:text-white">{site.name}</h3>
<div className="flex items-center gap-1 text-sm text-neutral-500 dark:text-neutral-400">
{site.domain}
<a
href={`https://${site.domain}`}
target="_blank"
rel="noopener noreferrer"
className="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300"
onClick={(e) => e.stopPropagation()}
>
<ExternalLinkIcon className="h-3 w-3" />
</a>
</div>
</div>
</div>
{/* "Live" Indicator */}
<div className="flex items-center gap-2 rounded-full bg-green-50 px-2 py-1 text-xs font-medium text-green-700 dark:bg-green-900/20 dark:text-green-400">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
</span>
Active
</div>
</div>
{/* Mini Stats Grid */}
<div className="mb-6 grid grid-cols-2 gap-4 rounded-lg bg-neutral-50 p-3 dark:bg-neutral-800/50">
<div>
<p className="text-xs text-neutral-500">Visitors (24h)</p>
<p className="font-mono text-lg font-medium text-neutral-900 dark:text-white">--</p>
</div>
<div>
<p className="text-xs text-neutral-500">Pageviews</p>
<p className="font-mono text-lg font-medium text-neutral-900 dark:text-white">--</p>
</div>
</div>
{/* Actions */}
<div className="mt-auto flex gap-2">
<Link
href={`/sites/${site.id}`}
className="flex-1"
>
<Button variant="primary" className="w-full justify-center text-sm">
<BarChartIcon className="w-4 h-4" />
View Dashboard
</Button>
</Link>
{(user?.role === 'owner' || user?.role === 'admin') && (
<button
type="button"
onClick={() => 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"
>
<SettingsIcon className="h-4 w-4" />
</button>
)}
</div>
</div>
))}
{sites.map((site) => {
const data = siteStats[site.id]
return (
<SiteCard
key={site.id}
site={site}
stats={data?.stats ?? null}
statsLoading={!data}
onDelete={onDelete}
canDelete={canDelete}
/>
)
})}
{/* Resources Card */}
<div className="flex flex-col items-center justify-center rounded-2xl border border-dashed border-neutral-300 bg-neutral-50 p-6 text-center dark:border-neutral-700 dark:bg-neutral-900/50">

463
components/skeletons.tsx Normal file
View File

@@ -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 <div className={`${SK} rounded ${className}`} />
}
export function SkeletonCircle({ className = '' }: { className?: string }) {
return <div className={`${SK} rounded-full ${className}`} />
}
export function SkeletonCard({ className = '' }: { className?: string }) {
return <div className={`${SK} rounded-2xl ${className}`} />
}
// ─── List skeleton (icon + two text lines per row) ───────────
export function ListRowSkeleton() {
return (
<div className="flex items-center justify-between h-9 px-2 -mx-2">
<div className="flex items-center gap-3 flex-1">
<SkeletonLine className="h-5 w-5 rounded shrink-0" />
<SkeletonLine className="h-4 w-3/5" />
</div>
<SkeletonLine className="h-4 w-12" />
</div>
)
}
export function ListSkeleton({ rows = 7 }: { rows?: number }) {
return (
<div className="space-y-2">
{Array.from({ length: rows }).map((_, i) => (
<ListRowSkeleton key={i} />
))}
</div>
)
}
// ─── Table skeleton (header row + data rows) ─────────────────
export function TableSkeleton({ rows = 7, cols = 5 }: { rows?: number; cols?: number }) {
return (
<div className="space-y-2">
<div className={`grid gap-2 mb-2 px-2`} style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` }}>
{Array.from({ length: cols }).map((_, i) => (
<SkeletonLine key={`th-${i}`} className="h-4" />
))}
</div>
{Array.from({ length: rows }).map((_, i) => (
<div key={`tr-${i}`} className="grid gap-2 h-9 px-2 -mx-2" style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` }}>
{Array.from({ length: cols }).map((_, j) => (
<SkeletonLine key={`td-${i}-${j}`} className="h-4" />
))}
</div>
))}
</div>
)
}
// ─── Widget panel skeleton (used inside dashboard grid) ──────
export function WidgetSkeleton() {
return (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
<div className="flex items-center justify-between mb-4">
<SkeletonLine className="h-6 w-32" />
<div className="flex gap-1">
<SkeletonLine className="h-7 w-16 rounded-lg" />
<SkeletonLine className="h-7 w-16 rounded-lg" />
</div>
</div>
<div className="space-y-2 flex-1 min-h-[270px]">
<ListSkeleton rows={7} />
</div>
</div>
)
}
// ─── Stat card skeleton ──────────────────────────────────────
export function StatCardSkeleton() {
return (
<div className="p-4 rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900">
<SkeletonLine className="h-4 w-20 mb-2" />
<SkeletonLine className="h-8 w-28" />
</div>
)
}
// ─── Chart area skeleton ─────────────────────────────────────
export function ChartSkeleton() {
return (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="space-y-1">
<SkeletonLine className="h-3 w-16" />
<SkeletonLine className="h-7 w-24" />
</div>
))}
</div>
<SkeletonLine className="h-8 w-32 rounded-lg" />
</div>
<SkeletonLine className="h-64 w-full rounded-xl" />
</div>
)
}
// ─── Full dashboard skeleton ─────────────────────────────────
export function DashboardSkeleton() {
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
{/* Header */}
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-4">
<div>
<SkeletonLine className="h-8 w-48 mb-2" />
<SkeletonLine className="h-4 w-32" />
</div>
<SkeletonLine className="h-8 w-40 rounded-full" />
</div>
<div className="flex items-center gap-2">
<SkeletonLine className="h-10 w-24 rounded-lg" />
<SkeletonLine className="h-10 w-36 rounded-lg" />
</div>
</div>
</div>
{/* Chart */}
<div className="mb-8">
<ChartSkeleton />
</div>
{/* Widget grid (2 cols) */}
<div className="grid gap-6 lg:grid-cols-2 mb-8">
<WidgetSkeleton />
<WidgetSkeleton />
</div>
<div className="grid gap-6 lg:grid-cols-2 mb-8">
<WidgetSkeleton />
<WidgetSkeleton />
</div>
{/* Campaigns table */}
<div className="mb-8">
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
<SkeletonLine className="h-6 w-32 mb-4" />
<TableSkeleton rows={7} cols={5} />
</div>
</div>
</div>
)
}
// ─── Realtime page skeleton ──────────────────────────────────
export function RealtimeSkeleton() {
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8 h-[calc(100vh-64px)] flex flex-col">
<div className="mb-6">
<SkeletonLine className="h-4 w-32 mb-2" />
<SkeletonLine className="h-8 w-64" />
</div>
<div className="flex flex-col md:flex-row flex-1 gap-6 min-h-0">
{/* Visitors list */}
<div className="w-full md:w-1/3 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden flex flex-col bg-white dark:bg-neutral-900">
<div className="p-4 border-b border-neutral-200 dark:border-neutral-800">
<SkeletonLine className="h-6 w-32" />
</div>
<div className="p-2 space-y-1">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="p-4 space-y-2">
<div className="flex justify-between">
<SkeletonLine className="h-4 w-32" />
<SkeletonLine className="h-4 w-16" />
</div>
<SkeletonLine className="h-3 w-48" />
<div className="flex gap-2">
<SkeletonLine className="h-3 w-16" />
<SkeletonLine className="h-3 w-16" />
<SkeletonLine className="h-3 w-16" />
</div>
</div>
))}
</div>
</div>
{/* Session details */}
<div className="flex-1 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden bg-white dark:bg-neutral-900">
<div className="p-4 border-b border-neutral-200 dark:border-neutral-800">
<SkeletonLine className="h-6 w-40" />
</div>
<div className="p-6 space-y-6">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex gap-4 pl-6">
<SkeletonCircle className="h-3 w-3 shrink-0 mt-1" />
<div className="space-y-1 flex-1">
<SkeletonLine className="h-4 w-48" />
<SkeletonLine className="h-3 w-32" />
</div>
</div>
))}
</div>
</div>
</div>
</div>
)
}
// ─── Session events skeleton (for loading events panel) ──────
export function SessionEventsSkeleton() {
return (
<div className="relative pl-6 border-l-2 border-neutral-100 dark:border-neutral-800 space-y-8">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="relative">
<span className={`absolute -left-[29px] top-1 h-3 w-3 rounded-full ${SK}`} />
<div className="space-y-1">
<SkeletonLine className="h-4 w-48" />
<SkeletonLine className="h-3 w-32" />
</div>
</div>
))}
</div>
)
}
// ─── Uptime page skeleton ────────────────────────────────────
export function UptimeSkeleton() {
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
<div className="mb-8">
<SkeletonLine className="h-4 w-32 mb-2" />
<SkeletonLine className="h-8 w-24 mb-1" />
<SkeletonLine className="h-4 w-64" />
</div>
{/* Overall status */}
<SkeletonCard className="h-20 mb-6" />
{/* Monitor cards */}
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-5 space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<SkeletonCircle className="w-3 h-3" />
<SkeletonLine className="h-5 w-32" />
<SkeletonLine className="h-4 w-48 hidden sm:block" />
</div>
<SkeletonLine className="h-4 w-28" />
</div>
<SkeletonLine className="h-8 w-full rounded-sm" />
</div>
))}
</div>
</div>
)
}
// ─── Checks / Response time skeleton ─────────────────────────
export function ChecksSkeleton() {
return (
<div className="space-y-4">
<SkeletonLine className="h-40 w-full rounded-xl" />
<div className="space-y-1.5">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center justify-between py-1.5 px-2">
<div className="flex items-center gap-2">
<SkeletonCircle className="w-2 h-2" />
<SkeletonLine className="h-3 w-32" />
</div>
<SkeletonLine className="h-3 w-16" />
</div>
))}
</div>
</div>
)
}
// ─── Funnels list skeleton ───────────────────────────────────
export function FunnelsListSkeleton() {
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
<div className="mb-8">
<div className="flex items-center gap-4 mb-6">
<SkeletonLine className="h-10 w-10 rounded-xl" />
<div>
<SkeletonLine className="h-8 w-24 mb-1" />
<SkeletonLine className="h-4 w-64" />
</div>
</div>
<div className="grid gap-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
<SkeletonLine className="h-6 w-40 mb-2" />
<SkeletonLine className="h-4 w-64 mb-4" />
<div className="flex items-center gap-2">
{Array.from({ length: 3 }).map((_, j) => (
<div key={j} className="flex items-center">
<SkeletonLine className="h-7 w-20 rounded-lg" />
{j < 2 && <SkeletonLine className="h-4 w-4 mx-2 rounded" />}
</div>
))}
</div>
</div>
))}
</div>
</div>
</div>
)
}
// ─── Funnel detail skeleton ──────────────────────────────────
export function FunnelDetailSkeleton() {
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
<div className="mb-8">
<SkeletonLine className="h-4 w-32 mb-2" />
<SkeletonLine className="h-8 w-48 mb-1" />
<SkeletonLine className="h-4 w-64" />
</div>
<SkeletonCard className="h-80 mb-8" />
<div className="grid gap-4 md:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => (
<SkeletonCard key={i} className="h-28" />
))}
</div>
</div>
)
}
// ─── Notifications list skeleton ─────────────────────────────
export function NotificationsListSkeleton() {
return (
<div className="space-y-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="flex items-start gap-3 p-4 rounded-xl border border-neutral-200 dark:border-neutral-800">
<SkeletonCircle className="h-10 w-10 shrink-0" />
<div className="flex-1 space-y-2">
<SkeletonLine className="h-4 w-3/4" />
<SkeletonLine className="h-3 w-1/2" />
</div>
<SkeletonLine className="h-3 w-16 shrink-0" />
</div>
))}
</div>
)
}
// ─── Settings form skeleton ──────────────────────────────────
export function SettingsFormSkeleton() {
return (
<div className="space-y-6">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="space-y-2">
<SkeletonLine className="h-4 w-24" />
<SkeletonLine className="h-10 w-full rounded-lg" />
</div>
))}
<SkeletonLine className="h-10 w-28 rounded-lg" />
</div>
)
}
// ─── Goals list skeleton ─────────────────────────────────────
export function GoalsListSkeleton() {
return (
<div className="space-y-2">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex items-center justify-between py-3 px-4 rounded-2xl border border-neutral-200 dark:border-neutral-800">
<div className="flex items-center gap-2">
<SkeletonLine className="h-4 w-24" />
<SkeletonLine className="h-3 w-20" />
</div>
<div className="flex items-center gap-2">
<SkeletonLine className="h-4 w-10" />
<SkeletonLine className="h-4 w-12" />
</div>
</div>
))}
</div>
)
}
// ─── Pricing cards skeleton ──────────────────────────────────
export function PricingCardsSkeleton() {
return (
<div className="grid gap-6 md:grid-cols-3 max-w-5xl mx-auto">
{Array.from({ length: 3 }).map((_, i) => (
<SkeletonCard key={i} className="h-96" />
))}
</div>
)
}
// ─── Organization settings skeleton (members, billing, etc) ─
export function MembersListSkeleton() {
return (
<div className="space-y-3">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 p-3 rounded-xl">
<SkeletonCircle className="h-10 w-10 shrink-0" />
<div className="flex-1 space-y-1">
<SkeletonLine className="h-4 w-32" />
<SkeletonLine className="h-3 w-48" />
</div>
<SkeletonLine className="h-6 w-16 rounded-full" />
</div>
))}
</div>
)
}
export function InvoicesListSkeleton() {
return (
<div className="space-y-2">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex items-center justify-between py-3 px-4 rounded-lg">
<div className="flex items-center gap-3">
<SkeletonLine className="h-4 w-24" />
<SkeletonLine className="h-4 w-16" />
</div>
<SkeletonLine className="h-4 w-20" />
</div>
))}
</div>
)
}
export function AuditLogSkeleton() {
return (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 py-2 px-4">
<SkeletonLine className="h-3 w-28" />
<SkeletonLine className="h-3 w-16" />
<SkeletonLine className="h-3 w-48 flex-1" />
</div>
))}
</div>
)
}

View File

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

View File

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

View File

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

View File

@@ -24,9 +24,9 @@ export function getSignupUrl(redirectPath = '/auth/callback') {
export class ApiError extends Error {
status: number
data?: any
data?: Record<string, unknown>
constructor(message: string, status: number, data?: any) {
constructor(message: string, status: number, data?: Record<string, unknown>) {
super(message)
this.status = status
this.data = data
@@ -184,46 +184,5 @@ async function apiRequest<T>(
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

View File

@@ -47,7 +47,6 @@ export async function getUserOrganizations(): Promise<OrganizationMember[]> {
// 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<OrganizationInvitation> {
const body: any = {
email,
role
}
const body: Record<string, string> = { email, role }
if (captcha?.captcha_id) body.captcha_id = captcha.captcha_id
if (captcha?.captcha_solution) body.captcha_solution = captcha.captcha_solution

View File

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

View File

@@ -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<HTMLElement>) => {
const target = e.currentTarget
const tabs = Array.from(target.querySelectorAll<HTMLElement>('[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()
}
}, [])
}

View File

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

View File

@@ -1,4 +1,10 @@
import React from 'react'
/**
* Google's public favicon service base URL.
* Append `?domain=<host>&sz=<px>` to get a favicon.
*/
export const FAVICON_SERVICE_URL = 'https://www.google.com/s2/favicons'
import {
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
}

16
lib/utils/logger.ts Normal file
View File

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

View File

@@ -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 <AlertTriangleIcon className="w-4 h-4 shrink-0 text-amber-500" />
return <AlertTriangleIcon className="w-4 h-4 shrink-0 text-amber-500" aria-hidden="true" />
}
return <CheckCircleIcon className="w-4 h-4 shrink-0 text-emerald-500" />
return <CheckCircleIcon className="w-4 h-4 shrink-0 text-emerald-500" aria-hidden="true" />
}

66
middleware.ts Normal file
View File

@@ -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)
}
greptile-apps[bot] commented 2026-02-22 21:47:11 +00:00 (Migrated from github.com)
Review

Redirect to login discards the original URL

When an unauthenticated user visits a protected route like /sites/123, they are redirected to /login with no return_to or similar query parameter. After they authenticate, they'll land on / instead of being returned to the page they originally intended to visit. Consider preserving the original URL:

  if (!hasSession) {
    const loginUrl = new URL('/login', request.url)
    loginUrl.searchParams.set('return_to', pathname)
    return NextResponse.redirect(loginUrl)
  }

This would require the login flow to read return_to and redirect accordingly after authentication.

Prompt To Fix With AI
This is a comment left during a code review.
Path: middleware.ts
Line: 48-51

Comment:
**Redirect to login discards the original URL**

When an unauthenticated user visits a protected route like `/sites/123`, they are redirected to `/login` with no `return_to` or similar query parameter. After they authenticate, they'll land on `/` instead of being returned to the page they originally intended to visit. Consider preserving the original URL:

```suggestion
  if (!hasSession) {
    const loginUrl = new URL('/login', request.url)
    loginUrl.searchParams.set('return_to', pathname)
    return NextResponse.redirect(loginUrl)
  }
```

This would require the login flow to read `return_to` and redirect accordingly after authentication.

How can I resolve this? If you propose a fix, please make it concise.
**Redirect to login discards the original URL** When an unauthenticated user visits a protected route like `/sites/123`, they are redirected to `/login` with no `return_to` or similar query parameter. After they authenticate, they'll land on `/` instead of being returned to the page they originally intended to visit. Consider preserving the original URL: ```suggestion if (!hasSession) { const loginUrl = new URL('/login', request.url) loginUrl.searchParams.set('return_to', pathname) return NextResponse.redirect(loginUrl) } ``` This would require the login flow to read `return_to` and redirect accordingly after authentication. <details><summary>Prompt To Fix With AI</summary> `````markdown This is a comment left during a code review. Path: middleware.ts Line: 48-51 Comment: **Redirect to login discards the original URL** When an unauthenticated user visits a protected route like `/sites/123`, they are redirected to `/login` with no `return_to` or similar query parameter. After they authenticate, they'll land on `/` instead of being returned to the page they originally intended to visit. Consider preserving the original URL: ```suggestion if (!hasSession) { const loginUrl = new URL('/login', request.url) loginUrl.searchParams.set('return_to', pathname) return NextResponse.redirect(loginUrl) } ``` This would require the login flow to read `return_to` and redirect accordingly after authentication. How can I resolve this? If you propose a fix, please make it concise. ````` </details>
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/).*)',
],
}

View File

@@ -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=()',
greptile-apps[bot] commented 2026-02-22 21:47:13 +00:00 (Migrated from github.com)
Review

interest-cohort is not a recognized Permissions-Policy directive

interest-cohort was a Chrome-only proposal (FLoC) that was abandoned and replaced by the Topics API. Modern browsers will ignore or warn about this unrecognized directive. It can be safely removed, or you could replace it with browsing-topics=() if you want to opt out of the Topics API:

            value: 'camera=(), microphone=(), geolocation=(), browsing-topics=()',
Prompt To Fix With AI
This is a comment left during a code review.
Path: next.config.ts
Line: 37

Comment:
**`interest-cohort` is not a recognized Permissions-Policy directive**

`interest-cohort` was a Chrome-only proposal (FLoC) that was abandoned and replaced by the Topics API. Modern browsers will ignore or warn about this unrecognized directive. It can be safely removed, or you could replace it with `browsing-topics=()` if you want to opt out of the Topics API:

```suggestion
            value: 'camera=(), microphone=(), geolocation=(), browsing-topics=()',
```

How can I resolve this? If you propose a fix, please make it concise.
**`interest-cohort` is not a recognized Permissions-Policy directive** `interest-cohort` was a Chrome-only proposal (FLoC) that was abandoned and replaced by the Topics API. Modern browsers will ignore or warn about this unrecognized directive. It can be safely removed, or you could replace it with `browsing-topics=()` if you want to opt out of the Topics API: ```suggestion value: 'camera=(), microphone=(), geolocation=(), browsing-topics=()', ``` <details><summary>Prompt To Fix With AI</summary> `````markdown This is a comment left during a code review. Path: next.config.ts Line: 37 Comment: **`interest-cohort` is not a recognized Permissions-Policy directive** `interest-cohort` was a Chrome-only proposal (FLoC) that was abandoned and replaced by the Topics API. Modern browsers will ignore or warn about this unrecognized directive. It can be safely removed, or you could replace it with `browsing-topics=()` if you want to opt out of the Topics API: ```suggestion value: 'camera=(), microphone=(), geolocation=(), browsing-topics=()', ``` How can I resolve this? If you propose a fix, please make it concise. ````` </details>
},
{ key: 'X-XSS-Protection', value: '1; mode=block' },
{
key: 'Strict-Transport-Security',
value: 'max-age=63072000; includeSubDomains; preload',
},
],
},
]
},
async redirects() {
return [
{

View File

@@ -1,6 +1,6 @@
{
"name": "pulse-frontend",
"version": "0.10.0-alpha",
"version": "0.11.0-alpha",
"private": true,
"scripts": {
"dev": "next dev",

Binary file not shown.

After

Width:  |  Height:  |  Size: 878 KiB

21
types/iso-3166-2.d.ts vendored Normal file
View File

@@ -0,0 +1,21 @@
declare module 'iso-3166-2' {
interface SubdivisionInfo {
name: string
type: string
parent?: string
}
interface CountryInfo {
name: string
sub: Record<string, SubdivisionInfo>
}
const iso3166: {
data: Record<string, CountryInfo>
country(code: string): CountryInfo | undefined
subdivision(code: string): SubdivisionInfo | undefined
codes: string[]
}
export default iso3166
}

9
types/jspdf-autotable.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
import 'jspdf'
declare module 'jspdf' {
interface jsPDF {
lastAutoTable: {
finalY: number
}
}
}