[PULSE-60] Frontend hardening, UX polish, and security #35
44
CHANGELOG.md
@@ -6,6 +6,47 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.11.0-alpha] - 2026-02-22
|
||||
|
||||
### Added
|
||||
|
||||
- **Better page titles.** Browser tabs now show which site and page you're on (e.g. "Uptime · example.com | Pulse") instead of the same generic title everywhere.
|
||||
- **Link previews for public dashboards.** Sharing a public dashboard link on social media now shows a proper preview with the site name and description.
|
||||
- **Faster login redirects.** If you're not signed in and try to open a dashboard or settings page, you're redirected to login immediately instead of seeing a blank page first. Already-signed-in users who visit the login page are sent straight to the dashboard.
|
||||
- **Graceful error recovery.** If a page crashes, you now see a friendly error screen with a "Try again" button instead of a blank white page. Each section of the app has its own error message so you know exactly what went wrong.
|
||||
- **Security headers.** All pages now include clickjacking protection, MIME-sniffing prevention, a strict referrer policy, and HSTS. Browser APIs like camera and microphone are explicitly disabled.
|
||||
- **Better form experience.** Forms now auto-focus the first field when they open, text inputs enforce character limits with a visible counter when you're close, and the settings page warns you before navigating away with unsaved changes.
|
||||
- **Accessibility improvements.** The notification bell, workspace switcher, and all dashboard tabs are now fully keyboard-navigable. Screen readers announce unread counts, active organizations, and tab changes correctly. Decorative icons are hidden from assistive technology.
|
||||
- **Smooth organization switching.** Switching between organizations now shows a branded loading screen instead of a blank flash while the page reloads.
|
||||
- **Graceful server shutdown.** Deployments no longer kill in-flight requests or interrupt background tasks. The server finishes ongoing work before shutting down.
|
||||
- **Database connection pooling.** The backend now limits and recycles database connections, preventing exhaustion under load and reducing query latency.
|
||||
- **Date range validation.** Analytics, funnel, and uptime queries now reject invalid date ranges (end before start, or spans longer than a year) instead of silently returning empty or oversized results.
|
||||
- **Excluded paths limit.** Sites can now have up to 50 excluded paths. Previously there was no cap, which could slow down event processing.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Smoother loading experience.** Pages now show a subtle preview of the layout while data loads instead of a blank screen or spinner. This applies everywhere — dashboards, settings, uptime, funnels, notifications, billing, and detail modals.
|
||||
- **Clearer error messages.** When something goes wrong, the error message now tells you what failed (e.g. "Failed to load uptime monitors") instead of a generic "Failed to load data".
|
||||
- **Faster favicon loading.** Site icons in the dashboard, referrers, and campaigns now use Next.js image optimization for better caching and lazy loading.
|
||||
- **Tighter name limits.** Site, funnel, and monitor names are now capped at 100 characters instead of 255 — long enough for any real name, short enough to not break the UI.
|
||||
- **Stricter type safety.** Eliminated all `any` types and `@ts-ignore` suppressions across the codebase, so the TypeScript compiler catches more bugs at build time.
|
||||
- **Smaller page downloads.** Icon imports are now tree-shaken so only the icons actually used are included in the bundle.
|
||||
- **Removed debug logs.** Auth and organization-switching details no longer leak into the browser console in production. Error logs are now also suppressed in production and only appear during development.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Landing page dashboard preview.** The homepage now shows a realistic preview of the Pulse dashboard instead of an empty placeholder.
|
||||
- **Logout redirect loop.** Signing out no longer bounces you straight to Ciphera Auth. You now land on the Pulse homepage where you can choose to sign back in.
|
||||
- **No more loading flicker.** Fast-loading pages no longer flash a loading state for a split second before showing content.
|
||||
- **Organization context switch.** Switching away from a deleted organization now stores the session correctly instead of using an insecure fallback.
|
||||
- **Dark mode uptime chart.** The response time chart on the uptime page now correctly follows your dark mode preference instead of always showing a white tooltip background.
|
||||
- **Onboarding form limits.** The welcome page now enforces the same character limits as the rest of the app.
|
||||
- **Audit log reliability.** Failed audit log writes are now logged to the server instead of being silently ignored, so gaps in the audit trail are detectable.
|
||||
- **Safer error messages.** Server errors no longer expose internal details (database errors, stack traces) to the browser. You see a clear message like "Failed to create site" while the full error is logged server-side for debugging.
|
||||
- **Content Security Policy.** The backend CSP header was being overwritten by a duplicate, and the captcha service was incorrectly whitelisted under image sources instead of connection sources. Both are now fixed.
|
||||
- **Logout redirect loop.** Signing out no longer bounces you straight to Ciphera Auth. You now land on the Pulse homepage where you can choose to sign back in.
|
||||
- **Date range edge case.** The maximum date range check could be off by a day due to an internal time adjustment. It now compares calendar days accurately.
|
||||
|
||||
## [0.10.0-alpha] - 2026-02-21
|
||||
|
||||
### Changed
|
||||
@@ -127,7 +168,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
|
||||
---
|
||||
|
||||
[Unreleased]: https://github.com/ciphera-net/pulse/compare/v0.10.0-alpha...HEAD
|
||||
[Unreleased]: https://github.com/ciphera-net/pulse/compare/v0.11.0-alpha...HEAD
|
||||
[0.11.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.10.0-alpha...v0.11.0-alpha
|
||||
[0.10.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.9.0-alpha...v0.10.0-alpha
|
||||
[0.9.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.8.0-alpha...v0.9.0-alpha
|
||||
[0.8.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.7.0-alpha...v0.8.0-alpha
|
||||
|
||||
19
app/about/layout.tsx
Normal 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
|
||||
}
|
||||
@@ -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' }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
19
app/integrations/layout.tsx
Normal 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
|
||||
}
|
||||
@@ -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[]>([])
|
||||
|
|
||||
|
||||
const [isSwitchingOrg, setIsSwitchingOrg] = useState(() => {
|
||||
if (typeof window === 'undefined') return false
|
||||
return sessionStorage.getItem(ORG_SWITCH_KEY) === 'true'
|
||||
})
|
||||
|
||||
|
SSR/hydration mismatch from The lazy initializer reads from This trades a brief flash for a guaranteed clean hydration. In practice, the current approach may work due to Prompt To Fix With AI**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} />}
|
||||
|
||||
13
app/notifications/error.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
15
app/notifications/layout.tsx
Normal 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
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
13
app/org-settings/error.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
113
app/page.tsx
@@ -6,10 +6,13 @@ import { motion } from 'framer-motion'
|
||||
import { useAuth } from '@/lib/auth/context'
|
||||
import { initiateOAuthFlow, initiateSignupFlow } from '@/lib/api/oauth'
|
||||
import { listSites, deleteSite, type Site } from '@/lib/api/sites'
|
||||
import { getStats } from '@/lib/api/stats'
|
||||
import type { Stats } from '@/lib/api/stats'
|
||||
import { getSubscription, type SubscriptionDetails } from '@/lib/api/billing'
|
||||
import { LoadingOverlay } from '@ciphera-net/ui'
|
||||
import SiteList from '@/components/sites/SiteList'
|
||||
import { Button } from '@ciphera-net/ui'
|
||||
import Image from 'next/image'
|
||||
import { BarChartIcon, LockIcon, ZapIcon, CheckCircleIcon, XIcon, GlobeIcon } from '@ciphera-net/ui'
|
||||
import { toast } from '@ciphera-net/ui'
|
||||
import { getAuthErrorMessage } from '@ciphera-net/ui'
|
||||
@@ -17,29 +20,36 @@ import { getSitesLimitForPlan } from '@/lib/plans'
|
||||
|
||||
function DashboardPreview() {
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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)) {
|
||||
|
||||
13
app/sites/[id]/funnels/error.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
15
app/sites/[id]/funnels/layout.tsx
Normal 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
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
13
app/sites/[id]/realtime/error.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
15
app/sites/[id]/realtime/layout.tsx
Normal 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
|
||||
}
|
||||
@@ -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) => (
|
||||
|
||||
13
app/sites/[id]/settings/error.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
15
app/sites/[id]/settings/layout.tsx
Normal 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
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
13
app/sites/[id]/uptime/error.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
15
app/sites/[id]/uptime/layout.tsx
Normal 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
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
63
components/ErrorDisplay.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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.
|
||||
|
||||
50
components/dashboard/Sparkline.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
34
components/useMinimumLoading.ts
Normal 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
|
||||
}
|
||||
@@ -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:**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
28
lib/hooks/useTabListKeyboard.ts
Normal 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()
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
24
lib/hooks/useUnsavedChanges.ts
Normal 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])
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
},
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
Redirect to login discards the original URL When an unauthenticated user visits a protected route like This would require the login flow to read Prompt To Fix With AI**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/).*)',
|
||||
],
|
||||
}
|
||||
@@ -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=()',
|
||||
|
Prompt To Fix With AI**`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 [
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pulse-frontend",
|
||||
"version": "0.10.0-alpha",
|
||||
"version": "0.11.0-alpha",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
BIN
public/dashboard-preview-v2.png
Normal file
|
After Width: | Height: | Size: 878 KiB |
21
types/iso-3166-2.d.ts
vendored
Normal 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
@@ -0,0 +1,9 @@
|
||||
import 'jspdf'
|
||||
|
||||
declare module 'jspdf' {
|
||||
interface jsPDF {
|
||||
lastAutoTable: {
|
||||
finalY: number
|
||||
}
|
||||
}
|
||||
}
|
||||
Remaining
anytype fororgsstateThroughout this PR,
anytypes have been systematically replaced with proper types, butorgsis stillany[]. This should use theOrganizationMembertype that's already imported from@/lib/api/organization:Prompt To Fix With AI