diff --git a/CHANGELOG.md b/CHANGELOG.md index 678b847..c2c7244 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - **No more loading flicker.** Fast-loading pages no longer flash a loading state for a split second before showing content. - **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. +- **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. ## [0.10.0-alpha] - 2026-02-21 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/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/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/pricing/page.tsx b/app/pricing/page.tsx index 6163ba1..f31aa18 100644 --- a/app/pricing/page.tsx +++ b/app/pricing/page.tsx @@ -1,7 +1,18 @@ 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 (
diff --git a/app/share/[id]/layout.tsx b/app/share/[id]/layout.tsx new file mode 100644 index 0000000..0071b2d --- /dev/null +++ b/app/share/[id]/layout.tsx @@ -0,0 +1,72 @@ +import type { Metadata } from 'next' + +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: `https://www.google.com/s2/favicons?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/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]/layout.tsx b/app/sites/[id]/layout.tsx new file mode 100644 index 0000000..6b377d3 --- /dev/null +++ b/app/sites/[id]/layout.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from 'next' + +export const metadata: Metadata = { + title: 'Dashboard | Pulse', + description: 'View your site analytics, traffic, and performance.', + robots: { index: false, follow: false }, +} + +export default function SiteLayout({ + children, +}: { + children: React.ReactNode +}) { + return children +} diff --git a/app/sites/[id]/page.tsx b/app/sites/[id]/page.tsx index 7010baa..689b4b0 100644 --- a/app/sites/[id]/page.tsx +++ b/app/sites/[id]/page.tsx @@ -216,6 +216,10 @@ export default function SiteDashboardPage() { return () => clearInterval(interval) }, [siteId, dateRange, todayInterval, multiDayInterval, isSettingsLoaded, loadData, loadRealtime]) + useEffect(() => { + if (site?.domain) document.title = `${site.domain} | Pulse` + }, [site?.domain]) + const showSkeleton = useMinimumLoading(loading) if (showSkeleton) { diff --git a/app/sites/[id]/realtime/layout.tsx b/app/sites/[id]/realtime/layout.tsx new file mode 100644 index 0000000..64b256b --- /dev/null +++ b/app/sites/[id]/realtime/layout.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from 'next' + +export const metadata: Metadata = { + title: 'Realtime | Pulse', + description: 'See who is on your site right now.', + robots: { index: false, follow: false }, +} + +export default function RealtimeLayout({ + children, +}: { + children: React.ReactNode +}) { + return children +} diff --git a/app/sites/[id]/realtime/page.tsx b/app/sites/[id]/realtime/page.tsx index 632045b..2ddced3 100644 --- a/app/sites/[id]/realtime/page.tsx +++ b/app/sites/[id]/realtime/page.tsx @@ -91,6 +91,10 @@ export default function RealtimePage() { } } + useEffect(() => { + if (site?.domain) document.title = `Realtime · ${site.domain} | Pulse` + }, [site?.domain]) + const showSkeleton = useMinimumLoading(loading) if (showSkeleton) 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 9c90e27..2ff7514 100644 --- a/app/sites/[id]/settings/page.tsx +++ b/app/sites/[id]/settings/page.tsx @@ -317,6 +317,10 @@ export default function SiteSettingsPage() { setTimeout(() => setSnippetCopied(false), 2000) } + useEffect(() => { + if (site?.domain) document.title = `Settings · ${site.domain} | Pulse` + }, [site?.domain]) + const showSkeleton = useMinimumLoading(loading) if (showSkeleton) { 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 2ead55b..b00a060 100644 --- a/app/sites/[id]/uptime/page.tsx +++ b/app/sites/[id]/uptime/page.tsx @@ -703,6 +703,10 @@ export default function UptimePage() { setShowEditModal(true) } + useEffect(() => { + if (site?.domain) document.title = `Uptime · ${site.domain} | Pulse` + }, [site?.domain]) + const showSkeleton = useMinimumLoading(loading) if (showSkeleton) return