diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b66357..06558e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,47 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +## [0.11.0-alpha] - 2026-02-22 + +### Added + +- **Better page titles.** Browser tabs now show which site and page you're on (e.g. "Uptime · example.com | Pulse") instead of the same generic title everywhere. +- **Link previews for public dashboards.** Sharing a public dashboard link on social media now shows a proper preview with the site name and description. +- **Faster login redirects.** If you're not signed in and try to open a dashboard or settings page, you're redirected to login immediately instead of seeing a blank page first. Already-signed-in users who visit the login page are sent straight to the dashboard. +- **Graceful error recovery.** If a page crashes, you now see a friendly error screen with a "Try again" button instead of a blank white page. Each section of the app has its own error message so you know exactly what went wrong. +- **Security headers.** All pages now include clickjacking protection, MIME-sniffing prevention, a strict referrer policy, and HSTS. Browser APIs like camera and microphone are explicitly disabled. +- **Better form experience.** Forms now auto-focus the first field when they open, text inputs enforce character limits with a visible counter when you're close, and the settings page warns you before navigating away with unsaved changes. +- **Accessibility improvements.** The notification bell, workspace switcher, and all dashboard tabs are now fully keyboard-navigable. Screen readers announce unread counts, active organizations, and tab changes correctly. Decorative icons are hidden from assistive technology. +- **Smooth organization switching.** Switching between organizations now shows a branded loading screen instead of a blank flash while the page reloads. +- **Graceful server shutdown.** Deployments no longer kill in-flight requests or interrupt background tasks. The server finishes ongoing work before shutting down. +- **Database connection pooling.** The backend now limits and recycles database connections, preventing exhaustion under load and reducing query latency. +- **Date range validation.** Analytics, funnel, and uptime queries now reject invalid date ranges (end before start, or spans longer than a year) instead of silently returning empty or oversized results. +- **Excluded paths limit.** Sites can now have up to 50 excluded paths. Previously there was no cap, which could slow down event processing. + +### Changed + +- **Smoother loading experience.** Pages now show a subtle preview of the layout while data loads instead of a blank screen or spinner. This applies everywhere — dashboards, settings, uptime, funnels, notifications, billing, and detail modals. +- **Clearer error messages.** When something goes wrong, the error message now tells you what failed (e.g. "Failed to load uptime monitors") instead of a generic "Failed to load data". +- **Faster favicon loading.** Site icons in the dashboard, referrers, and campaigns now use Next.js image optimization for better caching and lazy loading. +- **Tighter name limits.** Site, funnel, and monitor names are now capped at 100 characters instead of 255 — long enough for any real name, short enough to not break the UI. +- **Stricter type safety.** Eliminated all `any` types and `@ts-ignore` suppressions across the codebase, so the TypeScript compiler catches more bugs at build time. +- **Smaller page downloads.** Icon imports are now tree-shaken so only the icons actually used are included in the bundle. +- **Removed debug logs.** Auth and organization-switching details no longer leak into the browser console in production. Error logs are now also suppressed in production and only appear during development. + +### Fixed + +- **Landing page dashboard preview.** The homepage now shows a realistic preview of the Pulse dashboard instead of an empty placeholder. +- **Logout redirect loop.** Signing out no longer bounces you straight to Ciphera Auth. You now land on the Pulse homepage where you can choose to sign back in. +- **No more loading flicker.** Fast-loading pages no longer flash a loading state for a split second before showing content. +- **Organization context switch.** Switching away from a deleted organization now stores the session correctly instead of using an insecure fallback. +- **Dark mode uptime chart.** The response time chart on the uptime page now correctly follows your dark mode preference instead of always showing a white tooltip background. +- **Onboarding form limits.** The welcome page now enforces the same character limits as the rest of the app. +- **Audit log reliability.** Failed audit log writes are now logged to the server instead of being silently ignored, so gaps in the audit trail are detectable. +- **Safer error messages.** Server errors no longer expose internal details (database errors, stack traces) to the browser. You see a clear message like "Failed to create site" while the full error is logged server-side for debugging. +- **Content Security Policy.** The backend CSP header was being overwritten by a duplicate, and the captcha service was incorrectly whitelisted under image sources instead of connection sources. Both are now fixed. +- **Logout redirect loop.** Signing out no longer bounces you straight to Ciphera Auth. You now land on the Pulse homepage where you can choose to sign back in. +- **Date range edge case.** The maximum date range check could be off by a day due to an internal time adjustment. It now compares calendar days accurately. + ## [0.10.0-alpha] - 2026-02-21 ### Changed @@ -127,7 +168,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), --- -[Unreleased]: https://github.com/ciphera-net/pulse/compare/v0.10.0-alpha...HEAD +[Unreleased]: https://github.com/ciphera-net/pulse/compare/v0.11.0-alpha...HEAD +[0.11.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.10.0-alpha...v0.11.0-alpha [0.10.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.9.0-alpha...v0.10.0-alpha [0.9.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.8.0-alpha...v0.9.0-alpha [0.8.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.7.0-alpha...v0.8.0-alpha diff --git a/app/about/layout.tsx b/app/about/layout.tsx new file mode 100644 index 0000000..cb91b21 --- /dev/null +++ b/app/about/layout.tsx @@ -0,0 +1,19 @@ +import type { Metadata } from 'next' + +export const metadata: Metadata = { + title: 'About | Pulse', + description: 'Pulse is built by Ciphera — privacy-first web analytics made in Switzerland.', + openGraph: { + title: 'About | Pulse', + description: 'Pulse is built by Ciphera — privacy-first web analytics made in Switzerland.', + siteName: 'Pulse by Ciphera', + }, +} + +export default function AboutLayout({ + children, +}: { + children: React.ReactNode +}) { + return children +} diff --git a/app/actions/auth.ts b/app/actions/auth.ts index 79d0e15..869cfff 100644 --- a/app/actions/auth.ts +++ b/app/actions/auth.ts @@ -1,6 +1,7 @@ 'use server' import { cookies } from 'next/headers' +import { logger } from '@/lib/utils/logger' const AUTH_API_URL = process.env.NEXT_PUBLIC_AUTH_API_URL || process.env.NEXT_PUBLIC_AUTH_URL || 'http://localhost:8081' @@ -102,7 +103,7 @@ export async function exchangeAuthCode(code: string, codeVerifier: string, redir } } catch (error: unknown) { - console.error('Auth Exchange Error:', error) + logger.error('Auth Exchange Error:', error) const isNetwork = error instanceof TypeError || (error instanceof Error && (error.name === 'AbortError' || /fetch|network|ECONNREFUSED|ETIMEDOUT/i.test(error.message))) @@ -112,18 +113,13 @@ export async function exchangeAuthCode(code: string, codeVerifier: string, redir export async function setSessionAction(accessToken: string, refreshToken?: string) { try { - console.log('[setSessionAction] Decoding token...') if (!accessToken) throw new Error('Access token is missing') const payloadPart = accessToken.split('.')[1] const payload: UserPayload = JSON.parse(Buffer.from(payloadPart, 'base64').toString()) - console.log('[setSessionAction] Token Payload:', { sub: payload.sub, org_id: payload.org_id }) - const cookieStore = await cookies() const cookieDomain = getCookieDomain() - - console.log('[setSessionAction] Setting cookies with domain:', cookieDomain) cookieStore.set('access_token', accessToken, { httpOnly: true, @@ -146,8 +142,6 @@ export async function setSessionAction(accessToken: string, refreshToken?: strin }) } - console.log('[setSessionAction] Cookies set successfully') - return { success: true, user: { @@ -159,7 +153,7 @@ export async function setSessionAction(accessToken: string, refreshToken?: strin } } } catch (e) { - console.error('[setSessionAction] Error:', e) + logger.error('[setSessionAction] Error:', e) return { success: false as const, error: 'invalid' } } } diff --git a/app/auth/callback/page.tsx b/app/auth/callback/page.tsx index 30396c4..b2a8c34 100644 --- a/app/auth/callback/page.tsx +++ b/app/auth/callback/page.tsx @@ -1,6 +1,7 @@ 'use client' import { useEffect, useState, Suspense, useRef, useCallback } from 'react' +import { logger } from '@/lib/utils/logger' import { useRouter, useSearchParams } from 'next/navigation' import { useAuth } from '@/lib/auth/context' import { AUTH_URL, default as apiRequest } from '@/lib/api/client' @@ -96,7 +97,7 @@ function AuthCallbackContent() { return } if (state !== storedState) { - console.error('State mismatch', { received: state, stored: storedState }) + logger.error('State mismatch', { received: state, stored: storedState }) setError('Invalid state') return } diff --git a/app/error.tsx b/app/error.tsx new file mode 100644 index 0000000..4b1380b --- /dev/null +++ b/app/error.tsx @@ -0,0 +1,13 @@ +'use client' + +import ErrorDisplay from '@/components/ErrorDisplay' + +export default function GlobalError({ reset }: { error: Error; reset: () => void }) { + return ( + + ) +} diff --git a/app/faq/layout.tsx b/app/faq/layout.tsx new file mode 100644 index 0000000..696202e --- /dev/null +++ b/app/faq/layout.tsx @@ -0,0 +1,19 @@ +import type { Metadata } from 'next' + +export const metadata: Metadata = { + title: 'FAQ | Pulse', + description: 'Frequently asked questions about Pulse, privacy, GDPR compliance, and how it works.', + openGraph: { + title: 'FAQ | Pulse', + description: 'Frequently asked questions about Pulse, privacy, GDPR compliance, and how it works.', + siteName: 'Pulse by Ciphera', + }, +} + +export default function FaqLayout({ + children, +}: { + children: React.ReactNode +}) { + return children +} diff --git a/app/features/layout.tsx b/app/features/layout.tsx new file mode 100644 index 0000000..9ec58ac --- /dev/null +++ b/app/features/layout.tsx @@ -0,0 +1,19 @@ +import type { Metadata } from 'next' + +export const metadata: Metadata = { + title: 'Features | Pulse', + description: 'Dashboards, funnels, uptime monitoring, realtime visitors, and more — all without cookies.', + openGraph: { + title: 'Features | Pulse', + description: 'Dashboards, funnels, uptime monitoring, realtime visitors, and more — all without cookies.', + siteName: 'Pulse by Ciphera', + }, +} + +export default function FeaturesLayout({ + children, +}: { + children: React.ReactNode +}) { + return children +} diff --git a/app/integrations/layout.tsx b/app/integrations/layout.tsx new file mode 100644 index 0000000..52a43b0 --- /dev/null +++ b/app/integrations/layout.tsx @@ -0,0 +1,19 @@ +import type { Metadata } from 'next' + +export const metadata: Metadata = { + title: 'Integrations | Pulse', + description: 'Add Pulse analytics to Next.js, React, Vue, WordPress, and more in under a minute.', + openGraph: { + title: 'Integrations | Pulse', + description: 'Add Pulse analytics to Next.js, React, Vue, WordPress, and more in under a minute.', + siteName: 'Pulse by Ciphera', + }, +} + +export default function IntegrationsLayout({ + children, +}: { + children: React.ReactNode +}) { + return children +} diff --git a/app/layout-content.tsx b/app/layout-content.tsx index 1f50e03..727254f 100644 --- a/app/layout-content.tsx +++ b/app/layout-content.tsx @@ -8,22 +8,39 @@ import { useAuth } from '@/lib/auth/context' import { useOnlineStatus } from '@/lib/hooks/useOnlineStatus' import Link from 'next/link' import { useEffect, useState } from 'react' +import { logger } from '@/lib/utils/logger' import { getUserOrganizations, switchContext } from '@/lib/api/organization' import { setSessionAction } from '@/app/actions/auth' +import { LoadingOverlay } from '@ciphera-net/ui' import { useRouter } from 'next/navigation' +const ORG_SWITCH_KEY = 'pulse_switching_org' + export default function LayoutContent({ children }: { children: React.ReactNode }) { const auth = useAuth() const router = useRouter() const isOnline = useOnlineStatus() const [orgs, setOrgs] = useState([]) - + const [isSwitchingOrg, setIsSwitchingOrg] = useState(() => { + if (typeof window === 'undefined') return false + return sessionStorage.getItem(ORG_SWITCH_KEY) === 'true' + }) + + // * Clear the switching flag once the page has settled after reload + useEffect(() => { + if (isSwitchingOrg) { + sessionStorage.removeItem(ORG_SWITCH_KEY) + const timer = setTimeout(() => setIsSwitchingOrg(false), 600) + return () => clearTimeout(timer) + } + }, [isSwitchingOrg]) + // * Fetch organizations for the header organization switcher useEffect(() => { if (auth.user) { getUserOrganizations() .then((organizations) => setOrgs(Array.isArray(organizations) ? organizations : [])) - .catch(err => console.error('Failed to fetch orgs for header', err)) + .catch(err => logger.error('Failed to fetch orgs for header', err)) } }, [auth.user]) @@ -32,9 +49,10 @@ export default function LayoutContent({ children }: { children: React.ReactNode try { const { access_token } = await switchContext(orgId) await setSessionAction(access_token) + sessionStorage.setItem(ORG_SWITCH_KEY, 'true') window.location.reload() } catch (err) { - console.error('Failed to switch organization', err) + logger.error('Failed to switch organization', err) } } @@ -47,6 +65,10 @@ export default function LayoutContent({ children }: { children: React.ReactNode const headerHeightRem = 6; const mainTopPaddingRem = barHeightRem + headerHeightRem; + if (isSwitchingOrg) { + return + } + return ( <> {auth.user && } diff --git a/app/notifications/error.tsx b/app/notifications/error.tsx new file mode 100644 index 0000000..8a14fd3 --- /dev/null +++ b/app/notifications/error.tsx @@ -0,0 +1,13 @@ +'use client' + +import ErrorDisplay from '@/components/ErrorDisplay' + +export default function NotificationsError({ reset }: { error: Error; reset: () => void }) { + return ( + + ) +} diff --git a/app/notifications/layout.tsx b/app/notifications/layout.tsx new file mode 100644 index 0000000..40928a2 --- /dev/null +++ b/app/notifications/layout.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from 'next' + +export const metadata: Metadata = { + title: 'Notifications | Pulse', + description: 'View your alerts and activity updates.', + robots: { index: false, follow: false }, +} + +export default function NotificationsLayout({ + children, +}: { + children: React.ReactNode +}) { + return children +} diff --git a/app/notifications/page.tsx b/app/notifications/page.tsx index 0635cf5..2b4fe85 100644 --- a/app/notifications/page.tsx +++ b/app/notifications/page.tsx @@ -15,7 +15,8 @@ import { } from '@/lib/api/notifications' import { getAuthErrorMessage } from '@ciphera-net/ui' import { formatTimeAgo, getTypeIcon } from '@/lib/utils/notifications' -import { Button, ArrowLeftIcon, Spinner } from '@ciphera-net/ui' +import { Button, ArrowLeftIcon } from '@ciphera-net/ui' +import { NotificationsListSkeleton, useMinimumLoading } from '@/components/skeletons' import { toast } from '@ciphera-net/ui' const PAGE_SIZE = 50 @@ -29,6 +30,7 @@ export default function NotificationsPage() { const [offset, setOffset] = useState(0) const [hasMore, setHasMore] = useState(true) const [loadingMore, setLoadingMore] = useState(false) + const showSkeleton = useMinimumLoading(loading) const fetchPage = async (pageOffset: number, append: boolean) => { if (append) setLoadingMore(true) @@ -127,10 +129,8 @@ export default function NotificationsPage() {

- {loading ? ( -
- -
+ {showSkeleton ? ( + ) : error ? (
{error} diff --git a/app/org-settings/error.tsx b/app/org-settings/error.tsx new file mode 100644 index 0000000..f9889a3 --- /dev/null +++ b/app/org-settings/error.tsx @@ -0,0 +1,13 @@ +'use client' + +import ErrorDisplay from '@/components/ErrorDisplay' + +export default function OrgSettingsError({ reset }: { error: Error; reset: () => void }) { + return ( + + ) +} diff --git a/app/org-settings/page.tsx b/app/org-settings/page.tsx index 0ed5837..2d7fdab 100644 --- a/app/org-settings/page.tsx +++ b/app/org-settings/page.tsx @@ -1,5 +1,6 @@ import { Suspense } from 'react' import OrganizationSettings from '@/components/settings/OrganizationSettings' +import { SettingsFormSkeleton } from '@/components/skeletons' export const metadata = { title: 'Organization Settings - Pulse', @@ -10,7 +11,17 @@ export default function OrgSettingsPage() { return (
- Loading...
}> + +
+
+
+
+
+ +
+
+ }>
diff --git a/app/page.tsx b/app/page.tsx index 02d85b4..8ea1c77 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -6,10 +6,13 @@ import { motion } from 'framer-motion' import { useAuth } from '@/lib/auth/context' import { initiateOAuthFlow, initiateSignupFlow } from '@/lib/api/oauth' import { listSites, deleteSite, type Site } from '@/lib/api/sites' +import { getStats } from '@/lib/api/stats' +import type { Stats } from '@/lib/api/stats' import { getSubscription, type SubscriptionDetails } from '@/lib/api/billing' import { LoadingOverlay } from '@ciphera-net/ui' import SiteList from '@/components/sites/SiteList' import { Button } from '@ciphera-net/ui' +import Image from 'next/image' import { BarChartIcon, LockIcon, ZapIcon, CheckCircleIcon, XIcon, GlobeIcon } from '@ciphera-net/ui' import { toast } from '@ciphera-net/ui' import { getAuthErrorMessage } from '@ciphera-net/ui' @@ -17,29 +20,36 @@ import { getSitesLimitForPlan } from '@/lib/plans' function DashboardPreview() { return ( -
- {/* * Glow behind the image */} +
- - {/* * Static Container */} -
- {/* * Header of the fake browser window */} -
-
-
-
+ {/* * Browser chrome */} +
+
+
+
+
- - {/* * Placeholder for actual dashboard screenshot - replace src with real image later */} -
-
- -

Dashboard Preview

-
+ + {/* * Screenshot with bottom fade */} +
+ Pulse analytics dashboard showing visitor stats, charts, top pages, referrers, locations, and technology breakdown +
-
+
) } @@ -97,10 +107,13 @@ function ComparisonSection() { } +type SiteStatsMap = Record + export default function HomePage() { const { user, loading: authLoading } = useAuth() const [sites, setSites] = useState([]) const [sitesLoading, setSitesLoading] = useState(true) + const [siteStats, setSiteStats] = useState({}) const [subscription, setSubscription] = useState(null) const [subscriptionLoading, setSubscriptionLoading] = useState(false) const [showFinishSetupBanner, setShowFinishSetupBanner] = useState(true) @@ -112,6 +125,37 @@ export default function HomePage() { } }, [user]) + useEffect(() => { + if (sites.length === 0) { + setSiteStats({}) + return + } + let cancelled = false + const today = new Date().toISOString().split('T')[0] + const emptyStats: Stats = { pageviews: 0, visitors: 0, bounce_rate: 0, avg_duration: 0 } + const load = async () => { + const results = await Promise.allSettled( + sites.map(async (site) => { + const statsRes = await getStats(site.id, today, today) + return { siteId: site.id, stats: statsRes } + }) + ) + if (cancelled) return + const map: SiteStatsMap = {} + results.forEach((r, i) => { + const site = sites[i] + if (r.status === 'fulfilled') { + map[site.id] = { stats: r.value.stats } + } else { + map[site.id] = { stats: emptyStats } + } + }) + setSiteStats(map) + } + load() + return () => { cancelled = true } + }, [sites]) + useEffect(() => { if (typeof window === 'undefined') return if (localStorage.getItem('pulse_welcome_completed') === 'true') setShowFinishSetupBanner(false) @@ -133,8 +177,8 @@ export default function HomePage() { setSitesLoading(true) const data = await listSites() setSites(Array.isArray(data) ? data : []) - } catch (error: any) { - toast.error(getAuthErrorMessage(error) || 'Failed to load sites: ' + ((error as Error)?.message || 'Unknown error')) + } catch (error: unknown) { + toast.error(getAuthErrorMessage(error) || 'Failed to load your sites') setSites([]) } finally { setSitesLoading(false) @@ -162,8 +206,8 @@ export default function HomePage() { await deleteSite(id) toast.success('Site deleted successfully') loadSites() - } catch (error: any) { - toast.error(getAuthErrorMessage(error) || 'Failed to delete site: ' + ((error as Error)?.message || 'Unknown error')) + } catch (error: unknown) { + toast.error(getAuthErrorMessage(error) || 'Failed to delete site') } } @@ -362,20 +406,29 @@ export default function HomePage() { )}
- {/* * Global Overview */} + {/* * Global Overview - min-h ensures no layout shift when Plan & usage loads */}
-
+

Total Sites

{sites.length}

-
+

Total Visitors (24h)

-

--

+

+ {sites.length === 0 || Object.keys(siteStats).length < sites.length + ? '--' + : Object.values(siteStats).reduce((sum, { stats }) => sum + (stats?.visitors ?? 0), 0).toLocaleString()} +

-
+

Plan & usage

{subscriptionLoading ? ( -

...

+
+
+
+
+
+
) : subscription ? ( <>

@@ -456,7 +509,7 @@ export default function HomePage() { )} {(sitesLoading || sites.length > 0) && ( - + )}

) diff --git a/app/pricing/page.tsx b/app/pricing/page.tsx index 6381381..f31aa18 100644 --- a/app/pricing/page.tsx +++ b/app/pricing/page.tsx @@ -1,10 +1,30 @@ import { Suspense } from 'react' +import type { Metadata } from 'next' import PricingSection from '@/components/PricingSection' +import { PricingCardsSkeleton } from '@/components/skeletons' + +export const metadata: Metadata = { + title: 'Pricing | Pulse', + description: 'Simple, transparent pricing for privacy-first web analytics. Free tier included.', + openGraph: { + title: 'Pricing | Pulse', + description: 'Simple, transparent pricing for privacy-first web analytics. Free tier included.', + siteName: 'Pulse by Ciphera', + }, +} export default function PricingPage() { return (
- Loading...
}> + +
+
+
+
+ +
+ }>
diff --git a/app/share/[id]/error.tsx b/app/share/[id]/error.tsx new file mode 100644 index 0000000..9756b5f --- /dev/null +++ b/app/share/[id]/error.tsx @@ -0,0 +1,13 @@ +'use client' + +import ErrorDisplay from '@/components/ErrorDisplay' + +export default function ShareError({ reset }: { error: Error; reset: () => void }) { + return ( + + ) +} diff --git a/app/share/[id]/layout.tsx b/app/share/[id]/layout.tsx new file mode 100644 index 0000000..60de8ee --- /dev/null +++ b/app/share/[id]/layout.tsx @@ -0,0 +1,73 @@ +import type { Metadata } from 'next' +import { FAVICON_SERVICE_URL } from '@/lib/utils/icons' + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8082' + +interface SharePageParams { + params: Promise<{ id: string }> +} + +export async function generateMetadata({ params }: SharePageParams): Promise { + const { id } = await params + const fallback: Metadata = { + title: 'Public Dashboard | Pulse', + description: 'Privacy-first web analytics — view this site\'s public stats.', + openGraph: { + title: 'Public Dashboard | Pulse', + description: 'Privacy-first web analytics — view this site\'s public stats.', + siteName: 'Pulse by Ciphera', + type: 'website', + }, + twitter: { + card: 'summary', + title: 'Public Dashboard | Pulse', + description: 'Privacy-first web analytics — view this site\'s public stats.', + }, + } + + try { + const res = await fetch(`${API_URL}/public/sites/${id}/dashboard?limit=1`, { + next: { revalidate: 3600 }, + }) + if (!res.ok) return fallback + + const data = await res.json() + const domain = data?.site?.domain + if (!domain) return fallback + + const title = `${domain} analytics | Pulse` + const description = `Live, privacy-first analytics for ${domain} — powered by Pulse.` + + return { + title, + description, + openGraph: { + title, + description, + siteName: 'Pulse by Ciphera', + type: 'website', + images: [{ + url: `${FAVICON_SERVICE_URL}?domain=${domain}&sz=128`, + width: 128, + height: 128, + alt: `${domain} favicon`, + }], + }, + twitter: { + card: 'summary', + title, + description, + }, + } + } catch { + return fallback + } +} + +export default function ShareLayout({ + children, +}: { + children: React.ReactNode +}) { + return children +} diff --git a/app/share/[id]/page.tsx b/app/share/[id]/page.tsx index 5ed4452..cc28021 100644 --- a/app/share/[id]/page.tsx +++ b/app/share/[id]/page.tsx @@ -1,10 +1,12 @@ 'use client' import { useCallback, useEffect, useState } from 'react' +import Image from 'next/image' import { useParams, useSearchParams, useRouter } from 'next/navigation' import { getPublicDashboard, getPublicStats, getPublicDailyStats, getPublicRealtime, getPublicPerformanceByPage, type DashboardData, type Stats, type DailyStat, type PerformanceByPageStat } from '@/lib/api/stats' import { toast } from '@ciphera-net/ui' import { getAuthErrorMessage } from '@ciphera-net/ui' +import { ApiError } from '@/lib/api/client' import { LoadingOverlay, Button } from '@ciphera-net/ui' import Chart from '@/components/dashboard/Chart' import TopPages from '@/components/dashboard/ContentStats' @@ -13,7 +15,9 @@ import Locations from '@/components/dashboard/Locations' import TechSpecs from '@/components/dashboard/TechSpecs' import PerformanceStats from '@/components/dashboard/PerformanceStats' import { Select, DatePicker as DatePickerModal, Captcha, DownloadIcon, ZapIcon } from '@ciphera-net/ui' +import { DashboardSkeleton, useMinimumLoading } from '@/components/skeletons' import ExportModal from '@/components/dashboard/ExportModal' +import { FAVICON_SERVICE_URL } from '@/lib/utils/icons' // Helper to get date ranges const getDateRange = (days: number) => { @@ -152,8 +156,9 @@ export default function PublicDashboardPage() { setCaptchaId('') setCaptchaSolution('') setCaptchaToken('') - } catch (error: any) { - if ((error.status === 401 || error.response?.status === 401) && (error.data?.is_protected || error.response?.data?.is_protected)) { + } catch (error: unknown) { + const apiErr = error instanceof ApiError ? error : null + if (apiErr?.status === 401 && (apiErr.data as Record)?.is_protected) { setIsPasswordProtected(true) if (password) { toast.error('Invalid password or captcha') @@ -162,10 +167,10 @@ export default function PublicDashboardPage() { setCaptchaSolution('') setCaptchaToken('') } - } else if (error.status === 404 || error.response?.status === 404) { + } else if (apiErr?.status === 404) { toast.error('Site not found') } else if (!silent) { - toast.error(getAuthErrorMessage(error) || 'Failed to load dashboard: ' + ((error as Error)?.message || 'Unknown error')) + toast.error(getAuthErrorMessage(error) || 'Failed to load public dashboard') } } finally { if (!silent) setLoading(false) @@ -192,8 +197,10 @@ export default function PublicDashboardPage() { loadDashboard() } - if (loading && !data && !isPasswordProtected) { - return + const showSkeleton = useMinimumLoading(loading && !data && !isPasswordProtected) + + if (showSkeleton) { + return } if (isPasswordProtected && !data) { @@ -279,13 +286,16 @@ export default function PublicDashboardPage() { Public Dashboard

- {site.name} { (e.target as HTMLImageElement).src = '/globe.svg' }} + unoptimized /> {site.domain}

diff --git a/app/sites/[id]/error.tsx b/app/sites/[id]/error.tsx new file mode 100644 index 0000000..e1ea50a --- /dev/null +++ b/app/sites/[id]/error.tsx @@ -0,0 +1,13 @@ +'use client' + +import ErrorDisplay from '@/components/ErrorDisplay' + +export default function DashboardError({ reset }: { error: Error; reset: () => void }) { + return ( + + ) +} diff --git a/app/sites/[id]/funnels/[funnelId]/page.tsx b/app/sites/[id]/funnels/[funnelId]/page.tsx index 3695f52..4b7c0bc 100644 --- a/app/sites/[id]/funnels/[funnelId]/page.tsx +++ b/app/sites/[id]/funnels/[funnelId]/page.tsx @@ -4,7 +4,8 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { useParams, useRouter } from 'next/navigation' import { ApiError } from '@/lib/api/client' import { getFunnel, getFunnelStats, deleteFunnel, type Funnel, type FunnelStats } from '@/lib/api/funnels' -import { toast, LoadingOverlay, Select, DatePicker, ChevronLeftIcon, ArrowRightIcon, TrashIcon, useTheme, Button } from '@ciphera-net/ui' +import { toast, Select, DatePicker, ChevronLeftIcon, ArrowRightIcon, TrashIcon, useTheme, Button } from '@ciphera-net/ui' +import { FunnelDetailSkeleton, useMinimumLoading } from '@/components/skeletons' import Link from 'next/link' import { BarChart, @@ -63,7 +64,7 @@ export default function FunnelReportPage() { if (status === 404) setLoadError('not_found') else if (status === 403) setLoadError('forbidden') else setLoadError('error') - if (status !== 404 && status !== 403) toast.error('Failed to load funnel data') + if (status !== 404 && status !== 403) toast.error('Failed to load funnel details') } finally { setLoading(false) } @@ -91,8 +92,10 @@ export default function FunnelReportPage() { } } - if (loading && !funnel) { - return + const showSkeleton = useMinimumLoading(loading && !funnel) + + if (showSkeleton) { + return } if (loadError === 'not_found' || (!funnel && !stats && !loadError)) { diff --git a/app/sites/[id]/funnels/error.tsx b/app/sites/[id]/funnels/error.tsx new file mode 100644 index 0000000..2be69ce --- /dev/null +++ b/app/sites/[id]/funnels/error.tsx @@ -0,0 +1,13 @@ +'use client' + +import ErrorDisplay from '@/components/ErrorDisplay' + +export default function FunnelsError({ reset }: { error: Error; reset: () => void }) { + return ( + + ) +} diff --git a/app/sites/[id]/funnels/layout.tsx b/app/sites/[id]/funnels/layout.tsx new file mode 100644 index 0000000..fe54962 --- /dev/null +++ b/app/sites/[id]/funnels/layout.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from 'next' + +export const metadata: Metadata = { + title: 'Funnels | Pulse', + description: 'Track conversion funnels and user journeys.', + robots: { index: false, follow: false }, +} + +export default function FunnelsLayout({ + children, +}: { + children: React.ReactNode +}) { + return children +} diff --git a/app/sites/[id]/funnels/new/page.tsx b/app/sites/[id]/funnels/new/page.tsx index 187ad5a..74f2fa0 100644 --- a/app/sites/[id]/funnels/new/page.tsx +++ b/app/sites/[id]/funnels/new/page.tsx @@ -84,7 +84,7 @@ export default function CreateFunnelPage() { toast.success('Funnel created') router.push(`/sites/${siteId}/funnels`) } catch (error) { - toast.error('Failed to create funnel') + toast.error('Failed to create funnel. Please try again.') } finally { setSaving(false) } @@ -120,8 +120,13 @@ export default function CreateFunnelPage() { value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. Signup Flow" + autoFocus required + maxLength={100} /> + {name.length > 80 && ( + 90 ? 'text-amber-500' : 'text-neutral-400'}`}>{name.length}/100 + )}
) : loadingEvents ? ( -
-
-
+ ) : (
{sessionEvents.map((event, idx) => ( diff --git a/app/sites/[id]/settings/error.tsx b/app/sites/[id]/settings/error.tsx new file mode 100644 index 0000000..5f53d0b --- /dev/null +++ b/app/sites/[id]/settings/error.tsx @@ -0,0 +1,13 @@ +'use client' + +import ErrorDisplay from '@/components/ErrorDisplay' + +export default function SiteSettingsError({ reset }: { error: Error; reset: () => void }) { + return ( + + ) +} diff --git a/app/sites/[id]/settings/layout.tsx b/app/sites/[id]/settings/layout.tsx new file mode 100644 index 0000000..6a44c2e --- /dev/null +++ b/app/sites/[id]/settings/layout.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from 'next' + +export const metadata: Metadata = { + title: 'Site Settings | Pulse', + description: 'Configure your site tracking, privacy, and goals.', + robots: { index: false, follow: false }, +} + +export default function SiteSettingsLayout({ + children, +}: { + children: React.ReactNode +}) { + return children +} diff --git a/app/sites/[id]/settings/page.tsx b/app/sites/[id]/settings/page.tsx index fc30439..e5d34ea 100644 --- a/app/sites/[id]/settings/page.tsx +++ b/app/sites/[id]/settings/page.tsx @@ -1,18 +1,19 @@ 'use client' -import { useEffect, useState } from 'react' +import { useEffect, useState, useRef } from 'react' import { useParams, useRouter } from 'next/navigation' import { getSite, updateSite, resetSiteData, deleteSite, type Site, type GeoDataLevel } from '@/lib/api/sites' import { listGoals, createGoal, updateGoal, deleteGoal, type Goal } from '@/lib/api/goals' import { toast } from '@ciphera-net/ui' import { getAuthErrorMessage } from '@ciphera-net/ui' -import { LoadingOverlay } from '@ciphera-net/ui' +import { SettingsFormSkeleton, GoalsListSkeleton, useMinimumLoading } from '@/components/skeletons' import VerificationModal from '@/components/sites/VerificationModal' import ScriptSetupBlock from '@/components/sites/ScriptSetupBlock' import { PasswordInput } from '@ciphera-net/ui' import { Select, Modal, Button } from '@ciphera-net/ui' import { APP_URL } from '@/lib/api/client' import { generatePrivacySnippet } from '@/lib/utils/privacySnippet' +import { useUnsavedChanges } from '@/lib/hooks/useUnsavedChanges' import { getSubscription, type SubscriptionDetails } from '@/lib/api/billing' import { getRetentionOptionsForPlan, formatRetentionMonths } from '@/lib/plans' import { motion, AnimatePresence } from 'framer-motion' @@ -86,6 +87,7 @@ export default function SiteSettingsPage() { const [editingGoal, setEditingGoal] = useState(null) const [goalForm, setGoalForm] = useState({ name: '', event_name: '' }) const [goalSaving, setGoalSaving] = useState(false) + const initialFormRef = useRef('') useEffect(() => { loadSite() @@ -146,13 +148,27 @@ export default function SiteSettingsPage() { // Data retention (default 6 = free-tier max; avoids flash-then-clamp for existing sites) data_retention_months: data.data_retention_months ?? 6 }) + initialFormRef.current = JSON.stringify({ + name: data.name, + timezone: data.timezone || 'UTC', + is_public: data.is_public || false, + excluded_paths: (data.excluded_paths || []).join('\n'), + collect_page_paths: data.collect_page_paths ?? true, + collect_referrers: data.collect_referrers ?? true, + collect_device_info: data.collect_device_info ?? true, + collect_geo_data: data.collect_geo_data || 'full', + collect_screen_resolution: data.collect_screen_resolution ?? true, + enable_performance_insights: data.enable_performance_insights ?? false, + filter_bots: data.filter_bots ?? true, + data_retention_months: data.data_retention_months ?? 6 + }) if (data.has_password) { setIsPasswordEnabled(true) } else { setIsPasswordEnabled(false) } - } catch (error: any) { - toast.error(getAuthErrorMessage(error) || 'Failed to load site: ' + ((error as Error)?.message || 'Unknown error')) + } catch (error: unknown) { + toast.error(getAuthErrorMessage(error) || 'Failed to load site settings') } finally { setLoading(false) } @@ -264,9 +280,23 @@ export default function SiteSettingsPage() { data_retention_months: formData.data_retention_months }) toast.success('Site updated successfully') + initialFormRef.current = JSON.stringify({ + name: formData.name, + timezone: formData.timezone, + is_public: formData.is_public, + excluded_paths: formData.excluded_paths, + collect_page_paths: formData.collect_page_paths, + collect_referrers: formData.collect_referrers, + collect_device_info: formData.collect_device_info, + collect_geo_data: formData.collect_geo_data, + collect_screen_resolution: formData.collect_screen_resolution, + enable_performance_insights: formData.enable_performance_insights, + filter_bots: formData.filter_bots, + data_retention_months: formData.data_retention_months + }) loadSite() - } catch (error: any) { - toast.error(getAuthErrorMessage(error) || 'Failed to update site: ' + ((error as Error)?.message || 'Unknown error')) + } catch (error: unknown) { + toast.error(getAuthErrorMessage(error) || 'Failed to save site settings') } finally { setSaving(false) } @@ -280,8 +310,8 @@ export default function SiteSettingsPage() { try { await resetSiteData(siteId) toast.success('All site data has been reset') - } catch (error: any) { - toast.error(getAuthErrorMessage(error) || 'Failed to reset data: ' + ((error as Error)?.message || 'Unknown error')) + } catch (error: unknown) { + toast.error(getAuthErrorMessage(error) || 'Failed to reset site data') } } @@ -296,8 +326,8 @@ export default function SiteSettingsPage() { await deleteSite(siteId) toast.success('Site deleted successfully') router.push('/') - } catch (error: any) { - toast.error(getAuthErrorMessage(error) || 'Failed to delete site: ' + ((error as Error)?.message || 'Unknown error')) + } catch (error: unknown) { + toast.error(getAuthErrorMessage(error) || 'Failed to delete site') } } @@ -317,8 +347,50 @@ export default function SiteSettingsPage() { setTimeout(() => setSnippetCopied(false), 2000) } - if (loading) { - return + const isFormDirty = initialFormRef.current !== '' && JSON.stringify({ + name: formData.name, + timezone: formData.timezone, + is_public: formData.is_public, + excluded_paths: formData.excluded_paths, + collect_page_paths: formData.collect_page_paths, + collect_referrers: formData.collect_referrers, + collect_device_info: formData.collect_device_info, + collect_geo_data: formData.collect_geo_data, + collect_screen_resolution: formData.collect_screen_resolution, + enable_performance_insights: formData.enable_performance_insights, + filter_bots: formData.filter_bots, + data_retention_months: formData.data_retention_months + }) !== initialFormRef.current + + useUnsavedChanges(isFormDirty) + + useEffect(() => { + if (site?.domain) document.title = `Settings · ${site.domain} | Pulse` + }, [site?.domain]) + + const showSkeleton = useMinimumLoading(loading) + + if (showSkeleton) { + return ( +
+
+
+
+
+
+
+ +
+ +
+
+
+
+ ) } if (!site) { @@ -429,11 +501,15 @@ export default function SiteSettingsPage() { type="text" id="name" required + maxLength={100} value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value })} className="w-full px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-lg bg-neutral-50/50 dark:bg-neutral-900/50 focus:bg-white dark:focus:bg-neutral-900 focus:border-brand-orange focus:ring-4 focus:ring-brand-orange/10 outline-none transition-all duration-200 dark:text-white" /> + {formData.name.length > 80 && ( + 90 ? 'text-amber-500' : 'text-neutral-400'}`}>{formData.name.length}/100 + )}
@@ -970,7 +1046,7 @@ export default function SiteSettingsPage() {

{goalsLoading ? ( -
Loading goals…
+ ) : ( <> {canEdit && ( @@ -1037,6 +1113,7 @@ export default function SiteSettingsPage() { value={goalForm.name} onChange={(e) => setGoalForm({ ...goalForm, name: e.target.value })} placeholder="e.g. Signups" + autoFocus className="w-full px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-lg bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white" required /> @@ -1048,10 +1125,14 @@ export default function SiteSettingsPage() { value={goalForm.event_name} onChange={(e) => setGoalForm({ ...goalForm, event_name: e.target.value })} placeholder="e.g. signup_click (letters, numbers, underscores only)" + maxLength={64} className="w-full px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-lg bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white" required /> -

Only letters, numbers, and underscores; spaces become underscores. Invalid characters cannot be used. Max 64 characters after formatting.

+
+

Letters, numbers, and underscores only. Spaces become underscores.

+ 56 ? 'text-amber-500' : 'text-neutral-400'}`}>{goalForm.event_name.length}/64 +
{editingGoal && goalForm.event_name.trim().toLowerCase().replace(/\s+/g, '_') !== editingGoal.event_name && (

Changing event name does not reassign events already tracked under the previous name.

)} diff --git a/app/sites/[id]/uptime/error.tsx b/app/sites/[id]/uptime/error.tsx new file mode 100644 index 0000000..87bd036 --- /dev/null +++ b/app/sites/[id]/uptime/error.tsx @@ -0,0 +1,13 @@ +'use client' + +import ErrorDisplay from '@/components/ErrorDisplay' + +export default function UptimeError({ reset }: { error: Error; reset: () => void }) { + return ( + + ) +} diff --git a/app/sites/[id]/uptime/layout.tsx b/app/sites/[id]/uptime/layout.tsx new file mode 100644 index 0000000..af93f93 --- /dev/null +++ b/app/sites/[id]/uptime/layout.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from 'next' + +export const metadata: Metadata = { + title: 'Uptime | Pulse', + description: 'Monitor your site uptime and response times.', + robots: { index: false, follow: false }, +} + +export default function UptimeLayout({ + children, +}: { + children: React.ReactNode +}) { + return children +} diff --git a/app/sites/[id]/uptime/page.tsx b/app/sites/[id]/uptime/page.tsx index cf4b001..85a666b 100644 --- a/app/sites/[id]/uptime/page.tsx +++ b/app/sites/[id]/uptime/page.tsx @@ -20,7 +20,8 @@ import { import { toast } from '@ciphera-net/ui' import { useTheme } from '@ciphera-net/ui' import { getAuthErrorMessage } from '@ciphera-net/ui' -import { LoadingOverlay, Button, Modal } from '@ciphera-net/ui' +import { Button, Modal } from '@ciphera-net/ui' +import { UptimeSkeleton, ChecksSkeleton, useMinimumLoading } from '@/components/skeletons' import { AreaChart, Area, @@ -283,8 +284,8 @@ function UptimeStatusBar({ // * Component: Response time chart (Recharts area chart) function ResponseTimeChart({ checks }: { checks: UptimeCheck[] }) { - const { theme } = useTheme() - const colors = theme === 'dark' ? CHART_COLORS_DARK : CHART_COLORS_LIGHT + const { resolvedTheme } = useTheme() + const colors = resolvedTheme === 'dark' ? CHART_COLORS_DARK : CHART_COLORS_LIGHT // * Prepare data in chronological order (oldest first) const data = [...checks] @@ -510,9 +511,7 @@ function MonitorCard({ {/* Response time chart */} {loadingChecks ? ( -
- Loading checks... -
+ ) : checks.length > 0 ? ( <> @@ -616,7 +615,7 @@ export default function UptimePage() { setSite(siteData) setUptimeData(statusData) } catch (error: unknown) { - toast.error(getAuthErrorMessage(error) || 'Failed to load uptime data') + toast.error(getAuthErrorMessage(error) || 'Failed to load uptime monitors') } finally { setLoading(false) } @@ -704,7 +703,13 @@ export default function UptimePage() { setShowEditModal(true) } - if (loading) return + useEffect(() => { + if (site?.domain) document.title = `Uptime · ${site.domain} | Pulse` + }, [site?.domain]) + + const showSkeleton = useMinimumLoading(loading) + + if (showSkeleton) return if (!site) return
Site not found
const monitors = Array.isArray(uptimeData?.monitors) ? uptimeData.monitors : [] @@ -932,8 +937,13 @@ function MonitorForm({ value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value })} placeholder="e.g. API, Website, CDN" + autoFocus + maxLength={100} className="w-full px-3 py-2 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-white placeholder-neutral-400 focus:outline-none focus:ring-2 focus:ring-brand-orange focus:border-transparent text-sm" /> + {formData.name.length > 80 && ( + 90 ? 'text-amber-500' : 'text-neutral-400'}`}>{formData.name.length}/100 + )}
{/* URL with protocol dropdown + domain prefix */} diff --git a/app/sites/new/page.tsx b/app/sites/new/page.tsx index cdcd4d1..da34c13 100644 --- a/app/sites/new/page.tsx +++ b/app/sites/new/page.tsx @@ -1,6 +1,7 @@ 'use client' import { useState, useEffect } from 'react' +import { logger } from '@/lib/utils/logger' import { useRouter } from 'next/navigation' import Link from 'next/link' import { createSite, listSites, getSite, type Site } from '@/lib/api/sites' @@ -65,7 +66,7 @@ export default function NewSitePage() { router.replace('/') } } catch (error) { - console.error('Failed to check limits', error) + logger.error('Failed to check limits', error) } finally { setLimitsChecked(true) } @@ -87,7 +88,7 @@ export default function NewSitePage() { sessionStorage.setItem(LAST_CREATED_SITE_KEY, JSON.stringify({ id: site.id })) } } catch (error: unknown) { - toast.error(getAuthErrorMessage(error) || 'Failed to create site: ' + ((error as Error)?.message || 'Unknown error')) + toast.error(getAuthErrorMessage(error) || 'Failed to create site. Please try again.') } finally { setLoading(false) } @@ -191,6 +192,8 @@ export default function NewSitePage() { setFormData({ ...formData, name: e.target.value })} placeholder="My Website" @@ -204,6 +207,7 @@ export default function NewSitePage() { setFormData({ ...formData, domain: e.target.value.toLowerCase().trim() })} placeholder="example.com" diff --git a/app/welcome/page.tsx b/app/welcome/page.tsx index 295b374..759c6fb 100644 --- a/app/welcome/page.tsx +++ b/app/welcome/page.tsx @@ -162,7 +162,7 @@ function WelcomeContent() { setStep(3) } } catch (err) { - toast.error(getAuthErrorMessage(err) || 'Failed to switch organization') + toast.error(getAuthErrorMessage(err) || 'Failed to switch workspace') } finally { setSwitchingOrgId(null) } @@ -659,6 +659,7 @@ function WelcomeContent() { placeholder="My Website" value={siteName} onChange={(e) => setSiteName(e.target.value)} + maxLength={100} className="w-full" />
@@ -672,6 +673,7 @@ function WelcomeContent() { placeholder="example.com" value={siteDomain} onChange={(e) => setSiteDomain(e.target.value.toLowerCase().trim())} + maxLength={253} className="w-full" />

diff --git a/components/ErrorDisplay.tsx b/components/ErrorDisplay.tsx new file mode 100644 index 0000000..6bed625 --- /dev/null +++ b/components/ErrorDisplay.tsx @@ -0,0 +1,63 @@ +'use client' + +import { Button } from '@ciphera-net/ui' + +interface ErrorDisplayProps { + title?: string + message?: string + onRetry?: () => void + onGoHome?: boolean +} + +/** + * Shared error UI for route-level error.tsx boundaries. + * Matches the visual style of the 404 page. + */ +export default function ErrorDisplay({ + title = 'Something went wrong', + message = 'An unexpected error occurred. Please try again or go back to the dashboard.', + onRetry, + onGoHome = true, +}: ErrorDisplayProps) { + return ( +

+
+
+
+
+ +
+
+ + + +
+ +

+ {title} +

+

+ {message} +

+ +
+ {onRetry && ( + + )} + {onGoHome && ( + + + + )} +
+
+
+ ) +} diff --git a/components/Footer.tsx b/components/Footer.tsx index 0adeaa1..ed6b2cd 100644 --- a/components/Footer.tsx +++ b/components/Footer.tsx @@ -5,7 +5,7 @@ import Image from 'next/image' import { GithubIcon, TwitterIcon, SwissFlagIcon } from '@ciphera-net/ui' interface FooterProps { - LinkComponent?: any + LinkComponent?: React.ElementType appName?: string isAuthenticated?: boolean } diff --git a/components/PricingSection.tsx b/components/PricingSection.tsx index 41e346f..42b7663 100644 --- a/components/PricingSection.tsx +++ b/components/PricingSection.tsx @@ -1,6 +1,7 @@ 'use client' import { useState, useEffect } from 'react' +import { logger } from '@/lib/utils/logger' import { useSearchParams } from 'next/navigation' import { motion } from 'framer-motion' import { Button, CheckCircleIcon } from '@ciphera-net/ui' @@ -140,7 +141,7 @@ export default function PricingSection() { // Clear intent localStorage.removeItem('pulse_pending_checkout') } catch (e) { - console.error('Failed to parse pending checkout', e) + logger.error('Failed to parse pending checkout', e) localStorage.removeItem('pulse_pending_checkout') } } @@ -150,8 +151,7 @@ export default function PricingSection() { // Helper to get all price details const getPriceDetails = (planId: string) => { - // @ts-ignore - const basePrice = currentTraffic.prices[planId] + const basePrice = currentTraffic.prices[planId as keyof typeof currentTraffic.prices] // Handle "Custom" if (basePrice === null || basePrice === undefined) return null @@ -203,9 +203,9 @@ export default function PricingSection() { throw new Error('No checkout URL returned') } - } catch (error: any) { - console.error('Checkout error:', error) - toast.error('Failed to start checkout. Please try again.') + } catch (error: unknown) { + logger.error('Checkout error:', error) + toast.error('Failed to start checkout — please try again') } finally { setLoadingPlan(null) } diff --git a/components/WorkspaceSwitcher.tsx b/components/WorkspaceSwitcher.tsx index a177337..6d8ff26 100644 --- a/components/WorkspaceSwitcher.tsx +++ b/components/WorkspaceSwitcher.tsx @@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation' import { PlusIcon, PersonIcon, CubeIcon, CheckIcon } from '@radix-ui/react-icons' import { switchContext, OrganizationMember } from '@/lib/api/organization' import { setSessionAction } from '@/app/actions/auth' +import { logger } from '@/lib/utils/logger' import Link from 'next/link' export default function OrganizationSwitcher({ orgs, activeOrgId }: { orgs: OrganizationMember[], activeOrgId: string | null }) { @@ -12,7 +13,6 @@ export default function OrganizationSwitcher({ orgs, activeOrgId }: { orgs: Orga const [switching, setSwitching] = useState(null) const handleSwitch = async (orgId: string | null) => { - console.log('Switching to organization:', orgId) setSwitching(orgId || 'personal') try { // * If orgId is null, we can't switch context via API in the same way if strict mode is on @@ -34,18 +34,18 @@ export default function OrganizationSwitcher({ orgs, activeOrgId }: { orgs: Orga // * Note: switchContext only returns access_token, we keep existing refresh token await setSessionAction(access_token) - // Force reload to pick up new permissions - window.location.reload() + sessionStorage.setItem('pulse_switching_org', 'true') + window.location.reload() } catch (err) { - console.error('Failed to switch organization', err) + logger.error('Failed to switch organization', err) setSwitching(null) } } return ( -
-
+
+ @@ -75,21 +75,28 @@ export default function OrganizationSwitcher({ orgs, activeOrgId }: { orgs: Orga ))} @@ -99,7 +106,7 @@ export default function OrganizationSwitcher({ orgs, activeOrgId }: { orgs: Orga href="/onboarding" className="w-full flex items-center gap-2 px-3 py-2 text-sm text-neutral-500 hover:text-blue-600 dark:text-neutral-400 dark:hover:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/10 rounded-lg transition-colors mt-1" > -
+ Create Organization diff --git a/components/dashboard/Campaigns.tsx b/components/dashboard/Campaigns.tsx index bd0f6a3..31bed08 100644 --- a/components/dashboard/Campaigns.tsx +++ b/components/dashboard/Campaigns.tsx @@ -1,9 +1,12 @@ 'use client' import { useState, useEffect, useMemo } from 'react' +import { logger } from '@/lib/utils/logger' import Link from 'next/link' +import Image from 'next/image' import { formatNumber } from '@ciphera-net/ui' -import { Modal, ArrowRightIcon, Button, Spinner } from '@ciphera-net/ui' +import { Modal, ArrowRightIcon, Button } from '@ciphera-net/ui' +import { TableSkeleton } from '@/components/skeletons' import { ChevronDownIcon, DownloadIcon } from '@ciphera-net/ui' import { getCampaigns, CampaignStat } from '@/lib/api/stats' import { getReferrerFavicon, getReferrerIcon, getReferrerDisplayName } from '@/lib/utils/icons' @@ -56,7 +59,7 @@ export default function Campaigns({ siteId, dateRange }: CampaignsProps) { const result = await getCampaigns(siteId, dateRange.start, dateRange.end, 10) setData(result) } catch (e) { - console.error(e) + logger.error(e) } finally { setIsLoading(false) } @@ -72,7 +75,7 @@ export default function Campaigns({ siteId, dateRange }: CampaignsProps) { const result = await getCampaigns(siteId, dateRange.start, dateRange.end, 100) setFullData(result) } catch (e) { - console.error(e) + logger.error(e) } finally { setIsLoadingFull(false) } @@ -110,11 +113,14 @@ export default function Campaigns({ siteId, dateRange }: CampaignsProps) { const useFavicon = faviconUrl && !faviconFailed.has(source) if (useFavicon) { return ( - setFaviconFailed((prev) => new Set(prev).add(source))} + unoptimized /> ) } @@ -292,9 +298,8 @@ export default function Campaigns({ siteId, dateRange }: CampaignsProps) { >
{isLoadingFull ? ( -
- -

Loading...

+
+
) : ( <> diff --git a/components/dashboard/Chart.tsx b/components/dashboard/Chart.tsx index 355090f..792f2de 100644 --- a/components/dashboard/Chart.tsx +++ b/components/dashboard/Chart.tsx @@ -14,6 +14,7 @@ import { } from 'recharts' import type { TooltipProps } from 'recharts' import { formatNumber, formatDuration, formatUpdatedAgo } from '@ciphera-net/ui' +import Sparkline from './Sparkline' import { ArrowUpRightIcon, ArrowDownRightIcon, BarChartIcon, Select, Button, DownloadIcon } from '@ciphera-net/ui' import { Checkbox } from '@ciphera-net/ui' @@ -208,51 +209,6 @@ function getTrendContext(dateRange: { start: string; end: string }): string { return `vs previous ${days} days` } -// * Mini sparkline SVG for KPI cards -function Sparkline({ - data, - dataKey, - color, - width = 56, - height = 20, -}: { - data: Array> - dataKey: string - color: string - width?: number - height?: number -}) { - if (!data.length) return null - const values = data.map((d) => Number(d[dataKey] ?? 0)) - const max = Math.max(...values, 1) - const min = Math.min(...values, 0) - const range = max - min || 1 - const padding = 2 - const w = width - padding * 2 - const h = height - padding * 2 - - const points = values.map((v, i) => { - const x = padding + (i / Math.max(values.length - 1, 1)) * w - const y = padding + h - ((v - min) / range) * h - return `${x},${y}` - }) - - const pathD = points.length > 1 ? `M ${points.join(' L ')}` : `M ${points[0]} L ${points[0]}` - - return ( - - - - ) -} - export default function Chart({ data, prevData, diff --git a/components/dashboard/ContentStats.tsx b/components/dashboard/ContentStats.tsx index b7d65dd..7f2e956 100644 --- a/components/dashboard/ContentStats.tsx +++ b/components/dashboard/ContentStats.tsx @@ -1,9 +1,12 @@ 'use client' import { useState, useEffect } from 'react' +import { logger } from '@/lib/utils/logger' import { formatNumber } from '@ciphera-net/ui' +import { useTabListKeyboard } from '@/lib/hooks/useTabListKeyboard' import { TopPage, getTopPages, getEntryPages, getExitPages } from '@/lib/api/stats' -import { Modal, ArrowUpRightIcon, LayoutDashboardIcon, Spinner } from '@ciphera-net/ui' +import { Modal, ArrowUpRightIcon, LayoutDashboardIcon } from '@ciphera-net/ui' +import { ListSkeleton } from '@/components/skeletons' interface ContentStatsProps { topPages: TopPage[] @@ -21,6 +24,7 @@ const LIMIT = 7 export default function ContentStats({ topPages, entryPages, exitPages, domain, collectPagePaths = true, siteId, dateRange }: ContentStatsProps) { const [activeTab, setActiveTab] = useState('top_pages') + const handleTabKeyDown = useTabListKeyboard() const [isModalOpen, setIsModalOpen] = useState(false) const [fullData, setFullData] = useState([]) const [isLoadingFull, setIsLoadingFull] = useState(false) @@ -47,7 +51,7 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain, } setFullData(filterGenericPaths(data)) } catch (e) { - console.error(e) + logger.error(e) } finally { setIsLoadingFull(false) } @@ -102,7 +106,7 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain, )}
-
+
{(['top_pages', 'entry_pages', 'exit_pages'] as Tab[]).map((tab) => ( )}
-
+
{(['map', 'countries', 'regions', 'cities'] as Tab[]).map((tab) => (